* Reduce beast effect texture import sizes
* Fall back to GitHub LFS for missing mirror objects
* Disable local LFS filter process for non-LFS checkouts
* Use IL2CPP for Standalone Unity builds
* Install .NET SDK for Unity builds
* Expose setup-dotnet SDK to Unity builds
* Set dotnet host path for Unity Addressables
* Build Standalone Addressables with Mono backend
* Disable Addressables player rebuilds in CI
* Require Unity IL2CPP modules in CI
* Use Mono for macOS Windows Unity cross-builds
* Clean up C++ test warnings
* Keep player setup expected map size readable
* Declare direct Bazel module dependencies (#7069)
* Check Shardok map proto parse results
* Prepare Apple and protobuf module resolution
* Declare direct Bazel module dependencies
* Add Eagle and Shardok scene rebuild tool
* Clear copied split-scene external references
* Make split scene rebuild idempotent
* Snapshot scene roots before rebuild deletion
* Generate Eagle and Shardok split scenes
* Record ID scoring benchmark baseline
* Add real battle benchmark subset
* Record all real battle scoring matrix
* Record close battle scoring experiments
* Correct real battle benchmark round-limit results
* Document Shardok scoring benchmark baseline
Follow-up to #6802 that swapped the proto-shape JSON resources for a
generated `ExpansionTutorialResults.scala` (820-line `Vector` literal).
Replaces that with the originally-intended shape from the design
discussion: a `tutorial_expansion.db` resource in the same format as a
live `game.db`, read back via `SqliteHistory`.
Changes:
- New `tutorial_expansion.db` (~500KB binary resource) generated from
the previous `ExpansionTutorialResults.results` by running them
through `withNewResults` with a placeholder resulting state. (The
placeholder is recorded but never read — `allActionResults`
reconstructs each `ActionResultC` from the decomposed per-entity
tables only.)
- New `SqliteHistory.allActionResults`: public method that returns
every `ActionResultT` in the db without folding through the applier.
Skips replay/snapshots, so it's safe on a db whose resulting-state
columns are placeholders.
- `TutorialPhaseResultsLoader` rewritten as 10 lines: copy resource
to tempfile, `SqliteHistory.loaded(...).allActionResults`. Same
public surface as before.
- Deleted: `ExpansionTutorialResults.scala` (820 generated lines),
`TutorialDbGenerator.scala` (one-shot tool, job done).
- BUILD deps on the loader collapse from ~30 to 3 — the loader no
longer references domain types directly.
The generator stayed alive long enough to cross-validate the .db
round-trip against the Scala source (the test asserted
`expansionResults shouldBe ExpansionTutorialResults.results`); that
passed before this PR was finalized, so the round-trip is faithful.
The cross-validation assertion goes away with the Scala source.
Net: 4 source files changed (+540 / -3050 lines), plus the new
binary resource. `TutorialPhaseResultsLoaderTest` 1/1 green; full
Scala suite 225/225 green.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Closes the converter-chain retirement that PRs #6798, #6799, and #6801
chipped away at. The last remaining caller of
`ActionResultProtoConverter.fromProto` was
`TutorialPhaseResultsLoader`, which read proto-shape JSON tutorial
resource files and walked them through the converter.
Replaced the resource-based loader with compile-time-baked Scala
constants:
- New `ExpansionTutorialResults.scala` (820 lines, generated) contains
the same `Vector[ActionResultT]` that the JSON used to produce, but
as a literal expression.
- New `TutorialResultsCodegen` was the one-shot generator that walked
an `ActionResultC` via `Product` introspection + the Scala 3 enum's
superclass reflection, emitting fully-qualified Scala source. Used
once to convert the two existing resource files; deleted in this
commit since its job is done.
- `TutorialPhaseResultsLoader` is now ~10 lines: just references
`ExpansionTutorialResults.results` and an empty mobilization vector
(the original mobilization JSON was `{}`).
Deleted:
- `ActionResultProtoConverter.scala` + BUILD target
- `ChangedHeroConverter.scala` + BUILD target (only caller was the
parent converter)
- `ChangedFactionConverter.scala` + BUILD target (same)
- `TutorialPhaseResultsExtractor.scala` (broken anyway — it read
`.e0a` chunk files through `PersistedHistory` machinery that #6798
deleted)
- `expansion_results.json`, `mobilization_results.json`, and their
resource BUILD target
BUILD churn: visibility widened on ~10 `model/state` and
`model/action_result/concrete` targets so the new
`tutorial_phase_results_loader` can see them. Two unused
`action_result_proto_converter` deps fell out of `service` and
`service/new_game_creation`. Dependency cleanup is minor compared to
the deletions; net ~4,000 lines removed.
`json4s` reflection on Scala 3 case classes is broken in this
codebase (the `ScalaSigReader` is Scala 2-only and throws
`NoClassDefFoundError: scala/quoted/staging/package$`), which is why
the codegen used `Product` introspection + JVM-class-name parsing
instead of a runtime JSON serializer.
Verification: full Scala suite 225/225 green;
`TutorialPhaseResultsLoaderTest` still passes against the new path
(asserts the expected `generatedTextRequest.requestId` values are
preserved through the round-trip).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Drops the only remaining production caller of `ActionResultProtoConverter.toProto`.
The admin history endpoint had been doing `domain → proto → JSON`:
```
val arProto = ActionResultProtoConverter.toProto(ar)
val actionJson = writePretty(JsonFormat.toJson(arProto))
```
Switched to `writePretty(ar)` — json4s reflects on the case class
directly. The Go admin server passes `action_json` straight through to
an HTML template (no parsing), so a format change is tolerable; the
field's proto-comment "serialized as JSON" describes the previous
implementation, not a contract.
After this:
- The `getRawGameHistory` flow still uses scalapb's `JsonFormat` —
the inputs there are raw `ActionResult` protos (loaded from the
legacy `.e0r` admin-debug path), not domain types.
- The `getCurrentGameState` flow still uses `JsonFormat` on the
`GameState` proto — out of scope here (it's `GameStateConverter`,
not `ActionResultProtoConverter`).
- `game_admin_service` BUILD target drops its
`:action_result_proto_converter` dep.
One ActionResultProtoConverter caller remains:
`TutorialPhaseResultsLoader` (reads bundled tutorial JSON resources
via `fromProto`); retiring it needs the tutorial JSON files
regenerated in a domain-shape format.
Verification: full Scala suite 225/225 green; `game_admin_service`
builds cleanly. The new JSON output format should be visually
checked against the existing admin UI history view — json4s
DefaultFormats reflection on Scala 3 enums and sum types may produce
slightly different shape than scalapb's JsonFormat did. No code
parses this JSON; the admin UI just renders it as a string.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Removes one of the three remaining ActionResultProtoConverter callers
left after #6798. `fromResults(Vector[ActionResult])` only existed as a
test convenience that walked proto results through
`ActionResultProtoConverter.fromProto`. Its 17 call sites — across
`InMemoryHistoryTest`, `SqliteHistoryTest`, and `GameControllerTest` —
have been switched to the existing `fromScalaResults(Vector[ActionResultT])`
sibling, with each test's local `makeActionResult` helper now returning
domain `ActionResultC` instead of the proto.
After this:
- `InMemoryHistory.scala` no longer imports `ActionResultProtoConverter`
or the internal `ActionResult` proto.
- `in_memory_history` BUILD target drops the proto + converter deps;
three test targets drop the proto deps that fell to unused under
strict-deps.
- The two remaining ActionResultProtoConverter callers are
`GameAdminServiceImpl` (admin JSON serialization) and
`TutorialPhaseResultsLoader` (tutorial resource loading), both of
which need bigger replacements before they go.
Verification: full Scala suite 225/225 green.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Closes out the legacy non-SQLite history implementation. SqliteHistory
has been the sole runtime backend since 4.5h.5d shipped; the only
remaining caller of PersistedHistory was the RemoveActions admin tool,
whose BUILD target was already commented out.
Changes:
- RemoveActions rewritten on SqliteHistory: open game.db directly,
call truncateTo(count - countToTruncate), close. Persister-based
chunk-deletion logic falls away. BUILD target re-enabled with the
new (much smaller) dep set.
- PersistedHistory.scala, PersistedActionResult.scala, and
PersistedHistoryTest.scala deleted; their BUILD targets removed.
- games_manager's transitive `:persisted_history` dep removed (was
unused even before this PR).
- Stale doc-comment references to PersistedHistory in SqliteHistory
and FullGameHistory rephrased to stand on their own.
Out of scope: ActionResultProtoConverter (and the per-entity converter
chain) still exists. It's no longer used by any history path, but
remains alive for GameAdminServiceImpl's admin JSON serialization and
TutorialPhaseResultsLoader's resource loading. Retiring those is a
separate effort with bigger blast radius (custom JSON serializer for
ActionResultC, regenerated tutorial JSON resources).
Verification: full Scala suite 225/225 green (was 226 with the
deleted PersistedHistoryTest); RemoveActions builds.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Continues #6791 (which removed the v15-v20 column/vector-table
backfills). Same predicate: after #6787 every persisted game.db is at
v >= 21 in S3, and #6784 dropped the `payload` column. The v3-v14
per-entity backfills (which read each existing `action_results.payload`
to populate the decomposed tables) are unreachable on any healthy game
and cannot run anyway with `payload` gone.
Removed:
- `def backfill(connection: Connection): Unit` from each of the 11
per-entity table writers (ChangedHero/Faction/Battalion/Province +
NewBattalion/Faction/Hero/Province + Notification +
GeneratedTextRequest + ClientTextVisibilityExtension).
- The corresponding `<Writer>.backfill(connection)` calls from
applyV3-V14 in SqliteHistory. Each migration collapses to a single
`execEach(connection, schemaStatements)`.
- applyV6ReapplyChangedEntityDecomposition (#6738 hotfix) keeps its
DROP+CREATE schema reset for the ChangedHero/Faction tables; the
re-backfill step is gone. For pre-#6737 games it would still fix
the table shape but leave them empty — same "schema v < 15 is no
longer supported" caveat as for v15-v20.
- 15 `"<entity> vN backfill"` tests in SqliteHistoryTest and 3
orphaned section banners (v3, v4, v5).
- Unused proto imports in 11 writer files; unused private helpers
(`factionRowCount`) and unused imports in SqliteHistoryTest.
- ~40 BUILD deps that fall out as unused: each writer no longer needs
`internal:action_result_scala_proto` or
`proto_converters:action_result_proto_converter`. Several writers
needed their per-entity proto target added directly (e.g.
`battalion_scala_proto`, `hero_scala_proto`, `faction_scala_proto`)
because they used `.toByteArray` on the proto type — previously
available transitively via `action_result_scala_proto`.
Verification: `sqlite_history_test` 75/75 green (90 - 15 backfill
tests deleted); full Scala suite 226/226 green.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Ensure Bazel is available in self-hosted workflows
* Allow Bazel CI to use Command Line Tools
* Set macOS C++ deployment target for Bazel
* Use Bazel macOS minimum OS flags
* Raise macOS Bazel deployment target for filesystem
* Ensure git-lfs is available for CI fetches
* Authenticate CI LFS fetches with workflow token
* Disable stale LFS hooks during CI checkout
* Disable LFS filters during CI checkout
* Prepare Git LFS before persistent checkout
* Harden generated Bazel rc and LFS auth
* Let Xcode sync skip without full Xcode
* Harden Unity build cache markers
* Require full Xcode for mactools sync
After #6787 (upload-on-load) deployed and the admin-load pass pushed
every persisted game.db to v ≥ 21 in S3, the v15-v20 migration
backfills that re-derived their new columns/tables from
`action_results.payload` are unreachable on any healthy game. This PR
deletes that code so the proto dependency surface shrinks and the
"only used by backfill" file headers stop misleading future readers.
Removed:
- `backfill(Connection)` (and supporting `bindOpt*` helpers / `select`
loops) from each of the 6 writers that populated the 4.5h.1–4
scalars/vectors:
- ActionResultEntityRefColumnsWriter (v15)
- ActionResultEnumColumnsWriter (v16)
- ActionResultNumericColumnsWriter (v17)
- ActionResultDateBattleColumnsWriter (v18)
- ActionResultStructuredBlobColumnsWriter (v19)
- ActionResultVectorTablesWriter (v20)
- `applyV15`–`applyV20` collapse to a single line each — just
`execEach(connection, ...schemaStatements)`. Doc note added that
schema v < 15 is no longer supported (if `applyV15` ever runs on a
non-empty `action_results` table, the new columns are left NULL and
state diverges silently; the migration runner's `schema_version`
gate is the only protection).
- 6 corresponding `"action_results vN backfill"` tests in
`SqliteHistoryTest` (one per migration). The matching `vN live
write` tests stay — those still exercise the live-write column
binding.
BUILD dep cleanup falls out: each of those writers no longer needs
`internal:action_result_scala_proto` or
`proto_converters:action_result_proto_converter` (the
payload-parse + full-converter round-trip is gone). The remaining
narrow deps (`tutorial_phase_scala_proto`, `game_type_scala_proto`,
etc.) cover the proto-enum `.value` reads that `bindColumns` still
needs.
Verification: `sqlite_history_test` 90/90 green (96 - 6 backfill
tests deleted); full Scala suite 226/226 green.
The per-entity table backfills (v3-v14) still parse `payload` and are
out of scope here — they kept ChangedHero/Faction/Battalion games
alive across the .5c cutover, and removing them needs the same
"prove all games are at v ≥ 21 in S3" gate. Future cleanup.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Upload migrated game.db to persister on load (red)
Prep for 4.5i (deleting v15-v20 backfill payload-parsing). Before we
can delete migration code, every persisted `game.db` needs to be at the
current schema in S3 — otherwise a server restart re-downloads a stale
blob and re-walks the old migrations.
Today `SqliteHistory.loaded` runs migrations on the local file but
nothing pushes the migrated form back to the persister until an action
is actually committed (`withNewResults` is the only upload trigger).
Result: admin-loading every game upgrades the local server's copy but
leaves S3 at the old schema, so the upgrade unwinds on restart.
This commit adds two red tests:
1. Loading a v1 DB out of the persister with a wiped local copy must
bump the persister's `saveCount` — i.e., the migrated bytes get
pushed back. Today this fails because nothing in the load path
uploads.
2. Loading an already-current DB must NOT bump `saveCount`. Today
this passes vacuously (the load path never uploads), so it's a
forward-guarantee that the fix won't spam uploads on every restart
of a healthy server.
GREEN will thread an "any pending migrations applied?" boolean out of
`Migrations.applyPending` and, when true, do a one-shot
checkpoint+upload after constructing the `SqliteHistory`.
Holding for review of the test design before I land GREEN on the same
branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Upload migrated game.db to persister on load (green)
Thread an "any pending migrations applied?" boolean out of
`Migrations.applyPending` and through `initializeSchema`. In `loaded`,
when migrations actually ran and a persister is set, do a one-shot
checkpoint+upload so the migrated bytes propagate to S3.
Factored the WAL-checkpoint + readAllBytes + persister.save block out
of `maybeUploadToPersister` into a shared
`uploadCurrentBytesToPersister(p, nowMillis)` helper; `loaded` calls
the public no-arg variant. The `lastUploadAtMillis` field is anchored
inside the upload so the subsequent withNewResults time-based trigger
stays correct.
`create` discards the migration boolean explicitly: a fresh game has
no stale S3 form to compete with, and the first `withNewResults`
upload will be the v-current bytes anyway.
Verification: sqlite_history_test 95/95 green; full Scala suite
226/226 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Background. We just hit a production state-loss bug on a saved game:
`Province 40 has an army that should have already arrived`. Root cause:
`ChangedProvinceTableWriter` was added in 4.5b (#6725, 5/17) but its call
into `withNewResults` wasn't wired until 4.5h.5c (#6779, 5/28). For the
11 days between, every action committed had `payload` populated but
zero rows in `action_changed_provinces`. When 4.5h.5d (#6784) dropped
`payload`, those actions' changedProvinces became permanently lost.
On the affected game that's ~24k actions out of 28k.
ChangedHero and ChangedFaction had similar (hours-long) wiring gaps, but
v6 (#6741) included a re-backfill that rescued them. Every other
decomposition (4.5e/f/g/h.4) shipped writer + migration in a single PR,
so no orphan window. Only ChangedProvince bled data in production.
This PR adds two complementary guards so the same shape of bug can't
ship again:
1) New `PerEntityWriteDispatcher` object owns the fan-out to every
per-entity decomposition writer. Its `writeAll` matches `ar` against
the full `ActionResultC` case-class constructor pattern, binding
every field (Vector ones by name, scalars as `_`). Adding any new
field to `ActionResultC` breaks the constructor arity at compile
time, forcing whoever adds the field to come here and decide
"scalar → new column on `action_results`" or "vector → new
per-entity table + writer + wiring".
2) New test ("withNewResults populates every per-entity decomposition
table when given an ActionResult with one of each vector")
constructs an ActionResult with a non-empty entry for every 17
`Vector[_]` field on `ActionResultC`, runs `withNewResults`, and
asserts every per-entity table has >= 1 row. Catches "wrote a
writer but didn't call it" — which is the second variant of the
same class of bug.
`SqliteHistory.withNewResults` now delegates to
`PerEntityWriteDispatcher.writeAll`. The previous inline foreach blocks
plus the `ChangedProvinceConverter` import move to the dispatcher.
Visibility of `model/action_result/changed_province/concrete` widened to
`//src/test/scala/net/eagle0/eagle/service:__pkg__` so the new test
can build the source `ChangedProvinceC` directly.
Memory updated: `changed_entity_decomposition_pitfalls.md` now lists
this as a third recurring pitfall with the guard documented.
Verification: `sqlite_history_test` 94/94 green; full Scala suite
226/226 green.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.5d (red): assert action_results.payload column is gone
Final 4.5h chunk: stop writing the `payload` blob and drop the column from
the SQLite schema. After .5c the read path no longer touches `payload`, so
it's now dead weight on every write. .5d closes that loop.
Two red tests:
1. Fresh DB: `PRAGMA table_info(action_results)` must not include `payload`.
2. v20 → v21 upgrade: a DB that has a populated `payload` column gets the
column dropped on `SqliteHistory.loaded`, and a subsequent read still
returns the action via `ActionResultDbReader`.
Both fail RED because the column is still present (v20 schema). GREEN will:
- Stop binding `payload` in the `withNewResults` INSERT (and drop
`ActionResultProtoConverter.toProto` from the write path).
- Add migration v21 to `ALTER TABLE action_results DROP COLUMN payload`.
Holding for review of the test design before I land GREEN on the same branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.5d (green): stop writing payload and drop the column
Final step of the 4.5x decomposition. After this commit every
ActionResultC field on disk lives in a dedicated column or per-entity
table; `action_results.payload` is gone.
Changes:
- Add migration v21: ALTER TABLE action_results DROP COLUMN payload.
SQLite >= 3.35.0 supports DROP COLUMN; the xerial sqlite-jdbc driver
bundles a new-enough engine.
- Remove `payload` from the withNewResults INSERT (drops the bind at
position 6 and the ActionResultProtoConverter.toProto call) and
renumber the trailing column positions.
- Drop the ActionResultProtoConverter import from SqliteHistory.scala
and the corresponding sqlite_history Bazel deps
(action_result_proto_converter, action_result_scala_proto); add
changed_province_scala_proto, which had been pulled in transitively
via the action_result proto.
- Delete the .5b parity test and the .5c "read path doesn't touch
payload" test. Both queried `payload` directly to make their
assertions; with the column gone, the assertions are moot. Their
intent — that ActionResultDbReader assembles a faithful
ActionResultC from the decomposed tables — is now covered implicitly
by every test that round-trips through withNewResults + last/all.
Verification: `sqlite_history_test` 93/93 green; full Scala suite
226/226 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.5c (red): assert read path no longer depends on payload
Continues 4.5h.5 after .5b (the ActionResultDbReader orchestrator).
This sub-PR is the read-path cutover: swap replayFrom + readActionAt +
stateAfter to use ActionResultDbReader instead of
ActionResultProtoConverter.fromProto(payload), and wire
ChangedProvinceTableWriter into withNewResults so the cutover is
correctness-complete for changedProvinces.
The cleanest single test for "read path doesn't touch payload" is to
persist an action, then corrupt its payload column (overwrite with a
1-byte garbage blob), then re-load and read. If anything in the read
path still calls parseFrom on payload, that parse throws
InvalidProtocolBufferException; the test fails. If the read path uses
ActionResultDbReader (which doesn't touch payload), the test sees the
populated fields and passes.
The test exercises both stateAfter (which replays actions to advance
state) and readActionAt (which returns the last action) via a single
SqliteHistory.last call — both must avoid touching the corrupted
payload.
This commit (red):
- No code changes to SqliteHistory; the read path still parses payload.
- One red test: persist an action with 4 populated scalar/Option fields,
wipe payload column to x'00', reload, call last, assert the fields
come back populated.
The GREEN commit will:
- Swap replayFrom + readActionAt + the stateAfter inner loop to
ActionResultDbReader.read (no more parseFrom + fromProto).
- Wire ChangedProvinceTableWriter into withNewResults (so live actions
populate action_changed_provinces — needed for the read path to
return changedProvinces correctly).
- Note: payload is still written by withNewResults (kept around as a
safety net for one PR); .5d drops the column entirely.
Verification: sqlite_history_test 92/93 pass, 1 RED for the right
reason (parseFrom throws "Protocol message contained an invalid tag
(zero)" on the corrupted bytes).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.5c (green): swap read path to ActionResultDbReader, wire ChangedProvinceTableWriter
stateAfter / replayFrom / readActionAt now go through ActionResultDbReader.read,
which assembles ActionResultC from the decomposed columns + per-entity tables
(4.5b–4.5h.4) instead of `parseFrom(payload)`. Also wires
ChangedProvinceTableWriter into withNewResults so the action_changed_provinces
table is populated by live writes (it was already populated by the 4.5b
backfill; this closes the gap for new actions). The `payload` column is still
written; it gets dropped in 4.5h.5d.
The RED test added in 6b6134afef passes now that the corrupted payload is
no longer touched on read; gave it a `newDate` so the applier (which runs
during stateAfter's replay) has a date.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.5b (red): ActionResultDbReader stub + parity test
Continues 4.5h.5 after .5a (the two missing per-entity readers). This
sub-PR adds the top-level orchestrator that will assemble a domain
ActionResultC from the 24 action_results columns + the eleven per-entity
table readers — without touching the legacy payload BLOB.
The 24 columns now on action_results (added by 4.5h.1, .2a, .2b, .2c,
.3) cover every top-level scalar/Option field of ActionResultC plus the
three structured-Option blobs (new_battle_proto / new_chronicle_entry_proto
/ new_eagle_map_proto). The eleven per-entity vectors are read via the
existing per-entity table readers:
- ChangedBattalionTableReader, ChangedFactionTableReader,
ChangedHeroTableReader, ChangedProvinceTableReader (last from .5a)
- NewBattalionTableReader, NewFactionTableReader, NewHeroTableReader,
NewProvinceTableReader
- NotificationTableReader (readNew + readRemoved)
- GeneratedTextRequestTableReader, ClientTextVisibilityExtensionTableReader
- ActionResultVectorTablesReader (the five v20 vector tables, from .5a)
Parity contract with the converter-based read:
- For every field both paths handle, this reader produces an
ActionResultC equal to ActionResultProtoConverter.fromProto(payload).
- newGeneratedTextRequests is the one intentional asymmetry: the
converter drops it ("LLM text requests are not used by the
applier"), this reader returns the populated vector from the
decomposed table. Strictly better.
- changedProvinces is excluded from this PR's parity test:
ChangedProvinceTableWriter is not yet wired into withNewResults
(that's a .5c concern). Adding it back to parity is straightforward
once the wiring lands.
Reader is unused by production — replayFrom still goes through
ActionResultProtoConverter.fromProto(payload). The cutover lands in
.5c; the payload column gets dropped in .5d.
This commit (red):
- ActionResultDbReader stub: read returns a near-empty ActionResultC
(only actionResultType = UnknownActionUnspecified). GREEN lands the
real read.
- One parity test in SqliteHistoryTest: builds a rich source
ActionResultC (one of each shape — scalar/Option columns, structured
blobs, v20 vector tables, per-entity vectors except changedProvinces
and newGeneratedTextRequests), persists via withNewResults, then
asserts ActionResultDbReader.read produces the same ActionResultC as
the converter-based read of the payload.
- Visibility widening:
model/action_result/concrete:action_result_concrete adds
service:__pkg__; model/proto_converters:action_result_proto_converter
adds test/.../service:__pkg__.
Verification: sqlite_history_test 91/92 pass, 1 RED for the right reason
(stub returns near-empty AR; parity assertion fails the long structural
comparison).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.5b (green): implement ActionResultDbReader
Fills in the ActionResultDbReader stub from the red commit with the
real read implementation. The parity test added in red now passes:
ActionResultDbReader.read produces an ActionResultC equal to
ActionResultProtoConverter.fromProto(payload) for every field both
paths handle.
Implementation:
- Single action_results SELECT for the 24 column values (action_result_type,
the 4 entity-ref columns, 4 enum columns, 4 numeric Option columns,
date pair + resolved_battle, 3 blob columns).
- Eleven per-entity reader calls (ChangedBattalion/Faction/Hero/Province,
NewBattalion/Faction/Hero/Province, NotificationTableReader.readNew +
readRemoved, GeneratedTextRequest, ClientTextVisibilityExtension,
ActionResultVectorTables — readDestroyedBattalionIds/etc).
- All thirty fields plug straight into ActionResultC's case-class
constructor — single allocation, no .copy() (the custom copy method
uses different parameter names like `actingFaction` vs `actingFactionId`
and `removedHeroes` vs `removedHeroIds`).
Decoding helpers:
- getOptInt / getOptLong: nullable INTEGER via JDBC's wasNull() pattern
(getInt returns 0 for SQL NULL, indistinguishable without wasNull).
- decodeGameType: handles the (new_game_type, new_tutorial_phase) pair.
Writer never stores UNKNOWN_GAME_TYPE (stores NULL instead), so
fromProto's UNKNOWN→throw path is unreachable; defensive throw on
None from CommandTypeConverter.fromProto for the same reason.
- decodeDate: handles the (new_date_year, new_date_month) pair.
Inconsistent (one NULL) would throw IllegalArgumentException from
Date.Month.fromInt(0), surfacing writer bugs loudly.
- Blobs use Option(rs.getBytes(...)).map(parseFrom + Converter.fromProto).
BUILD additions (green-only):
- action_result_db_reader deps: all 11 per-entity readers + 6 enum/blob
converters + their proto packages (chronicle_entry, command_type,
eagle_map_info, game_type, round_phase, tutorial_phase, shardok_battle)
+ Date domain type + eagle_internal_exception.
The read path is NOT swapped here — replayFrom still parses payload.
The reader is unused by production until .5c.
Verification: sqlite_history_test 92/92 pass; full Scala test suite
226/226.
Next: 4.5h.5c — swap replayFrom to use ActionResultDbReader, stop
writing payload, wire ChangedProvinceTableWriter into withNewResults.
Then .5d drops the payload column.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.5a (red): missing per-entity readers (stub) + round-trip tests
Begins Phase 4.5h.5 (the final read-path-swap chunk). Per the agreed
four-slice plan:
.5a (this PR): the two missing per-entity readers + round-trip tests
with their writers. Readers exist but are unused by
production.
.5b: the top-level ActionResultDbReader orchestrator +
parity tests asserting it produces the same ActionResultC
as the converter-based read path.
.5c: swap replayFrom to use the new reader, stop writing
payload.
.5d: drop the payload column.
The 4.5b–g per-entity readers were created alongside their writers
(paired RED+GREEN PRs). Two gaps remain:
- ChangedProvinceTableReader: ChangedProvince was the first option-B
entity (4.5b) and its reader was deferred because nothing needed it
until 4.5h.
- ActionResultVectorTablesReader: the v20 vector tables (4.5h.4) were
built with only a writer because the read path hadn't started yet.
Both readers are needed by .5b's top-level orchestrator. Adding them
now closes the gap.
This commit (red):
- ChangedProvinceTableReader stub: read returns Vector.empty. Reads
from action_changed_provinces' changed_province_proto BLOB (the
scalar denorm columns are ignored on read; the blob is authoritative
per the writer's contract).
- ActionResultVectorTablesReader stub: five public read methods (one
per v20 table), all returning Vector.empty. The four primitive-ID
methods will share an internal readIds helper in GREEN;
readNewBattalionTypes will parse battalion_type_proto via
BattalionTypeConverter.fromProto.
- Two red tests in SqliteHistoryTest: round-trip ChangedProvinces via
the writer + new reader (two protos, exercises n ordering); round-trip
all five vectors via withNewResults + new reader.
- Visibility widening: model/action_result/changed_province:changed_province
and model/proto_converters:changed_province_converter to add the
service main + test service packages (GREEN reader will need the
converter).
Verification: sqlite_history_test 89/91 pass, 2 RED for the right reason
(stub readers return Vector.empty → "Vector() was not equal to Vector(17,
23)").
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.5a (green): implement missing per-entity readers
Fills in the two reader stubs from the red commit with real
implementations. No new tests — the round-trip tests added in the red
commit now turn green.
ChangedProvinceTableReader:
- read(connection, actionSeq): SELECT changed_province_proto ORDER BY n,
parse each blob via ChangedProvinceProto.parseFrom, convert to domain
via ChangedProvinceConverter.fromProto.
ActionResultVectorTablesReader:
- readDestroyedBattalionIds / readRemovedHeroIds / readRemovedFactionIds
/ readAffectedFactionIds: all delegate to the shared private readIds
helper which does SELECT $idColumn FROM $table WHERE action_seq = ?
ORDER BY n. Table and column names are internal constants, never user
input.
- readNewBattalionTypes: SELECT battalion_type_proto ORDER BY n, parse
each blob via BattalionTypeProto.parseFrom, convert via
BattalionTypeConverter.fromProto. The type_id column is ignored on
read — the blob is authoritative.
BUILD additions (green-only):
- changed_province_table_reader deps:
internal:changed_province_scala_proto (for parseFrom),
proto_converters:changed_province_converter (for fromProto).
- action_result_vector_tables_reader deps:
common:battalion_type_scala_proto (for parseFrom),
proto_converters:battalion_type_converter (for fromProto).
Verification: sqlite_history_test 91/91 pass; full Scala test suite
226/226.
Next: 4.5h.5b — top-level ActionResultDbReader orchestrator that uses
all the per-entity readers (plus the action_results column reads) to
assemble an ActionResultC. Parity tests assert it matches the
converter-based read.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.4 (red): action_results vector tables schema + tests
Continues 4.5h after .3 (structured-Option blob columns). Five remaining
top-level ActionResultC vectors decomposed into child tables (one row
per element), matching the per-entity table precedent from 4.5e–g:
- action_destroyed_battalion_ids (destroyedBattalionIds: Vector[BattalionId])
- action_removed_hero_ids (removedHeroIds: Vector[HeroId])
- action_removed_faction_ids (removedFactionIds: Vector[FactionId])
- action_affected_faction_ids (affectedFactionIds: Vector[FactionId])
- action_new_battalion_types (newBattalionTypes: Vector[BattalionType]
— quest-hybrid: type_id queryable +
whole battalion_type_proto BLOB)
All five share the (action_seq, n) PK pattern (n = position in the
source vector — the #6735/#6737/#6738 disambiguator). The four
primitive-ID tables index the id column for the obvious "actions
affecting entity X" lookups. BattalionType is a flat 12-field struct
that's only ever looked up by type_id, so it gets the quest-hybrid
shape (type_id column + whole battalion_type_proto BLOB) rather than
fully columnizing — consistent with how flat-but-deep entities are
handled elsewhere in this package.
All FK back to action_results(action_seq) ON DELETE CASCADE.
Unified writer (ActionResultVectorTablesWriter) with a facade
write(connection, actionSeq, ar: ActionResultT) that iterates all five
vectors internally. SqliteHistory.withNewResults calls it once per
action; backfill calls the same facade per row after fromProto. Single
source of truth, no risk of drift between live + backfill.
Read path is NOT swapped here — replayFrom still parses payload. The
rows are "shadow" until 4.5h.5 drops payload and switches the read
path.
This commit (red):
- ActionResultVectorTablesWriter stub: real schemaStatements (5 CREATE
TABLE + 5 CREATE INDEX), no-op write + no-op backfill.
- Migration v20 registered.
- Two red tests in SqliteHistoryTest, each asserting all 5 tables: live-
write via withNewResults, and backfill from an existing payload.
Shared assertVectorTablesPopulated helper checks every row of every
table.
After this lands, the only remaining 4.5h work is .5 — drop payload +
read-path swap.
Verification: sqlite_history_test 87/89 pass, 2 RED for the right reason
(tables exist per migration, no rows because stubs are no-ops →
rs.next() returns false in withRow).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.4 (green): implement action_results vector tables
Fills in the ActionResultVectorTablesWriter stub from the red commit
and wires SqliteHistory.withNewResults to call the writer's facade
once per action. v20 backfill turns from a no-op into a real per-row
decomposition of every existing payload.
Writer (ActionResultVectorTablesWriter):
- write(connection, actionSeq, ar): facade that dispatches to four
insertIdVector calls (one per primitive-ID vector, each batched) plus
insertNewBattalionTypes. Single source of truth for "what gets
inserted from a domain ActionResultT" across live + backfill.
- insertIdVector(connection, table, idColumn, actionSeq, ids): batched
INSERT into one of the four primitive-ID join tables. No-op when ids
is empty. (table/idColumn are internal constants, never user input.)
- insertNewBattalionTypes: batched INSERT into action_new_battalion_types
binding type_id + BattalionTypeConverter.toProto(bt).toByteArray.
Domain → proto happens here at the storage edge.
- backfill(connection): drains every action_results.payload (closing
the SELECT first per the xerial quirk), then per row goes through
ActionResultProtoConverter.fromProto and delegates to write.
SqliteHistory.withNewResults:
- Single new line after the existing per-vector loops:
ActionResultVectorTablesWriter.write(connection, seq, awrs.actionResult).
The facade iterates all five vectors internally.
BUILD additions (green-only):
- action_result_vector_tables_writer deps:
action_result_scala_proto (for parseFrom),
action_result_proto_converter (for fromProto in backfill),
battalion_type_converter (for toProto + transitively pulls in the
BattalionType proto for .toByteArray).
The read path is NOT touched here — replayFrom still parses payload.
The rows are "shadow" until 4.5h.5 drops payload and switches the
read path.
Verification: sqlite_history_test 89/89 pass; full Scala test suite
226/226.
Next: 4.5h.5 — drop payload BLOB, swap replayFrom to assemble from
the columns + per-entity blobs. The last 4.5h chunk.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.3 (red): action_results structured-Option blob columns schema + tests
Continues 4.5h after .2 (the nine scalar/Option columns split across .2a/.2b/.2c).
Three structured-Option top-level ActionResultC fields promoted as
nullable BLOB columns on action_results — small nested aggregates that
aren't analytics-queried at this level, so per the design doc's stopping
rule (same as ChangedProvince / newProvinces / etc.) each becomes a
single whole-entity BLOB column rather than being further decomposed:
- new_battle_proto (newBattle: Option[ShardokBattle])
- new_chronicle_entry_proto (newChronicleEntry: Option[ChronicleEntry])
- new_eagle_map_proto (newEagleMap: Option[EagleMapInfo])
No indexes — these are BLOBs whose internals aren't queried at the SQL
level; the analytical handles for "which actions started a battle /
advanced chronicle / loaded a map" come from per-entity tables and
scalar columns elsewhere on action_results.
Same shared-bindColumns-helper pattern as .2a / .2b / .2c — backfill
will go through ActionResultProtoConverter.fromProto and delegate to
bindColumns; live INSERT in withNewResults calls the same helper. Single
source of truth, no risk of drift between live + backfill.
Read path is NOT swapped here — replayFrom still parses payload. The
columns are "shadow" until the final 4.5h sub-PR drops payload and
switches the read path.
This commit (red):
- ActionResultStructuredBlobColumnsWriter stub: real schemaStatements
(3 ALTER TABLE ADD COLUMN ... BLOB), no-op backfill.
- Migration v19 registered.
- Two red tests in SqliteHistoryTest: live-write asserts the three BLOB
columns populated from the ActionResultC fields (assertions parse the
stored bytes via the per-entity converter and compare to the domain
instance — robust to serialization-order differences); backfill asserts
the same from an existing payload.
- Visibility widening: model/state/shardok_battle:shardok_battle,
model/state/chronicle_entry:chronicle_entry,
model/proto_converters/shardok_battle:shardok_battle_converter,
model/proto_converters/chronicle_entry:chronicle_entry_converter,
model/proto_converters:eagle_map_info_converter — all to add the
test (and where relevant, service) packages.
After this lands, the remaining 4.5h work is the primitive-ID vectors +
the small newBattalionTypes vector, then the payload drop + read-path
swap.
Verification: sqlite_history_test 85/87 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getBytes returns null and
parseFrom(null) throws NPE).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.3 (green): implement action_results structured-Option blob columns
Fills in the ActionResultStructuredBlobColumnsWriter stub from the red
commit and wires the live INSERT in SqliteHistory.withNewResults to call
the writer's bindColumns helper. v19 backfill turns from a no-op into a
real per-row UPDATE through the full ActionResultProtoConverter.fromProto.
Writer (ActionResultStructuredBlobColumnsWriter):
- bindColumns(ps, startPosition, ar): single source of truth for the
three blob column bindings — each is the per-entity converter's
toProto.toByteArray (or SQL NULL for None).
* new_battle_proto ← ShardokBattleConverter.toProto(...).toByteArray
* new_chronicle_entry_proto ← ChronicleEntryConverter.toProto(...).toByteArray
* new_eagle_map_proto ← EagleMapInfoConverter.toProto(...).toByteArray
- backfill(connection): drains every action_results.payload (closing
the SELECT first per the xerial quirk), then per row goes through
ActionResultProtoConverter.fromProto and delegates to bindColumns.
- bindOptBytes helper via .fold (no match-on-Option).
SqliteHistory.withNewResults:
- INSERT extended from 21 to 24 columns. The three new bindings are a
single call:
ActionResultStructuredBlobColumnsWriter.bindColumns(insertStmt,
startPosition = 22, awrs.actionResult).
BUILD additions (green-only):
- action_result_structured_blob_columns_writer deps:
action_result_scala_proto, eagle_pkg, action_result_trait,
action_result_proto_converter, eagle_map_info_converter,
chronicle_entry_converter, shardok_battle_converter.
- Widened eagle_map_info_converter visibility to include service:__pkg__
(chronicle_entry_converter and shardok_battle_converter already had
service visibility from the red commit / prior phases).
The read path is NOT touched here — replayFrom still parses payload.
These columns are "shadow" until the final 4.5h sub-PR drops payload
and switches the read path.
Verification: sqlite_history_test 87/87 pass; full Scala test suite
226/226.
Next: 4.5h.4 — primitive-ID vectors (destroyedBattalionIds,
removedHeroIds, removedFactionIds, affectedFactionIds) + the small
newBattalionTypes vector. Then 4.5h.5 = drop payload + read-path swap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.2c (red): action_results date + resolvedBattle columns schema + tests
Closes the 4.5h.2 family (enums → numerics → date+text). The last two
scalar/Option top-level ActionResultC fields:
Three nullable columns + two indexes:
- new_date_year (newDate.year)
- new_date_month (newDate.month.value, 1..12)
- resolved_battle (resolvedBattle: Option[String] — shardok game id)
The year/month pair mirrors the v1 date_year/date_month pattern already
used for the resulting state's currentDate — newDate is a distinct field
(the change-of-date event), but storing it identically keeps the table
shape uniform. A composite (new_date_year, new_date_month) index matches
the v1 composite on the resulting-state date columns; resolved_battle
is independently indexed.
Same shared-bindColumns-helper pattern as .2a / .2b — backfill goes
through ActionResultProtoConverter.fromProto, then delegates to
bindColumns; live write calls the same bindColumns from the existing
INSERT in withNewResults. Single source of truth, no risk of drift.
Read path is NOT swapped here — replayFrom still parses payload. The
columns are "shadow" until the final 4.5h sub-PR drops payload and
switches the read path.
This commit (red):
- ActionResultDateBattleColumnsWriter stub: real schemaStatements
(3 ALTER TABLE ADD COLUMN + 2 CREATE INDEX, including the composite
on (year, month)), no-op backfill.
- Migration v18 registered.
- Two red tests: live-write asserts all three columns populated; backfill
asserts the same from an existing payload.
After this lands, 4.5h.2 is complete. The remaining 4.5h work is
structured Options (newBattle / newChronicleEntry / newEagleMap),
primitive-ID vectors, the small newBattalionTypes vector, and finally
the payload drop + read-path swap.
Verification: sqlite_history_test 83/85 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getInt returns 0).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.2c (green): implement action_results date + resolvedBattle columns
Fills in the ActionResultDateBattleColumnsWriter stub from the red
commit and wires the live INSERT in SqliteHistory.withNewResults to call
the writer's bindColumns helper. v18 backfill turns from a no-op into a
real per-row UPDATE through the full ActionResultProtoConverter.fromProto.
This commit closes the 4.5h.2 family — all 9 scalar/Option top-level
ActionResultC fields are now promoted onto action_results columns. The
remaining 4.5h work is structured Options, primitive-ID vectors, and the
payload drop + read-path swap.
Writer (ActionResultDateBattleColumnsWriter):
- bindColumns(ps, startPosition, ar): single source of truth for the
three column bindings — delegates to bindOptDate (year/month pair)
and bindOptString (resolved_battle).
- backfill(connection): drains every action_results.payload, then per
row goes through ActionResultProtoConverter.fromProto and delegates to
bindColumns. Same pattern as .2a / .2b.
- bindOptDate(ps, yearIdx, monthIdx, opt): Option[Date] → (setInt year,
setInt month.value) or (setNull, setNull), via .fold.
- bindOptString(ps, idx, opt): Option[String] → setString or setNull,
via .fold.
SqliteHistory.withNewResults:
- INSERT extended from 18 to 21 columns. The three new bindings are a
single call:
ActionResultDateBattleColumnsWriter.bindColumns(insertStmt,
startPosition = 19, awrs.actionResult).
BUILD additions (green-only):
- action_result_date_battle_columns_writer deps:
action_result_scala_proto, eagle_pkg (for type aliases referenced
through ActionResultT), action_result_trait,
action_result_proto_converter, model/state/date.
The read path is NOT touched here — replayFrom still parses payload.
These columns are "shadow" until the final 4.5h sub-PR drops payload
and switches the read path.
Verification: sqlite_history_test 85/85 pass; full Scala test suite
226/226.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.2b (red): action_results numeric columns schema + tests
Continues 4.5h after .2a (enum columns). The second of the three 4.5h.2
sub-PRs (enums → numerics → date+text).
Four numeric-primitive top-level ActionResultC fields promoted as
nullable INTEGER columns + indexes:
- new_round_id (newRoundId: Option[RoundId] — RoundId = Int)
- new_victor_faction_id (newVictorFactionId: Option[FactionId] — FactionId = Int)
- game_ended (gameEnded: Option[Boolean] — stored as 0/1)
- new_random_seed (newRandomSeed: Option[Long] — SQLite INTEGER takes int64)
Note new_round_id is distinct from the v1 round_id column: the latter is
the resulting state's current round id (always set), this is the action's
change-of-round event (sparse, only set when an action advances the round).
Unlike .2a's enums, these four are direct Option-unwrap from the proto
(no UNKNOWN→None or back-compat semantics), so the GREEN backfill is
simpler — though it'll still go through ActionResultProtoConverter.fromProto
to keep the bindColumns helper as the single source of truth for "what
gets bound and from what" across live INSERT + backfill UPDATE.
The shared-bindColumns-helper pattern from .2a continues. Live-write
path is the existing INSERT INTO action_results (...) in withNewResults,
extended in GREEN to bind these four additional columns positionally via
ActionResultNumericColumnsWriter.bindColumns(insertStmt, position, ar).
Read path is NOT swapped here — replayFrom still parses payload. The
columns are "shadow" until the final 4.5h sub-PR drops payload and
switches the read path.
This commit (red):
- ActionResultNumericColumnsWriter stub: real schemaStatements
(4 ALTER TABLE ADD COLUMN + 4 CREATE INDEX), no-op backfill (no
bindColumns yet — that lands in green alongside the impl).
- Migration v17 registered.
- Two red tests in SqliteHistoryTest: live-write asserts the four
columns populated from the ActionResultC fields; backfill asserts
the four columns populated from an existing payload.
Verification: sqlite_history_test 81/83 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getInt returns 0).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.2b (green): implement action_results numeric columns
Fills in the ActionResultNumericColumnsWriter stub from the red commit
and wires the live INSERT in SqliteHistory.withNewResults to call the
writer's bindColumns helper. v17 backfill turns from a no-op into a
real per-row UPDATE through the full ActionResultProtoConverter.fromProto.
Writer (ActionResultNumericColumnsWriter):
- bindColumns(ps, startPosition, ar): single source of truth for the
four numeric-column bindings.
* new_round_id ← ar.newRoundId (Option[Int])
* new_victor_faction_id ← ar.newVictorFactionId (Option[Int])
* game_ended ← ar.gameEnded.map(_→0/1) (Option[Boolean])
* new_random_seed ← ar.newRandomSeed (Option[Long], setLong)
- backfill(connection): drains every action_results.payload (closing
the SELECT first per the xerial quirk), then issues a batched UPDATE
per row. Each row goes through ActionResultProtoConverter.fromProto
to get the domain ActionResultC, then delegates to bindColumns at
position 1. Same pattern as .2a — keeps live + backfill on a single
source of truth.
- bindOptInt / bindOptLong helpers via .fold (no match-on-Option).
SqliteHistory.withNewResults:
- INSERT extended from 14 to 18 columns. The four new bindings are a
single call: ActionResultNumericColumnsWriter.bindColumns(insertStmt,
startPosition = 15, awrs.actionResult).
BUILD additions (green-only):
- action_result_numeric_columns_writer deps: action_result_scala_proto,
eagle_pkg (for type aliases referenced through ActionResultT),
action_result_trait, action_result_proto_converter.
The read path is NOT touched here — replayFrom still parses payload.
These columns are "shadow" until the final 4.5h sub-PR drops payload
and switches the read path.
Verification: sqlite_history_test 83/83 pass; full Scala test suite
226/226.
Next in 4.5h.2: .2c — newDate (year+month columns) + resolvedBattle
(text column) — the last scalar/Option chunk before the structured
Options + primitive-ID vectors + the payload drop.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.2a (red): action_results enum columns schema + tests
Continues 4.5h after .1 (entity-ref columns). The 4.5h.2 family is the
remaining 9 scalar/Option ActionResultC fields, split per the three-group
plan: .2a = enums (this PR), .2b = numeric primitives, .2c = Date +
String.
The three enum-typed fields become four columns — GameType is a sum type
(Normal vs Tutorial(phase: TutorialPhase)) so it needs a kind discriminator
plus a wrapped TutorialPhase column. All nullable INTEGER, all indexed:
- new_round_phase (newRoundPhase: Option[RoundPhase])
- last_command_type (lastCommandTypeForActingProvince: Option[CommandType])
- new_game_type (newGameType: Option[GameType]) — kind discriminator
- new_tutorial_phase (TutorialPhase wrapped inside GameType.Tutorial;
NULL when Normal or None)
Storage value is the **proto enum number** (Converter.toProto(domain).value)
for all three — the proto enum's wire-format number is intentionally
stable across schema evolutions, unlike e.g. Scala enum .ordinal. The
domain RoundPhase.value happens to match the proto enum number, but going
through the converter for all three keeps the pattern uniform (and forces
any drift into one place — the converter).
GREEN backfill will go through the full ActionResultProtoConverter.fromProto
rather than reading proto fields directly, because the domain
lastCommandTypeForActingProvince has a back-compat fallback path to a
deprecated SelectedCommand field. The converter is the single source of
truth for "domain Option-value from this payload"; reusing it eliminates
drift risk for the cost of a one-time per-row conversion on the migration
path.
Live-write path is the existing INSERT INTO action_results (...) in
withNewResults, extended in GREEN to bind these four additional columns
positionally. Read path is NOT swapped here — replayFrom still goes
through the full converter on the payload blob; the columns are "shadow"
until the final 4.5h sub-PR drops payload and switches the read path.
This commit (red):
- ActionResultEnumColumnsWriter stub: real schemaStatements
(4 ALTER TABLE ADD COLUMN + 4 CREATE INDEX), no-op backfill.
- Migration v16 registered.
- Two red tests in SqliteHistoryTest: live-write exercises the Tutorial
branch (both new_game_type AND new_tutorial_phase populated); backfill
exercises the Normal branch (new_game_type set, new_tutorial_phase
NULL) so the two tests together cover both GameType branches.
Verification: sqlite_history_test 79/81 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getInt returns 0).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.2a (green): implement action_results enum columns
Fills in the ActionResultEnumColumnsWriter stub from the red commit and
wires the live INSERT in SqliteHistory.withNewResults to call the writer's
new bindColumns helper. v16 backfill turns from a no-op into a real
per-row UPDATE through the full ActionResultProtoConverter.fromProto.
Writer (ActionResultEnumColumnsWriter):
- bindColumns(ps, startPosition, ar): single source of truth for "what
the four enum columns get bound to from a domain ActionResultT".
Returns nothing; binds 4 positional parameters via the shared
bindOptInt helper. Used by both the live INSERT and the migration
UPDATE — eliminates drift risk between the two paths.
- backfill(connection): drains every action_results.payload (closing the
SELECT first per the xerial quirk), then issues a batched UPDATE per
row. Each row goes through the full ActionResultProtoConverter.fromProto
to get the domain ActionResultC (which handles the deprecated
SelectedCommand fallback for lastCommandTypeForActingProvince), then
delegates to bindColumns at position 1.
- GameType handling: GameType.Tutorial(phase) populates BOTH
new_game_type AND new_tutorial_phase; GameType.Normal populates only
new_game_type, leaving new_tutorial_phase NULL (rather than the proto
sentinel TUTORIAL_PHASE_UNKNOWN = 0).
SqliteHistory.withNewResults:
- INSERT extended from 10 to 14 columns. The four new bindings are a
single call: ActionResultEnumColumnsWriter.bindColumns(insertStmt,
startPosition = 11, awrs.actionResult). The 4.5h.1 entity-ref columns
stay inline (their inline-vs-helper choice can be revisited later).
BUILD additions (green-only):
- action_result_enum_columns_writer deps: tutorial_phase_scala_proto,
action_result_scala_proto, eagle_pkg (for the BattalionId etc. types
referenced through ActionResultT), action_result_trait,
action_result_proto_converter, command_type_converter,
game_type_converter, round_phase_converter.
- Widened visibility of command_type_converter and round_phase_converter
to include service:__pkg__.
The read path is NOT touched here — replayFrom still parses payload and
goes through ActionResultProtoConverter.fromProto. These columns are
"shadow" until the final 4.5h sub-PR drops payload and switches the
read path.
Verification: sqlite_history_test 81/81 pass; full Scala test suite
226/226.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.1 (red): action_results entity-ref columns schema + tests
Begins Phase 4.5h. The first chunk: four top-level entity-ref Option fields
from ActionResultC promoted onto the existing action_results table as
nullable INTEGER columns, indexed for the obvious query shapes
("show all actions by hero X"):
- acting_hero_id (domain actingHeroId / proto leader)
- acting_faction_id (domain actingFactionId / proto player)
- province_id (domain provinceId / proto province)
- province_id_acted (domain provinceIdActed / proto province_acted)
Proto field names don't match the domain — the column names follow the
domain since that's what the read path will ultimately expose.
Distinct from the per-entity table writers: there's no new table here,
and no per-row write helper. The live-write path is the existing
INSERT INTO action_results (...) statement in withNewResults, which the
green commit extends to bind these four extra columns positionally
(RED: INSERT unchanged, new columns default to NULL, so getInt returns 0
and the test fails 0 != 9).
The read path is NOT swapped here — replayFrom still parses the payload
blob and goes through ActionResultProtoConverter.fromProto. The columns
are "shadow" until the final 4.5h sub-PR drops payload and switches the
read path.
This commit (red):
- ActionResultEntityRefColumnsWriter stub: real schemaStatements
(4 ALTER TABLE ADD COLUMN + 4 CREATE INDEX), no-op backfill.
- Migration v15 registered (runs the schema; backfill is the stub no-op).
- Two red tests in SqliteHistoryTest: live-write asserts the four
columns are populated from the ActionResultC fields, and backfill
asserts the four columns are populated from an existing payload.
Verification: sqlite_history_test 77/79 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getInt returns 0). The impl
that turns them green is the next commit on this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5h.1 (green): implement action_results entity-ref columns
Fills in the ActionResultEntityRefColumnsWriter.backfill stub from the
red commit and extends the existing INSERT in SqliteHistory.withNewResults
to populate the four new columns. v15 backfill turns from a no-op into
a real one-row-per-existing-payload UPDATE.
Writer (ActionResultEntityRefColumnsWriter):
- backfill(connection): drains every action_results.payload (fully
closing the SELECT ResultSet before any UPDATE — the xerial
sqlite-jdbc quirk), then issues one batched UPDATE per row binding
leader/player/province/provinceActed from the parsed proto. The
UPDATE is unconditional: rows whose four proto fields are all unset
bind NULL × 4, identical to the post-ALTER default — keeps the logic
uniform with no extra branching.
- bindOptInt helper: Option[Int] → setInt or setNull, via .fold (no
match-on-Option).
SqliteHistory.withNewResults:
- The INSERT INTO action_results (...) statement is extended from 6
columns to 10 — adding acting_hero_id, acting_faction_id, province_id,
province_id_acted at positions 7–10. The four new bindings each use
.fold to handle the Option[Int] domain field. The existing
date_year/date_month pattern-match stays as-is (local convention).
BUILD addition (green-only):
- action_result_entity_ref_columns_writer: action_result_scala_proto
(for ActionResultProto.parseFrom in backfill).
The read path is NOT touched here — replayFrom still parses payload and
goes through ActionResultProtoConverter.fromProto. These columns are
"shadow" until the final 4.5h sub-PR drops payload and switches the
read path.
Verification: sqlite_history_test 79/79 pass; full Scala test suite
226/226.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5g.3 (red): client text visibility extensions decomposition tests + schema
Final 4.5g chunk. ClientTextVisibilityExtensionT is small and all-scalar
(textId: ClientTextId + recipientFactionIds: Vector[FactionId]) with no
sealed-hierarchy variants and no nested aggregates — so it's a **refined**
entity, fully columnized with no *_proto BLOB. Two tables, mirroring the
ChangedFaction primitive-ID-join precedent:
- action_client_text_visibility_extensions (parent): one row per
extension, (action_seq, n) PK, text_id queryable column (indexed).
- action_client_text_visibility_extension_recipients (child): one row
per (extension, faction_id), PK (action_seq, n, faction_id),
faction_id indexed. Set semantics: the reader sorts by faction_id on
read, matching the existing ChangedFaction _*_ids children.
Both FK back to action_results(action_seq) ON DELETE CASCADE directly
(matching the existing convention) — cascading from the parent action
keeps both tables in sync without an explicit parent-to-child FK.
There is no ClientTextVisibilityExtensionConverter file — conversion is
inlined in ActionResultProtoConverter. Backfill will build a
ClientTextVisibilityExtensionC directly from the proto's nested
ClientTextVisibilityExtension fields.
This commit (red):
- ClientTextVisibilityExtensionTableWriter stub: real schemaStatements
(both tables + indexes), no-op write/backfill.
- ClientTextVisibilityExtensionTableReader stub: read returns
Vector.empty.
- Migration v14 registered (runs the schema; backfill is the stub
no-op).
- Two red tests in SqliteHistoryTest: live-write/reader round-trip
preserving text_id and recipients (including an extension with empty
recipients, to exercise the no-child-rows case), and backfill from an
existing payload (asserts parent rows + recipients child rows for both
the populated and empty-recipients case).
- client_text_visibility_extension_trait visibility widened to the
service main + test packages; client_text_visibility_extension_concrete
visibility widened to the service main + test service package.
Verification: sqlite_history_test 75/77 pass, 2 RED for the right reason
(schema exists, rows absent). The impl that turns them green is the next
commit on this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5g.3 (green): implement client text visibility extensions decomposition
Fills in the ClientTextVisibilityExtensionTableWriter / Reader stubs from
the red commit and wires the write into withNewResults. v14 backfill
turns from a no-op into the real per-extension decomposition of every
existing action_results.payload.
Writer (ClientTextVisibilityExtensionTableWriter):
- write(connection, actionSeq, n, extension): one INSERT into
action_client_text_visibility_extensions (parent row with text_id),
then a batched INSERT into action_client_text_visibility_extension_recipients
(one row per faction_id). Skips the child INSERT entirely when
recipientFactionIds is empty.
- backfill(connection): reads every action_results.payload (fully
draining the SELECT ResultSet before any INSERT), parses ActionResult
proto, iterates clientTextVisibilityExtensions and builds a
ClientTextVisibilityExtensionC directly from the nested proto fields
(the same inline conversion ActionResultProtoConverter does), then
delegates to write.
Reader (ClientTextVisibilityExtensionTableReader):
- read(connection, actionSeq): two-pass — first drain parent rows
(n, text_id) ordered by n, then per-parent SELECT of the recipient
faction_ids ordered ascending (set semantics, matching
ChangedFactionTableReader's _*_ids children).
SqliteHistory:
- withNewResults: after the action_results executeBatch (so the FK
target rows exist), for each (awrs, i) iterate
awrs.actionResult.clientTextVisibilityExtensions.zipWithIndex and call
ClientTextVisibilityExtensionTableWriter.write — same loop shape as
the other per-vector writes already in place.
BUILD additions (green-only):
- client_text_visibility_extension_table_writer: action_result_scala_proto
(for ActionResultProto.parseFrom) + client_text_visibility_extension_concrete
(for the ClientTextVisibilityExtensionC constructor in backfill).
- client_text_visibility_extension_table_reader:
client_text_visibility_extension_concrete (same — assembling each
read row).
Verification: sqlite_history_test 77/77 pass; full Scala test suite
226/226.
This closes Phase 4.5g. Next is 4.5h: top-level ActionResultC scalar
columns on action_results + drop the payload BLOB, swapping the read
path to scalar columns + per-entity blobs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5g.2 (red): generated text requests decomposition tests + schema
Continues Phase 4.5g. After notifications (4.5g.1), this is the second
chunk: the newGeneratedTextRequests "new entity" vector.
GeneratedTextRequestT is a sealed trait (FixedHeroName, GeneratedHeroName,
and the ~45-case LlmRequestT enum), so per the quest-hybrid stopping rule
each request is stored as a whole-entity generated_text_request_proto BLOB
plus a queryable `kind` discriminator column — the same shape as
notifications, minus the notification-only `deferred` column (a
GeneratedTextRequest carries no field beside the proto).
One table, action_new_generated_text_requests (there is no "removed"
counterpart): (action_seq, n, kind, generated_text_request_proto), PK
(action_seq, n), kind indexed.
A wrinkle worth noting: the production read path
(ActionResultProtoConverter.fromProto) deliberately drops
newGeneratedTextRequests ("not used by the applier"), so the proto blob in
action_results.payload is the only place old requests survive. Backfill
therefore reads the proto's repeated new_generated_text_requests field
directly, and the decomposed table becomes the first relational home for
this data.
This commit (red):
- GeneratedTextRequestTableWriter stub: real schemaStatements (table +
kind index), no-op write/backfill.
- GeneratedTextRequestTableReader stub: read returns Vector.empty.
- Migration v13 registered (runs the schema; backfill is the stub no-op).
- Two red tests in SqliteHistoryTest: newGeneratedTextRequests round-trip
preserving kind + order via withNewResults, and backfill from an
existing payload (asserting the kind discriminator in n order).
The converter and domain-type targets were already visible to the service
and test packages, so no visibility widening was needed.
Verification: sqlite_history_test 73/75 pass, 2 RED for the right reason
(schema exists, rows absent). The impl that turns them green is the next
commit on this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5g.2 (green): implement generated text requests decomposition
Fills in the GeneratedTextRequestTableWriter / Reader stubs from the red
commit and wires the write into withNewResults. v13 backfill turns from a
no-op into the real per-request decomposition of every existing
action_results.payload.
Writer (GeneratedTextRequestTableWriter):
- write(connection, actionSeq, n, request): INSERT one row with
(action_seq, n, kind, generated_text_request_proto). Converts the
domain request to proto exactly at the storage edge via
GeneratedTextRequestConverter.toProto.
- kindOf(request): productPrefix-based discriminator. Every subtype —
FixedHeroName, GeneratedHeroName, and each LlmRequestT enum case — is
a Product, so the compiler proves the match exhaustive (no fallback,
which would be unreachable under -Werror).
- backfill(connection): reads every action_results.payload (fully
draining the SELECT ResultSet before any INSERT, the xerial
sqlite-jdbc quirk), parses ActionResult proto, then iterates the proto's
repeated new_generated_text_requests field directly — because
ActionResultProtoConverter.fromProto deliberately drops this vector
("not used by the applier"), the blob is the only place these
requests survive. Each is converted to the domain type via
GeneratedTextRequestConverter.fromProto and delegated to write.
Reader (GeneratedTextRequestTableReader):
- read(connection, actionSeq): SELECT generated_text_request_proto in n
order, parse each blob, convert via fromProto.
SqliteHistory:
- withNewResults: after the action_results executeBatch (so the FK target
rows exist), for each (awrs, i) iterate
awrs.actionResult.newGeneratedTextRequests.zipWithIndex and call
GeneratedTextRequestTableWriter.write — same loop shape as the other
per-vector writes already in place.
BUILD additions (green-only):
- generated_text_request_table_writer: action_result_scala_proto (for
ActionResultProto.parseFrom) + generated_text_request_converter (for
toProto / fromProto at the storage edge).
- generated_text_request_table_reader: llm_request_scala_proto (for
GeneratedTextRequestProto.parseFrom — the target name is historical
baggage; the proto is generated_text_request.proto) +
generated_text_request_converter.
Verification: sqlite_history_test 75/75 pass; full Scala test suite
225/225.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5g.1 (red): notifications decomposition tests + schema
Starts Phase 4.5g — notifications + generated text requests. This is the
first chunk: the newNotifications / removedNotifications vectors.
NotificationT's details is a ~45-subtype sealed hierarchy, so per the
quest-hybrid stopping rule each notification is stored as a whole-entity
notification_proto BLOB plus a queryable `kind` discriminator column.
Two wrinkles unique to notifications:
- deferred is NOT in the proto. NotificationConverter.toProto returns
(NotificationProto, Boolean) — the Boolean is deferred, carried
beside the message. So deferred is its own column.
- the proto representation of newNotifications is lossy: it splits into
a single new_deferred_notification + a repeated notifications_to_deliver,
so multiple deferred new notifications and original order across the
split don't survive a proto round-trip. The decomposed tables store
the domain vector faithfully — one row per element, in n order, each
with its own deferred — which is strictly better than the proto.
Both action_new_notifications and action_removed_notifications share the
shape (action_seq, n, kind, deferred, notification_proto), PK
(action_seq, n), kind indexed.
This commit (red):
- NotificationTableWriter stub: real schemaStatements (both tables +
kind indexes), no-op writeNew/writeRemoved/backfill.
- NotificationTableReader stub: readNew/readRemoved return Vector.empty.
- Migration v12 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: newNotifications round-trip
preserving deferred + order (the proto-can't-do-this case),
removedNotifications round-trip, and backfill from an existing payload
(asserting the kind discriminator).
- notification_trait / notification_concrete / notification_converter
visibility widened to the service main and/or test packages.
Verification: sqlite_history_test 70/73 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5g.1 (green): implement notifications decomposition
Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.
- NotificationTableWriter.writeNew / writeRemoved: INSERT one row per
notification at (action_seq, n) — kind discriminator + deferred column
+ the Notification proto bytes. kind = details.productPrefix (clean
subtype name; the compiler proves the Product case exhaustive over the
sealed NotificationDetails hierarchy, so no reflection and no
45-case map). deferred comes from NotificationConverter.toProto's
second tuple element (it lives beside the proto, not in it).
- NotificationTableWriter.backfill: for every existing payload,
reconstruct newNotifications exactly as ActionResultProtoConverter
does (single new_deferred_notification ++ notifications_to_deliver),
plus removedNotifications; write each. Reads all payloads before any
INSERT (closes the SELECT ResultSet first).
- NotificationTableReader.readNew / readRemoved: inverse — parse each
blob and combine with the deferred column via
NotificationConverter.fromProto(proto, deferred), ordered by n.
- SqliteHistory.withNewResults: live-write loop now also writes
newNotifications + removedNotifications.
- BUILD: writer/reader gain action_result_scala_proto /
action_result_notification_details_scala_proto + notification_converter
deps.
Verification: sqlite_history_test 73/73 pass; full //src/test/scala/...
225/225 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5f.4 (red): newProvinces decomposition tests + schema
Last and deepest of the "new entity" vectors (after newBattalions
4.5f.1, newFactions 4.5f.2, newHeroes 4.5f.3): newProvinces. A
ProvinceT creation snapshot transitively embeds armies, units, heroes,
battalions, events, deferred changes — exactly the nesting 4.5b's
ChangedProvince kept as a proto blob by the same stopping rule. So it
takes the whole-entity province_proto BLOB shape + a queryable
province_id index.
action_new_provinces is a brand-new table, distinct from 4.5b's
action_changed_provinces (deltas). PK is (action_seq, n) from the
start — the disambiguator the #6735/#6737/#6738 outages taught us — so
multiple new provinces in one action all land; province_id is indexed
but non-unique.
This commit (red):
- NewProvinceTableWriter stub: real schemaStatements (CREATE the
(action_seq, n) table + index), no-op write and backfill.
- NewProvinceTableReader stub: returns Vector.empty.
- Migration v11 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: live write + reader round-trip,
two new provinces preserved in n order, and backfill from an existing
payload.
- ProvinceC concrete visibility widened to the service test package
(ProvinceT and ProvinceConverter are already //visibility:public).
Verification: sqlite_history_test 67/70 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5f.4 (green): implement newProvinces decomposition
Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring. Completes Phase 4.5f
(all four "new entity" vectors decomposed).
- NewProvinceTableWriter.write: INSERT one row per new province at
(action_seq, n) — province_id column (denormalized, indexed) plus the
Province proto bytes in province_proto. Domain type in;
ProvinceConverter.toProto only at the blob storage edge.
- NewProvinceTableWriter.backfill: decompose every newProvinces entry in
every existing action_results.payload. Reads all payloads before any
INSERT (closes the SELECT ResultSet first); the proto field is a
Province per entry, converted via fromProto before write.
- NewProvinceTableReader.read: inverse — parse each blob back to a
ProvinceT, ordered by n. (Round-trip test confirms ProvinceConverter
is lossless for the test province.)
- SqliteHistory.withNewResults: live-write loop now also writes
newProvinces (same n-disambiguator pattern as the other new-entity
vectors).
- BUILD: writer/reader gain action_result_scala_proto /
province_scala_proto + province converter deps.
Verification: sqlite_history_test 70/70 pass; full //src/test/scala/...
225/225 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5f.3 (red): newHeroes decomposition tests + schema
Third of the "new entity" vectors (after newBattalions 4.5f.1 and
newFactions 4.5f.2): newHeroes. Full HeroT creation snapshots (no
Changed* wrapper), embedding backstory versions, backstory events, and
faction biases — a deep entity, so it takes the whole-entity hero_proto
BLOB shape + a queryable hero_id index.
action_new_heroes is a brand-new table (the "new entity" vectors had no
v2 scaffolding). PK is (action_seq, n) from the start — the
disambiguator the #6735/#6737/#6738 outages taught us — so multiple new
heroes in one action all land; hero_id is indexed but non-unique.
This commit (red):
- NewHeroTableWriter stub: real schemaStatements (CREATE the
(action_seq, n) table + index), no-op write and backfill.
- NewHeroTableReader stub: returns Vector.empty.
- Migration v10 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: live write + reader round-trip,
two new heroes preserved in n order, and backfill from an existing
payload.
- hero converter target visibility widened to the service main and test
packages (HeroT and HeroC are already visible to service).
Verification: sqlite_history_test 64/67 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5f.3 (green): implement newHeroes decomposition
Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.
- NewHeroTableWriter.write: INSERT one row per new hero at (action_seq,
n) — hero_id column (denormalized, indexed) plus the Hero proto bytes
in hero_proto. Domain type in; HeroConverter.toProto only at the blob
storage edge.
- NewHeroTableWriter.backfill: decompose every newHeroes entry in every
existing action_results.payload. Reads all payloads before any INSERT
(closes the SELECT ResultSet first); the proto field is a Hero per
entry, converted via fromProto before write.
- NewHeroTableReader.read: inverse — parse each blob back to a HeroT,
ordered by n. (Round-trip test confirms HeroConverter is lossless over
all HeroC fields.)
- SqliteHistory.withNewResults: live-write loop now also writes
newHeroes (same n-disambiguator pattern as newBattalions/newFactions).
- BUILD: writer/reader gain action_result_scala_proto / hero_scala_proto
+ hero converter deps.
Verification: sqlite_history_test 67/67 pass; full //src/test/scala/...
225/225 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5f.2 (red): newFactions decomposition tests + schema
Second of the "new entity" vectors (after newBattalions, 4.5f.1):
newFactions. Full FactionT creation snapshots (no Changed* wrapper),
which transitively embed diplomacy offers, faction relationships, and
reconned province views — a deep entity, so it takes the whole-entity
faction_proto BLOB shape + a queryable faction_id index.
action_new_factions is a brand-new table (the "new entity" vectors had
no v2 scaffolding). PK is (action_seq, n) from the start — the
disambiguator the #6735/#6737/#6738 outages taught us — so multiple new
factions in one action all land; faction_id is indexed but non-unique.
This commit (red):
- NewFactionTableWriter stub: real schemaStatements (CREATE the
(action_seq, n) table + index), no-op write and backfill.
- NewFactionTableReader stub: returns Vector.empty.
- Migration v9 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: live write + reader round-trip,
two new factions preserved in n order, and backfill from an existing
payload.
- faction (FactionT) / faction/concrete (FactionC) / faction converter
target visibility widened to the service main and/or test packages.
Verification: sqlite_history_test 61/64 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5f.2 (green): implement newFactions decomposition
Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.
- NewFactionTableWriter.write: INSERT one row per new faction at
(action_seq, n) — faction_id column (denormalized, indexed) plus the
Faction proto bytes in faction_proto. Domain type in;
FactionConverter.toProto only at the blob storage edge.
- NewFactionTableWriter.backfill: decompose every newFactions entry in
every existing action_results.payload. Reads all payloads before any
INSERT (closes the SELECT ResultSet first); the proto field is a
Faction per entry, converted via fromProto before write.
- NewFactionTableReader.read: inverse — parse each blob back to a
FactionT, ordered by n.
- SqliteHistory.withNewResults: live-write loop now also writes
newFactions (same n-disambiguator pattern as newBattalions).
- BUILD: writer/reader gain action_result_scala_proto /
faction_scala_proto + faction converter deps.
Verification: sqlite_history_test 64/64 pass; full //src/test/scala/...
225/225 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5f.1 (red): newBattalions decomposition tests + schema
Starts Phase 4.5f — decomposing the "new entity" vectors
(newProvinces/newHeroes/newFactions/newBattalions) out of the
ActionResult payload blob. Per the session cadence, 4.5f is split into
one PR per vector; this is the first, newBattalions — the lowest-risk
because it reuses BattalionConverter and the deep-blob pattern just
landed for ChangedBattalion (4.5e), establishing the action_new_*
table convention.
newBattalions holds full BattalionT creation snapshots (no Changed*
wrapper). It's a deep entity: one whole-entity battalion_proto BLOB
(the Battalion proto bytes) + a queryable battalion_id index.
action_new_battalions is a brand-new table (the "new entity" vectors
had no v2 scaffolding). Its PK is (action_seq, n) from the start — the
disambiguator the #6735/#6737/#6738 outages taught us to use — so
multiple new battalions in one action all land; battalion_id is indexed
but non-unique.
This commit (red):
- NewBattalionTableWriter stub: real schemaStatements (CREATE the
(action_seq, n) table + index), no-op write and backfill.
- NewBattalionTableReader stub: returns Vector.empty.
- Migration v8 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: live write + reader round-trip,
two new battalions preserved in n order, and backfill from an existing
payload.
- battalion (BattalionT) target visibility widened to the service main
package.
Verification: sqlite_history_test 58/61 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5f.1 (green): implement newBattalions decomposition
Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.
- NewBattalionTableWriter.write: INSERT one row per new battalion at
(action_seq, n) — battalion_id column (denormalized, indexed) plus the
Battalion proto bytes in battalion_proto. Domain type in;
BattalionConverter.toProto only at the blob storage edge.
- NewBattalionTableWriter.backfill: decompose every newBattalions entry
in every existing action_results.payload. Reads all payloads before
any INSERT (closes the SELECT ResultSet first); the proto field is a
Battalion per entry, converted via fromProto before write.
- NewBattalionTableReader.read: inverse — parse each blob back to a
BattalionT, ordered by n.
- SqliteHistory.withNewResults: live-write loop now also writes
newBattalions (same n-disambiguator pattern as the changed-* family).
- BUILD: writer/reader gain action_result_scala_proto /
battalion_scala_proto + battalion_converter deps.
Verification: sqlite_history_test 61/61 pass; full //src/test/scala/...
225/225 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5e (red): ChangedBattalion decomposition tests + schema
Sets up Phase 4.5e — decomposing ChangedBattalion. It's a deep entity
(ChangedBattalionC's sole field is the contained BattalionT), so it
takes the whole-entity blob shape like ChangedProvince: one
changed_battalion_proto BLOB (the Battalion proto bytes) plus a
queryable battalion_id index.
Critically, the v2 action_changed_battalions scaffolding (from 4.5a)
carried the same latent (action_seq, battalion_id) PK bug that caused
the #6735/#6737/#6738 outages for the other entities. v7 fixes it up
front: PK is (action_seq, n), battalion_id is indexed but non-unique,
so two ChangedBattalions for the same battalion in one action both
land.
This commit (red):
- ChangedBattalionTableWriter stub: real schemaStatements (DROP the
v2 shape + CREATE the (action_seq, n) table + index), no-op write
and backfill.
- ChangedBattalionTableReader stub: returns Vector.empty.
- Migration v7 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest:
- live write + reader round-trip of a domain ChangedBattalionC.
- two ChangedBattalions with the same battalion_id preserved in n
order (the dup-entity case).
- backfill from an existing payload (the backfill-on-load path that
crashed the other entities in prod).
- changed_battalion_concrete visibility widened to the service main +
test packages; battalion_converter / battalion_type_id /
battalion/concrete widened to the service test package for the
fixtures.
All three battalion tests are RED on the stubs with clean assertion
mismatches (schema exists, rows absent). The impl that turns them
green is the next commit on the same branch.
Verification: sqlite_history_test 55/58 pass, 3 RED for the right
reason.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5e (green): implement ChangedBattalion decomposition
Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.
- ChangedBattalionTableWriter.write: INSERT one row per ChangedBattalion
at (action_seq, n) — battalion_id column (denormalized, indexed) plus
the Battalion proto bytes in changed_battalion_proto. Domain type in;
BattalionConverter.toProto only at the blob storage edge.
- ChangedBattalionTableWriter.backfill: decompose every ChangedBattalion
in every existing action_results.payload. Reads all payloads before any
INSERT (closes the SELECT ResultSet first — xerial dislikes interleaving
writes with an open ResultSet); the proto field is a Battalion per entry,
converted via fromProto and wrapped in ChangedBattalionC before write.
- ChangedBattalionTableReader.read: inverse — parse each blob back to a
ChangedBattalionC, ordered by n.
- SqliteHistory.withNewResults: live-write loop now also writes
changedBattalions (same n-disambiguator pattern as ChangedHero /
ChangedFaction).
- BUILD: writer/reader gain action_result_scala_proto / battalion_scala_proto
+ battalion_converter deps; battalion_converter visibility widened to the
service main package.
Verification: sqlite_history_test 58/58 pass; full //src/test/scala/...
225/225 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Fast-follow to #6739, which shipped ChangedFactionTableReader with
round-trip tests covering only Tier-1 scalars, a primitive-ID join
field, the empty case, and the same-faction_id n disambiguation. The
small-struct and Tier-3 leaf-blob decoders were left untested (deferred
because the reader has no production consumer yet). Adding that coverage
now per request.
New test "round-trip the small-struct and Tier-3 leaf-blob decoders"
builds a ChangedFactionC exercising every remaining decoder and asserts
the reader reconstructs it exactly:
- PrestigeModifier (new + removed): kind TEXT <-> Type enum
(Festival / SuppressedBeasts / TutorialHandicap), ordered by i.
- FactionRelationship: relationship_level TEXT <-> RelationshipLevel
(Ally / Hostile), nullable reset_date year/month <-> Option[Date]
(one Some, one None).
- TrustLevelUpdate: explicit columns, two entries ordered by i.
- DiplomacyOffer (AllianceOffer): Tier-3 leaf blob round-tripped via
DiplomacyOfferConverter.
- ProvinceView: Tier-3 leaf blob round-tripped via ProvinceViewConverter.
- One OutgoingTruceOfferFactionId for the wrapper path.
Test-only change plus three additive visibility entries exposing the
model targets the fixture constructs to the service test package
(state/faction:prestige_modifier, state/faction:faction_relationship,
state/diplomacy_offer:diplomacy_offer); view/province was already
test-visible. No production logic change.
Verification:
- sqlite_history_test - 55/55 pass.
- //src/test/scala/... - 225/225 pass.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5d.2 (red): ChangedFactionTableReader round-trip tests
Adds the failing tests for the 4.5d.2 read path — the inverse of
ChangedFactionTableWriter, reconstructing ChangedFactionT for an
action from the v5 decomposed tables (parent + 15 child tables).
Parallels ChangedHeroTableReader (#6733). Nothing in production
consumes it yet; it verifies the v5 columns round-trip and seeds
4.5h.
Three test cases:
- Round-trip a ChangedFactionC (Tier-1 scalars + a primitive-ID
join field) through withNewResults and assert the reader returns
it. Focused fixture — the writer's per-table-type behavior is
covered in detail by the v5 backfill tests; the reader test only
needs to prove it inverts the writer end-to-end.
- Return Vector.empty for an action with no ChangedFactions (vacuous
on the stub; kept for the green-side guarantee).
- Preserve two ChangedFactions with the same faction_id in their
original (n) order — exercises the (action_seq, n) PK from the
#6737 hotfix.
Reader returns rows in `n` order (not faction_id), matching the
post-hotfix ChangedHeroTableReader contract. Stub returns
Vector.empty so the two non-vacuous cases are RED with clean
assertion mismatches. The impl that turns them green is the next
commit on the same branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5d.2: implement ChangedFactionTableReader (turns red tests green)
Fills in the stub. Inverse of ChangedFactionTableWriter:
- Pass 1: SELECT the parent rows from action_changed_factions
WHERE action_seq = ? ORDER BY n, decoding Tier-1 scalars into a
ChangedFactionC with empty Vector fields.
- Pass 2: for each parent (keyed by its n), read the 15 child tables
filtered by (action_seq, n) and .copy() the Vector fields in:
- 9 primitive-ID join tables → Vector[Int] (sorted by id; these
are set-semantics fields with no `i`, so original order isn't
preserved — equivalent for the applier). The four Outgoing*
ones wrap the ids back in their type-safety case classes.
- 4 small-struct tables ordered by i: PrestigeModifier (kind
string → Type via prestigeTypeFromString), FactionRelationship
(relationship_level string → RelationshipLevel, nullable
reset_date year/month → Option[Date]), TrustLevelUpdate.
- 2 Tier-3 leaf-blob tables ordered by i: parse the proto bytes
and convert via DiplomacyOfferConverter.fromProto /
ProvinceViewConverter.fromProto.
Returns ChangedFactionT in n order, matching the post-#6737
ChangedHeroTableReader contract; multiple entries with the same
faction_id come back in their original positions.
Test coverage scope: the three reader tests cover Tier-1 scalars, a
primitive-ID join field, the empty case, and the same-faction_id n
disambiguation. The small-struct and leaf-blob decoders are not
round-tripped here — exercising them needs Scala model imports
(PrestigeModifier / FactionRelationship / DiplomacyOffer / ProvinceView)
and test-package visibility widenings on four model targets. Deferred
because the reader has no production consumer yet (4.5h will wire it up
and bring its own broader coverage); the writer side that those
columns come from is already validated end-to-end by the v5 backfill
test. A reader decoder bug therefore can't cause a live outage today.
Verification:
- sqlite_history_test - 54/54 pass, including the 3 reader tests.
- //src/test/scala/... - 225/225 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
- Replace deprecated FindObjectsByType(FindObjectsSortMode) and
FindFirstObjectByType with non-deprecated overloads.
- Replace deprecated GetInstanceID() with GetEntityId() in three
files that use the value as a dictionary key or for equality.
- Prefix fire-and-forget async calls with `_ =` to silence CS4014
in ConnectionHandler, CustomBattleHandler, ErrorHandler,
ShardokGameModel, and ResourceFetcher.
- Remove the unused `listen` field from ConnectionHandler.
- Suppress CS0162 around tutorial content intentionally preserved
after an unconditional return, and CS0067 on
SingleInstanceEnforcer.OnDeepLinkReceived (declared cross-platform
but only invoked on UNITY_STANDALONE_WIN).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Same shape as the ChangedProvince hotfix in #6735 (PR title "Hotfix:
v3 backfill crash on duplicate-province ChangedProvinces"), applied
preemptively to the other two decomposed entity tables.
The crash:
!!! CRITICAL: Failed to load game 2798739292a60a5f:
SQLITE_CONSTRAINT_PRIMARYKEY ... action_changed_factions.action_seq,
action_changed_factions.faction_id
at ChangedFactionTableWriter.writeParentRow
at ChangedFactionTableWriter.backfill
at SqliteHistory$Migrations$.applyV5ChangedFactionDecomposition
The root cause is identical to the ChangedProvince case I'd just
hot-fixed. The v2 / v4 / v5 schemas for action_changed_heroes and
action_changed_factions used (action_seq, entity_id) as the PK,
assuming at most one Changed<Entity> per entity per action. That
invariant doesn't hold: ActionResultApplierImpl folds through the
changed<Entity> Vector sequentially and applies each entry as its
own delta, so two entries for the same entity_id are two valid
sequential deltas, both applied.
I failed to apply the ChangedProvince lesson when fixing the analogous
ChangedFaction crash, and the structurally-identical bug was sitting
in ChangedHero too (no production game has hit it yet, but it's the
same landmine — fixing preemptively in this PR per Dan's request).
Fix shape (mirrors the ChangedProvince hotfix):
- Parent table PK becomes (action_seq, n), where n is the position
within the source ActionResult's changed<Entity> vector. entity_id
becomes an indexed column but no longer unique.
- write() takes n as a parameter; backfill iterates with
changed<Entity>.zipWithIndex and passes n.
- SqliteHistory.withNewResults call sites updated to use
zipWithIndex for both entities.
- Schema rewrite is via DROP + CREATE in the same migration
(applyV4ChangedHeroColumns and applyV5ChangedFactionDecomposition
edited in place, same justification as the ChangedProvince hotfix:
affected games never successfully ran these migrations, so editing
isn't retroactively rewriting their history).
- For ChangedFaction's 15 child tables (4.5d.1): all gain n column,
FK on (action_seq, n) → parent, keep faction_id denormalized for
query convenience. Child tables that had their own within-vector
index (renamed from n to i to avoid colliding with the parent's
n). PK becomes (action_seq, n, i) for indexed children or
(action_seq, n, <id>) for join tables.
- For ChangedHero's events child (4.5c.1): same treatment — add n
for parent FK, rename internal n → i, keep hero_id denormalized,
PK (action_seq, n, i).
- ChangedHeroTableReader updated to order by n (preserves the source
changedHeroes vector order — multiple entries with the same
hero_id come back in their original positions) and to filter
events by (action_seq, n) so same-hero_id entries get their own
event sets.
Tests:
- Existing tests that query "WHERE entity_id = X" on parent / child
rows still work (entity_id remains a column, denormalized on
children; single-row fixtures still match uniquely).
- Updated v4 backfill test's events ORDER BY n → ORDER BY i.
- Updated ChangedHeroTableReader "ordered by hero_id" test → now
asserts insertion order (by n) instead of sorted-by-hero_id.
- Added two regression tests:
- ChangedHero: two ChangedHeroes with the same hero_id in one
action round-trip via the reader in their original order.
- ChangedFaction: two ChangedFactions with the same faction_id
in one action both land in the parent + child tables at n=0
and n=1.
Out of scope: games on other deployments that successfully ran the
old (buggy) v4/v5 (because their history lacked duplicate-entity
actions) keep the old PK shape. They're not currently exercising
the bug. A separate v6 migration for those can land when 4.5h needs
the decomposed read path; same trade-off as the ChangedProvince
hotfix.
Verification:
- sqlite_history_test - 50/50 pass, including both new regressions
and the updated reader ordering test.
- //src/test/scala/... - 225/225 pass. No regressions.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5d.2 (red): live ChangedFaction decomposition test
Adds a failing test for the 4.5d.2 live-write contract. 4.5d.1
(#6734) added v5 schema + backfill but left SqliteHistory.with-
NewResults unchanged — it only writes the action_results payload
blob and (since 4.5c.2) ChangedHero. On a freshly-created game the
v5 backfill is a no-op, so action_changed_factions and its 15
child tables stay empty forever. That gap is what 4.5d.2 closes.
The new test builds a Scala ChangedFactionC directly (factionId 7,
newFactionHeadHeroId 11, newName "foo", newLeaderHeroIds [10, 20]),
hands it through withNewResults on a fresh DB, and asserts the
parent row plus the new_leader_hero_ids join table populated.
Scope: parent row + one join-table assertion. The writer's full
per-table behavior is already exercised by the v5 backfill tests
landed in 4.5d.1; the live-path test only needs to prove
withNewResults invokes ChangedFactionTableWriter.write at all. RED
on the current code with a clean assertion mismatch (false was not
equal to true at withRow's rs.next() — no parent row exists).
Adds changed_faction_concrete to the test BUILD deps (for the
Scala ChangedFactionC import). The impl that turns this green is
the next commit on the same branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5d.2: wire ChangedFactionTableWriter into withNewResults
Turns the red test from the previous commit green. The existing
ChangedHero decomposition loop in withNewResults now also iterates
awrs.actionResult.changedFactions and calls ChangedFactionTableWriter.
write for each. Same shape as the ChangedHero live-path loop landed
in #6730. Sits inside the same transaction, after the action_results
executeBatch (FK target rows must exist), so a failure rolls back the
action_results inserts too.
Updates the comment to reflect both 4.5c.2 (hero) and 4.5d.2 (faction)
as the live write paths sharing this loop.
Verification:
- sqlite_history_test - 48/48 pass, including the previously-red
4.5d.2 live write path test.
- //src/test/scala/... - 225/225 pass. No regressions on the critical
write path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5d.1 (red): ChangedFaction schema v5 + backfill red tests
Sets up the refined per-field decomposition of ChangedFaction (Decisions
#2 in the design doc: classified `refined`, no whole-entity blob).
ChangedFactionC has 15 non-scalar Vector fields. Per the design doc's
refined-option-B tiers, each gets a child table sized to its shape:
- 9 primitive-ID join tables for Vector[HeroId/FactionId/ProvinceId]
and the four Outgoing*OfferFactionId wrappers. Keyed by
(action_seq, faction_id, value) so the join carries set semantics
with no n column.
- 4 small-struct tables for PrestigeModifier (new + removed),
FactionRelationship (changed), and TrustLevelUpdate. Explicit
columns + n for ordering.
- 2 Tier-3 leaf-blob tables for DiplomacyOffer (sealed with 5
subtypes including the deeper RansomOffer) and ProvinceView (deep
aggregate with FullProvinceInfo plus nested vectors). Leaf-blob
avoids re-incurring the child-table explosion option B exists to
prevent.
All 15 tables FK (action_seq, faction_id) into the existing v2
action_changed_factions parent table ON DELETE CASCADE. Tier-1
scalars stay on that parent table (v2). Backfill populates parent
row + every applicable child row from each ChangedFaction in each
existing action_results.payload.
This commit (red):
- Writer stub (ChangedFactionTableWriter) with the real 15-table
schemaStatements (the contract under review) and no-op write +
backfill methods.
- Migration v5 hooked into SqliteHistory.Migrations.
- Two red tests in SqliteHistoryTest. The first exercises a fully-
populated ChangedFaction through seedV1WithPayloads + load and
asserts the parent row and every child table got the expected
rows. The second asserts a minimal ChangedFaction leaves child
tables empty but still emits a parent row. Both RED on the stub:
schema exists (no "no such table" errors) but rows are absent, so
the assertions fail cleanly.
- changed_faction_concrete visibility widened to the production
service package (and the service test package) so the writer can
consume ChangedFactionT. Same precedent as for ChangedHero in
#6730.
The impl commit (next, same branch) fills in `write` and `backfill`
to turn the tests green.
Verification:
- sqlite_history_test - 44/46 pass, 2 RED for the right reason
(clean assertion mismatches at the row-count and column checks,
not exceptions or compile errors).
- //src/test/scala/... - 224/225 targets green elsewhere. No
regressions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5d.1: implement ChangedFactionTableWriter (turns red tests green)
Fills in write + backfill so the two v5 backfill tests from the
previous commit pass.
write(connection, actionSeq, ChangedFactionT) pattern-matches to
ChangedFactionC and fans out:
- Parent row: INSERT into v2 action_changed_factions with the
Tier-1 scalar / entity-reference columns.
- 9 primitive-ID join tables via a shared insertIds helper:
(action_seq, faction_id, <id>) batched inserts. Covers
newLeaderHeroIds, removedLeaderHeroIds,
removedFactionRelationshipFactionIds,
removedIncomingDiplomacyOfferFactionIds,
removedReconnedProvinceIds, and the four
newOutgoing{Truce,Alliance,Invitation,Ransom}OfferFactionIds
(mapped through .fid to unwrap the type-safety wrappers).
- 4 small-struct tables: PrestigeModifier kind column uses
prestigeModifierType.toString (matches case-object names like
"Festival", "SuppressedBeasts"); FactionRelationship's reset_date
becomes a year/month nullable pair, relationship_level uses
toString of the sealed RelationshipLevel.
- 2 Tier-3 leaf-blob tables: DiplomacyOffer and ProvinceView convert
per-element via DiplomacyOfferConverter.toProto(...).toByteArray
and ProvinceViewConverter.toProto(...).toByteArray respectively,
exactly at the storage edge.
backfill parses each existing payload, converts each ChangedFaction
proto to the domain type via ChangedFactionConverter.fromProto, then
delegates to the shared write path - one code path for live and
backfill.
Test fixture fix:
- DiplomacyOfferProto in the v5 backfill fixture now sets both
offer_details (AllianceOfferDetails) AND status (UNRESOLVED). Real
payloads have both; the converter rejects "Empty offer details"
and "Unknown diplomacy offer status".
BUILD:
- changed_faction_table_writer gains deps on the model concrete /
converter / state targets it now references: action_result_-
concrete:changed_faction_concrete, proto_converters:changed_-
faction_converter, proto_converters/diplomacy_offer,
proto_converters/view/province:province_view, state/diplomacy_-
offer, state/faction:faction_relationship + :prestige_modifier,
state/date, view/province:province_view, internal:action_result_-
scala_proto.
- 5 visibility widenings on production targets to expose them to
//src/main/scala/.../service:__pkg__: changed_hero_concrete's
precedent, applied here to changed_faction_concrete,
proto_converters:changed_faction_converter, proto_converters/-
diplomacy_offer:diplomacy_offer, state/diplomacy_offer:diplomacy_-
offer, state/faction:faction_relationship + :prestige_modifier.
- Test BUILD gains diplomacy_offer_status_scala_proto for the
fixture status field.
Verification:
- sqlite_history_test - 46/46 pass, including both v5 backfill
tests.
- //src/test/scala/... - 225/225 pass. No regressions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Production games failed to load with SQLITE_CONSTRAINT_PRIMARYKEY
during the v3 ChangedProvince migration:
UNIQUE constraint failed: action_changed_provinces.action_seq,
action_changed_provinces.province_id
The v2 schema PK on action_changed_provinces was (action_seq,
province_id), which assumes at most one ChangedProvince per province
per action. That invariant doesn't hold: ActionResultApplierImpl folds
through result.changedProvinces sequentially and applies each as a
delta, so two entries for the same province in one action are two
valid sequential deltas, both applied. The v3 backfill iterated
.changedProvinces.foreach and INSERTed each, hitting the PK constraint
on the second entry. Confirmed in production logs for games
cf27e3e7... and 2798739...
Fix: change v3 to drop the v2 action_changed_provinces table and
recreate it with PK (action_seq, n), where n is the position within
the source ActionResult's changedProvinces vector. province_id stays
as an indexed column but no longer constrains uniqueness. Two
ChangedProvinces with the same province_id now land as two separate
rows with different n values.
Why edit v3 instead of adding a new migration:
- The games crashing are at schema_version less than 3 (or sitting at
v2 with v3 never run). v3 itself crashes before any new migration
could fix things. Editing v3 is the only path that lets affected
games load.
- The CLAUDE.md "migrations are append-only" rule exists to prevent
silently changing semantics for games that have already migrated.
Here, the affected games never successfully ran v3, so editing it
isn't retroactively rewriting their history.
- Nothing reads action_changed_provinces yet (4.5h hasn't shipped),
so dropping and recreating the table is safe. v2 created it empty
for crashing games; the drop is a no-op there.
- For games on other deployments that did successfully run the old
buggy v3 (no duplicate-province actions in their history), their
schema stays with the old shape. They're not currently exercising
the bug. A separate v6 migration can drop+recreate for them when
4.5h needs the read path. Out of scope for this hotfix.
Changes:
- ChangedProvinceTableWriter.schemaStatements now drops the v2 table
and recreates it with the new PK shape plus the n column and the
changed_province_proto BLOB. CREATE INDEX on province_id recreated
(drop took it).
- ChangedProvinceTableWriter.write takes (connection, actionSeq, n,
cp); INSERT includes n.
- backfill iterates changedProvinces.zipWithIndex.foreach and passes
n to write.
- Regression test: an ActionResult with two ChangedProvinces sharing
province_id (id=7, goldDelta 5 and 10) lands as both rows ordered
by n. Pre-fix this crashes loaded() with SQLITE_CONSTRAINT_PRIMARYKEY.
Verification:
- sqlite_history_test - 45/45 pass, including the new regression.
- Full Scala suite - 225/225 pass.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5c.2 (red): ChangedHeroTableReader round-trip tests
Adds the failing tests for the 4.5c.2 read path. ChangedHeroTableReader
is the inverse of ChangedHeroTableWriter — it reconstructs the
ChangedHeroT domain values for a given action_seq from the v4 tables
(action_changed_heroes plus action_changed_heroes_new_backstory_events).
Nothing in production consumes it yet; replayFrom and friends still
parse action_results.payload. The reader exists to verify the v4
columns are a faithful round-trip representation (paired with the
writer landed in #6730) and to seed 4.5h, which will swap the production
read path off the payload blob entirely.
Three test cases for the read path contract:
- Round-trip a single ChangedHeroC with all three tiers populated
(Tier-1 scalars, Tier-2 loyalty/vigor pairs, Tier-3 backstory
event) through withNewResults and assert the reader reconstructs
the exact same Scala value.
- Return Vector.empty for an action with no ChangedHeroes (the
vacuous case — currently passes because the stub also returns
Vector.empty, kept for the green-side guarantee).
- Return multiple ChangedHeroT ordered by hero_id (the writer does
not preserve insertion order, so the contract is sort by id).
Stub returns Vector.empty so the red signal is a clean assertion
mismatch (parallel to the red test in #6730). Two of three cases are
RED on this commit. The impl will land as a follow-up commit on the
same branch after review.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5c.2: implement ChangedHeroTableReader (turns red tests green)
Fills in the stub from the previous commit so the round-trip tests
pass. Inverse of ChangedHeroTableWriter:
- Pass 1: SELECT every row from action_changed_heroes WHERE action_seq
= ? ORDER BY hero_id. Decode Tier-1 columns directly into
ChangedHeroC fields. Tier-2 loyalty/vigor pairs decode to StatChange
via column-name discrimination (delta column non-NULL means
StatDelta, absolute column non-NULL means StatAbsolute, both NULL
means StatNoChange, both non-NULL throws — that would be a violated
schema invariant). newProfession reverses the writer: column holds
the proto enum ordinal, ProfessionProto.fromValue then
ProfessionConverter.fromProto. Backstory text id is a nullable
String.
- Pass 2: for each hero, SELECT event_proto FROM
action_changed_heroes_new_backstory_events WHERE action_seq = ? AND
hero_id = ? ORDER BY n, parse each blob via
EventForHeroBackstoryProto.parseFrom plus
EventForHeroBackstoryConverter.fromProto. Reuses one
PreparedStatement across all heroes.
Two passes (not a JOIN) because xerial sqlite-jdbc dislikes
interleaving statements with an open ResultSet, and the per-hero
event cardinality is small. The hero ResultSet is drained and closed
before any event query runs.
Verification:
- sqlite_history_test — 44/44 pass, including the three reader cases.
- Full Scala suite — 225/225 pass. No regressions.
BUILD deps added to the reader target: profession + event_for_hero_-
backstory proto targets (for parseFrom and fromValue), the two
proto_converters/hero targets (for the converters), and state/unit_-
status (transitively referenced via the EventForHeroBackstory
converter signature — same as the writer needs).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5c.2 (red): failing test for live ChangedHero decomposition
Red-test PR, no production fix — committed for review before the
implementation, per the established workflow.
4.5c.1 (#6727) added the v4 schema plus a backfill that decomposes
ChangedHero out of *existing* action_results.payload during the
migration. It did not wire the decomposition into the live write path:
SqliteHistory.withNewResults still writes only the whole-ActionResult
payload blob (plus snapshots) and never calls ChangedHeroTableWriter.
On a freshly-created game the backfill is a no-op, so action_changed_heroes
and action_changed_heroes_new_backstory_events stay empty forever. That
gap is what 4.5c.2 closes.
This adds one failing test, "4.5c.2 live write path", asserting that a
ChangedHero pushed through withNewResults on a fresh DB lands in the v4
tables (tier-1/2 columns plus the tier-3 per-event blob rows) — symmetric
to the existing v4 backfill test but via the live path. It fails today on
the missing-row assertion (the action_results row is written, the
decomposed rows are not). It will go green when withNewResults invokes
ChangedHeroTableWriter.write inside the same transaction.
Scope notes:
- The test bypasses the applier deliberately. InMemoryHistory.fromResults
runs ActionResultApplierImpl, which looks up hero 80 in an empty state
and throws — not the behavior under test. The awrs is built directly
from ActionResultProtoConverter.fromProto plus a trivial resulting
state, exactly the shape withNewResults consumes.
- That needs two test deps (action_result_applier,
action_result_proto_converter) and one additive visibility entry on
the proto_converters target for the service test package — the
Bazel-recommended, precedented fix (the list already grants other test
packages). No production behavior change.
- Only the live-write half of 4.5c.2 is pinned here. The read-path
reconstruction depends on an API not yet designed and gets its own red
assertion when that lands.
- Full //src/test/scala/... is intentionally not run: a red-test PR makes
it nonzero by design, so it yields no clean signal. The touched service
test package was run instead — 18/19 targets green, only the intended
new test red.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Address review: build the Scala ActionResult directly, no converter
Review feedback: the test built a throwaway proto ActionResult only to
run it through ActionResultProtoConverter.fromProto. Construct the
domain ChangedHeroC / ActionResultC directly instead and hand
withNewResults exactly what it consumes.
Net changes vs the first commit:
- SqliteHistoryTest: the 4.5c.2 fixture is now ChangedHeroC (loyalty/
vigor as StatDelta/StatAbsolute, newProfession Profession.Mage, one
HeroDeparted backstory event) wrapped in ActionResultC. The tier-3
read-back still parses the stored EventForHeroBackstory proto bytes
and compares to an expected proto literal — that is verifying the
stored format, not converting the input.
- Reverts the proto_converters visibility entry from commit 1 (the
converter is no longer referenced), so that target is untouched vs
origin/main.
- Test deps: drop action_result_proto_converter, add the model targets
the direct construction needs (action_result_concrete,
changed_hero_concrete, action_result/types, the two state/hero
targets). action_result_applier stays (ActionResultWithResultingState).
- Widens three internal model targets to the service test package
(changed_hero_concrete, event_for_hero_backstory_trait, profession).
This is the cost of the direct approach over the converter (1 vs 3
visibility entries) and was chosen deliberately on review.
Still a red-test PR. The new test fails for the same reason as before
(no action_changed_heroes row after a live withNewResults on a fresh
DB). Verified: 40/41 in the suite pass, 18/19 service targets green,
only the intended new test red. scalafmt + gazelle pre-commit pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5c.2: wire ChangedHeroTableWriter into withNewResults (live path)
Implementation that turns the red test in #6730 green. 4.5c.1 (#6727)
added the v4 schema + a backfill that decomposed ChangedHero out of
existing action_results.payload during the migration, but left the
live write path untouched. On a freshly-created game the backfill is
a no-op, so action_changed_heroes and action_changed_heroes_new_-
backstory_events stayed empty forever. This wires the decomposition
into withNewResults so every new action's ChangedHero is written to
the v4 tables in the same transaction as the action_results row.
Mechanics:
- Hoist ActionResultProtoConverter.toProto out of the per-action loop
to compute each protoResult once. It is both the stored blob and
the source for the decomposed rows, so doing it twice was wasted
work.
- After the action_results batch executeBatch (so the FK target rows
exist), iterate protoResults again and call
ChangedHeroTableWriter.write for each ChangedHero. Still inside the
outer try, so a failure here rolls back the action_results inserts
too — write atomicity preserved.
Verification:
- //src/test/scala/net/eagle0/eagle/service:sqlite_history_test —
41/41 pass, including the previously-red 4.5c.2 live write path.
- //src/test/scala/net/eagle0/eagle/service/... — 19/19 targets green.
- //src/test/scala/... — 225/225 green. No regressions.
Read path is still the proto blob (replayFrom etc. unchanged). The
decomposed-table read path comes later in 4.5c.2 or in 4.5h when
action_results.payload is dropped.
This PR is stacked on #6730 (the red test). Merge #6730 first, then
this PR will rebase cleanly onto main.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Address review: writer takes Scala domain types, not proto
The 4.5c.1 writer took ChangedHeroProto because its only caller was
the backfill (whose source is proto bytes parsed from existing
payloads). With the 4.5c.2 live caller in place, that interface is
wrong — the live path has Scala domain types and was being forced to
hand them through a proto round-trip that the writer then re-pattern-
matched. Refactors the writer to take the domain type and converts
only at the actual storage boundaries.
Writer changes (ChangedHeroTableWriter):
- write(connection, actionSeq, ChangedHeroT) instead of
write(connection, actionSeq, ChangedHeroProto). Pattern-matches on
ChangedHeroC (sole concrete impl).
- Tier-1/2 columns read Scala fields directly. Loyalty/vigor branches
match on StatChange (StatDelta / StatAbsolute / StatNoChange)
instead of the proto Loyalty.LoyaltyDelta / Vigor.VigorAbsolute /
Empty cases.
- Tier-3 is the only proto edge: each EventForHeroBackstory is
converted via EventForHeroBackstoryConverter.toProto(event).toByteArray
inside the per-event INSERT loop, where the bytes actually go into
the BLOB column.
- Profession stored as the proto enum ordinal via
ProfessionConverter.toProto(p).value (None means no change means
NULL), symmetric with ChangedHeroConverter.
- backfill stays the entry point for migrating existing payloads:
parses ActionResult proto, converts each ChangedHero to ChangedHeroC
via ChangedHeroConverter.fromProto, then hands it to the shared
write. Live and backfill share one code path.
Caller changes (SqliteHistory.withNewResults):
- Drops the protoResults hoist that the previous impl introduced.
payload is back to a single inline ActionResultProtoConverter.toProto
in the action_results insert loop — exactly the pre-4.5c.2 shape.
- Decomposition loop iterates newResults.zipWithIndex and passes
awrs.actionResult.changedHeroes (Vector[ChangedHeroT]) straight to
the writer. No proto conversion in this loop at all.
The whole-action ActionResultProtoConverter.toProto at line 83 stays,
because the action_results.payload BLOB column is still proto bytes
until 4.5h drops it. That conversion is no longer load-bearing for the
decomposed writes though.
BUILD:
- changed_hero_table_writer drops common:profession_scala_proto (the
ProfessionConverter handles it), adds the model concrete and
converter targets it now references (changed_hero_concrete,
changed_hero_converter, hero:event_for_hero_backstory,
hero:profession), plus eagle_pkg (HeroId / ClientTextId aliases) and
state/unit_status (transitively referenced through the
EventForHeroBackstory converter signature).
- Three additive visibility entries on production targets, exposing
them to the production service package:
changed_hero_concrete, hero:event_for_hero_backstory, and
changed_hero_converter all gain
//src/main/scala/net/eagle0/eagle/service:__pkg__. This is the cost
of the domain-types-at-the-edge approach and was chosen on review.
Test fixture fix:
- The v4 backfill test fixture now sets a date on each backstory
event. Real EventForHeroBackstory protos always carry a date (the
converter requires it). The previous fixture got away with date-less
events because the old writer was a direct proto-bytes passthrough
and never went through the converter. The new backfill does, so the
fixture now matches what real payloads look like in the wild.
Verification:
- //src/test/scala/net/eagle0/eagle/service:sqlite_history_test —
41/41 pass, including both v4 backfill tests and the 4.5c.2 live
write path.
- //src/test/scala/... — 225/225 pass. No regressions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The 4.5c.1 PR (#6727) shipped a refinement of option B for ChangedHero
that the design doc never recorded, so the doc and the merged code
diverged. What actually merged: loyalty/vigor StatChange oneofs as
typed nullable column pairs (column name discriminates), and
newBackstoryEvents as per-event EventForHeroBackstory blobs in a child
table. No whole-entity changed_hero_proto blob. changed_hero.proto and
ChangedHeroConverter become deletable, only event_for_hero_backstory.proto
is retained as a leaf blob format.
Reconciles the doc with that reality:
- New authoritative "Refinement" subsection under Status defining the
three-tier pattern (scalar columns, typed columns for sealed
value-oneofs, per-row leaf-proto blobs in child tables) and the
per-entity classification all-scalar / refined / deep.
- Scope, "Deleting the proto", ChangedHero section, Decisions #1-#3,
and Sequencing rows 4.5c and 4.5i updated to match. 4.5c is split
into 4.5c.1 (merged) and 4.5c.2 (live write/read path, pending).
- 4.5i is no longer "near-nothing": refined entities' protos and
converters get deleted there, scope scales with how many entities
went refined.
Documentation only. No code or schema change.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds a no-`cd` rule to the CRITICAL BASH RULES section: run git/gh/bazel
directly from the cwd (repo/workspace discovery walks up from a subdir;
the shell resets cwd after every command so a cd never persists; and
`cd <dir> && <cmd>` also trips the no-chaining rule and forces an
approval prompt every time).
Also fixes the no-`git -C` rule, which previously said "Instead, cd to
the repo root" — directly contradicting the new rule. It now points at
the no-`cd` rule instead.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5c.1: ChangedHero scalar columns + proto blob (option B)
Same pattern as 4.5b/#6725, applied to ChangedHero. v4 migration:
ALTER TABLE action_changed_heroes ADD COLUMN changed_hero_proto BLOB +
a one-time backfill over existing action_results.payload that
populates the 4.5a scalar columns (faction change, XP/stat deltas,
profession, backstory text id, clear-events flag) and the full proto
blob. Inside the migration transaction; no-op on fresh games.
ChangedHero is shallow: only the sealed loyalty/vigor StatChange
oneofs and the newBackstoryEvents vector are non-scalar, and those
stay in the blob (the design doc flagged per-stat StatChange columns
as overkill — option B blobs them). new_profession stores the proto
enum ordinal, UNKNOWN_PROFESSION -> NULL, mirroring ChangedHeroConverter
and the improvement_type/action_result_type precedent.
Scalars are query denormalization; the blob is authoritative. Not in
this PR (4.5c.2): live withNewResults write path + read path.
~95-line writer, mirrors ChangedProvinceTableWriter. New
changed_hero_table_writer Bazel target. Tests: scalar promotion +
ChangedHero.parseFrom(blob) == original (covers the loyalty/vigor
oneofs that live only in the blob) + minimal-hero NULL case. Full
Scala suite green (225/225).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 4.5c.1: refined option B for ChangedHero — full columns + per-event blobs
Implements the two refinements suggested on #6727:
- Tier 2: store the loyalty/vigor StatChange oneofs as two nullable REAL
columns each (loyalty_delta/loyalty_absolute, vigor_delta/vigor_absolute).
The column name is the discriminator; at most one of each pair is non-NULL;
both NULL ⇒ StatNoChange. No kind column.
- Tier 3: keep the EventForHeroBackstory proto and store just those bytes,
one row per event, in action_changed_heroes_new_backstory_events.
ChangedHero now has no whole-entity blob: it round-trips losslessly from
columns + per-event blobs, so changed_hero.proto/ChangedHeroConverter become
deletable later while event_for_hero_backstory.proto is retained as the blob
format. Renames the v4 migration to applyV4ChangedHeroColumns and rewrites the
v4 tests for the new schema (loyalty/vigor columns + events child table).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
ChangedProvince shipped via option B (scalars + proto blob, #6725), not
the full-relational "decision locked" plan. Update the doc so it stops
contradicting what's on main and so 4.5c-i has a coherent plan:
- New "Status — option B adopted" section at the top; it supersedes the
body, which is kept as historical context / the migration target if a
structured-query need on deep aggregates ever appears.
- Rescind "Decisions locked #1" (fully relational, no blob fallback);
rewrite the locked decisions around option B + per-entity blob choice.
- Re-plan the Sequencing table per-entity: each gets 4.5a scalars + an
<entity>_proto BLOB, split .1 (schema+backfill) / .2 (live+read).
Estimate drops from ~3-4 weeks to ~1.
- Revise principle 7 (proto blob is the retained authoritative store),
the Scope (per-entity protos are NOT deleted), and "Deleting the
proto" (superseded).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Alternative to PR #6721. Applies the design doc's "stopping rule"
consistently: instead of exploding ChangedProvince's ~25 nested
aggregates into ~37 child tables, keep the v2 top-level
action_changed_provinces table (per-(action,province) scalar deltas +
the province_id index — the part anyone actually queries) and store the
full ChangedProvince proto in one changed_province_proto BLOB for
lossless round-trip / replay.
This is the same reasoning the doc already applies to Quest, applied to
the deep army/unit/hero nesting that is likewise never analytics-
queried. ~110 lines of writer vs ~1100 in the full-relational approach.
The scalar columns are pure query denormalization; the blob is
authoritative. A future fully-relational pass (if a structured-query
need on the deep aggregates ever materializes) is a pure
read-blob -> write-columns migration — which is why the blob must
survive any later proto deletion. This deliberately re-opens the doc's
"new-entity vectors are fully relational, no blob fallback" lock; the
quest hybrid is the precedent.
Not in this PR (would be 4.5b.2): the live withNewResults write path
and the read path. Nothing reads these columns yet.
v3 = ALTER TABLE action_changed_provinces ADD COLUMN
changed_province_proto BLOB + a one-time backfill over existing
action_results.payload (scalars + blob), inside the migration
transaction; a no-op on freshly-created games.
Tests: a ChangedProvince with a genuinely nested aggregate (defending
army -> combat unit) + a repeated field, asserting scalar promotion and
that ChangedProvince.parseFrom(blob) == original (proves the deep
structure round-trips), plus that no child tables are created. Full
Scala suite green (225/225).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Red test: tutorial game text store diverges from reload path
createTutorialGameForUser builds its ClientTextStore as the legacy
file-based ClientTextStoreImpl (persists to completeText.txt), but every
reload reconstructs the store from text_store.db via
SqliteClientTextStore.loaded. Texts generated during a tutorial session
(e.g. yearly chronicle updates) are therefore unrecoverable after a
save+reload, surfacing in production as:
EagleInternalException: Unknown dependency for LLM request
chronicle_update_373_11: chronicle_update_371_11
This is a failing regression test only — no fix yet. It creates a
tutorial game, persists a chronicle text through the game's live store,
then reads it back via the production reload path and asserts it
resolves. Fails today (TextGenerationDependencyUnknown).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Fix: tutorial creation uses SqliteClientTextStore (text_store.db)
createTutorialGameForUser built its ClientTextStore as the legacy
file-based ClientTextStoreImpl (completeText.txt), while every reload
reconstructs the store from text_store.db via SqliteClientTextStore.loaded
and normal game creation already uses SqliteClientTextStore. Texts
generated during a tutorial session (e.g. yearly chronicle updates) were
written to completeText.txt and lost on the first save+reload, causing:
EagleInternalException: Unknown dependency for LLM request
chronicle_update_373_11: chronicle_update_371_11
Switch tutorial creation to SqliteClientTextStore.createWithData, matching
the normal-creation path, so create and reload agree on text_store.db.
Behavior-preserving otherwise (unrequestedTexts/accessibleTo were already
empty here). Turns the regression test from the previous commit green;
full Scala suite (225 tests) passes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
All maps shipped with maxWindMph: 25 for every month, giving them no
geographic windiness identity. This sets maxWindMph based on each
map's terrain mix — open terrain (plains, still water) pushes the
ceiling up, dense terrain (forest, swamp) pulls it down — with a
small additional bump on winter months (proxied by snow/blizzard
chance).
Spread across maps now ranges roughly 20–29 mph (median 24).
Forest-heavy maps like Nikemi land at 20–22; open-water maps like
Chia and field maps like MeetThemInTheField land at 27–29.
Note: the existing exploding-roll mechanic in NewWeather amplifies
the tail proportionally, so a 28 mph cap produces ~55 mph outliers
where a 22 mph cap produces ~44 mph outliers (same ~5% explosion
frequency). That's intended texture but worth flagging if any of the
downstream wind consumers want a tighter cap.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Wind speed was previously a uniform-then-exploding roll against a
per-map maxWindMph, with no awareness of the rolled weather
conditions. Stormy days were as likely to be calm as sunny days.
This adds six per-condition multipliers (settings.tsv) applied on top
of the existing wind-speed formula. Defaults: 1.0× for sun/clouds,
1.2× for rain/snow, 1.5× for thunderstorms, 1.8× for blizzards.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Red test: Traveler move does not extend backstory visibility to dest ruler
Reproduces the [BACKSTORY-VISIBILITY] production diagnostic. When a Traveler
unaffiliated hero with a backstory moves into a province, the destination
province's ruling faction should gain visibility of the hero's backstory text
(as ProvinceConqueredAction and TravelCommand already do for their cases).
PerformUnaffiliatedHeroesAction.heroMovedResult builds the HeroMoved result
with no clientTextVisibilityExtensions, so this test FAILS until the fix:
Vector() did not contain element
ClientTextVisibilityExtensionC("Leah_backstory 0", Vector(2))
The move + relocation sanity assertions pass; only the visibility assertion
fails, isolating the root cause. Committed test-only for review before the fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Red test: stationary free hero's ruler change does not extend backstory visibility
Adds the broader case behind the [BACKSTORY-VISIBILITY] diagnostic: a free
hero with a backstory sitting still in a province whose current ruler is not
in the backstory's visibility set. Conquest already extends visibility via
ProvinceConqueredAction, but nothing heals a stationary hero whose ruler
changed by other means (faction split, vassal independence, inheritance).
The per-round unaffiliated-hero phase emits no clientTextVisibilityExtensions
for this case, so the test FAILS until the fix:
Vector() did not contain element
ClientTextVisibilityExtensionC("Leah_backstory 0", Vector(2))
14 pre-existing tests still pass; the 2 new red tests isolate the two gaps
(Traveler move-in, and stationary ruler-change). Test-only for review.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Fix: extend free-hero backstory visibility to current province ruler
Two complementary fixes for the [BACKSTORY-VISIBILITY] diagnostic; turns the
two red tests green (16/16 in the class, full Scala suite 224/224).
1. heroMovedResult: the HeroMoved ActionResult now emits a
ClientTextVisibilityExtensionC for the destination province's ruling
faction (guarded by backstoryVersions.nonEmpty, since backstoryTextId
throws on empty). Mirrors ProvinceConqueredAction and TravelCommand,
keeping visibility extension atomic with the move event.
2. EndUnaffiliatedHeroActionsPhaseAction: a per-round self-healing sweep
emits visibility extensions for every free hero with a backstory to its
province's current ruler. Covers ruler changes by non-conquest means
(faction split, vassal independence, inheritance) where the hero is
stationary, and retroactively heals already-stuck heroes. Visibility is
additive and de-duplicated downstream, so re-emitting each round is safe;
the engine has no visibility state to dedupe against, so this redundancy
is the deliberate tradeoff.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Redesign: fix backstory visibility at placement + one-time load migration
Replaces the per-round sweep with the true root-cause fix. Pool heroes arrive
with a pre-authored backstory and previously had NO visibility set at
appearance (the all-faction request fires only for spawned heroes with an
empty backstory), so they were legible only to whatever faction a later narrow
event happened to extend to.
- UnaffiliatedHeroAppearedAction: for the pre-existing-backstory branch, emit
a ClientTextVisibilityExtensionC to all factions, matching the spawn-time
policy. This is the missing event-driven hook.
- Reverted the EndUnaffiliatedHeroActionsPhaseAction per-round sweep: the only
engine path that changes a province's ruler is ProvinceConqueredAction
(already handled), so no perpetual sweep is needed for correctness.
- heroMovedResult fix (Fix 1) retained: extension stays atomic with the move.
- FreeHeroBackstoryVisibilityRecovery: one-time on-load repair (mirrors
MissingHeroNameRecovery) widening any free-hero backstory not visible to its
current province ruler. No-op for healthy games; heals already-stuck heroes
like prod hero 184. Wired into GamesManager.gameController load path.
Tests: placement-hook tests in UnaffiliatedHeroAppearedActionTest, migration
tests in new FreeHeroBackstoryVisibilityRecoveryTest, Test 1 (move-in) kept,
obsolete sweep test removed. Full Scala suite 225/225.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
When the Diplomacy command selector targets a faction, MapController
was drawing TargetedProvinces with per-province borders, so each of
the faction's provinces got its own outline (including the internal
borders between adjacent same-faction provinces). The visual the
user wanted is the unified outline used by notifications and popups:
one boundary around the faction's territory as a whole.
The mechanism already exists — ProvinceBorderManager.SetHighlightGroup
fills a per-province lookup texture that the shader consults to
suppress borders between provinces in the same group, and
OverrideTargetedProvinces uses it. TargetedProvinces was calling
ClearHighlightGroup unconditionally.
Add MapController.TargetedProvincesAsGroup. When true, the no-override
branch calls SetHighlightGroup(TargetedProvinces) before the existing
per-province strobe/border loop, producing the unified outline.
EagleGameController.Update sets the flag to
selector.TargetedFactionIds.Count > 0 alongside the existing
mapController.TargetedProvinces assignment, so it stays in sync with
the selector each frame.
DiplomacyCommandSelector itself needs no change — its TargetedFactionIds
is already non-empty when a target faction is picked, and the base
CommandSelector.TargetedProvinces getter already expands a non-empty
TargetedFactionIds into the faction's provinces. Other selectors
(AttackDecision, BattleAftermath) don't populate TargetedFactionIds
and so render per-province as before.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Introduces an append-only migration system for SqliteHistory and adds
the four "changed entity" top-level tables (scalar columns only). No
write or read paths target these tables yet — they're scaffolding for
the per-aggregate decomposition that lands across Phase 4.5b–e. See
docs/SQLITE_ACTION_RESULT_DECOMPOSITION.md.
Migration runner
- `SqliteHistory.Migrations` holds a Vector[Migration(version, apply)],
append-only. Versions must be strictly ascending; new schema work is
always a *new* migration, never an edit of an existing one.
- `applyPending` reads `metadata.schema_version`, runs every migration
with a higher version, bumps the stored version after each — the whole
sequence inside a single transaction so a partial failure rolls back
to the pre-migration version.
- `metadata.value` is now TEXT (was BLOB) to hold the schema_version as
a stringified int. The column had no readers before this PR.
v1 — baseline
The existing tables (action_results, state_snapshots, shardok_*),
unchanged, repackaged into the migration runner. Idempotent (CREATE
TABLE IF NOT EXISTS) so any pre-migration DB that already has these
tables ends up at v1 without an error.
v2 — changed-entity top-level tables
- action_changed_provinces (PK action_seq + province_id) with the
scalar fields of ChangedProvinceC.
- action_changed_heroes (PK action_seq + hero_id) with the scalar fields
of ChangedHeroC. StatChange fields (loyalty/vigor) and
newEventsForHeroBackstory are deferred — they belong in child tables
per the design doc.
- action_changed_factions (PK action_seq + faction_id) with the scalar
fields of ChangedFactionC.
- action_changed_battalions (PK action_seq + battalion_id only).
ChangedBattalionC has no scalar fields at the top level — its sole
field is the contained BattalionT, which gets its own decomposition
in Phase 4.5e.
- Per-entity-id indexes on each of the four tables so cross-game queries
like "all actions touching hero X" are single-index lookups.
- FOREIGN KEY (action_seq) REFERENCES action_results(action_seq)
ON DELETE CASCADE on each, so truncateTo only needs to drop from
action_results and the rest cascades.
Tests
- Fresh DB ends up at `latestSchemaVersion`.
- All four new tables exist after create.
- Re-loading is a no-op (no migrations re-run, no schema_version change).
- A simulated pre-v2 DB (v1 baseline + schema_version='1') upgrades to
the latest version and gains the new tables on load.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Phase 4's cutover wrote game.db only to local disk. PersistedHistory had
shipped every chunk through the Persister, so .e0a files landed in S3
(DigitalOcean Spaces in prod) automatically; SqliteHistory dropped that
because the dbFile-based factories never accepted a Persister.
Result: a Hetzner host crash would lose every active game; only
text_store.db survived in cloud storage. This restores parity with the
SQLITE_HISTORY_DESIGN.md plan (lines 43, 244–245, 271).
Mechanism
- `SqliteHistory` constructor now takes `dbFile` + `Option[Persister]`.
After each successful `withNewResults` commit, it calls
`maybeUploadToPersister(previousCount)`, which uploads the whole
game.db blob when either (a) the batch crossed at least one 25-action
snapshot boundary or (b) more than 60s has elapsed since the last
upload.
- Before reading bytes off disk, it issues
`PRAGMA wal_checkpoint(TRUNCATE)` so the uploaded file is a
self-consistent snapshot — without the checkpoint the upload would
miss whatever's still in game.db-wal.
- Upload failures are caught + logged so a transient cloud-storage
outage does not break local game progress.
- `AsyncS3Persister` (the production wiring) already coalesces saves for
the same key on a 500ms tick and has a JVM shutdown-hook flush, so
uploading more often than that costs at most a memcopy.
Load path
- New `SqliteHistory.loadedOrDownloaded(dbFile, persister)` first tries
the local file, otherwise tries `persister.retrieveAsStream("game.db")`
and writes it locally. Returns `None` when both are absent (the game
simply does not exist).
- `GamesManager.gameHistoryFromFiles` uses this so a wiped local saves
directory recovers games from cloud storage on next boot.
Plumbing
- `NewGameCreation.createEngine` and
`TutorialGameCreation.createTutorialGame` pass their `persister` to
`SqliteHistory.createWithScalaResults` (un-`@annotation.unused`).
- BUILD: sqlite_history depends on the persister target; the
SqliteHistoryTest target depends on it as well.
Tests
- An in-memory `FakePersister` records save calls.
- `withNewResults` triggers an upload at the 25-action boundary but not
before; a single batch spanning a boundary also triggers exactly one.
- No persister configured → no observable behavior change.
- `loadedOrDownloaded` opens the local file when present; downloads from
the persister when the local file is missing; returns `None` when
both are absent.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The Docker Build and Push workflow spends more time pushing than building
because the entire ~115MB Eagle classpath ships as one pkg_tar layer, so
every Scala commit re-uploads the whole blob to DOCR.
Add a jar_split rule that partitions the scala_binary's transitive runtime
jars by workspace into a stable third-party layer (76MB, 137 jars) and a
first-party layer (38MB, 989 jars). crane skips the unchanged deps blob, so
typical Scala-only commits re-upload ~38MB instead of ~115MB. Entrypoint
switches from -jar fat.jar to a -cp classpath glob over both layer dirs.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
GamesManager, NewGameCreation, and TutorialGameCreation now open per-game
game.db SQLite files via SqliteHistory instead of chunked .e0a blobs via
PersistedHistory. PersistedHistory remains in the tree so the existing
unit tests still build; it is no longer reachable from the running server.
- SqliteHistory.createWithScalaResults / emptyStartingState convenience
constructors mirror the PersistedHistory new-game shape (raw scala
ActionResultTs folded through the applier).
- GamesManager.gameHistoryFromFiles looks up
saveDirectoryForGame(gameId)/game.db and opens it with
SqliteHistory.loaded; the persister is now unused on that path.
- TutorialGameCreationTest's "successfully creates" case forces the lazy
val instead of calling createTutorialGame a second time with the same
gameId — duplicate creates would collide on snapshot PK.
- Tests that exercise game creation point eagle.save.dir at a temp dir so
the bazel sandbox can write game.db.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Design doc: full decomposition of ActionResult into relational tables
Follow-up to docs/SQLITE_HISTORY_DESIGN.md. Plans replacing the
proto-blob action_results.payload with native relational tables for
every field of ActionResultC, recursively.
Key facts that motivate full decomposition (vs blob middle ground):
- ActionResult-family protos are storage-only (one file imports them,
ChangedProvinceConverter; no RPC consumers).
- ActionResultC has no top-level polymorphism — 34 flat fields with no
sealed-trait discriminator at the action level.
- Pre-alpha is the right window: we can nuke save dirs as part of the
rollout, and we never have to migrate real user data.
Doc covers principles, top-level schema, the four changed-entity
families (~25 tables each under the ChangedProvince family), notifications
and generated-text-requests, the "new entity" vectors with a fallback
flag, schema migration scaffolding (now load-bearing), read/write/replay
path changes, and a 9-step sub-phase plan (4.5a–4.5i) starting after the
Phase 4 cutover lands. Three open questions for sign-off at the bottom.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Lock in the three open questions
- "New entity" vectors get full relational decomposition (no blob fallback).
- Sub-phase ordering stays as proposed.
- Phase 4 cutover lands separately, before 4.5a starts.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
All seven FullGameHistory shardok methods land, plus truncateTo cleanup
of orphaned battles. Pure foundation — no production caller yet.
Schema:
- shardok_results: (shardok_game_id, action_seq) -> ShardokActionResult bytes
- shardok_state: shardok_game_id -> (game_state bytes, last_eagle_round_id)
- shardok_player_results: (shardok_game_id, faction_id, seq) -> ActionResultView bytes
- shardok_player_commands: (shardok_game_id, faction_id) -> AvailableCommands bytes (nullable)
Behavioral semantics mirror PersistedHistory:
- shardokPlayerCount / shardokPlayerResultsSince fall back to faction -1
if the requested faction has no registered presence.
- shardokPlayerAvailableCommands does NOT fall back to -1 (exact match only).
- withNewShardokResults: append results and per-faction views, upsert
per-faction commands (replacing previous value, NULL if not in the new
availableCommands), upsert per-battle game_state. Touched factions =
old participants union newPlayerResults factions.
- withResetShardokResults: drop all rows for the given battle.
truncateTo now uses stateAfter(target).outstandingBattles to determine
which shardok rows to preserve; rows for any other battle are deleted in
the same transaction. Mirrors deleteOrphanedShardokFiles.
8 new tests covering each method plus the full battle reset case.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
All four trait methods land via a single replayFrom helper: find the latest
snapshot at or before the emit boundary, replay forward to the end of the
range, emit ActionResultWithResultingState only for action_seq >= emitFrom.
- all: replayFrom(0, count)
- since: clamp start to [0, count], replayFrom(start, count)
- sinceDate: SQL SELECT MIN(action_seq) WHERE date_year IS NOT NULL AND
(date_year, date_month) >= (?, ?), then replayFrom(thatSeq, count).
Matches PersistedHistory semantics: actions with NULL date are filtered
out (they're pre-game-start; once currentDate is Some it stays Some).
- recentResultsForRound: SQL SELECT MIN/MAX(action_seq) WHERE round_id = ?,
replay that contiguous range, filter by the Scala predicate.
stateAfter is refactored to share the new readLatestSnapshotAtOrBefore
helper. Behavior unchanged.
Tests cover all four methods including snapshot-boundary crossings and
out-of-range clamping.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds SqliteHistory implementing FullGameHistory. Phase 1 scope:
- Schema (action_results, state_snapshots, metadata) with WAL, NORMAL
synchronous, FK pragmas at connection init.
- Lifecycle: create(dbFile, startingState), createWithResults(...),
loaded(dbFile), close().
- Write paths: withNewResults (transactional batch insert + snapshot at
every 25-action boundary), truncateTo, saveNow (no-op).
- count cached in-memory, refreshed from DB on construction.
- last and stateAfter implemented via snapshot+replay (latest snapshot
<= N, replay forward via ActionResultApplier).
- Other read paths (all, since, sinceDate, recentResultsForRound) and
all shardok methods throw NotImplementedError with phase markers;
filled in in Phase 2 and 3.
Tests cover: empty create, withNewResults sequencing, snapshot writes
at the 25-boundary, stateAfter at and between boundaries, truncateTo,
saveNow no-op semantics, reload-and-continue.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 0: SQLite game history design doc
Per-game game.db replaces .e0a / .e0s / .e0i file-based persistence.
SqliteHistory implements the existing FullGameHistory trait so the
cutover at the call site is a single constructor swap.
Schema, connection lifecycle, read/write paths, snapshot strategy,
shardok results layout, migration trigger, rollback policy, and four
open questions called out for sign-off before Phase 1.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Phase 0: fold in user feedback — drop migration, separate DBs rationale
- Remove the migration importer entirely. At cutover we nuke save
directories one time; pre-alpha trade-off accepted explicitly.
- Add the writer-lock-contention rationale for keeping game.db and
text_store.db as separate files instead of one combined DB.
- Lock in the four open questions with the agreed defaults.
- Note the GameAdminServiceImpl.getActionDetail follow-up to move off
history.all; not blocking.
- Phase plan drops from 5-6 weeks to 3-4 weeks with migration gone.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
CommonConverters and ShardokInterfaceGrpcClient.fromCommon no longer
round-trip through the Eagle proto enums to harvest .value: Int just to
feed CommonProfession.fromValue / CommonBattalionTypeId.fromValue.
New CommonEnumConverters defines CommonProfessionConverter and
CommonBattalionTypeIdConverter with toCommon / fromCommon. The shardok
interface boundary now only touches domain types and the Common proto
it's actually emitting; the Eagle proto enums are out of this path.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
EagleUnit now holds domain HeroT and Option[BattalionT], and ExpandUnit
takes the domain GameState. That removes the per-battle
GameStateConverter.toProto call and the dual-arg playerInfo signature.
CommonConverters.toCommon reads domain fields and routes Profession /
BattalionTypeId through the existing proto converters to keep the
Common* enum values stable.
fromCommon now builds HeroC / BattalionC directly instead of building
proto types only to convert them back to domain at the call site.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Use domain GameState in ShardokInterfaceProxy.resolveBattle
The Shardok gRPC client used to take a proto GameState directly, which
forced GamesManager to convert domain → proto right before the call
and forced the client to inline its own proto-relationship-level
filtering. Now the trait accepts the domain GameState; the gRPC client
uses FactionUtils.alliedFactions for the watcher derivation and reads
the relationship enum via the domain Hostile case object. Proto
conversion still happens once inside the client where the unit-
construction helpers (toCommon, EagleUnit.ExpandUnit) require it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Use FactionUtils.nonHostileFactions and flag the dual-arg as TODO
Add a nonHostileFactions helper to FactionUtils paralleling alliedFactions,
covering Ally + Truce (and defensively Unknown). playerInfo now uses it
instead of the inline relationshipLevel != Hostile filter.
Add a TODO at the GameStateConverter.toProto call inside resolveBattle so
the dual-arg playerInfo signature (domain + proto) doesn't calcify -- it's
a transition state until toCommon and EagleUnit.ExpandUnit are moved to
take the domain GameState.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Populates the watcher_eagle_faction_ids field added in the prior
Shardok-side change. The watcher set is the union of each
participant's non-hostile faction relationships, minus the
participants themselves.
Once both this and the Shardok-side change are deployed, factions
allied to a battle's participants but not themselves participating
receive filtered views that include hero stats and hidden actions for
their allies' units.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Cache parsed games.e0es to skip per-lobby-tick re-parse
games.e0es was being read from disk and parsed by six call sites:
ensureGameLoaded, gamesFor, gamesForWithoutBlocking, loadGamesInBackground,
save (for the merge), and getAllRunningGamesSummary. Combined with the
EagleServiceImpl path that fans lobby updates out to every connected user
on every game-affecting action, this produced repeated "Read N ongoing
games from games.e0es" log lines per lobby tick and a parse-and-allocate
churn that grew with both lobby user count and games count.
The freshness justification ("called fresh each time to handle race
conditions during blue-green deployment") is already covered by the
existing flush-marker invalidation: when the active server flushes
state, the new server detects the flush-marker mod-time bump and clears
its caches.
Add an in-process cache (cachedRunningGames) populated lazily by the
read paths and refreshed at the end of save() from the just-written
state. The flush-marker invalidation path also clears the cache so
blue-green semantics are preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Use Option.getOrElse instead of match in runningGamesFromStorage
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
When a faction watches an ally's battle, the Eagle layer falls back to
Shardok's generic observer (factionId == -1) view because Shardok only
emits filtered views for battle participants. The observer view treats
every unit as an enemy, so visibility is knowledge-gated: battalion
stats (threshold 50) usually creep above the floor while hero stats
(threshold 75) lag behind, leaving watchers with battalion data but no
hero stats for their own allies' heroes.
This change adds a watcher_eagle_faction_ids field to NewGameRequest
and has Shardok emit one filtered view per watcher in GetUpdates. Each
watcher's view treats their allied participants' units as fully
visible (hero stats, hidden actions like meteor casts, hidden-target
coords) while non-allied units fall back to observer-style knowledge
gating.
The watcher path is implemented via new
GameStateFilteredForPlayerWithAllies and
ActionResultFilteredForPlayerWithAllies overloads that take alliedPids
explicitly, plus a new ShardokEngine::FilterNewResultsForWatcher that
drives them. The IsHiddenFromOpponents predicate now treats the
actor's allies as self for visibility purposes.
The Eagle side that populates watcher_eagle_faction_ids is in a
follow-up PR; this change is a no-op until that lands.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
If the AI's only neighbor is the proposing faction (every one of our
provinces' neighbors is owned by us or them), accepting a truce locks
us out of attacking the only faction we could expand into for the
duration of the truce. The existing alliance-acceptance guard already
catches this for alliances; truces had no such guard and the AI would
happily accept and stagnate.
When boxed-in:
- choose Imprisoned if the offer's eligible statuses include it
(which requires us to have at least one province, per
EligibleDiplomacyStatuses.maybeImprisonStatus)
- otherwise fall back to Rejected.
Factor the boxed-in predicate into a shared helper
(acceptingWouldBoxUsIn) and have allianceOfferAcceptanceChance use it
so the two paths stay in lockstep.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
When the midgame AI has no neutral or hostile neighbors anywhere on its
border, it has no path to growth and gets stuck in the
chosenNoNeutralNeighborsCommand fallback resting and improving forever.
The upstream allianceOfferAcceptanceChance guard refuses alliances that
would immediately box us in, but that state can still emerge later --
e.g., a shared hostile neighbor gets conquered by our ally.
Add a chooser that, in that state, finds the weakest bordering ally
(measured by province count, since post-truce conquest is the goal)
and emits a DiplomacyAvailable BreakAlliance command targeting them.
Inserted just before chosenNoNeutralNeighborsCommand so the recovery
path runs ahead of the catch-all rest/improve.
Truces are intentionally not treated as boxing-in: they expire, and
breaking would burn trust unnecessarily -- waiting them out is cheaper.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Hero names from the local heroNameCache (and FixedHeroName text) are
resolved synchronously inside UnrequestedTextHandler and stored as
complete in the ClientTextStore -- but unlike LLM-generated text, they
never flow through the streaming pipeline that pushes updates to
connected clients. Mid-session UIs sit on the "Hero" fallback until the
user reconnects (which triggers the on-subscribe bulk-send of every
accessible complete text).
Surface the resolved CompleteClientTexts from
clientTextStoreWithHandledUnrequestedTexts via a new
HandledUnrequestedTextsResult, and have GamesManager push them through
GameController.withResolvedTextsPushedToClients (which routes through
the existing humanClientsAfterUpdatingLlmStream path used for LLM
streaming responses). Initial-load callers continue to ignore the
list since the on-subscribe bulk-send already covers that case.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The AI thread's post path didn't run the only-one-option auto-loop, so
when an AI player ended their turn and control passed to a player whose
only available command was End Turn (e.g., a player with one stunned
unit), that player had to click End Turn manually. The human-driven
PostCommand path already called PostWhileCurrentPlayerHasOnlyOneOption.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Unity's internal FMOD wrapper logs an Error whenever the macOS audio
output device changes (headphones plug/unplug, AirPods, system output
switch): the wrapper tries to call setOutput on FMOD after System::init
and FMOD rejects with "Cannot call this command after System::init"
(error 32). The audio continues to play on whichever device was active
at app launch; nothing actionable for us, but the message was reaching
ErrorHandler and surfacing the in-game error panel.
Filter the specific message in ErrorHandler.HandleLog. Match both
halves of the string so a genuine FMOD failure (different cause) still
surfaces.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
ShardokBattles is populated when a NewBattle eagle update arrives, but
the matching ShardokGameModel isn't created until the first
ShardokActionResultResponse for that battle reaches the client (in
EagleGameModel.MakeGameModel via the ReceiveGameUpdate handler). In
that gap, the Observe button on an allied battle was unconditionally
interactable, and clicking it threw KeyNotFoundException out of
EagleGameController.GoToBattle's Model.ShardokGameModels[gameId]
indexer access.
Gate the Observe button on Model.ShardokGameModels.ContainsKey(gameId).
Show "Loading..." until the matching shardok model arrives, then flip
to interactable "Observe". The Self/Fight! path was already implicitly
gated via fightableGameId (derived from RunningShardokGameModels), so
no change needed there.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Centralize popup-driven province highlight in PopupCanvasController
Multiple PopupPanelController subclasses (PleaseRecruitMeCommandSelector,
NotificationPanel, ResolveDiplomacyCommandSelector,
ResolveRansomOfferCommandSelector) all wrote directly to
MapController.OverrideTargetedProvinces from their SetUpPanel hooks,
and SetUpPanel only ran when state changed. So when a notification
arrived or was dismissed while the Please Recruit Me popup was open,
the notification panel's SetUpPanel would overwrite the recruit
popup's province highlight, and the recruit popup would never
re-assert. Result: the requesting hero's province often wasn't
highlighted even though the popup said it should be.
Switch to a single-writer model:
- PopupPanelController exposes CurrentAffectedProvinceIds (the current
popup's affectedProvinceIds), and no longer writes to MapController
itself.
- PopupCanvasController gains a MapController reference and, in
Update, applies the topmost active panel's CurrentAffectedProvinceIds
every frame, so popups can't clobber one another. On the transition
from "popup active" to "no popup", we clear the override once and
then leave the field alone so non-popup writers (table hovers etc.)
can drive it normally.
- ResolveDiplomacyCommandSelector and ResolveRansomOfferCommandSelector
used to compute their highlight inline in SetUpPanel; that logic
moves into the PopupInfo construction so each offer carries the
right affectedProvinceIds, and their SetUpPanel overrides shrink
accordingly. PleaseRecruitMeCommandSelector and NotificationPanel
already populated affectedProvinceIds correctly and didn't need
changes.
PopupCanvasController has a new public MapController field. Wire it in
the prefab/scene where the PopupCanvasController lives (drag the
MapController object onto the new "Map Controller" Inspector field)
before testing — without it, opening a popup will throw a
NullReferenceException, which is intentional per the project's
no-defensive-checks policy on Inspector fields.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Wire MapController into both PopupCanvasController instances in Gameplay scene
Companion to the previous commit. Two PopupCanvasController instances
exist in Gameplay.unity (narrow + wide layout); both now reference the
MapController so the new single-writer Update path can drive
OverrideTargetedProvinces from any active popup.
Also includes auto-saved DialogueManager unitHighlight default colors
that Unity wrote into the scene when loading after the prior tutorial
unit-highlight PR landed — incidental, but moot to revert.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Add failing test for backstory visibility on hero imprisonment
When a hero is captured via battle aftermath and imprisoned, the captor
faction has no visibility on the prisoner's backstory text. This test
asserts the imprisonment ActionResult should emit a
ClientTextVisibilityExtensionC granting the imprisoning faction
visibility on the captured hero's backstory text -- matching the
parallel pattern in ProvinceConqueredAction.afterUpdatingUnaffiliatedHeroes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Extend backstory visibility to captor in convertToUnaffiliated
When a captured hero with an existing backstory is converted into an
unaffiliated hero (imprisoned or exiled), emit a
ClientTextVisibilityExtensionC granting the captor faction visibility
on the prisoner's backstory text. Without this the captor sees blank
text for their own prisoner -- the same gap ProvinceConqueredAction
already closes for inherited unaffiliated heroes when a province changes
hands.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Add failing test: March should mark the acting (menu) province as acted
Currently, MarchCommand.make takes only originProvinceId and marks
that as the only acted province (via provinceIdActed). If the player
opens the command menu on province A and chooses to march units from
a different origin province B, only B is marked acted. Province A
retains its full command menu and can issue another command in the
same round — the player can effectively shuffle units around without
spending province A's action.
Plumbing changes (necessary for the test to express the desired
behavior, behavior unchanged):
- Add actingProvinceId: ProvinceId parameter to MarchCommand.make.
The inner MarchCommand class accepts it but currently marks it
@scala.annotation.unused — immediateExecute does not yet emit a
hasActed change for the acting province.
- CommandFactory passes ac.actingProvinceId from the MarchAvailable
pair when constructing the MarchCommand. The wire protocol already
carries the menu province on EagleCommand.province_id, so no proto
or client changes are required.
- Existing MarchCommandTest cases pass actingProvinceId =
originProvinceId to preserve current behavior; they all still pass.
New failing test:
- "should mark the acting (menu) province as having acted, even when
marching from a different province" — sets actingProvinceId=14,
originProvinceId=7, asserts the result includes a ChangedProvinceC
for province 14 with setHasActed = Some(true). Currently fails:
"None was not equal to Some(true)" — no ChangedProvinceC for 14
exists in the result.
Implementation fix to follow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Mark the acting (menu) province as acted on March
Implementation for the test added in the previous commit.
When actingProvinceId == originProvinceId (the typical case where the
player issued March from the same province their units march from),
provinceIdActed = Some(originProvinceId) already marks that single
province as acted. No extra change needed.
When actingProvinceId != originProvinceId (the player opened the
menu on A, chose origin B), append a ChangedProvinceC for the acting
province with setHasActed = Some(true) so it can no longer issue
commands this round. The origin still gets hasActed via
provinceIdActed.
Drops @scala.annotation.unused on actingProvinceId since it's now
used.
All 8 MarchCommandTest cases pass, full Scala suite (223 tests) green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
formAwrs (called on game load) and withNewResults (called per applied
result at runtime) were eagerly running GameStateConverter.toProto on
every Scala game state to populate PersistedActionResult.gameState —
3 seconds of the 4.5-second game-load time per the timing logs and
ongoing per-result cost during play.
In practice, the proto game-state is only ever read at "last entry of
batch" boundaries:
- PersistedHistory.scala:214 — last entry of a loaded chunk during
orphan recovery.
- PersistedHistory.scala:471 — invariant check on
recentHistory.headOption.gameState.actionResultCount.
- PersistedHistory.scala:592 — results.last.gameState in saveNow,
passed to the next batch as starting state.
- PersistedHistory.scala:664 — toSave.last.gameState in withNewResults
chunk save, passed to next batch as starting state.
So at most ~1 in resultsPerSaveFile (25) entries ever needs the proto
form; for the others the conversion was pure waste. Make
PersistedActionResult.gameState a lazy val derived from
scalaGameState — entries that never become a "last in batch" never
pay for toProto. (Also drops the unused fromProto factory.)
formAwrsFromScala still eagerly converts the action result to proto
since saveIndividualResult writes individual ActionResult bytes for
crash recovery — that conversion happens regardless.
223 Scala tests pass.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The disk-load branch of stateAfter went through formAwrs, which
unconditionally calls GameStateConverter.toProto on every replayed
intermediate state — wasted work for stateAfter callers, since the
replayed entries are transient and never persisted. With a 19k-action
game, a single stateAfter(14790) call (e.g. from
UnrequestedTextHandler processing a stale incomplete LLM request)
materialized ~4500 PersistedActionResult instances, each holding both
proto and Scala forms of the game state, allocating an estimated
1.1-1.5 GB and OOMing the 2g heap on Province.apply during one of
those toProto calls.
Add a private replayScalaOnlyToState helper that replays in Scala and
returns just the final state, with no proto conversion. Use it in the
stateAfter disk-load branch, and short-circuit the replay at the
target index by taking only `count - initialIndex` actions instead of
replaying through persistedCount.
Behavior preserved:
- formAwrs is unchanged (the save flow still gets eager proto for
in-memory entries, avoiding repeated toProto cost across save
accumulations).
- partialGames load range is unchanged (chunk-range optimization
intentionally deferred).
- recentHistory branch is unchanged.
Per-call impact on the failing stateAfter(14790) path:
before: replay 4575 actions, run toProto 4575 times,
retain Vector[PersistedActionResult] of length 4575 with
proto + Scala state on every entry — ~1.1-1.5 GB transient.
after: replay 15 actions, no toProto calls,
allocate one GameState — a few MB.
223 Scala tests pass.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Add failing tests for CombatUnitSelector hero/battalion suitability
Documents three desired behaviors that the current greedy
selectedCombatBattalions() does not satisfy:
1. Engineer should be taken over a no-profession hero on a Longbowmen
battalion when only one slot is available — Suboptimal pairing is
preferable to leaving the much more capable Engineer behind.
2. Same for a Mage: a Suboptimal Longbowmen pairing is preferable to
leaving the Mage behind.
3. A Mage with only Restrictive options (e.g. only a non-casting
Heavy Cavalry battalion) should be skipped entirely, not glued to
the Restrictive battalion via the getOrElse fallback.
The current code:
- Uses a lexicographic (suitability.ordinal, -power) sort, so any
Optimal hero beats any Suboptimal hero regardless of how much more
capable the Suboptimal hero is. This causes (1) and (2).
- Has a getOrElse fallback that pairs the strongest hero with the
strongest battalion when every (hero, battalion) pair is Restrictive,
ignoring suitability. This causes (3).
These tests fail today and will pass after the planned fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Fix CombatUnitSelector matching: soft Suboptimal penalty, skip Restrictive
Three changes to selectedCombatBattalions's chooseOne:
1. Replace the lexicographic (suitability.ordinal, -power) sort with a
soft scoring scheme: power minus SuboptimalPairingPenalty (50) when
the pairing is Suboptimal. An Engineer's +200 profession bonus still
outweighs a no-profession hero on a Suboptimal Longbowmen pairing,
but an equally-strong Optimal hero still wins the slot.
2. Filter Restrictive heroes out of the candidate pool entirely for the
chosen battalion rather than relying on suitability ordering — so a
Mage can never win a non-casting battalion via lexicographic ranking.
3. Replace the getOrElse fallback (which previously glued any hero to
any battalion regardless of suitability) with a three-way match:
valid pairing exists -> take it; no battalions left -> strongest
hero marches alone (preserves prior behavior); battalions remain but
every pairing is Restrictive -> return None and stop adding heroes.
The five tests in CombatUnitSelectorTest now all pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Take Restrictive pairings rather than abandon a battalion
Earlier in this branch we treated "all options for a hero are
Restrictive" as a signal to skip the hero. That was wrong: a Restrictive
pairing (e.g. Mage leading Heavy Cavalry) is still better than leaving
the battalion behind entirely. The correct rule is "prefer non-Restrictive
heroes when any are available, but fall back to Restrictive rather than
losing the unit."
- Replace test 3 ("leave a mage behind ... if every battalion is
Restrictive") with the inverse: "take a mage on a Restrictive
battalion if that's the only way to take the battalion at all".
- Add a counterpart test confirming a no-profession hero is still
preferred over a mage on a Restrictive Heavy Cavalry when both are
available.
- Simplify chooseOne: always pick the strongest battalion, prefer
non-Restrictive heroes for it, fall back to Restrictive heroes if
none are non-Restrictive. Drop the early-return / skip path.
All six CombatUnitSelector tests pass; full Scala suite (223 tests)
still green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
In Eagle battles, computeShardokServerStatus checks each faction's
currentCommand to decide between YOUR_TURN, WAITING_FOR_AI,
WAITING_FOR_HUMAN_PLAYER, and PROCESSING. The AI's currentCommand,
however, isn't populated during the window when the AI is computing
its move — so the typical post-player-turn state had no faction with
a current command and fell through to PROCESSING. Clients never
observed WAITING_FOR_AI in practice; they bounced between YOUR_TURN
and "Processing...".
When no faction has a current command, infer the most useful status
from which other factions are present in the battle: WAITING_FOR_AI
if any AI faction remains, WAITING_FOR_HUMAN_PLAYER if any other
human remains, and PROCESSING only when no other factions are present
(an edge case at battle boundaries).
The custom-battle path (CustomBattleManager.customBattleStatus) was
already binary YOUR_TURN/WAITING_FOR_AI and not affected.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Wire ShardokServerStatus into the connection-status indicator
Client half of the Shardok-side connection-status work. The server
already populates ShardokServerStatus on SingleShardokGameResultResponse
(see #6685); this hooks it up to the persistent ConnectionStatusUI so
players can see whether they're up, the AI is thinking, or another human
is about to act while a battle is on screen.
- ShardokGameModel implements IShardokGameStateProvider and caches the
latest ShardokServerStatus, updated as new responses arrive in
EagleGameModel.
- ConnectionStatusUI gains a Shardok provider slot; when set, it renders
Your Turn / Waiting for AI / Waiting for Other Player / Processing
instead of the strategic-layer status.
- EagleGameController binds the UI to the Shardok model when entering a
battle; ShardokGameController clears the binding on exit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Fix PersistentUIManager namespace in ShardokGameController
CI build failed with:
error CS0234: The type or namespace name 'PersistentUIManager' does
not exist in the namespace 'eagle'
PersistentUIManager lives in the `common` namespace, and Shardok already
has `using common;`, so the `eagle.` qualifier was both wrong and
unnecessary.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Wire ShardokServerStatus through CustomBattleHandler
Custom battles use CustomBattleHandler as the gRPC subscriber instead
of GameModelUpdater, so the existing wiring on EagleGameController +
EagleGameModel didn't reach them — the indicator stayed on "Connected"
in custom battles even after the server began populating
ShardokServerStatus correctly.
- Set the ShardokGameModel as the connection-status UI's Shardok game
state provider when entering a custom battle. Cleanup is already
handled by ShardokGameController.EndTurn for both battle paths.
- Forward resp.ShardokServerStatus to the model from
CustomBattleHandler.ReceiveGameUpdate, mirroring the per-response
handling that GameModelUpdater already does for Eagle battles.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Add doc describing server-side work for Shardok status indicator
Hand-off note for the Scala worktree: client wiring on
shardok-status-client is complete, but the indicator only ever shows
"Connected" in battle because the server attaches a ShardokServerStatus
proto with status left at the default UNKNOWN value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Populate ShardokServerStatus in remaining response paths
PR #6685 added the ShardokServerStatus proto and populated it in
GameController.humanClientsAfterPostingResults, but three other
SingleShardokGameResultResponse construction sites left the field
unset, defaulting to UNKNOWN on the wire and producing the symptom
flagged in docs/SHARDOK_SERVER_STATUS_SERVER_WORK.md.
Fix:
- HumanPlayerClientConnectionState.streamUpdates / apply factory now
take a shardokServerStatusFor: ShardokGameId => ShardokServerStatus
callback and set the field on each per-battle response. Avoids a
Bazel dep cycle into game_controller by accepting the precomputed
function rather than calling computeShardokServerStatus directly.
- GameController.streamUpdates wires
GameController.computeShardokServerStatus into that callback.
- CustomBattleManager populates the field at both construction sites
via a small helper (YOUR_TURN if the human has a current command,
WAITING_FOR_AI otherwise) — custom battles have only one human
faction and the AI on -1, so the full computeShardokServerStatus
machinery is overkill.
Existing test sites in HumanPlayerClientConnectionStateTest pass a
stub shardokServerStatusFor since they don't exercise active battles.
Removes the now-stale TODO doc — its diagnosis (enum never set) no
longer applies.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Introduces a new ShardokServerStatus message on
SingleShardokGameResultResponse so the client can render a tactical-side
connection-status indicator (Your Turn / Waiting for AI / Waiting for
Other Player / Processing).
ShardokServerStatus is intentionally separate from ServerGameStatus so
the strategic and tactical layers don't have to evolve in lockstep.
GameController.computeShardokServerStatus picks the status from the
receiving player's perspective by checking shardokPlayerAvailableCommands
across factions and classifying them via aiClientFactionIds. The two
humanClientsAfterPostingResults overloads now thread an aiFactionIds set
through and populate the new field on every per-battle response.
This is the server half of a two-PR split. The client wiring lands
separately; until then the new field is emitted but unread, which is
safe.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Matches the existing pattern used for the Improve Province and Give Alms
buttons earlier in the strategic tutorial.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
When the last human drops a running game, the previous order was:
cancel-battles → archiveGame → mutate state → save() → return. Both
cancelBattlesForGame (gRPC roundtrip) and archiveGame (per-S3-file
copy + delete, all serialized) ran inside this.synchronized AND inside
the lock that gates the lobby push, so users could wait several
seconds to see the dropped game disappear from the lobby list.
Now we mutate state and save() first so the lobby update reflects the
removal immediately, then run cancelBattles + archive on a background
Future. Failures are logged.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Highlight Elena (and other reinforcements) reliably in tutorial dialogues
Elena Fyar's hex wasn't pulsing during her arrival dialogue even though
the trigger fired. The most likely causes are: she's matched in
ReserveUnitsById rather than UnitsById at the moment the dialogue plays,
the SelfHostility check rejects an allied-rendered reinforcement, or her
ProfessionInfo isn't populated on whichever diff happens to be the
latest UnitView in UnitsById when ApplyUnitHighlight runs.
Three changes, defense in depth:
- DialogueManager.ApplyUnitHighlight now scans UnitsById ∪ ReserveUnitsById
(still requires a placed Location), and treats any non-enemy unit as
highlightable rather than requiring SelfHostility.
- Elena's and Ranil's reinforcement dialogues, plus engineer_bombard
(which is Ranil speaking about himself), now match by heroName instead
of profession. Each dialogue is about a specific hero by name, and
heroName goes through unit.Name (text-id-based) which doesn't depend
on ProfessionInfo being present on the latest diff.
- duel keeps profession matching since the dialogue is about Champions
in general.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Re-apply tutorial unit highlight every frame
The Shardok controller's ModelUpdated() calls hexGrid.ClearOverlays() on
every model update, which wiped the tutorial unit highlight applied
during step 1 of a multi-step dialogue: by the time the player was
reading Marek's lead-in line about Elena (or Ranil), a follow-up server
update had cleared the overlay. Step 2 happened to survive because no
further model updates arrived between the player advancing the dialogue
and the render.
DialogueManager now stores the current step's highlight criteria and
re-applies it every frame from Update() so it survives any number of
ClearOverlays calls. ApplyUnitHighlight is now idempotent (clears its
tracked cells before re-scanning).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The previous 130 setting netted +30 effective bias after
FactionBiasFromImprisonment (-100), which then decayed to 0 within
3 rounds via NewRoundAction's per-round bias decay (additive -10/round
below |100|, multiplicative *0.9 above). By the time prison-round odds
had accumulated, the bias contribution was already gone.
Setting the config to 300 yields +200 effective bias, putting it in the
multiplicative-decay regime: ~+131 by round 4 (Nov) and ~+96 by round 7
(Feb). That keeps Luke comfortably over MinOddsForRecruitment (80)
through the intended early-tutorial recruitment window.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Old Marek's first tutorial-battle line previously named Shardok's Guard
as "the finest soldiers in the realm" and referred to "knights besides"
as a separate unit. Since the heavy-cavalry battalion in the attacking
army is named Doomriders, attribute the knights line to them directly
and drop the implicit second unit reference.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Several battle tutorial dialogues call out specific units by role
(longbows for archery, the champion for dueling, Marek for charging,
the engineer/paladin reinforcements, Hedrick for hiding) or by an
available action (melee, start fire). The dialogue had no way to point
at the unit on the battlefield, so the player had to find it themselves.
Adds an optional `highlightUnit` block to dialogue steps with criteria
fields (battalionType, profession, heroName, canStartFire, canMelee)
that DialogueManager resolves against the current Shardok model. Each
matching friendly unit gets a strobing border via HexGrid.OverlayCell
— the same primitive already used for archery range and movement
overlays — so the visual language matches existing combat highlights.
The overlay is cleared on step change and dialogue end.
Wires up the criteria for all relevant battle dialogues:
- archery_available → Longbowmen
- ability_charge_available → Old Marek the Learned
- melee_available → friendly unit with a MeleeCommand available
- start_fire_available → friendly unit with CanStartFire
- duel_available → Champion
- tutorial_reinforcement_paladin → Paladin (Elena)
- tutorial_reinforcement_engineer → Engineer (Ranil)
- engineer_near_enemy → Engineer
- hide_available → Hedrick the Hedge-Merchant
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The tutorial configures +110 faction-3 bias on Luke so that he becomes
recruitable shortly after capture, but EndBattleAftermathPhaseAction
applies FactionBiasFromImprisonment (-100) to the imprisoning faction
and merges in initialFactionBiases on top of it. The effective bias was
only +10, which left him needing several extra rounds of prison-time
odds adjustment before reaching MinOddsForRecruitment (~Feb/Mar 372 in
practice). Bumping to 130 raises effective bias to +30 (~4 rounds
earlier), pulling first recruitability to ~Nov 371 as intended.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Bregos, Bridget, and Hedrick now all start at -500 trust toward each
other. With trust drift at +1/round, that's effectively permanent for
tutorial timescales. The invitation gate (TrustForDiplomacy.scala) only
checks the inviter's trust toward the target, so previously Bregos's
trust toward the smaller factions was an unset 0 and he could try to
absorb them after his earliestRoundForInvitation passed.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The opening tutorial's pre-battle step instructed the player to click
"Battle!" and highlighted "GoToBattleButton", but that UI was replaced
by per-battle Fight buttons on the BattleProgressPanel. The dialogue now
references "Fight!" and BattleProgressRowController dynamically registers
its action button as "FightButton" with the tutorial target registry while
the row is in the fightable state, unregistering otherwise (and on
destroy) to avoid stale highlight targets.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The Shardok tutorial, narrative hook, and early-victory items are
already delivered by the dialogue system: tutorial_battle.json covers
combat mechanics start to finish, tutorial_opening provides Sadar's
backstory in the first scene, and surviving Tarn's attack is the
early-victory beat.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
EagleAppearsAction had Province32Stats / Province6Stats hardcoded
alongside hardcoded battalion definitions and random hero counts —
all duplicating the corresponding entries in game_parameters.json.
Any tuning of the normal game's Ingia/Soria starting state would
silently diverge in the tutorial.
Replace the hardcoded constants with a lazy load from
GameParametersUtils.defaultExpandedGameParameters: find the
SetFaction whose head is The Eagle, derive ProvinceConfig (stats,
random hero count, battalion templates) from each occupied province,
and look up which province belongs to the Eagle vs. Tarn by primary
ruling hero name. Battalions come through BattalionConverter.fromProto
and have IDs assigned at result time.
Behavior is preserved (verified by EagleAppearsActionTest).
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Add dialogue for Ranil and Elena forming their own factions
The tutorial_hero_faction_appears trigger fires when John Ranil and
Elena Fyar break off from Sadar's service to form their own factions
(Builders of a Better World, Champions of Justice) seven rounds after
The Eagle appears. The trigger had no dialogue script, so a major
narrative beat — losing two of the player's signature allies — fired
silently.
Add a 4-step dialogue: Marek frames the departures as the same
mysterious force that has been claiming the King's heroes; Ranil and
Elena each speak their reason for leaving; Marek closes by noting they
are not enemies and an alliance may be possible later.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Sharpen Marek's closing line to convey ambition
Old Marek now frames Ranil and Elena as obstacles the kingdom may need
to go through, not just former allies on different paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Don't blame Bregos Fyar as the realm's arsonist
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
The TutorialStrategicEventsTest comment claimed EagleAppearsAction's
result-creation was tested in TutorialGameCreationTest, but no such
tests existed — leaving the Eagle/Tarn faction setup uncovered.
Add EagleAppearsActionTest covering: faction creation and hostility,
The Eagle hero, Tarn's faction switch and loyalty, province 32 and 6
ownership / hero placement / stats, and battalion creation. Run
against a real bootstrapped tutorial game state via
TutorialGameCreation.createTutorialGame.
Also fix the stale comment in TutorialStrategicEventsTest.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Doc said Tarn's attacking army originates from province 32; the
config in tutorial_parameters.json:102 has originProvinceId: 31.
Province 32 (Ingia) is unoccupied at game start and only becomes
relevant later when The Eagle appears.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Catalog four triggers missing from the original doc:
tutorial_faction_appears, tutorial_hero_faction_appears,
tutorial_hero_departed, and tutorial_hero_departed_again (all from
OnStrategicActionResult), plus shardok_battle_reset (fired directly
from ShardokGameController, bypassing the registry).
Replace the brief expected-flow stub with the full strategic-map and
tactical-battle dialogue flow tables, plus the underlying narrative
arc, so future tutorial work has a concrete map of what fires and
when.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Update Small Eagle TODO: lobby fixes done, merge tutorial + onboarding
Mark lobby fixes complete and consolidate the tutorial and
first-session onboarding bullets into a single section, since they
overlap heavily (a guided first scenario is essentially a tutorial).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Add tutorial system architecture doc
Document the Unity tutorial system's class map, step lifecycle,
trigger catalog (with file:line citations), highlight targets,
persistence, and expected first-session flow. Calls out that the
step-based UI is currently dormant (RegisterAll early-returns) and
the dialogue system is the live path today.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Previously /games/<id> returned a plain-text 404 with no way back to
the games list. Now renders an HTML error page through the shared
layout with a link back to /games.
Adds a generic error.html template and a renderError helper so other
handlers can use the same pattern.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
AddressableLoader.cs and AssetUsageAuditor.cs were committed without
their .meta files, so each dev's Unity was generating a different
local GUID. Commit the metas to lock the GUIDs so any future
prefab/ScriptableObject reference will be stable across clones.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Rows where the viewer sees troop counts (allied) render non-empty
AttackerLabel / DefenderLabel, while other rows render them empty.
The labels' LayoutElement had MinWidth=-1, so Unity fell through to
TMP's content-dependent minWidth, making the bar area's inner edges
land at different X positions across rows whenever available width
was tight.
Pin MinWidth=100 (== PreferredWidth) on both labels so their width is
content-agnostic and the bar areas line up.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Rebakes many UI RectTransforms in the Gameplay scene: several panels
switch to layout-driven sizing (anchors/position/size zeroed to let a
parent LayoutGroup/ContentSizeFitter drive them) and a few panels get
explicit resizes.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Report WAITING_FOR_PLAYERS while an AI is taking its turn
computeServerGameStatus only considered other human factions when
checking whether to report WAITING_FOR_PLAYERS, so when a human player
finished their turn and the server transitioned to AI play it returned
None. The client keeps its previous YOUR_TURN status when the server
sends a None update (ServerStatus is only overwritten when non-null)
while HandleAvailableCommands clears commands on the same update, so
CheckForTurnMismatch fired a spurious reconnect after its 3s grace.
Pass all other faction IDs (human + AI) into computeServerGameStatus
so WAITING_FOR_PLAYERS is reported whenever any other player has
commands to act on.
* Count unrequested (queued) texts in hasIncompleteTextsAccessibleTo
Chronicle texts begin their life in unrequestedTexts and only move to
incompleteTexts once withMarkedRequested fires as the LLM request
actually starts. If computeServerGameStatus ran during that window -
after the player's commands had already cleared but before the LLM
request had been kicked off - hasIncompleteTextsAccessibleTo returned
false, the status fell through to None, and the client kept its stale
YOUR_TURN while its commands were cleared on the same update. That is
the actual trigger for the 3s turn-mismatch reconnect we kept chasing:
waiting for the chronicle text to start streaming, not waiting for an
AI turn.
Include 'unrequested' alongside 'incomplete' in both the in-memory
default implementation and the SQLite EXISTS query so we report
GENERATING_TEXT for the full queued + generating window.
The re-subscribe path in HumanPlayerClientConnectionState.streamUpdates
was computing ServerGameStatus using simpler logic than
humanClientsAfterPostingResults: it only reported YOUR_TURN (when the
player had commands) or None otherwise. When a client re-subscribed
while another player was taking their turn, a battle was in progress, or
chronicle text was generating, the server sent serverGameStatus=None and
empty availableCommands. The client cleared its command state but kept
the stale YOUR_TURN status from the previous update (ServerStatus is
only overwritten when non-null), which tripped CheckForTurnMismatch
after its 3s grace and triggered a spurious reconnect.
Extract GameController.computeServerGameStatus as a single source of
truth, and route it through HumanPlayerClientConnectionState.apply and
through rewindTo so all three paths agree on
BATTLE_IN_PROGRESS / YOUR_TURN / GENERATING_TEXT / WAITING_FOR_PLAYERS.
* Add failing test: fleeing army at hostile province should not shatter
A defender that fled a battle lands at its pre-selected flee province
with fleeProvinceId cleared. If that province has since been conquered
by the enemy, shatterStrandedArmies destroys the army outright — but the
army still has combat power and should get a chance at battle via the
normal HostileArmySetup → AttackDecision flow.
This commit flips the prior "should shatter" test to the behavior we
actually want. Fix to follow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Remove shatterStrandedArmies so fleeing armies can trigger battle
Fleeing armies arrive at their pre-selected flee province with
fleeProvinceId cleared (by ResolveBattleAction) so they can't flee
again. If the province has been conquered by the enemy in the meantime,
shatterStrandedArmies was destroying the retreating force outright
during ProvinceMoveResolution — even though the army still had combat
power and should get a chance at battle.
Removing the block lets these incoming armies fall through to
HostileArmySetup later in the same round, where they become hostile
army groups and trigger the normal AttackDecision → battle flow.
Also drops the now-unused ShatteredArmyUtils.shatteredStrandedArmyResult
helper.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Adds a deterministic chooser that walks a priority tree per turn toward
fulfilling an UpgradeBattalionQuest: arm-completes (optionally preceded by
travel and/or a safe food sale to cover the gold cost) -> train-completes
-> march in a pre-qualified neighbor battalion -> organize/top-off of the
quest battalion type -> march in an unqualified neighbor battalion ->
develop Economy/Agriculture -> train progress -> develop Infrastructure
-> arm progress. Travel is only toggled when it directly enables an
arm-completes finisher, to avoid fragile multi-turn plans.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* Add FightBeastsAloneQuest AI quest handler
When an unaffiliated hero offers a FightBeastsAloneQuest, the AI now
sends the weakest expendable non-leader hero to fight beasts without a
battalion. Only heroes weaker than the quest-giving hero are considered,
since the hero will almost certainly die.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add MaxExpendableHeroPowerRatio setting for FightBeastsAloneQuest
Hero must have power at most 75% of the quest-giving hero's power to
be considered expendable, ensuring the trade is clearly worthwhile.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add MaxExpendableHeroPowerRatio to settings.tsv and regenerate loader BUILD
The new setting was added to the settings BUILD.bazel but was missing from the
TSV source file, causing the auto-generated settings_loader to lack the dep.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add BattalionDiversityQuest AI quest handler
When an unaffiliated hero offers a BattalionDiversityQuest, the AI now
hires one battalion of a type it doesn't already have. Only types where
the province meets development requirements are considered, and the
province must have sufficient gold and food surplus.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Only hire battalion when it would reach 3+ distinct types
The quest requires 3+ different types at near-full capacity. Skip
hiring if the province has fewer than 2 existing types, since adding
one more wouldn't reach the required 3.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Reorganize the "Does Not Attempt" section into a prioritized plan for
future implementation and a list of quests not planned for active
pursuit.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When an unaffiliated hero has a SuppressRiotByForceQuest and the faction
has battalions available, the AI now prefers CrackDown over Give when
handling riots. This modifies the existing riot handler priority rather
than adding a quest command chooser, since riot handling runs before
FulfillQuestsCommandSelector.
The AI estimates survival odds against a 90th-percentile riot before
committing to CrackDown, and prefers non-leader heroes to avoid risking
the faction head.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Matches the quest's target outlawHeroId against available outlaws in the
province and issues an ApprehendOutlaw command when found.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Higher priority than the count-based ReconProvincesQuest since reconning
a specific province also counts toward generic recon counts. Prefers
targets not yet reconned by the faction.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add ReconProvincesQuest AI quest handler
When an unaffiliated hero has a ReconProvincesQuest, the AI now issues
a Recon command to reconnoiter a province, helping fulfill the quest.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Prioritize recon target provinces in ReconProvincesQuestCommandChooser
Sort candidates by (isNeighbor, isHostile, notRecentlyReconned) to
prefer hostile neighboring provinces that haven't been scouted recently,
matching the prioritization pattern from MidGameAIClient.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Deprioritize unowned provinces in recon target selection
Unowned provinces (no ruling faction) are now sorted last, even if
they're neighbors, since reconning owned hostile provinces is more
strategically valuable.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add GrandArmyQuest AI quest handler
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Address review: extract helper, use flatMap/inside, verify quest fulfillable
- Extract grandArmyTarget into a private method with pattern matching
- Replace organize match with flatMap chain
- Only return command if total troops after organizing meet the quest
target (prevents partial fulfillment)
- Replace asInstanceOf with inside() in tests
- Add test for insufficient resources to meet target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add AI quest handlers for blizzard, drought, and epidemic own-province filtering
Create ControlWeatherQuestCommandChooser to handle StartBlizzardQuest and
StartDroughtQuest via the ControlWeather command. Update StartEpidemicQuest
CommandChooser to skip quests targeting the acting faction's own provinces.
Both handlers verify the appropriate command is available before selecting.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Address review: use inside() pattern and extract questTargets method
- Replace asInstanceOf with inside() pattern in ControlWeather and
StartEpidemic test assertions
- Extract questTargets into a private method in
ControlWeatherQuestCommandChooser and match directly on its result
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add DevelopProvinces and MobilizeProvinces AI quest handlers
Implement IssueOrders-based quest command choosers so the AI can
fulfill DevelopProvincesQuest and MobilizeProvincesQuest by switching
province orders. Develop prefers non-hostile-neighbor provinces;
Mobilize prefers hostile-neighbor provinces. Mobilize is skipped when
a DevelopProvincesQuest also exists to avoid conflicting orders.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Extract ProvinceOrderQuestHelper to deduplicate order-switching logic
The Develop and Mobilize choosers shared nearly identical logic for
finding the IssueOrders command, counting current orders, partitioning
by hostile neighbors, and building the selection. Extract this into
ProvinceOrderQuestHelper.chooseOrders parameterized by target order
type, hostile-neighbor preference, and reason string.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove early returns from province order quest choosers
Use pattern matching, flatMap, and Option.when instead of early
returns for idiomatic Scala style.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Without a teammate to capitalize on the stun, a solo attacker casting Fear
just ends its turn no closer to killing units or capturing castles. The
state scorer currently rewards the resulting morale drop on the victim,
so minimax/MCTS can pick Fear over useful actions. Prune it in
AICommandFilter with one exception: if the victim is already on a fire
tile, the stun locks them there and the burn damage supplies the
follow-up, so Fear is still worthwhile in that case.
The rule only applies to attackers — defenders legitimately use Fear to
stall, which is their win condition.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The doc was missing 11 quest handlers that were added since it was
written: all prisoner quests, TotalDevelopment, SpendOnFeasts,
SendSupplies, RestProvince, StartEpidemic, and SwearBrotherhood.
Also fixes the priority order (Alliance is last, not first).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
There is no Ctrl key on mobile platforms, so the [CNTL] helper text
under the profession attack button is meaningless. Skip it when
running on IPhonePlayer or Android.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The profession attack button group (group 5) always showed "[CNTL]"
underneath, but that hint only makes sense for targeted commands like
Reduce where the player can ctrl-click a hex. Fortify has no target,
so showing the modifier key shortcut is misleading. Now the helper
text only appears when the group has a targeted command.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Dedupe stale ActionResultViews by unfiltered result count on main thread
Eagle's HandleUpdates previously applied every ActionResultView it was
handed, regardless of whether those results had already been applied.
The only count-based safety net was the heartbeat SyncMismatch check,
which triggers a reconnect but does not stop an in-flight MainQueue job
from applying stale deltas first. Applying a stale ActionResultView
(e.g. re-executing VASSAL_EXILED) can corrupt client state and throw
"Duplicate unaffiliated hero".
Track a main-thread-only high-water mark _lastAppliedUnfilteredCount and
gate HandleUpdates on it: if a delivery's UnfilteredResultCountAfter is
not ahead of the high-water mark but carries results, drop them with a
[DEDUP] log line. Reset the high-water mark in HandleStartingState so
state resyncs/rewinds rebuild the baseline cleanly.
This is defense-in-depth alongside the mid-stream SUBSCRIBE fix: it
protects the main thread against any source of stale/duplicate
ActionResultView delivery, not just the specific TryPendingCommands race.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Handle mid-batch exceptions in HandleUpdates by forcing a state resync
The previous commit added a dedupe gate but assumed ActionResultView
batches were applied atomically. If HandleNewHistoryEntry throws partway
through a batch, _currentModel is left partially mutated:
_lastAppliedUnfilteredCount never advances (so future batches would
re-apply the 1..k results that did land), while _lastUnfilteredResultCount
on the gRPC thread has already advanced (so the heartbeat sync check
reports "in sync" and never schedules a reconnect). The client
invisibly corrupts and the dedupe gate itself makes the next delivery
worse, not better.
Wrap the apply loop in try/catch. On exception:
- mark the model _modelCorrupted and refuse to apply any further
GameUpdates in ReceiveGameUpdate until a fresh StartingState arrives
- reset both _lastUnfilteredResultCount (under its lock) and
_lastAppliedUnfilteredCount to 0 so the upcoming subscribe asks the
server for a fresh snapshot
- report the exception via ErrorHandler so we still get a stack trace
- trigger PersistentConnection.ForceReconnect() to drive the resync
HandleStartingState clears the _modelCorrupted flag when the fresh
snapshot lands, at which point normal update processing resumes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Switches from ubuntu-latest to [self-hosted, bazel] and replaces
s3cmd with aws CLI. Installs aws via brew if not already present.
Uses DO_SPACES credentials (same key pair the Eagle server uses to
write archived games) and macOS date syntax.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
bazel clean races with other runners sharing the output_base on
/Volumes/remote_cache, causing "Directory not empty" failures
(4 of last 5 runs). Manual trigger preserved via workflow_dispatch.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Color unit labels via TMP_Text.color instead of rich-text tags
SetOneUnitLabels was building <color=#...>Name</color> / <b> wrapped
strings on every update. Since each label is rendered in a single color,
setting TMP_Text.color directly is cheaper (no per-update string
concatenation, no rich-text tag parsing) and safer (type-checked Color
instead of hex string formatting). Secondary-label bold is now a font
style set once at cell creation.
SetUnitInfoLabels now takes an explicit Color parameter; all four
callers updated.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Disable richText on hex-grid labels
None of the hex-grid TMP labels (hero names, battalion sizes, odds
percentages, action points, coord debug strings) use rich-text tags
after the previous commit. Disabling richText in the shared label
factory skips TMP's per-update tag-scanning pass and makes the labels
robust against accidental '<' characters in localized text.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Adds a third unit-label color in Shardok battles: purple (#8000FF) for a
player's mage that has already picked a meteor target but is still in the
Target state and could retarget on the same turn. Blue/black remain for
"has commands" / "no commands".
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Clear focus province when faction loses ruler to keep state valid
RuntimeValidator requires that every faction's focusProvinceId points
to a province it rules. ProvinceConqueredAction and
EndPlayerCommandsPhaseAction already emit ChangedFactionC with
clearFocusProvinceId for their respective pathways, but other pathways
(hero departures, battle casualties leaving a province unruled, etc.)
reach the applier's fixRulerIfNeeded hook without any faction-side
cleanup. The next validation pass then crashes with "Unowned focus
province for faction ...".
Clear the stale focus alongside fixRulerIfNeeded in the applier so any
ChangedProvinceC that removes or transfers a ruling faction also drops
the now-invalid focus pointers. The negative case (same ruler, just
losing some heroes) is preserved so active focuses aren't dropped
unnecessarily.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Switch to end-of-phase focus cleanup instead of applier side-effect
Reverts the applier-level focus-clearing added in the previous commit.
Modifying factions as a side-effect of applyChangedProvinceC violates
the event-sourcing model: every game state change should be described
by an ActionResult, not implicit in the applier.
New approach, matching the pattern used by other
EndPlayerCommandsPhaseAction-style cleanups:
- RuntimeValidator.validateFactions now only enforces the "focus must
be an owned province" invariant during PlayerCommands. Other phases
may legitimately leave a stale focusProvinceId in transit (e.g.
fixRulerIfNeeded silently clears rulingFactionId after hero
departures or battle casualties).
- EndVassalCommandsPhaseAction is the sole entry point into
PlayerCommands. Its final ActionResultC now carries
ChangedFactionC(clearFocusProvinceId = true) for every faction
whose focus province is no longer ruled by it, so the invariant
holds when the validator re-engages.
- Removes the three applier-level tests that exercised the reverted
side-effect, and adds EndVassalCommandsPhaseActionTest covering the
new cleanup behavior (unruled focus, focus taken by another
faction, and focus still held).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
shatterStrandedArmies only checked whether an incoming army's destination
was not its own faction's territory. Since isFriendlyMove does
`province.rulingFactionId.contains(...)`, unoccupied provinces (None)
also returned false, so any already-retreated army (fleeProvinceId = None)
arriving at an unoccupied province was spuriously shattered with a
"nowhere to withdraw to" notification.
Add an explicit `rulingFactionId.isDefined` guard so arrivals at
unoccupied provinces fall through to the normal uncontested conquest
flow.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
TryPendingCommands' stale-token branches called StreamOneGameAsync to
"refresh state" when a pending command's token was behind the current
token. This sent a SUBSCRIBE(unfilteredCount=N) over the active stream,
which caused the server to replay history.since(N) as a new
ActionResultResponse while normal updates were still in flight.
Those re-delivered ActionResultViews were then applied a second time on
the main thread, which could surface as (e.g.) "Duplicate unaffiliated
hero N in province M" when a VASSAL_EXILED action ran twice.
The refresh is unnecessary: we only reach the stale-token branch because
an ActionResultResponse already delivered the newer token and its
updates were applied. Drop the stale command without re-subscribing.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The divination and quest-ended prompts for SendSuppliesQuest and
SpendOnFeastsQuest both said "over $componentCount months", but
componentCount on these quests equals the total food/gold (since the
counter is incremented by units delivered, not months elapsed). This
produced confusing output like "send 1337 food over 1337 months".
Drop the time clause — these quests are purely cumulative totals.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When an exception propagates out of an async-void lambda enqueued to
MainQueue, it goes through AsyncMethodBuilderCore.ThrowAsync to Unity's
SynchronizationContext, which logs it but cannot undo the partial state
changes already applied by the failing update. Subsequent updates then
apply diffs on top of corrupt state, cascading further errors until the
client is effectively unusable.
Wrap ReceiveGameUpdate at all four call sites in HandleGameUpdate so
exceptions are caught at the source, logged via Debug.LogException
(triggering the in-game ErrorHandler panel) and the file logger, and the
enclosing lambda completes normally. TryPendingCommands and timing logs
still run, keeping the MainQueue pump healthy.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
VelociraptorEffect.prefab's animalPrefabs array references two
prefab GUIDs (8fa2338697777704fafc6374e9750de9 and
df9fda81573442940a2a92a3628b3cbc) from the Dino Pack Low Poly V1
asset bundle that were never committed to the repo in PR #6525.
The pack's Velociraptor.fbx, materials, and textures were added,
but the standalone .prefab files that wrap the fbx with an
Animation component and a MeshRenderer were missing.
The dangling references didn't fail until something in the live
game actually triggered ProvinceBeastsController.SpawnBeastsEffect
for a "velociraptor" beast, at which point AnimalEffect.SpawnAnimals
threw MissingReferenceException on the null array element.
Re-import the two referenced prefabs from Dino Pack Low Poly V1
(PrefabPack/PrefabSingleTexture/VelociraptorP/). The re-imported
prefab GUIDs match exactly, and the root GameObject fileIDs
(7255238702826593904 and 8644578109599837090) match the fileIDs
VelociraptorEffect.prefab already references. Internal references
(VelociraptorC1.mat material, Velociraptor.fbx mesh and animations,
VelociraptorMapAnims.controller) are all already in the repo.
The atlas-texture variant prefabs from the same pack were not
committed since nothing references them.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Multiple MainQueue actions (one per ActionResultResponse and
ShardokActionResultResponse) each call TryPendingCommands as
async void lambdas. When one yields at an await, the MainQueue
Update loop immediately invokes the next action, which re-enters
TryPendingCommands. Because PostRequest synchronously re-adds
the command to _pendingCommands before yielding for WriteAsync,
each parallel invocation re-drains the same buffered command and
writes it to the gRPC stream again. This produces bursts of 10+
identical writes in a single frame, all of which the server
rejects with BAD_TOKEN.
Wrap TryPendingCommands in a non-blocking SemaphoreSlim acquire.
If another invocation is already running, skip and return — any
items added to _pendingCommands while it runs will be picked up
by the next ActionResultResponse handler that fires.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Occasional disconnects are normal and usually recover on the first
retry, so the old behavior of immediately flashing "Reconnecting..."
(and a 2s countdown) on every blip was too noisy.
Now the first retry after a fresh disconnect is "silent": we don't
touch _currentState, so the status UI keeps showing Connected while
the retry attempt runs in the background. Only after the first retry
has itself failed does the ScheduleReconnect path flip state to
Reconnecting, at which point the user sees the countdown (starting
from the 4s second-attempt backoff).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Introduces the FocusProvinceStorageMultiplier setting (default 1.50) and
applies it to food and gold caps when a province is its ruling faction's
focus province, so focus provinces can stockpile extra reserves.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add delta gradient flash to battle thermometer
When the thermometer ratio changes, overlay a gradient on the delta
region showing the gaining side's color fading toward white-tinted,
making the territory shift visible. Fades out 2.5x faster than the
existing boundary flash line.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix invisible delta gradient by using FullRect sprite mesh
The gradient sprite was using the default SpriteMeshType.Tight which
trimmed out the fully-transparent pixel, producing either an empty or
half-sized mesh. Use SpriteMeshType.FullRect with a 256x1 texture for a
cleaner gradient, and SetAsLastSibling so the overlay renders on top of
the attacker/defender bars. The boundary flash line also reasserts
last-sibling so it stays visible above the gradient.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add diagnostic logging for delta gradient
Temporary logging to diagnose why the gradient overlay isn't visible
in-game. Will be removed after diagnosis.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* DEBUG: render delta gradient as opaque magenta to test positioning
Temporarily replace the gradient sprite with plain opaque magenta and
add canvas renderer state logging to isolate whether the problem is
with the sprite/shader or with positioning/z-order/masking.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* DEBUG: use attackerBar sprite, bright green, no fade
Set the delta gradient to use the attacker bar's known-working sprite,
force opaque bright green, and disable the fade coroutine so the image
stays permanently visible. Also log the full transform path to confirm
which thermometer instance is being modified.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* DEBUG: test gradient sprite with permanent green tint
Swap back to the procedural 256x1 gradient sprite while keeping the
permanent no-fade green tint. This isolates whether the gradient sprite
itself renders correctly (gradient visible) or has a mesh/UV issue
(solid green or invisible).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Use losing side's color for delta gradient, remove debug scaffolding
The previous implementation tinted the delta region with the *gaining*
side's color, but the bar underneath the delta region is already that
same color — so the gradient was nearly invisible (faction color fading
to a slightly lighter faction color).
Flip it around: use the *losing* side's color. The old owner's
territory briefly lingers in the lost region, then fades to reveal the
new owner's bar underneath. This is a more intuitive visualization of a
territory change.
Also flip the gradient direction so the opaque (losing-color) edge sits
at the old boundary (where the losing side's territory previously
ended), fading toward the new boundary.
Remove the debug logging, green color override, and disabled fade that
were added while diagnosing the earlier rendering issue, and re-enable
the DeltaGradientRoutine fade coroutine.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Flip delta gradient direction
The losing color should be on the side of the delta closer to the
losing side's remaining bar, so it visually points "home" to its own
side. The transparent edge reveals the new owner's bar underneath.
- Attacker gained → losing=defender → opaque on right (near defender).
- Defender gained → losing=attacker → opaque on left (near attacker).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Double the delta gradient fade duration
Previously 0.16s (flashDuration/2.5); now 0.32s (flashDuration/1.25)
so the gradient is easier to see.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
RandomHeroGenerator.createLowPowerHero pre-populates backstoryVersions
with hero_${id}_backstory_0, but UnaffiliatedHeroAppearedAction only
emits a HeroInitialBackstoryRequest when backstoryVersions is empty. The
rubber-band spawn path in PerformUnaffiliatedHeroesAction was relying on
that action to emit the request, so the initial backstory text ID was
orphaned from the start — never registered with ClientTextStore, always
resolving to TextGenerationDependencyUnknown.
Clear backstoryVersions before handing the hero to
UnaffiliatedHeroAppearedAction so it creates a proper
hero_${id}_initial_backstory_$roundId text ID with a matching LLM
request. This restores effectiveBackstory's ability to fall back to the
initial backstory when later updates are legitimately skipped (e.g. for
heroes only visible to AI factions).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
3.1 Flash-Lite is faster (throughput + TTFT), meaningfully smarter, and
still among the cheapest Gemini options, making it a strict upgrade
over 2.5 Flash-Lite for narrative text generation.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Logs a warning in GameController.withHandledEngineAndResults when a
free hero's most recent backstory is not visible to the province's
ruling faction. Includes game ID, history count, round, and phase
to help trace back to the action that caused the visibility gap.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Retry beast effect Addressable loads on failure
Beast effect prefabs are loaded from a remote CDN via Addressables.
If the download failed (network hiccup, CDN timeout), the effects
were permanently lost for the session with no retry. Now retries up
to 3 times with backoff delays (2s, 5s, 10s). Also releases failed
handles to avoid leaking resources.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Extract AddressableLoader utility with retry logic for all Addressable loads
All Addressable assets load from a remote CDN and a single transient
network failure permanently broke the affected feature for the session.
Extracts retry logic (3 retries with 2s/5s/10s backoff) into a shared
AddressableLoader.LoadWithRetry<T>() utility and updates all 6 callers:
ProvinceBeastsController, TacticalAssetLoader, SoundManager,
ImageForTerrainTracker, BattleInProgressController, ProvinceActionAnimator.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Removed ButtonManagerBasic and UIGradient components from the lobby
AvailableGame prefab (they were no-ops — the button already had a
normal onClick callback). Then deleted all non-texture content from
Modern UI Pack: Scripts, Animations, Editor, Fonts, Scenes, Unused
prefabs, and Documentation. Only Textures/ is retained.
Saves ~34 MB of unused assets.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add editor tool to audit third-party asset pack usage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Include Addressable group entries in asset usage audit
The previous version only traced dependencies from build scenes, completely
missing assets loaded via Addressables. This caused ~8.5 GB of assets to
be falsely reported as unused. Now traces dependencies from all Addressable
group entries (Beast Effects, Hex Tiles, Tactical Assets, Sound Effects)
in addition to build scenes.
Also logs all USED files per pack for easier analysis.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove Pixel Fonts Megapack from audit pack list
Pack was deleted in PR #6620.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Asset usage audit (scenes + Addressables) confirmed 0 files from this
pack are referenced anywhere in the build. Saves 2 MB.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When an army flees after losing a battle, the flee province is consumed
but was not being cleared. This caused armies arriving at a now-hostile
flee province to get attack decision options instead of being shattered.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The Shardok Container prefab had defender on the left and attacker on
the right, but BattleThermometer code forces the defender bar to the
right and attacker bar to the left. This caused the wide bar to appear
next to the smaller number. Swap the field references so bars and labels
are on matching sides.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Wire up flashDuration on BattleThermometer in the Battle Progress Row
prefab and right-align the attacker troop count label. Includes Unity
editor re-serialization of Emu and peasant prefabs.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
FactionUtils.hostilityStatus treats both truce and alliance as non-hostile,
which caused battle player infos to show Allied hostility for truce holders.
The client then displayed an "Observe" button that threw an exception on click
because the server correctly rejects observation for truce-only relationships.
Fix shardokBattlePlayerInfos to use hasAlliance directly instead of the
general hostilityStatus, so only actual alliances produce Allied hostility.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Previously the flash only triggered when the ratio changed by more
than 0.001. Now it flashes on every update after the first, so each
day tick produces a visible flash even if the ratio barely moved.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
ResolveBattleAction was discarding the original fleeProvinceId for
armies that lost a battle, causing them to shatter instead of retreating
to the province the player designated when dispatching the army.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
postBattleUpdate sent a BattleProgressResponse for every Shardok
action, even when the gated round hadn't changed. In AI-only battles
this floods clients with dozens of identical-day updates per round.
Now only sends battle progress when the gated round actually advances,
reducing redundant updates while still delivering Shardok action
results to participants.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
withHandledEngineAndResults used the 5-parameter overload of
humanClientsAfterPostingResults which did not send battle progress.
This meant non-combatant observers only received battle progress
updates when Shardok sent a GameUpdateResponse, not during Eagle
strategic turn resolution. Switch to the 7-parameter overload to
forward existing battle progress on every client update.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The gated round (lastRevealedRound) used max(previous, current) to
prevent information leakage, but never reset when all battles ended.
This caused new battles to start at the previous battle's final day
(e.g. "Day 29") instead of "Day 0".
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
BattleDay was only set from BattleProgressResponse, so it retained
stale values from previous battles until the first progress update
arrived for the new battle.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
A thin white line briefly flashes at the attacker/defender boundary
whenever the troop ratio changes, giving visual feedback during battle.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Use isDefender flag in Shardok thermometer and game model
Use the new is_defender field from ShardokBattlePlayerInfo instead of
assuming player 0 is always the defender. Updates ShardokGameController
thermometer, ShardokGameModel.IsDefender, EagleGameModel.MakeGameModel,
and CustomBattleHandler player setup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update Gameplay scene layout and soldier prefabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add is_defender field (field 5) to ShardokBattlePlayerInfo so the
client can identify which players are defenders without assuming
player 0 is always the defender. Populate it from
ShardokPlayer.isDefender in BattleFilter and serialize it in
ShardokBattleViewConverter.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Two fixes for the battle progress thermometer:
1. Swap attacker/defender sides so attacker fills from left and defender
from right, matching the expected layout.
2. Add missing BattleProgressResponse case in PersistentClientConnection's
HandleGameUpdate switch. The server was sending progress updates but
the client silently dropped them, causing the bar to stay at 50%.
Also extract UpdateBattleProgressDisplay helper in EagleGameController
and call it from SwapModel's early-return path so progress updates aren't
skipped when the player has active strategic commands.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
ConfigureToggle used toggle.isOn = false which fires the ToggleChanged
callback. That callback accesses SelectedDecision, which throws when no
toggle is on yet during SetUpUI. Use SetIsOnWithoutNotify instead.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The updatedBattleProgress method assumed player 0 was always the
defender, but RequestBattlesAction builds the players vector with
attackers first and defender last. Use the isDefender flag from the
battle's player list to correctly classify troop counts.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When ConfigureToggle sets isOn=false on a toggle that is the last active
member of a ToggleGroup with allowSwitchOff=false, Unity forces it back
to true. The toggle is then removed from the group but retains the stale
isOn=true state. This causes the wrong option to be sent to the server
(e.g. Recruit when only Imprison/Return/Execute were available).
Fix by removing the toggle from its group before setting isOn=false.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When a battle is first created, participants now see troop counts and an
accurate balance bar immediately instead of a blank 50/50 bar while
waiting for the first Shardok GameUpdateResponse.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- BattleThermometer: accept IList<Color> for attacker colors, dynamically
create extra bar segments for multi-attacker battles
- BattleProgressRowController: look up defender faction from province
RulingFactionId and attacker factions from PlayerInfos, using
LightPlayerColor for faction-correct colors; use DarkColoredProvinceName
for more readable province name text
- ShardokGameController: wrap single attacker color in list for new API
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
ModelUpdated() never unconditionally cleared overlays, so stale highlights
persisted after phase transitions (setup -> running) and after issuing a
command that ends the turn. Add ClearOverlays() at the top of ModelUpdated()
so overlays are always wiped before being selectively re-drawn, and in
EndTurn() for immediate visual feedback when clicking Commit/End Turn.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add battle progress tracking and real-time progress updates
During BATTLE_RESOLUTION phase, track Shardok battle progress (troop counts
and defender/attacker ratios) and push synchronized updates to clients via
a new BattleProgressResponse message. All battles advance together using
gated round progression to prevent information leakage. Allied observers
see troop counts; non-allied players see only the ratio bar.
Key changes:
- Add defender_ratio, troop counts, and winning_faction_id to ShardokBattleView proto
- Add updated_battles to GameStateViewDiff proto
- Add BattleProgressResponse to GameUpdate oneof
- Track battle progress in GameController with synchronized round gating
- Send enriched battle views to clients via HumanPlayerClientConnectionState
- Update GameStateViewDiffer to detect battle updates
- Add GameStateViewDifferTest with 6 test cases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Move BattleProgressResponse to views layer to fix proto layering
BattleProgressResponse only contains ShardokBattleView objects, which
belong to the views layer. Move it from eagle.proto (api layer) to
game_state_view.proto (views layer) and remove the direct
shardok_battle_view.proto import from eagle.proto.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add battle progress display for non-combatant observers
During BATTLE_RESOLUTION, non-combatant players now see real-time
progress bars instead of a single "Observe Battle" button. Each bar
shows defender/attacker balance with green/red coloring. Allied battles
show troop counts and an Observe button; non-allied battles show only
the bar. When a battle ends, the bar is replaced with Victory/Defeat.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix TextAlignmentOptions.MidlineCenter compile error and meta GUID conflict
MidlineCenter does not exist in Unity 6 TMP; use Center instead.
Also include reassigned .meta GUID to resolve conflict with duel.asset.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add BattleProgressPanel GameObject to Gameplay scene
Wire the panel to EagleGameController.battleProgressPanel with
VerticalLayoutGroup and ContentSizeFitter components.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove BattleInProgressController from BattleProgressPanel GameObject
BattleInProgressController belongs on the map, not the progress panel.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove unused ShardokGameStateView import from GameController
The import triggered -Werror and broke the battle_progress_test build.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Show BattleProgressPanel for all battles, not just observed ones
The panel was only activated for non-combatant observers. Now it shows
whenever there are battles, alongside the "Battle!" button for combatants.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Use ShardokBattles instead of RunningShardokGameModels for panel visibility
RunningShardokGameModels requires the client to have connected to the
Shardok game and received a GameRunning status, which may not have
happened yet. ShardokBattles comes from the server game state view and
is available immediately when battles exist.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Refactor BattleProgressPanel to use prefab rows and display battle day
Replace programmatic CreateRow()/PopulateRow() with a prefab-based
approach using BattleProgressRowController. The panel now instantiates
rows from a prefab into a scroll view, displays "Day X" from the
server's gated round, and uses colored province names.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Use BattleThermometer in battle progress rows
Replace manual anchor/color logic with the existing BattleThermometer
component, which already handles both troop-count and ratio-only modes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix row container cleanup, observe button visibility, and prefab transforms
Clear stale children from rowContainer on first update. Only show
Observe button for allied battles (not the player's own). Fix bar
rotation/scale in row prefab. Add debug logging for battle progress.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update BattleProgressPanel layout in Gameplay scene
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Replace goToBattleButton with per-row Fight!/Waiting.../Observe buttons
Each battle row now has its own action button: "Fight!" for the
fightable battle, "Waiting..." (grayed) for other own battles,
and "Observe" for allied battles. The global goToBattleButton is removed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove goToBattleButton from scene and adjust battle panel layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Adjust battle progress panel layout
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The server already computes lastRevealedRound (the minimum round across
all active battles) for fair reveal timing, but never sent it to the
client. Add current_day field to the proto and pass gatedRound through
sendBattleProgress so clients can display the battle day.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Guard idle and walk Play() calls with HasState(), matching the existing
pattern already used for eat/attack transitions.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add battle progress tracking with proto-based troop counts
Adds real-time battle progress tracking so non-combatant observers can
see defender/attacker troop ratios during BATTLE_RESOLUTION phase.
C++ Shardok extracts current_round and per-player troop counts from the
FlatBuffer game state and sends them as new proto fields on
GameUpdateResponse, avoiding the previous approach of parsing opaque
FlatBuffer bytes as Protobuf on the Scala side.
Scala Eagle tracks progress via BattleProgressTracker, gates round
reveals across concurrent battles, and enriches ShardokBattleView with
defender_ratio and troop counts for allied observers.
Includes tests for both C++ troop count extraction and Scala progress
tracking logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove default values from ShardokBattleView fields
Make all new fields (defenderRatio, defenderTroopCount,
attackerTroopCount, winningFactionId) required at construction sites
instead of relying on defaults.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Preserve battleProgress across GameController operations
Convert withHumanClient, withAiClient, stopStreaming, aiClientCommands,
and withHandledEngineAndResults from explicit GameController construction
to copy(), so battleProgress and lastRevealedRound are preserved when
clients connect/disconnect or commands are processed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Populates the new proto fields (current_round, player_troop_counts) on
GameUpdateResponse by extracting data from the FlatBuffer game state in
ShardokGameController::GetUpdates(). Counts troops for each player
across NORMAL, RESERVE, and NEVER_ENTERED units.
Updates the OnUpdates callback signature to pass the new fields through
EagleInterfaceGrpcServer to Eagle.
Includes tests verifying troop count extraction for both normal games
and games with reserved slots.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Adds new proto fields to support real-time battle progress updates for
non-combatant observers:
- shardok_internal_interface: PlayerTroopCount message, current_round
and player_troop_counts on GameUpdateResponse
- shardok_battle_view: defender_ratio, troop counts for allied viewers,
winning_faction_id
- game_state_view: updated_battles on GameStateViewDiff,
BattleProgressResponse message
- eagle.proto: battle_progress_response in GameUpdate oneof
- BUILD.bazel: game_state_view_scala_proto target
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Deletes game folders from eagle/archived/ where directory.e0i is over
1 month old. Runs weekly on Sundays at 05:00 UTC with manual trigger support.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The surviving units data was being sent by the server but never displayed.
Wire the EventBasedTable to show heroes and battalions using the same
CombatUnitRowController pattern as AttackDecisionCommandSelector.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
MarkAllAsSeen() assumed entries[0] had the latest date, but entries
in whats-new.json aren't necessarily in chronological order. When a
backdated entry was added at position 0, the saved last-seen date was
older than other entries, causing them to reappear on every sign-in.
Now scans all entries to find the maximum date.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When a game is archived (last player exits) or deleted (admin console),
S3 files under eagle/save/{gameId}/ are now moved to eagle/archived/{gameId}/
to prevent the lazy-loader from resurrecting stale data and to keep S3 clean.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Preserve schemaVersion when saving loaded games to games.e0es
GamesManager.save() was building RunningGame entries for loaded games
without setting schemaVersion, causing it to default to 0. This made
every game appear as needing migration on every server restart after
being loaded into memory, even when the schema version hadn't changed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Also stamp currentSchemaVersion on newly created games
Pass GameMigrator.currentVersion into GamesManager so new games get the
correct schema version on their first save, avoiding a needless no-op
migration on the next server restart.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
delete() and deleteAll() only removed from the first (local) persister,
leaving stale files in S3. This caused rewound shardok battles to be
resurrected via lazy-loading from S3.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Fix battle progress parsing FlatBuffer bytes as Protobuf
The updatedBattleProgress method was calling ShardokGameStateView.parseFrom()
on opaque FlatBuffer bytes, causing InvalidProtocolBufferException in production.
Fix: Add current_round and player_troop_counts fields to GameUpdateResponse
proto so Shardok serializes the data explicitly. Eagle reads from these new
proto fields instead of trying to parse the opaque game state bytes.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add tests for battle progress proto fields
C++ test verifies troop count extraction from FlatBuffer game state
matches GetUpdates() logic. Scala test covers updatedBattleProgress()
for tracker creation, same-round updates, round boundary snapshots,
multi-attacker summation, and missing defender defaults.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add battle progress tracking and real-time progress updates
During BATTLE_RESOLUTION phase, track Shardok battle progress (troop counts
and defender/attacker ratios) and push synchronized updates to clients via
a new BattleProgressResponse message. All battles advance together using
gated round progression to prevent information leakage. Allied observers
see troop counts; non-allied players see only the ratio bar.
Key changes:
- Add defender_ratio, troop counts, and winning_faction_id to ShardokBattleView proto
- Add updated_battles to GameStateViewDiff proto
- Add BattleProgressResponse to GameUpdate oneof
- Track battle progress in GameController with synchronized round gating
- Send enriched battle views to clients via HumanPlayerClientConnectionState
- Update GameStateViewDiffer to detect battle updates
- Add GameStateViewDifferTest with 6 test cases
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Move BattleProgressResponse to views layer to fix proto layering
BattleProgressResponse only contains ShardokBattleView objects, which
belong to the views layer. Move it from eagle.proto (api layer) to
game_state_view.proto (views layer) and remove the direct
shardok_battle_view.proto import from eagle.proto.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The Sparkle-2.6.4.tar.xz extracts to ./bin/sign_update, not
./Sparkle-2.6.4/bin/sign_update. This broke mac builds when the
runner had a fresh /tmp/sparkle-cache.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
new List<ProvinceId>(heroInfo.provinceId) calls the capacity constructor,
creating an empty list with capacity = provinceId. Changed to collection
initializer syntax { heroInfo.provinceId } so the province ID is actually
added as an element. The PopupPanelController already feeds this list into
mapController.OverrideTargetedProvinces for highlighting, but the list was
always empty.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Include game ID, command type, and province/player info in the error
log line so failed commands are diagnosable without reproducing.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
SoldierEffect.prefab referenced 3 of 4 character models as raw .fbx files
(PT_Male_Soldier_01.fbx, PT_Female_Soldier_01.fbx, PT_Female_Soldier_02.fbx).
Raw FBX instantiation doesn't carry material assignments, causing models to
render with the default white material. Updated references to use the proper
.prefab files from Polytope Studio which have materials correctly configured.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
All prefab override fields are null at runtime on Windows builds only.
Log the missing fields and gracefully bail out of ApplyPersistedSettings,
Start, and HandleEscape so the game can still launch and we can
investigate whether other prefab overrides are also broken.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
GitHub Actions retention-days: 3 is unreliable — 1,846 expired artifacts
(282 MB) accumulated over 5+ weeks. Add a cleanup-expired job that deletes
artifacts older than 3 days before the storage check runs.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Suppress harmless stream-closed IllegalStateException in SyncResponseObserver
The most common Sentry exception was "Stream is already completed" from
CustomBattleManager. This happens when Shardok sends battle results to a
client that already disconnected — the isCancelled check passes because
"cancelled" and "completed" are different stream states, so the underlying
onNext throws IllegalStateException. Catch it silently in all three stream
methods, matching the existing pattern in GameController.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Narrow catch to only suppress "already completed" stream exceptions
Only catch IllegalStateException when the message contains "already completed",
so unexpected IllegalStateExceptions still propagate to Sentry.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The wind-assisted range-3 check was allowing tiles in adjacent hex
directions (±60° from wind), producing a 120° cone. This let longbows
shoot northwest with a northeast wind. Tighten the check so only tiles
whose direction matches the wind direction (or sits on the boundary
between two sectors, one of which is the wind direction) qualify.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The control command's CommandDescriptor has target_unit but no target
coords, so the client treated it as fully untargeted and animated toward
the nearest enemy instead of the controlled undead unit. Now
PlayUntargetedCommandAnimation uses the command's target_unit when
available. Also set target_unit on the CONTROLLED action result so
server-side history replays animate correctly.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The ProvinceBeastsController could attempt to spawn beast effects before
centroids arrived from CDN, causing "No centroid found" warnings. The
effect prefabs (loaded from local Addressables) often finished loading
before the centroids (fetched over the network), creating a window where
UpdateBeastsEffects would proceed without centroid data.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Guard AnimalEffect animator state plays with HasState check
Some animal prefab animator controllers don't have states named idle,
walk, eat, or attack, causing spammy "State could not be found" and
"Invalid Layer Index '-1'" errors every frame. Added SafePlay helper
that checks HasState before calling Play.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix AnimalEffect animator state errors for kangaroo, leopard, black panther
Three animator controllers (KangarooMapAnims, LeopardMapAnims,
BlackPantherMapAnims) used "idle1" instead of "idle", causing
"State could not be found" errors every frame. Renamed to "idle".
Also skip eat/attack actions for animals whose controllers don't have
those states (e.g. scorpion has no eat) — falls back to idle instead
of playing a missing state.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
On reconnect, HandleStartingState clears all ShardokGameModels and
re-creates them, then the server replays the full battle history. Each
ChangedReserveUnit in the replayed history was triggering auto-placement
to starting positions, causing visible UI thrashing — especially with
repeated reconnect attempts during idle periods. The user's manual
placements (local-only until submitted) were lost each time.
Added SkipAutoPlacement flag to ShardokGameModel, set during resync
model creation and cleared after the first successful HandleUpdates.
This prevents auto-placement during history replay while preserving
it for genuinely new battles.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The 4 decision toggles (Advance, Demand Tribute, Withdraw, Safe Passage)
were not wired to ToggleChanged in the prefab, so selecting Demand Tribute
never activated the tribute container with gold/food sliders. Also changed
ConfigureToggle to use SetIsOnWithoutNotify when disabling unavailable
toggles, preventing a "No decision selected" exception during setup.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Documents phased approach for migrating from Built-In Render Pipeline
to Universal Render Pipeline before Unity drops BiRP support after 6.7.
Covers shader inventory, long-lived branch strategy, and effort estimates.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Unaffiliated hero backstory updates were only visible to the current
province owner, so when another faction conquered and tried to recruit,
the backstory was missing. Now unaffiliated hero backstory updates use
empty recipientFactionIds (meaning all factions), matching the initial
backstory behavior.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Upgrade Unity from 6000.3.8f1 to 6000.4.0f1
Also clear the CI runner's Library/ cache when the Unity version
changes to prevent infinite import loops on version upgrades.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix em dash characters in workflow YAML breaking GitHub Actions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix gray textures after Unity 6.4 upgrade by switching to embedded materials
materialLocation: 0 (External) is deprecated in Unity 6.4 and causes FBX
models to render without textures. Changed all 749 .fbx.meta files to use
materialLocation: 1 (Embedded) which is the new default.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Trigger CI checks
* Add trailing newline to workflow file to re-trigger CI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix YAML syntax error on line 125 of unity_build.yml
The run: value contained | and > characters that confused the YAML parser.
Switching to block scalar syntax (run: |) avoids the ambiguity.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remap bridge materials to fix gray textures after materialLocation change
With materialLocation: 1 (Embedded), Unity ignores external .mat files.
Added externalObjects remapping so the bridge FBX models still use the
correctly-textured bridges_d material from TileableBridgePack/Materials/.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add material remapping to all remaining bridge FBX meta files
The old-format meta files (serializedVersion: 18) had no materialLocation
or externalObjects fields, causing Unity 6.4 to default to the deprecated
External material path and render bridges gray. Added externalObjects
remapping and materialLocation: 1 to all 47 remaining bridge models.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Use UISprite with Mask component on the Bar Area container to clip
the colored bars with smooth rounded corners.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add battle thermometer to Shardok tactical view
Replace the always-visible armies table with a horizontal thermometer bar
showing defender vs attacker troop balance at a glance. The bar uses
Fantasy RPG boss gauge sprites with faction-matched colors. Hovering the
thermometer reveals the full armies table as a popup.
Also adds icons to Army Info Row and removes the totals row from the
armies table since troop counts are now shown on the thermometer.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix thermometer bar sizing and null model crash
Use anchor-based horizontal sizing instead of fillAmount, preserving
vertical inspector settings. Replace BarMask with plain Bar Area
container. Guard UpdateReserves against null Model when Shardok
container is active at game launch.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
UpdateNotificationManager lives in Gameplay.unity which is the
persistent base scene — it is never unloaded. DontDestroyOnLoad is
unnecessary and produces a warning because the object is a non-root
UI child under a Canvas. Remove the call entirely.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Remove happy-path confirmation logs that fire on every normal startup
and provide no diagnostic value: tutorial UI built, state loaded, token
cache initialized, Sparkle version/platform info, auth configured,
session restored, and WhatsNew check results.
Error/warning logs (LogWarning, LogError) are preserved.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
FearAnimator maintained its own _activeObjects list, Track(), Untrack(),
and OnDestroy() that duplicated base class TrackedEffectAnimator's
tracking. Since all objects were created via CreateTrackedObject() (which
adds to the base's _trackedEffects), but Track() was never called,
_activeObjects was always empty — making FearAnimator's Untrack() calls
no-ops and its OnDestroy() cleanup redundant.
Remove the dead code and let the base class handle all tracking.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The tutorial battle is too hard for new players. Buff the defender's
starting longbowmen and two reinforcement battalions by ~20% to make
the battle easier, staying within battalion type capacity limits.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Handle ShardokBattleResetResponse in Unity client
Route the battle reset message through PersistentClientConnection and
EagleGameModel to the ShardokGameModel, which clears its mutable state
and signals the controller. The controller clears overlays, labels, and
the game-over UI so the restarted battle rebuilds normally from new
setup results.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add Old Marek dialogue on battle reset
When a tutorial battle resets, Old Marek comments on a strange feeling
of deja vu and encourages the player to try a different approach.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Wait for Eagle confirmation before allowing battle exit
Previously, the Shardok controller showed "Exit Battle" as soon as the
game reached Victory state and immediately cleaned up the model. This
meant a subsequent ShardokBattleResetResponse had nothing to reset.
Now the model stays alive after Victory, and "Exit Battle" only appears
once Eagle confirms removal via RemovedBattleIds. If a battle reset
arrives instead, the model is still intact for ResetForNewBattle().
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix GetPlayerName crash with negative playerId after battle reset
ResetForNewBattle sets CurrentPlayer to -1, which passed the
"playerId < players.Count" check but threw on list access.
Add a lower bounds check.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Reset battle tutorials on battle reset so they re-fire on retry
Adds ResetBattleTriggerFlags() for combat-only trigger flags and
ResetCompletedScriptsById() for selective dialogue reset. Strategic
tutorial progress is preserved.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Add receiveBattleReset to BattleUpdateReceiver trait
- Handle BattleResetResponse in ShardokInterfaceGrpcClient streaming
- Implement receiveBattleReset in GamesManager (delegates to controller)
- Add no-op implementation in CustomBattleManager
- Add postBattleReset to GameController: resets shardok history and
notifies human clients via ShardokBattleResetResponse
- Add sendBattleReset to HumanPlayerClientConnectionState
- Add withResetShardokResults to FullGameHistory/PersistedHistory/InMemoryHistory
- Add shardok_battle_scala_proto dependency to BUILD.bazel
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- Add IsTutorialBattleEnabled() and DidDefenderLose() to ShardokEngine
- Add WaitResult enum (GAME_OVER, DISCONNECTED, BATTLE_RESET) replacing
bool return from WaitForUpdatesAndPush
- Add OnBattleReset() to StreamSubscriber interface
- Detect tutorial loss in WaitForUpdatesAndPush and return BATTLE_RESET
- In SubscribeToGame, loop on BATTLE_RESET: tear down the old game,
restart from serialized NewGameRequest, and stream fresh state
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Fix settings panel not showing on first Escape press
SettingsPanelController lives on an inactive panel prefab, so Start()
only runs the first time the panel is activated. When ToggleActive()
activated the panel, Start() would immediately set _active=false and
hide it again, swallowing the first Escape press. The slider
initialization in Start() also triggered volume change callbacks,
causing an audible volume shift.
Sync _active with the panel's current state in Start() instead of
forcing it to false.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove defensive null checks on Inspector fields in SettingsPanelController
Per project convention, Inspector fields should throw NullReferenceException
if not linked rather than silently skipping code.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove tileBorderWidth serialized fields from Settings Panel prefab
These fields were removed from SettingsPanelController.cs; clean up the
prefab to avoid Unity serialization warnings.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Fix Shardok battle race condition — hex tiles sometimes never render
ImageForTerrainTracker and TacticalAssetLoader were plain C# singletons
whose LoadAsync() coroutines ran on ShardokGameController (which gets
deactivated). ConnectionHandler.Start() deactivates the shardokContainer,
killing any in-flight coroutines. The singletons' _loadStarted guard
then prevented reloading, leaving IsLoaded permanently false and
SetUpGameAfterLoad() spinning forever at its while-loop.
Convert both to MonoBehaviour singletons meant to live on a persistent
GameObject (Main Camera). They now own their own Awake() → LoadAsync()
lifecycle and are immune to container deactivation. Remove the redundant
StartCoroutine kicks from ShardokGameController and EagleGameController.
Requires adding ImageForTerrainTracker and TacticalAssetLoader components
to Main Camera in Gameplay.unity (scene change not included in this
commit).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add ImageForTerrainTracker and TacticalAssetLoader to Main Camera
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
xcodebuild archive can reset the user keychain search list during long
builds, dropping the temporary CI keychain that holds the Apple
Distribution signing cert. Re-add and unlock the keychain right before
the export step so xcodebuild -exportArchive can find the certificate.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Gitea's mirror-sync API is async — the GitHub webhook triggers it but
returns 200 before the sync completes. CI runners can start git lfs pull
before new LFS objects are available on Gitea. Extract LFS fetch into a
shared script with retry logic (5 attempts, 15s backoff) to bridge the
gap.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
RemoveController was using std::shared_lock (read lock) while calling
erase() on the runningControllers map. This is undefined behavior
since other threads could be concurrently reading the map. Use
std::unique_lock (write lock) instead.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add hippogryph beast effect using DragonEffect flight system
Reuses DragonEffect.cs (flight/ground state machine) with a custom
HippogryphMapAnims controller mapping 15 animation states to the
PROTOFACTOR Hippogriff asset. Includes PBR materials, 12 animation
FBXes, and tuned flight parameters (scale 8, fly height 12).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Reduce hippogriff texture maxTextureSize to 512
The Addressable bundle build was failing during ArchiveAndCompress
because the hippogriff textures were imported at 4096x4096 (default
from the asset pack). For a map-view beast effect, 512 is plenty.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix texture .meta files stored as LFS pointers instead of YAML
The broad directory-level LFS pattern in .gitattributes was catching
.meta text files. Replace with a global *.jpg pattern (other binary
formats already have global patterns) and re-add the 8 texture .meta
files as regular blobs so Unity can read them on CI.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add BattleResetResponse proto messages for tutorial battle reset
Add BattleResetResponse to the internal Shardok interface (oneof case 4
in GameStatusResponse) and ShardokBattleResetResponse to the client-facing
Eagle API (oneof case 8 in GameUpdate). These signal that a tutorial battle
should reset after the defender loses.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add no-op handler for BattleResetResponse in exhaustive match
Prevents -Werror build failure from non-exhaustive pattern match
after adding the new oneof case. Full handling in follow-up PR.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Each xcodebuild archive creates a new ~3.6GB Unity-iPhone-<random>
folder in DerivedData that was never cleaned up, accumulating on
the build runner.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add black panther, rhinoceros, gorilla, hyena, leopard, warthog, emu,
kangaroo, and tasmanian devil to the custom animations table. Remove
them from the "not yet set up" section and note which remaining pack
animals are not in beasts.tsv.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Engineers standing on a bridge tile can attempt to destroy it. The
player selects an adjacent traversable, unoccupied tile as the
destination. A D100 success roll determines if the bridge is destroyed.
Whether the roll succeeds or fails, the engineer moves to the selected
tile. Bridges are no longer valid Reduce targets.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Add Unity client support for Blow Bridge command
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Improve Blow Bridge UX: icon, sound, animation position and rendering
- Change command icon from catapult (reduce.png) to manabomb
(Engineering_25_manabomb.png)
- Use explosion impact sound (Fire Spelll 02) for attempt, structural
breakage sound for success, failure horn for failure
- Fix animation position: use sourceGridIndex (bridge hex) instead of
targetGridIndex (engineer's destination) since the engineer starts ON
the bridge and jumps to an adjacent hex
- Preserve cached source position for BlowBridge results so the server's
post-move actor location doesn't override the bridge hex
- Replace hammer/axe attempt animation with debris explosion
(AnimateRepairFailed) matching the destructive nature of the action
- No visual animation on failure, just the negative sound
- Add zPosition parameter to AnimateRepairFailed so BlowBridge debris
renders in front of the bridge 3D object (at Z=-5)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When the AI thread throws an exception, instead of re-throwing (which
calls std::terminate and silently kills the thread), store the error
message, set an atomic flag, and wake up waiting subscribers. The gRPC
stream subscriber detects the failure and returns INTERNAL status to
Eagle, which triggers Sentry notification. The error message includes
game ID, player ID, and round number for easier debugging.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
ToPlayerInfoProto() was not copying the cannot_become_outlaw flag
when converting flatbuffer PlayerInfo to proto for the AI's
GameStateView. This caused the AI to always think outlaw was
available, generating extra BECOME_OUTLAW_COMMAND entries and
triggering a command count mismatch crash.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:28:59 -07:00
4813 changed files with 474036 additions and 836721 deletions
**NEVER chain bash commands.** Do not use `&&`, `||`, or `;` to combine commands. Each command must be a separate Bash tool call. Use parallel tool calls when commands are independent.
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
## CRITICAL UNITY PROCESS RULES (NEVER VIOLATE)
**NEVER kill Unity without asking the user first.** The user might be actively using the Unity Editor. Do not kill,
force-quit, terminate, or otherwise stop Unity processes unless the user explicitly approves that specific action.
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER use `git -C`.** Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
**ALWAYS use this workflow:**
1. Create a feature branch from origin/main
2. Commit to that branch
3. Create a PR with `gh pr create`
4. Wait for user to merge (DO NOT run `gh pr merge`)
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
## Worktree Usage
Prefer using the current worktree for small requested fixes. Create a separate worktree only when the user asks for one,
when the current branch/state would make the change unsafe, or when isolating a large/risky change is clearly beneficial.
**NEVER leave the current worktree without explicit user approval.** Do not create, switch to, edit, commit in, or run
git commands from another worktree unless the user has specifically asked for that worktree or approved the move. If the
current worktree is unsafe because of dirty state, report that and ask before using a different worktree.
Codex may edit code and run git commands in the current worktree, even when unrelated files are dirty, as long as it:
- does not revert, reset, clean, or overwrite unrelated changes
- stages only the files relevant to the requested change
- verifies the staged diff before committing
- creates feature branches and PRs as usual
- does not push to main/master or merge PRs
When the user says a Codex-created PR has been merged, delete the corresponding local branch and temporary worktree
automatically, unless there is uncommitted work that would make cleanup unsafe.
## PR Timing
When code changes appear correct locally, create the PR promptly even if long-running builds or tests are still running.
Opening the PR starts CI on the build machines, uploads results to the Bazel remote cache, and can speed up subsequent
local validation. Continue monitoring any already-started local and CI validation after opening the PR.
For PRs expected to trigger substantial Bazel work in CI, run the relevant Bazel builds and tests locally too. This
workstation is fast, and local Bazel runs can help hydrate the remote cache for the CI builders while still giving
earlier signal on failures.
## GitHub CLI PR Bodies
When creating or editing PR descriptions with multiline bodies, write the body to a temporary markdown file and pass it
with `gh pr create --body-file <path>` or `gh pr edit <number> --body-file <path>`. Do not use multiline `$'...'`
shell quoting for PR bodies; Codex's permission matching may treat that as a complex shell command instead of a clean
**MANDATORY: Before running `git commit`, verify:**
1.**If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
2.**If you modified C++ or C# files:** Run `clang-format -i` on the modified files
3.**If you modified Scala files:** scalafmt will run automatically via pre-commit hook
4.**ALWAYS run the full test suite for your language changes before pushing:**
- For Scala changes: `bazel test //src/test/scala/...`
- For C++ changes: `bazel test //src/test/cpp/...`
- This catches exhaustive pattern match errors and other compile-time failures that single-target builds miss
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
### Bazel Cache Hydration
When making changes that will trigger GitHub Actions Bazel builds, run the matching CI script instead of hand-writing
`bazel build` commands. The scripts keep local cache hydration aligned with CI targets, compilation modes, and flags:
```bash
./scripts/build_eagle_ci.sh
./scripts/build_shardok_ci.sh
./scripts/hydrate_bazel_remote_cache.sh
```
Use direct `bazel build` commands only for targeted debugging where CI cache hydration is not the goal.
### Code Formatting
```bash
# ALWAYS run clang-format after making any C++ or C# code changes
# 2. Run performance tests multiple times on your branch to reduce noise
for i in 12 3;do
echo"=== Run $i ==="
./scripts/ai_perf_test.sh 2>&1| grep -A 20"AI Search Performance Summary"
done
# Save or note the results
# 3. Switch to main branch and run the same tests
git checkout main
for i in 12 3;do
echo"=== Run $i ==="
./scripts/ai_perf_test.sh 2>&1| grep -A 20"AI Search Performance Summary"
done
# 4. Compare the results between your branch and main
# Key metrics to compare:
# - Commands evaluated at each depth (e.g., "Depth 3: 169/523 commands")
# - Average search depth achieved
# - Completion rates at each depth
```
**Important notes:**
- Run tests multiple times (3-5) to account for performance variance
- Focus on commands evaluated at each depth rather than total commands
- Commands at different depths aren't directly comparable (depth 3 is more valuable than depth 2)
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
behavior changes.
## Troubleshooting Scala Build Errors
### MissingType Errors
When you see errors like:
```
dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.eagle.internal.game_state.type.GameState
```
**This is NOT a Scala compiler crash.** This is a missing dependency in BUILD.bazel.
**How to fix:**
1. Identify the missing type from the error message (e.g., `game_state.GameState`)
2. Find the Bazel target that provides this type (e.g., `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`)
3. Add it to the `deps` of the failing target
4. If the type appears in a public method signature, also add it to `exports` so downstream targets can see it
**Common pattern:** When adding a method to a class that takes or returns a proto type, the proto dependency often needs to be added to both `deps` AND `exports`.
### Bazel Clean
**NEVER run `bazel clean` without asking first.** It rarely fixes actual issues and wastes significant rebuild time. The issues that seem like they need `bazel clean` are usually:
- Missing imports in Scala code
- Missing dependencies in BUILD.bazel
- Missing exports for types used in public signatures
## Game Content
**Maps:**`.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
**Configuration:** Game parameters in `/src/main/resources/net/eagle0/eagle/game_parameters.json`
**Data Files:** TSV format for battalions, heroes, and other game data
**NEVER modify hero names.** Do not change names in `heroes.tsv`, `generated_heroes`, or any other hero data files. The names are carefully chosen and are not to be altered.
## Deployment
- Bazel handles multi-language builds and dependencies
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
- Docker containerization available via `ci/eagle_run.Dockerfile`
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
missing_toolchain_error="No Linux x86_64 toolchain matched. Docker/Eagle image builds should pass --platforms=//:linux_x86_64 and --extra_toolchains=@llvm_toolchain_linux//:cc-toolchain-x86_64-linux when compiling C++ code.",
)
# Platform for cross-compiling to Linux ARM64
@@ -19,6 +21,7 @@ platform(
"@platforms//os:linux",
"@platforms//cpu:aarch64",
],
missing_toolchain_error="No Linux ARM64 toolchain matched. Shardok ARM64 builds should pass --platforms=//:linux_arm64 and --extra_toolchains=@llvm_toolchain_linux_arm64//:cc-toolchain-aarch64-linux.",
**NEVER chain bash commands.** Do not use `&&`, `||`, or `;` to combine commands. Each command must be a separate Bash tool call. Use parallel tool calls when commands are independent.
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER use `git -C`.**Instead, cd to the repo root and run git commands from there.
**NEVER use `git -C`.**Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
@@ -226,7 +228,7 @@ to be used for different players or game situations within the same server proce
**C# (Unity Client):**
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
echo"Refusing to upload Addressables. Raise ADDRESSABLES_MAX_UPLOAD_BYTES or ADDRESSABLES_MAX_SINGLE_FILE_BYTES only after confirming the upload is intentional."
exit1
fi
}
if ! command -v aws >/dev/null 2>&1;then
ifcommand -v brew >/dev/null 2>&1;then
brew install awscli
else
echo"ERROR: aws CLI is unavailable, and Homebrew is not on PATH"
exit1
fi
fi
if[ ! -d "$SERVER_DATA"];then
echo"No Addressables bundles found at $SERVER_DATA"
This document proposes 5 new hero professions for Eagle0. Each profession has abilities for both the Eagle (strategic) and Shardok (tactical) game layers.
This document proposes new hero professions for Eagle0. Each profession should matter in both the Eagle strategic layer
and the Shardok tactical layer, and should be earned through a prime stat in the same way existing professions are.
| **Mage** | Control Weather: start/end blizzards and droughts in the current or neighboring province | Lightning Bolt, Meteor, Freeze Water, Start Fire access/enhancement | Strategic weather control and high-impact elemental battlefield effects |
| **Necromancer** | Start Epidemic in the current or neighboring province | Raise Dead, Fear | Attrition pressure, undead creation, morale attack |
@@ -6,6 +6,16 @@ Be able to support a small (10-50 user) private alpha, including with strangers.
Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/17RTt3-4Wl2AAVMRLodaC3a84E4de6xuQTWBPvCRM484/edit?pli=1&tab=t.0), but most of that is not necessary for MVP.
## Current priority order
1. Finish tutorial & first-session onboarding, especially the clear first-session goal and guided first scenario.
2. Fix the basic Shardok AI issues that can make tactical battles feel broken or unfair.
3. Publish a known issues doc so alpha testers do not repeatedly report the same rough edges.
4. Verify the existing game-ended flow, then mark the win condition done if "all other factions defeated" works end-to-end.
5. Add mid-game progression events where the King recognizes the player as they gain power.
6. Add account-linking when provider switching or account recovery becomes important for alpha support.
7. Leave terms of service and Windows code signing deferred until they matter for public release or broader distribution.
## Required
### Gameplay Productionization
@@ -17,7 +27,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [x]~~Fix long disconnects on deployments~~
- [x] Fix the Mac installer
- [x]~~Still not reconnecting after deployments~~
- [] Notify about client updates, button to come directly back
- [x] Notify about client updates, button to come directly back
- [x] Generatedtext healing
- [x] Kill outstanding shardok requests when game is deleted
@@ -51,7 +61,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
### Basic Gameplay
- [ ] Tutorial
- [ ] Tutorial & first-session onboarding
- [x] In the Your Warlord panel, say what the profession is
- [x]~~And separate panels for each profession when you encounter one~~
- [x]~~Command tutorial for each command the first time it's clicked~~
@@ -62,17 +72,16 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [x]~~Time to swear brotherhood~~
- [x]~~When you get large, or~~
- [x]~~When you get a good candidate~~
- [] Shardok tutorial!
- [x]~~Shardok tutorial!~~
- [x]~~Narrative hook in first few minutes - why should I care about my warlord?~~
- [ ] Clear first-session goal ("try to capture your first province" or similar)
- [x]~~Early small victory to build momentum~~
- [ ] Guided first scenario vs. overwhelming sandbox?
- [ ] Basic Shardok AI stuff fixed
- [] Lobby fixes
- [x]~~Lobby fixes~~
- [ ] Have goals / ending
- [ ] Win condition: all other factions defeated
- [ ] Mid-game progression: King recognizes you as you gain power (generated events)
Follow-up to [SQLITE_HISTORY_DESIGN.md](SQLITE_HISTORY_DESIGN.md). This document plans the second wave of the SQLite migration: decomposing the `ActionResult` proto blob in `action_results.payload` into native relational tables, deleting the proto representation entirely for everything in scope.
## Status — option B adopted (updated 2026-05-17)
**This section supersedes the original plan below. Where the body conflicts with this section, this section wins.** The full-relational design is kept below for historical context and as the migration target if a structured-query need on deep aggregates ever materializes.
The original plan (decompose every field into relational tables; "decision locked") was implemented for `ChangedProvince` first — ~37 child tables, ~1500 lines (PR #6721) — and then **rejected on cost/benefit**. We adopted **option B**: apply the "stopping rule" (originally carved out only for `Quest`) *consistently*. Shipped and merged as Phase 4.5b in **PR #6725**:
- Keep the per-entity **top-level table with scalar / entity-reference columns + indexes** (the 4.5a tables). That is the part that earns its keep — "which actions changed province/hero/faction X, by how much" is one-line SQL.
- Store the **rest of the aggregate as one `<entity>_proto BLOB` column** on that row: lossless round-trip / replay, no child tables for the deep nesting.
- Scalar columns are pure query denormalization; **the proto blob is authoritative**. A later fully-relational pass (if ever needed) is a pure read-blob → write-columns migration — which is why the blob must survive any future proto deletion.
Rationale: the deep army/unit/hero/claimant nesting is never analytics-queried — the exact property that justified blobbing `Quest`. Exploding it into ~37 tables was code volume without query value. The blob also means deep-aggregate field changes need **no SQL migration** (only promoted scalar columns do), which is the right shape for the volatile parts of the model. This rescinds "Decisions locked #1" and revises principle 7, the Scope, the Sequencing table, and "Deleting the proto" (all updated in place below).
**This section further supersedes the body and the Status section above where they conflict; it wins.** It records the pattern actually shipped for `ChangedHero` in Phase 4.5c.1 (#6727), which is a refinement of option B, not the whole-entity-blob shape the body still describes for that entity.
Option B as originally stated keeps **one `<entity>_proto BLOB`** per row for the entire non-scalar residual. For a *shallow* entity that is more blob than it needs. Refined option B decomposes the residual into three tiers instead of one blob:
- **Tier 2 — typed columns for sealed value-oneofs.** A sealed choice over simple values (e.g. `ChangedHeroC`'s `loyalty` / `vigor``StatChange`, subtypes `StatDelta`/`StatAbsolute`/`StatNoChange`) becomes a **pair of nullable columns** (`loyalty_delta` / `loyalty_absolute`, …). The *column name is the discriminator*; at most one of each pair is non-NULL; both NULL ⇒ the no-change case. No `kind` column.
- **Tier 3 — per-row leaf-proto blobs in a child table.** A repeated/optional nested aggregate becomes a child table keyed by `(action_seq, entity_id, n)` storing the **leaf** proto's bytes one row per element (e.g. `action_changed_heroes_new_backstory_events` holding `EventForHeroBackstory` blobs) — *not* a whole-entity blob.
**Consequence (changes Decisions #1–#3 and 4.5i):** an entity fully covered by Tiers 1–3 has **no whole-entity blob**. It round-trips losslessly from columns + per-leaf blobs, so its per-entity proto and converter (`changed_hero.proto` / `ChangedHeroConverter`) become genuinely dead code, deletable in 4.5i. Only the **leaf** protos used as Tier-3 blob payloads (`event_for_hero_backstory.proto`) are retained as a wire format. The whole-entity `<entity>_proto BLOB` is now reserved for **deep** entities where Tiers 2–3 would be more work than value (`ChangedProvince`, the "new entity" snapshots).
Per-entity decision (supersedes Decisions #2): classify each entity as **all-scalar** (Tier 1 only, no blob — e.g. likely `ChangedFaction`), **refined** (Tiers 1–3, no whole-entity blob, proto deletable — `ChangedHero`), or **deep** (Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained — `ChangedProvince`, new-entity snapshots). Default to **refined** for shallow entities; fall back to **deep** only when the residual is too varied to be worth Tiers 2–3. Wherever the body or Status says "the proto blob is authoritative," for a *refined* entity read "the columns + per-leaf-proto blobs are authoritative."
## Why
The first wave (Phases 1–4) stores each action as a single proto blob in `action_results.payload`. That blob is opaque to SQL — any query about action contents (changed provinces, heroes affected, etc.) requires deserializing every row. The `action_result_type`, `round_id`, and date columns we denormalized are the only handles SQL has into actions.
Two observations make full decomposition more attractive than the original design admitted:
1.**The proto exists for storage only.** 92 Scala files reference `ChangedProvince`, but exactly one imports the proto type — `ChangedProvinceConverter.scala`. Everything else (action handlers, AI, utils) operates on `ChangedProvinceC`. The proto is **not** in any client-facing RPC contract — it lives under `eagle.internal`. Same is true for `ChangedHero`, `ChangedFaction`, `ChangedBattalion`, `Notification`, `GeneratedTextRequest`, and `ActionResult` itself. Replacing proto serialization with SQL writes does **not** lose forward/backward compatibility, because there are no external consumers depending on the proto wire format.
2.**`ActionResultC` has no polymorphism at the top level.** It's a 34-field flat struct — `Option`s and `Vector`s, but no `sealed trait` discriminator at the action level. Every action has the same shape; the `actionResultType` enum tells you *which fields* are populated. This means the entire structure is relationalizable without per-action-type tables.
The work is bounded, the proto becomes dead code at the end of it, and it has to happen before alpha — once we have users, every schema change costs a real migration. Doing it pre-alpha lets us nuke save dirs as part of deployment.
## Scope
**In:**`action_results.payload` (the whole-`ActionResult` blob) is gone by the end. Per-entity scalar / entity-reference columns make the *queryable* surface (deltas, touched entities) native SQL. **Under refined option B (see Status):**`internal/action_result.proto` and *deep* entities' sub-message protos (`changed_province.proto`, …) are retained as blob payloads; *refined* entities' protos (`changed_hero.proto`) are decomposed away and deleted, with only their Tier-3 leaf protos (`event_for_hero_backstory.proto`) kept. (The original plan deleted all of them, then a draft of option B kept all of them; neither holds — it's now per-entity.)
**Out:**
-`state_snapshots.payload` — stays as proto-blob `GameState`. These are caches for replay, not authoritative data; querying inside a snapshot adds nothing over querying the action deltas that produced it. Cost/benefit doesn't justify decomposing them.
- Shardok tables — already designed in Phase 3, no changes here. The shardok protos (`ShardokActionResult`, etc.) are owned by the Shardok subsystem and are part of an actual RPC contract; they stay.
- Client text — already SQLite-backed (`SqliteClientTextStore`).
## Principles
1.**One table per non-trivial aggregate type.** Every `Vector[X]` or `Option[X]` where `X` is a Scala case class with multiple fields gets its own table.
2.**Vectors of primitives → join tables.**`Vector[Int]` becomes a two-column table: `(parent_key, value)`. No JSON arrays, no comma-separated strings — those defeat the point.
3.**Optional aggregates → presence by row existence.**`Option[X]` for aggregate `X` is a row that may or may not exist with the matching parent key. The natural primary key on `(action_seq)` enforces 0-or-1-row semantics for `Option[X]` aggregates and `(action_seq, n)` for vectors.
4.**Sealed traits get a discriminator column.** When a Scala sealed trait has a few subtypes with diverging fields, prefer one wide table with `kind TEXT NOT NULL` + nullable per-subtype columns over per-subtype tables. When subtypes diverge significantly (>5 unrelated fields each), use per-subtype tables. Decide per case.
5.**`ON DELETE CASCADE` everywhere.** Every child table has `FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE`. `truncateTo` becomes a single `DELETE FROM action_results WHERE action_seq >= ?` and the cascades do the rest.
6.**Schema migrations are now real.** Once we have a relational schema, evolving it means SQL migrations. We introduce a real migration runner (see "Schema evolution" below). The `metadata` table's `schema_version` becomes load-bearing.
7.**(Revised under option B — see Status.) The proto blob is the retained authoritative store for each entity's deep aggregates; scalar / entity-reference columns are denormalized projections of it.** The original principle assumed full decomposition and proto deletion. Instead each entity keeps one proto-blob source of truth and denormalizes only the queryable columns from the same write — so there is no dual *relational* representation to keep in sync.
## Top-level: `action_results`
The `payload BLOB` column goes away. Scalar fields become columns; aggregate fields move to related tables.
Each `Vector[Aggregate]` field on `ActionResultC` gets its own table (covered below).
### Indexes
We keep the existing indexes (`round_id`, `(date_year, date_month)`) and add a few that obvious queries need:
-`idx_action_results_acting_hero_id`
-`idx_action_results_acting_faction_id`
-`idx_action_results_province_id`
Plus indexes on the per-entity join tables on the entity-id side, so cross-game queries like "all actions touching hero X" become single-index lookups:
`ChangedProvinceC` has ~50 fields: scalar deltas, nested aggregates, ID vectors. The scalar fields become columns on `action_changed_provinces`; aggregate fields become child tables.
```sql
CREATETABLEaction_changed_provinces(
action_seqINTEGERNOTNULL,
province_idINTEGERNOTNULL,
-- resource changes
gold_deltaINTEGER,
food_deltaINTEGER,
-- stat changes
new_price_indexREAL,
economy_deltaREAL,
agriculture_deltaREAL,
infrastructure_deltaREAL,
economy_devastation_deltaREAL,
agriculture_devastation_deltaREAL,
infrastructure_devastation_deltaREAL,
support_deltaREAL,
-- round properties
set_has_actedINTEGER,-- 0/1/NULL
set_ruler_is_travelingINTEGER,
-- ruling faction changes
clear_ruling_faction_idINTEGERNOTNULLDEFAULT0,
new_ruling_faction_idINTEGER,
-- misc scalar fields
new_locked_improvement_kindTEXT,-- 'none', 'new'
new_locked_improvement_valueINTEGER,-- ImprovementType if kind='new'
The vector and optional-aggregate fields of `ChangedProvinceC` become per-aggregate child tables, all keyed by `(action_seq, province_id)`:
-`action_changed_provinces_new_unaffiliated_heroes(action_seq, province_id, n, ...)` — one row per added hero, with `UnaffiliatedHero` fields as columns
-`action_changed_provinces_changed_unaffiliated_heroes(...)` — same shape
-`action_changed_provinces_removed_unaffiliated_hero_ids(action_seq, province_id, hero_id)` — join table for ID vector
-`action_changed_provinces_new_deferred_change` — `Option[DeferredChange]` (singleton, with discriminator since `DeferredChangeT` is sealed)
That's a lot of tables — ~25 just under `ChangedProvince`. The schema is mechanical once the principles are set; what makes it tractable is that every table follows the same shape: parent FK, `n` (vector index) where ordering matters, the aggregate's fields as columns, child aggregates as their own tables.
Same pattern. Each gets its top-level table keyed by `(action_seq, entity_id)`, plus child tables for nested aggregates and ID vectors. Indexes on the entity id (`hero_id`, `faction_id`, `battalion_id`) so cross-game queries are fast.
`ChangedHeroC` is a *refined* entity (see "Refinement" in Status): no whole-entity blob. Its sealed `StatChange` (`StatDelta`/`StatAbsolute`/`StatNoChange`) `loyalty` and `vigor` oneofs are Tier-2 column pairs (`loyalty_delta`/`loyalty_absolute`, `vigor_delta`/`vigor_absolute`; column name discriminates, both-NULL ⇒ `StatNoChange`). Its `newBackstoryEvents: Vector[EventForHeroBackstory]` is Tier-3: per-event `EventForHeroBackstory` blobs, one row each, in `action_changed_heroes_new_backstory_events`. Columns + per-event blobs round-trip losslessly, so `changed_hero.proto`/`ChangedHeroConverter` become deletable (4.5i) and only `event_for_hero_backstory.proto` is retained as a blob format.
## "New" entity tables
`ActionResultC.newProvinces: Vector[ProvinceT]`, `newHeroes`, `newFactions`, `newBattalions`. These are *full snapshots* of new entities at creation.
These are bigger and recursive: `ProvinceT` itself has fields including its armies, heroes, battalions, events, etc. Decomposing fully duplicates the structure already covered by the existing `state_snapshots` blob (which we're explicitly keeping). The decomposition still needs to happen because we're dropping the proto entirely — and these vectors live in `ActionResultC`, which we're decomposing.
**Decision**: go fully relational here too. Each "new entity" gets its top-level table (`action_new_provinces` etc.) keyed by `(action_seq, entity_id)`, with the same per-aggregate-field decomposition pattern as the changed-entity tables. Where a "new entity" type contains its own nested aggregates, those aggregates either share child tables with the `changed_*` family (preferred where the field shape is identical, e.g., `MovingArmy`, `UnaffiliatedHero`) or get their own (where they diverge).
This is the most expensive single chunk of the decomposition — explicitly called out so we know what we're committing to in PR 4.5f.
`NotificationT` and `GeneratedTextRequestT` are both sealed traits. Use the discriminator-column pattern: `kind TEXT NOT NULL` plus nullable per-subtype columns, unless a subtype's fields are too divergent to share a row.
## Schema evolution
We can't punt on this any more. With this many tables, the next field addition can't be "edit the proto." It needs a real migration mechanism.
Conventions:
-`metadata['schema_version']` is a small integer, currently `1`.
- A `Migrations` object holds an ordered `Vector[(Int, Connection => Unit)]` of migration steps.
- On `SqliteHistory.loaded`, the schema version is read; any pending migrations are applied in a single transaction; the version is bumped.
- New games start at the latest version.
- Migration steps are append-only — never edit an existing step.
This is the standard Rails / Flyway / etc. pattern, scaled down to one file.
valscala=ActionResultDbReader.read(connection,actionSeq)// assembles from action_results + child tables
```
`ActionResultDbReader.read` is one query per child table (or a single multi-result query with carefully ordered joins, though that has aggregation pitfalls). Per-table queries are simpler to write and SQLite query planning handles them well.
For bulk reads (`since`, `all`, `sinceDate`, etc.), the existing `replayFrom` helper iterates action seqs in order, and currently parses one proto per action. The new equivalent runs one batched query per child table for the relevant action_seq range, materializing rows into a map keyed by action_seq, then walks the range emitting `ActionResultC` instances assembled from the maps. This is more code than the proto version but doesn't change the algorithmic shape.
## Write path
`withNewResults` becomes:
1. INSERT into `action_results` (scalar fields).
2. For each non-empty aggregate: INSERT N rows into the corresponding child table.
All inside a single transaction. Per-action insert count is bounded by what the action does — most actions touch 0–3 provinces, 0–5 heroes, 0–10 changed entities total, plus 0–2 notifications. So the typical insert is ~5–20 rows. With WAL and a single per-batch transaction, this is cheap.
## Replay performance
Today's `replayFrom` parses one proto per action. Decomposed, each action requires per-child-table queries. For a 25-action replay between snapshots, that's still small absolute work — maybe 100–500 rows fetched from a handful of indexed tables. Should be faster than proto parsing once the JIT warms up, but won't matter either way at typical sizes.
## Deleting the proto
> **Superseded by refined option B (see Status).** Whole-`ActionResult` `payload` is removed in 4.5h. After that it depends on per-entity classification: *refined* entities' per-entity sub-message protos + converters (e.g. `changed_hero.proto`) **do** become dead code and are deleted in 4.5i; *deep* entities' whole-entity blob protos, the leaf protos used as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`), and `Quest` are *retained*. The original full-deletion plan below is kept for historical context.
Once `SqliteHistory` writes and reads exclusively from the new tables:
1. Delete `ActionResultProtoConverter.scala`, the `Changed{Province,Hero,Faction,Battalion}Converter.scala` files, the `Notification`/`GeneratedTextRequest`/etc. converters.
2. Delete the proto definitions: `action_result.proto`, `changed_province.proto`, `changed_hero.proto`, `changed_faction.proto`, `notification.proto`, `generated_text_request.proto`, anything else in this scope.
3. Delete the corresponding `_scala_proto` Bazel targets.
4. Gazelle should drop the unused deps from downstream targets.
This is satisfying but should land *last*, after everything's stable. The proto files are dead code at that point — keeping them around for a PR or two while we verify migration is fine.
## Sequencing
**Phase 4** (cutover) lands as planned: `GamesManager` swaps to `SqliteHistory`, save dirs get nuked, action data is the single proto-blob `payload`. Days of work, validates the lifecycle integration without conflating with schema design.
**Phase 4.5** (this design) starts after Phase 4 is verified stable. Subdivided into small PRs. **Re-planned for option B (see Status):** each entity gets the 4.5a scalar/entity-ref columns + a `<entity>_proto BLOB`; no child-table explosion. Each decomposition PR splits into `.1` (schema + backfill over existing `payload`) and `.2` (live `withNewResults` write path + read path), per the precedent set by 4.5b.
| 4.5d | `ChangedFaction`: same pattern (mostly scalar already; small/empty blob — see Decisions #2) | planned |
| 4.5e | `ChangedBattalion`: `changed_battalion_proto BLOB` (sole field is the contained `BattalionT`) + battalion_id index from 4.5a | planned |
| 4.5f | "New entity" vectors (`newProvinces`/`newHeroes`/`newFactions`/`newBattalions`): one table per type keyed by `(action_seq, entity_id)` + a couple of queryable handles + `<entity>_proto BLOB`. Biggest win for B — these are the deepest snapshots | planned |
| 4.5h | Top-level `ActionResultC` scalar/entity-ref columns on `action_results` (acting_hero_id, acting_faction_id, province_id, …) + **drop `payload BLOB`**. Read path = scalar columns + per-entity proto blobs (close to today's proto parse; simpler than the original per-table-join plan) | planned |
| 4.5i | Delete proto files/targets that became dead code. *Refined* entities' per-entity protos + converters (e.g. `changed_hero.proto`/`ChangedHeroConverter`) **are deleted** here. *Deep* entities' protos + leaf Tier-3 protos (e.g. `event_for_hero_backstory.proto`) + `Quest` are **retained** as blob payloads. No longer near-nothing — scope scales with how many entities went *refined* | planned |
Each PR nukes save dirs as part of its rollout (still pre-alpha). Each PR is independently reviewable and revertable. Under option B the per-PR cost is far lower than the original estimate (~110-line writer per entity, no child-table schema), so the remaining sequence is roughly **1 week**, not 3–4.
After 4.5: Phase 5 (idle-game eviction) and Phase 6 (final cleanup) proceed as the parent design doc lays out.
## What we gain at the end
- **Single source of truth.** Action data lives in tables; no proto representation for the things in scope.
- **Native SQL analytics.** Cross-game queries like "actions touching province X" or "all riots in year Y" are one-line SELECTs. No backfill code needed.
- **Inspectable saves.** `sqlite3 game.db` shows readable data. Debugging is normal SQL work, not "parse this binary blob."
- **Cleaner replay path.** No proto parsing during replay. The `ActionResultProtoConverter` (currently called per replayed action in `replayFrom`) goes away entirely.
- **Less code.** ~7 proto files deleted; their generated Scala targets deleted; their converter classes deleted; the dual proto/Scala representation collapses to one.
- **Schema migrations as a first-class concept.** Future field additions are SQL migrations, which is the right shape for a system that owns its storage.
## What we lose
- **The "edit one proto" workflow** for adding fields. Adding `newFooDelta: Double` to `ChangedProvinceC` is now a Scala edit + SQL migration step + converter update. More steps. Pre-alpha this is cheap; post-alpha (when migrations apply to real data) it's a careful PR but still bounded.
- **A safety net for unrecognized fields.** Proto silently keeps unknown fields on parse; SQL doesn't. We accept this because we control both ends.
## Decisions locked
1.**Refined option B is the pattern (see "Refinement" in Status; rescinds both "fully relational, no blob fallback" and the original whole-entity-blob-per-entity rule).** Per entity, three tiers: scalar/entity-ref columns (Tier 1) + typed columns for sealed value-oneofs (Tier 2) + per-row leaf-proto blobs in child tables (Tier 3). A whole-entity `<entity>_proto BLOB` is used **only for deep entities** where Tiers 2–3 cost more than they're worth. No child-table explosion for deep aggregates.
2.**Classify each entity: all-scalar / refined / deep (supersedes the old "is a blob needed").***All-scalar* → Tier 1 only, no blob (likely `ChangedFaction`). *Refined* → Tiers 1–3, no whole-entity blob, per-entity proto deletable (`ChangedHero`). *Deep* → Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained (`ChangedProvince`, new-entity snapshots). Default to *refined* for shallow entities; fall back to *deep* only when the residual is too varied for Tiers 2–3.
3.**What's retained vs. deleted depends on the classification.**`action_results.payload` (the whole-`ActionResult` blob) still goes away in 4.5h. *Refined* entities' per-entity sub-message protos + converters become dead code, deleted in 4.5i; only the **leaf** protos they use as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`) are retained. *Deep* entities' protos + `Quest` stay as whole-entity blob payloads. So 4.5i is no longer near-nothing.
4.**Sub-phase ordering** as in the Sequencing table; each decomposition PR splits into `.1` (schema + backfill) and `.2` (live write + read).
5.**Phase 4 landed separately, before 4.5a** (historical, unchanged). Cutover validated the lifecycle integration with the simpler schema before decomposition began.
The current persistence layer is file-based: action results are chunked into `.e0a` files, individual results spill to `.e0i` files for crash recovery, state snapshots live in `.e0s` files, and a `directory.e0i` index tracks chunks. This shape has produced three workarounds in recent history:
1. A `games.e0es` cache (PR #6706) to avoid re-reading the running-games list.
2. Dual-storage in `PersistedActionResult` (proto bytes + parsed proto) to amortize serialization across save flushes.
3. Lazy `gameState` (PR #6692) to avoid eager proto conversion on every replayed step, after PR #6691 fixed the `stateAfter` OOM that materialized ~4500 `PersistedActionResult` instances during a stale-LLM-replay path.
Each is a workaround for the same root cause: file-based random access is expensive, so we cache aggressively, and the cache layers are fragile (one misplaced `.map(_.gameState)` reintroduces the OOM).
The alternative — SQLite as a per-game container, with proto bytes stored as BLOB columns indexed by action sequence number — gives us random access by construction, removes the need for all three workarounds, and makes idle-game eviction cheap (rehydrate from the DB instead of reparsing files). The pattern is already in production here: `SqliteClientTextStore` does exactly this for the client text cache. We extend the same pattern to game history.
## Goal & non-goals
**In scope:**
- Per-game SQLite container (`game.db`) holding action history, state snapshots, and shardok results.
- A new `SqliteHistory` class implementing the existing `FullGameHistory` trait, drop-in replacement for `PersistedHistory` at the call site.
**Out of scope (deferred or rejected):**
- **Migration of existing saves.** Pre-alpha there are only two users and a handful of test games. Cheaper to nuke the existing save directories at cutover than to write and verify a migration importer. New games created post-cutover land in `game.db` directly; existing games are gone.
- The top-level `games.e0es` running-games registry. Stays as-is; its cache (PR #6706) already works.
- Idle-game eviction. Enabled by this work but lands in a follow-up phase after `SqliteHistory` is the authoritative path.
- A shared cross-game analytics DB. Per-game design supports ad-hoc cross-game queries via iterate-and-aggregate; build the centralized analytics DB only if/when those queries become hot.
## File layout
`game.db` lives in the existing per-game save directory, alongside `text_store.db`:
```
${EAGLE_SAVE_DIR}/${gameIdHex}/
├── game.db # NEW — action history, snapshots, shardok results
└── text_store.db # existing — client text (SqliteClientTextStore)
```
Existing `.e0a` / `.e0s` / `.e0i` / `directory.e0i` files do not coexist with `game.db`: at cutover we nuke save directories one time. There is no migration path and no legacy-file fallback.
### Why `game.db` and `text_store.db` are separate files (not tables in one DB)
It's tempting to combine them — one file per game, one connection per game, one cloud-upload story. The reason not to: **writer-lock contention.** SQLite serializes writers per-database file. `SqliteClientTextStore` writes frequently during LLM streaming (one `UPDATE` per token append on `texts.text`). `SqliteHistory` writes per-action during turn commits. Combining them means an in-flight LLM token append can block a turn commit, or vice versa. Separate DBs have separate writer locks and never contend. Subsystem ownership (each package owning its own schema, independent migration paths if the schemas evolve) is a bonus.
`Persister` integration follows the `SqliteClientTextStore` pattern: `game.db` is uploaded to / downloaded from cloud storage as a single opaque blob, keyed by the filename. On first load, if `game.db` is missing locally, try `persister.retrieveAsStream("game.db")`. If both are missing, this is a new game and we create an empty DB.
## Schema
```sql
-- Action results, one row per action_result_index.
CREATETABLEaction_results(
action_seqINTEGERPRIMARYKEY,-- 0-based, dense, never sparse
action_result_typeINTEGERNOTNULL,-- denormalized for filtering
round_idINTEGERNOTNULL,-- denormalized for recentResultsForRound
date_yearINTEGER,-- denormalized for sinceDate; NULL pre-game-start
date_monthINTEGER,-- denormalized for sinceDate; NULL pre-game-start
-- State snapshots at chunk boundaries (and the starting state at seq 0).
-- boundary_action_seq is the action_seq AFTER which the snapshot reflects state.
-- The starting state lives at boundary_action_seq = 0 (state before any actions).
CREATETABLEstate_snapshots(
boundary_action_seqINTEGERPRIMARYKEY,
payloadBLOBNOTNULL-- GameState proto bytes
)WITHOUTROWID;
-- Shardok per-battle results.
CREATETABLEshardok_results(
shardok_game_idTEXTNOTNULL,
action_seqINTEGERNOTNULL,-- 0-based within this shardok game
payloadBLOBNOTNULL,-- ShardokActionResult proto bytes
PRIMARYKEY(shardok_game_id,action_seq)
)WITHOUTROWID;
-- Shardok per-game state (one row per shardok_game_id).
CREATETABLEshardok_state(
shardok_game_idTEXTPRIMARYKEY,
game_stateBLOBNOTNULL,-- ShardokGameState proto bytes
last_eagle_round_idINTEGERNOTNULL
)WITHOUTROWID;
-- Shardok per-player results (filtered ActionResultView per faction).
CREATETABLEshardok_player_results(
shardok_game_idTEXTNOTNULL,
faction_idINTEGERNOTNULL,
seqINTEGERNOTNULL,
payloadBLOBNOTNULL,-- ShardokActionResultView proto bytes
PRIMARYKEY(shardok_game_id,faction_id,seq)
)WITHOUTROWID;
-- Shardok per-player available commands (latest only, keyed by game + faction).
CREATETABLEshardok_player_commands(
shardok_game_idTEXTNOTNULL,
faction_idINTEGERNOTNULL,
payloadBLOB,-- ShardokAvailableCommands proto bytes; NULL = no commands
PRIMARYKEY(shardok_game_id,faction_id)
)WITHOUTROWID;
-- Schema version and starting state.
CREATETABLEmetadata(
keyTEXTPRIMARYKEY,
valueBLOBNOTNULL
);
-- Seeded with: schema_version=1
```
### Column rationale
- **`action_seq`** as `INTEGER PRIMARY KEY` (the SQLite rowid alias) — densest possible storage and no separate index; range scans for `since(start)` are O(log n + result count).
- **`WITHOUT ROWID`** on tables with synthetic keys to skip the implicit rowid column.
- **Denormalized `action_result_type`, `round_id`, `date_year`, `date_month`** — these are the predicates the existing read paths use (`recentResultsForRound`, `sinceDate`). Keeping them as columns avoids parsing the proto blob to filter.
- **No `created_at` timestamp** — wall-clock time isn't queried by the History trait, and game date (year/month) is what matters semantically.
- **`payload BLOB`** — the proto bytes are the source of truth for the structured action result. Polymorphic action types make a relational schema painful for limited gain; the denormalized columns above cover the queries we need.
- **Snapshots keyed by `boundary_action_seq`** — `stateAfter(N)` finds `MAX(boundary_action_seq) WHERE boundary_action_seq <= N`, returns that snapshot, replays forward `N - boundary_action_seq` actions. Snapshot at `0` is the game's starting state.
### Snapshot strategy
Match the existing `resultsPerSaveFile = 25` boundary: write a `state_snapshots` row every 25 actions. That gives the same replay-window cost as the current chunk-file design (`stateAfter(N)` replays at most 25 actions to reach an arbitrary point), and matches the cadence developers are already calibrated to.
Snapshots are GameState proto bytes, identical in shape to today's chunk-file `startingState`. The migration importer derives them directly from the chunk files. New games write a snapshot after every 25th `withNewResults` action.
## Connection lifecycle
Mirror `SqliteClientTextStore`:
- One `Connection` per loaded game, opened when `GamesManager` loads the game, closed when the game is evicted (future eviction work) or the server shuts down.
-`Class.forName("org.sqlite.JDBC")` + `DriverManager.getConnection("jdbc:sqlite:${path}")` on open.
-`PRAGMA journal_mode = WAL` on every connection open. WAL gives us crash-safe writes and lets readers proceed concurrently with the single writer — important because gRPC stream readers (humanPlayerClientConnectionState) query history mid-turn.
-`PRAGMA synchronous = NORMAL` (the WAL-recommended setting; durability is preserved through WAL checkpoint).
-`PRAGMA foreign_keys = ON` (defensive; we have no FKs today but cheap to enable).
- Auto-commit on by default. Transactions explicitly opened for `withNewResults` (batch of action inserts + optional snapshot) and `truncateTo` (deletes across all tables).
### Threading
The current `PersistedHistory` is an immutable case class; `withNewResults` returns a new instance. `SqliteHistory` cannot be pure-immutable (the DB is mutable state) but should present the same interface: methods that "change" the history return `this` after a successful write. The underlying `Connection` is shared.
JDBC `Connection` is not thread-safe in general; SQLite's JDBC driver serializes operations per-connection. Existing call sites already serialize writes through the `EngineApplier` flow, so single-threaded write access is preserved. Reads from gRPC stream readers can use the same connection — SQLite serializes them transparently, and WAL prevents read-write blocking.
## Read paths
How each `FullGameHistory` method maps to SQL:
| Method | Query |
|---|---|
| `count` | `SELECT COALESCE(MAX(action_seq), -1) + 1 FROM action_results` (cached as a counter after the first read) |
| `last` | `SELECT payload FROM action_results ORDER BY action_seq DESC LIMIT 1` + state from `stateAfter(count)` |
| `all` | `SELECT payload FROM action_results ORDER BY action_seq` — used by `GameAdminServiceImpl.getActionDetail` to fetch one action by index. See follow-up note below. |
| `since(start)` | `SELECT payload FROM action_results WHERE action_seq >= ? ORDER BY action_seq` |
| `sinceDate(date)` | `SELECT payload FROM action_results WHERE (date_year, date_month) >= (?, ?) ORDER BY action_seq` |
| `recentResultsForRound(round, pred)` | `SELECT payload FROM action_results WHERE round_id = ? AND action_seq > ? ORDER BY action_seq` where `?` is the cutoff matching current `recentHistory` semantics (last N actions, or all actions for the current round) |
| `stateAfter(N)` | Find latest snapshot ≤ N, replay forward via `replayApplier` (same logic as `replayScalaOnlyToState`) |
For methods that return `Vector[ActionResultWithResultingState]` (with resulting state per row): we **do not** materialize per-row gameStates. Instead, fold the actions through `replayApplier` starting from the latest snapshot ≤ start, producing the states on the fly. This matches what `formAwrs` does today, with one critical difference: no `PersistedActionResult` wrapper is allocated, and no proto-conversion is performed per step. This is the same shape as `replayScalaOnlyToState`, generalized to produce intermediate states.
### `all()` follow-up
`GameAdminServiceImpl.getActionDetail` is the one production caller. It fetches a single action by index from the full vector. Either of these is cheaper than materializing the whole history:
- Replace the call site with `history.since(index).headOption`.
- Add a new `actionAt(index): Option[ActionResultWithResultingState]` method and drop `all()` from the trait entirely.
`SqliteHistory.all` will work — it's just `SELECT * ORDER BY action_seq` — but it's expensive (materializes the full history into memory), so we should switch the admin caller in a small follow-up PR. Not blocking the SQLite work.
### `recentResultsForRound` semantics
Today this filters `recentHistory` (the in-memory tail) by `roundId`. In SQLite there's no in-memory/persisted split — all results live in the DB. The semantics shift slightly: return all results matching `round_id` after a configurable cutoff. The cutoff should match today's behavior (results since the start of the current round, or some bounded recent window). Default to "all results with the given `round_id`," which is correct as long as `round_id` uniquely identifies a round across the game's history (it does, per the current `RoundId` model).
## Write paths
### `withNewResults(newResults)`
In a transaction:
1.`INSERT INTO action_results (action_seq, action_result_type, round_id, date_year, date_month, payload) VALUES ...` — one row per new result. Use `addBatch()` for multiple.
2. For each new result whose `action_seq % 25 == 0` (snapshot boundary), `INSERT INTO state_snapshots(boundary_action_seq, payload) VALUES (?, ?)` with the GameState proto bytes.
3. Commit.
The denormalized columns (`action_result_type`, `round_id`, `date_year`, `date_month`) are extracted from the action's resulting state at insert time. They are immutable once written; if the schema interpretation changes, a migration is required.
The "individual result for crash recovery" pattern (`.e0i` files) is replaced by: the action result is durably written when the transaction commits. WAL gives us atomicity per transaction. No separate crash-recovery file is needed.
### `saveNow`
Becomes a no-op in normal operation — writes are already durable per `withNewResults` commit. We keep the method on the trait for API compatibility but the implementation just returns `this`. (We could call `PRAGMA wal_checkpoint(TRUNCATE)` here to roll the WAL into the main DB file, useful before cloud upload; defer this until we measure WAL growth.)
### `truncateTo(targetActionCount)`
In a transaction:
1.`DELETE FROM action_results WHERE action_seq >= ?`
2.`DELETE FROM state_snapshots WHERE boundary_action_seq > ?`
3. Shardok cleanup: `DELETE FROM shardok_results / shardok_state / shardok_player_results / shardok_player_commands WHERE shardok_game_id NOT IN (...)` (the set of battles still outstanding at the truncate point; mirrors `deleteOrphanedShardokFiles`).
4. Commit.
`truncateTo(0)` resets the game; everything after the starting-state snapshot is deleted.
## Shardok results
The shardok subsystem currently lives in `.e0s` files (one per battle, full per-battle state). It's loaded selectively at game-load time, only for outstanding battles (see `PersistedHistory.apply` line 248-256).
Moving to SQLite, the four shardok tables above capture:
-`shardok_results` — the per-battle result stream
-`shardok_state` — current per-battle state (one row per battle)
-`shardok_player_commands` — current available commands per faction
`withNewShardokResults` writes to all four in a transaction. `shardokCount`, `shardokGameState`, etc., become single-row indexed lookups.
The selective-load optimization disappears with SQLite: we don't proactively read anything; queries hit the DB on demand. The "load only outstanding battles" logic is replaced by "query by `shardok_game_id` when needed."
## Crash recovery
WAL replaces the `.e0i` individual-result-file mechanism. On startup:
- WAL is automatically replayed by SQLite if the previous shutdown was unclean. No application code needed.
- A successful commit means durable; an interrupted commit means rolled back. No half-written state visible.
The current "orphaned individual results on load" path (`loadIndividualResults` in `PersistedHistory.apply`) is gone.
## Cutover
No migration path. At cutover:
1. Stop the server.
2. Nuke the contents of `${EAGLE_SAVE_DIR}` (and `${EAGLE_ARCHIVE_DIR}` if there's anything there).
3. Deploy.
4. New games created post-deploy land in `game.db` directly.
Pre-alpha there are only two users and a handful of test games; a one-time nuke is cheaper than a verified migration importer. Trade-off accepted by the user explicitly.
### Testing strategy (no migration)
Without migration, we don't need equivalence-vs-`PersistedHistory` testing. The correctness gate becomes:
1.**Adapt `PersistedHistoryTest`** to run against `SqliteHistory` instead. The 890-line existing test suite covers the trait surface exhaustively. Same assertions, new implementation under test.
2.**Property-style end-to-end test**: create a synthetic game, run N batches of `withNewResults`, verify that `since(start)`, `stateAfter(N)`, `sinceDate(date)`, `recentResultsForRound`, `truncateTo`, etc., all produce expected values. Run with a deterministic random seed.
3.**Manual smoke test in dev**: start a fresh game, play several turns, restart the server, verify saves persist and replay correctly.
## Cloud-storage integration
`Persister.save(key, bytes)` and `persister.retrieveAsStream(key)` already handle local-vs-S3 transparently. `SqliteClientTextStore` integrates by treating `text_store.db` as a single binary blob: upload after writes, download before reads.
`SqliteHistory` will do the same with `game.db`:
- **On load:** if local `game.db` missing, try to download via `persister.retrieveAsStream("game.db")`. Fall back to migration from `.e0a` if both are missing.
- **On save:** after a meaningful change (turn boundary? configurable cadence?), upload the current `game.db` file to S3. Need to decide the upload trigger — too frequent and we burn S3 PUTs; too rare and crash recovery is lossy.
**Open question:** the right upload cadence. Today, chunk saves trigger cloud upload at chunk boundaries (every 25 actions). The simplest match: keep that cadence, upload `game.db` once per chunk boundary. We could be smarter (only upload if WAL has been checkpointed; only upload deltas) but those are optimizations.
Every other consumer of `FullGameHistory` is unchanged.
## Decisions locked
1.**Per-game `game.db`** (not shared across games).
2.**`game.db` and `text_store.db` stay separate files** (not combined into one DB). Writer-lock contention is the deciding factor.
3.**No migration** of existing saves. Nuke save directories at cutover. Pre-alpha trade-off accepted by the user.
4.**No feature flag.** Cutover is unconditional. Failures are loud.
5.**Cloud upload cadence**: match the existing 25-action chunk cadence. Upload `game.db` once per snapshot boundary.
6.**`recentResultsForRound`**: return all results for the given round (no recent-window cap). The `round_id` predicate bounds the scan naturally.
7.**WAL checkpointing**: rely on SQLite's auto-checkpoint. Add explicit `PRAGMA wal_checkpoint` only if WAL file growth becomes a problem.
8.**Schema versioning**: seed `metadata` with `schema_version = 1`. Schema migration mechanism deferred until we need a v2.
9.**`history.all`**: keep on the trait for now; admin call site moved to `since(i).headOption` (or a new `actionAt(i)` method) in a small follow-up PR. Not blocking.
| 4 | Cutover in `GamesManager` (nuke `${EAGLE_SAVE_DIR}` as part of deploy) | 2-3 days |
| 5 | Idle-game eviction | 3-5 days |
| 6 | Post-alpha cleanup (delete `PersistedHistory`, `PersistedActionResult`, `PartialGameUtils`, the chunk-file save code, `games.e0es` cache if no longer needed) | 1-2 days |
**Total: ~3-4 weeks** (down from 5-6 — migration and equivalence-testing dropped).
The follow-up to migrate `GameAdminServiceImpl.getActionDetail` off `history.all` is a small, independent PR that can land any time.
This document describes the tutorial battle system for first-time players. The system provides a scripted introductory battle that teaches combat basics while telling a story.
This document describes the current opening tutorial battle. It is based on the live implementation in `TutorialGameCreation.scala`, `TutorialBattleController.cpp`, `ShardokGamesManager.cpp`, `ResolveBattleAction.scala`, and the Unity dialogue scripts.
For the Unity tutorial UI/dialogue architecture, see `TUTORIAL_SYSTEM.md`. For copy and content notes, see `TUTORIAL_CONTENT.md`.
---
## Overview
## Current Flow
When a new player starts their first game in tutorial mode, they experience:
When a player creates a tutorial game at the default `OpeningBattle` phase:
1.**Narrative Intro** - Story screens introducing the scenario
2.**Immediate Battle** - Skip strategic map, start directly in combat
3.**Scripted Flee** - Enemy flees when player is "on the ropes"
5.**Heroes Join** - Reinforcement heroes join player's faction after battle
1.`TutorialGameCreation.createTutorialGame()` creates a `GameType.Tutorial(TutorialPhase.OpeningBattle)` game starting in `RoundPhase.BattleRequest`.
2.Province 14, **Onmaa**, belongs to Sadar Rakon's faction and already has a defending army.
3.Bregos Fyar's faction has a hostile attacking army, led by Ikhaan Tarn, already moving from province 31 to Onmaa.
4.`RequestBattlesAction` immediately creates the Shardok battle and tags tutorial reinforcement hero IDs `100` and `101`.
5.`GamesManager` stores the generated `TutorialBattleConfig` for this game and passes it to Shardok when the battle starts.
6. Shardok creates the battle with the configured timed reinforcement events.
7. Unity plays the active dialogue scripts from `tutorial_strategic.json` and `tutorial_battle.json`.
There is also a tutorial phase-start path. Starting after `OpeningBattle` applies precomputed tutorial setup results from `TutorialPhaseResultsLoader`; this is why the Unity post-battle trigger logic handles a "battle was never observed" auto-resolve path.
---
## Battle Configuration
## Battle Setup
### Defender (Player - John Ranil)
- **Heroes**: John Ranil + 2 random vassals (3 total)
|`elena_reinforcement_round_5` | End of round 5 or later | 101 | Elena Fyar | Fyar's Vanguard, Heavy Infantry, 535 size, 75 training, 75 armament |
| `ranil_reinforcement_round_7` | End of round 7 or later | 100 | John Ranil | Ranil's Riders, Heavy Cavalry, 458 size, 80 training, 80 armament |
**Unity Client:**
-`NarrativeScreenController.cs`: Modal screen with title, body text, optional image
-`EagleGameController.cs`: Checks for pending narrative screens on game start
Shardok creates reinforcement units at battle creation time with `PENDING_REINFORCEMENT` status. `TutorialBattleController` activates them when their event triggers and returns a `TUTORIAL_REINFORCEMENTS_ARRIVED` action result. Unity maps that action to profession-specific dialogue triggers:
**Narrative Content** (defined in `tutorial_game_parameters.json`):
```
Screen 1: "The Engineer of Onmaa" - Player backstory
Screen 2: "The Traitor Arrives" - Tarn's attack
Screen 3: "Defend Your Home" - Call to action
```
-`tutorial_reinforcement_paladin` for Elena Fyar
-`tutorial_reinforcement_engineer` for John Ranil
### Phase 2: Auto-Start Battle
**Configuration:**
-`tutorial_game_parameters.json`: Contains `tutorialBattle` config with attacker, target, flee conditions
- Fires when available commands no longer include HandleCapturedHeroCommand
- Marek explains the province needs support rebuilt after the battle
- Engineers (John Ranil) can **Improve** the province — build infrastructure, develop economy
- Paladins (Elena Fyar) can **Give Alms** — distribute food to win hearts
- Instructions highlight **Improve** and **Give Alms** buttons
- Goal: get Support to **40** before January for tax revenue
**Implementation:**
- Dialogues defined in `tutorial_strategic.json`
-`tutorial_battle_ended`: fire when tutorial battle is removed from `RunningShardokGameModels`
-`tutorial_rebuild_support`: fire when available commands no longer include `HandleCapturedHeroCommand`
- Register `ImproveButton` and `AlmsButton` as highlight targets in `TutorialTargetRegistry`
See `TUTORIAL_CONTENT.md` for full dialogue text.
The reinforcement action includes an `attacker_starting_position_index`; Shardok resolves that to currently open map positions and starts a reinforcement placement phase.
---
## File Summary
## Scripted Event Controller
`TutorialBattleController` is a generic event-driven controller, even though the current tutorial battle only uses timed reinforcements.
Supported trigger types:
-`after_round`
-`units_lost`
-`damage_taken`
-`unit_killed`
Supported action types:
-`flee`
-`reinforcements`
Events are checked at the end of each round, evaluated in config order, and each `event_id` fires at most once.
---
## Battle Resolution
`RequestBattlesAction` marks tutorial opening battles with `reinforcementHeroIds = Set(100, 101)`. `ResolveBattleAction` uses that metadata so reinforcement heroes are accepted when battle results return, even though they were not part of the original defending army.
After the opening battle resolves, `ResolveBattleAction` applies `TutorialTarnDisappearsAction` for `GameType.Tutorial(TutorialPhase.OpeningBattle)`. Tarn is removed from captured/unaffiliated province state and from his faction's leaders list, matching the post-battle dialogue that he has vanished.
`TutorialBattleAutoResolve` provides a synthetic defender victory for tutorial setup paths that skip past the opening battle. In that synthetic result:
- The defender wins.
- Reinforcement heroes 100 and 101 survive.
- Tarn is marked outlawed/escaped.
- Other attacker heroes are captured.
- Defender battalions take about 30% casualties.
- Attacker battalions are destroyed.
---
## Unity Dialogue Hooks
Strategic tutorial dialogue:
-`game_started`: opening Onmaa monologue, ending with `FightButton` highlighted.
-`tutorial_battle_ended`: post-battle aftermath.
-`tutorial_rebuild_support`: support rebuilding guidance after captured-hero handling is done.
Battle tutorial dialogue:
-`shardok_placement_started`: placement and unit overview.
-`shardok_battle_running`: first player turn guidance.
- Ability/terrain triggers such as `archery_available`, `melee_available`, `ability_charge_available`, `start_fire_available`, `thunderstorm`, `duel_available`, `hide_available`, and `engineer_near_enemy`.
- Reinforcement triggers `tutorial_reinforcement_paladin` and `tutorial_reinforcement_engineer`.
- Capture triggers `enemy_hero_captured` and `friendly_hero_captured`.
-`shardok_battle_reset`: replay dialogue after a battle reset.
---
## Files To Check First
### New Files
| File | Purpose |
|------|---------|
| `TutorialBattleController.hpp/cpp` | C++ scripted flee and reinforcement logic |
|`src/main/scala/net/eagle0/eagle/library/actions/impl/action/RequestBattlesAction.scala` | Creates the opening battle and tags reinforcement hero IDs |
| `src/main/scala/net/eagle0/eagle/library/actions/impl/action/ResolveBattleAction.scala` | Resolves reinforcements and applies Tarn disappearance |
| `src/main/scala/net/eagle0/eagle/service/tutorial/TutorialBattleAutoResolve.scala` | Synthetic result for phase-start paths after the opening battle |
| `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Tutorial/Triggers/TutorialTriggerRegistry.cs` | Converts strategic and tactical events into dialogue triggers |
| `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Resources/Dialogues/tutorial_battle.json` | Active battle dialogue content |
---
## Testing Checklist
- [ ]New user sees narrative screens before battle
- [ ]Battle starts immediately after narratives (no strategic map)
- [ ]Player has correct units (3 heroes, 3 battalions)
- [ ]Attacker has correct units (3 heroes, 3 battalions)
- [ ]Player cannot flee
- [ ]Tarn flees after player loses 1 unit
- [ ]Tarn flees after 5 rounds (if no units lost)
- [ ] Reinforcements appear when Tarn flees
- [ ]Tutorial popups appear at correct moments
- [ ]Reinforcement heroes join faction after battle victory
This document defines all tutorial content. Edit this to refine the text, then update `TutorialContentDefinitions.cs` to match.
This document describes tutorial content. The active tutorial experience is driven by the narrative dialogue JSON files under `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Resources/Dialogues/`.
The older step-based modal/overlay content in `TutorialContentDefinitions.cs` is currently dormant: `RegisterAll()` returns before registering any sequences. Keep that in mind when editing this file; changing `TutorialContentDefinitions.cs` will not affect the live tutorial until the early return is removed and the overlap with dialogue triggers is audited.
---
@@ -181,17 +183,16 @@ These fire after the first tutorial battle ends and the player returns to the st
>
> Any of our heroes can develop the land or give alms to the people — nothing wins hearts faster than a full belly. Engineers like John Ranil are especially effective at improving a province, and paladins like Elena Fyar are the best at winning hearts. Either way, we need the people's support before the tax collectors come round in January.
**Instructions:**Use **Improve** and**Give Alms** to raise Support. You must have at least **40** support in the province by January in order to collect gold and food in taxes.
**Instructions:**John Ranil's step highlights `ImproveProvinceButton` and explains **Improve**. Elena Fyar's step highlights `GiveAlmsButton` and explains**Give Alms**. You must have at least **40** support in the province by January in order to collect gold and food in taxes.
-Need new trigger events: `tutorial_battle_ended`and `tutorial_rebuild_support`
-`tutorial_battle_ended` should fire when the tutorial battle is removed from `RunningShardokGameModels` in `EagleGameModel.ApplyGameStateViewDiff`
-`tutorial_rebuild_support` should fire when available commands no longer include `HandleCapturedHeroCommand` (i.e. the captured heroes phase is done and the player has regular commands available)
- The "rebuild" dialogue should highlight the Improve and Give Alms buttons in the command panel (requires registering them as highlight targets)
- Both dialogues are defined in `tutorial_strategic.json`.
-`tutorial_battle_ended` fires from `TutorialTriggerRegistry.CheckTutorialBattleEnded()`when the opening battle is gone and strategic commands are available. It also handles the server-side auto-resolve path where the Unity client never observed a running Shardok model.
-`tutorial_rebuild_support` fires from `TutorialTriggerRegistry.CheckTutorialRebuildSupport()` when available commands no longer include `HandleCapturedHeroCommand` and the notification panel has no visible info.
-`ImproveProvinceButton` and `GiveAlmsButton` are runtime command-button targets registered by `CommandButtonPanelController`.
---
@@ -210,11 +211,10 @@ These fire after the first tutorial battle ends and the player returns to the st
## Adding New Tutorials
1. Add entry to this document
2.Update `TutorialContentDefinitions.cs`:
- For onboarding: add to `CreateOnboardingSequence()`
- For contextual: add to `RegisterStrategicTutorials()` or `RegisterTacticalTutorials()`
3. Ensure trigger event exists in `TutorialTriggerRegistry.cs`
4. Test the flow
2.For active tutorial content, add or edit a script in `Assets/Resources/Dialogues/tutorial_strategic.json` or `tutorial_battle.json`.
3. Ensure the trigger event exists in `TutorialTriggerRegistry.cs` or is fired directly by the relevant controller.
4. If reviving the dormant step-UI system, remove the early return in `TutorialContentDefinitions.RegisterAll()` only after auditing duplicate trigger coverage with the dialogue JSON.
This document describes how the Unity client's tutorial system is wired together: the classes involved, the trigger catalog, the step lifecycle, and the expected first-session flow. It complements two existing docs:
- **`TUTORIAL_CONTENT.md`** — human-facing copy for tutorial steps and dialogues
- **`TUTORIAL_BATTLE_SYSTEM.md`** — the scripted opening battle at Onmaa
It also overlaps slightly with the in-tree `Assets/Tutorial/TUTORIAL_PLAN.md`, which is an older implementation plan.
All paths below are relative to `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/`.
---
## Current State (important)
There are **two parallel subsystems** that share the same trigger plumbing:
| Narrative dialogue system | **Live** | `Tutorial/Dialogue/*`, `Resources/Dialogues/*.json` |
`TutorialContentDefinitions.RegisterAll()` returns early with the comment *"Old tutorial content suppressed — replaced by narrative dialogue system."* As a result:
- `OnboardingSequence` is never assigned, so `StartOnboarding()` no-ops at the "No onboarding sequence assigned" branch.
- No contextual tutorial sequences are registered with the trigger registry.
- The trigger registry still fires events normally, but nothing on the step-UI side is listening.
- `DialogueManager` consumes those same events and matches them against scripts in `Resources/Dialogues/` (`tutorial_strategic.json`, `tutorial_battle.json`).
- Dialogue completion is persisted per game through `TutorialDialogueProgressStore`, so completed scripts do not replay after a reconnect or scene reload for the same game.
**In practice today, the entire active tutorial experience runs through the dialogue system.** The step-UI machinery is preserved for future use — content definitions still exist below the early return in `TutorialContentDefinitions.cs` for reference.
---
## Class Map
### Orchestration
- **`Tutorial/TutorialManager.cs`** — Singleton. Owns `OnboardingSequence`, the active step queue, the trigger registry, and the dialogue manager handle. Initialized by `EagleGameController.SetUpGame()` and `ShardokGameController.SetUpGame()`.
- **`Tutorial/TutorialState.cs`** — PlayerPrefs persistence. Tracks `OnboardingCompleted`, `OnboardingStepReached`, completed sequence IDs (HashSet for O(1) lookup), dismissed hints, and a global "tutorials disabled" preference.
- **`Tutorial/TutorialTargetRegistry.cs`** — Maps string IDs to `RectTransform`s for highlighting. Static targets are Inspector-assigned (`ProvinceInfoPanel`, `SupportField`, `CommitButton`, etc.); dynamic targets register at runtime (e.g., hero rows from `HeroesAndBattalionsPanelController`).
- **`Tutorial/TutorialDialogueCoordinator.cs`** — Recomputes durable dialogue triggers from the current strategic model and sends them directly to `DialogueManager`. This covers reconnects, phase-start tutorial games, and strategic beats that should be recoverable from state.
### Triggers
- **`Tutorial/Triggers/TutorialTriggerRegistry.cs`** (~1100 lines) — Routes game events to both the step UI (when sequences are registered) and `DialogueManager`. Maintains one-shot session flags so the same first-encounter trigger doesn't fire twice.
- **`Tutorial/Content/TutorialSequence.cs`** — `ScriptableObject` holding ordered `TutorialStep`s plus `IsOnboarding` flag and lifecycle callbacks.
- **`Tutorial/Content/TutorialContentDefinitions.cs`** — Static class that *would* register all sequences. Currently short-circuits before any registration.
### UI (step UI — dormant)
- **`Tutorial/UI/TutorialUIManager.cs`** — Coordinates which presenter renders each step.
- **`Tutorial/UI/TutorialCanvasBuilder.cs`** — Builds the Canvas at runtime (no prefab dependency).
1. **Triggered** — `TutorialTriggerRegistry` raises an event (e.g., `game_started`, `battle_entered`).
2. **Queued or shown** — If no active sequence, show immediately. If an active step is hidden (`DisplayMode.None`), interrupt it. If both new and active are command tutorials, interrupt for responsiveness. Otherwise queue.
3. **Rendered** — `TutorialUIManager.ShowCurrentStep()` dispatches to the modal/overlay/hint presenter based on `DisplayMode`.
4. **Awaiting completion** — One of:
- `ButtonClick` — user dismisses
- `GameEvent` — wait for a named event (e.g., `province_selected`)
- `Timer` — auto-advance after delay
- `UIInteraction` — wait for the highlighted target to be interacted with
- `Condition` — custom predicate (rare)
5. **Advance** — `AdvanceStep()` moves to the next step or completes the sequence; completion writes to `TutorialState`.
Hidden steps (`DisplayMode.None`) deliberately don't block — they exist so contextual tutorials can fire while the onboarding sequence is waiting on an async event.
---
## Display Modes
| Mode | Renderer | Notes |
|------|----------|-------|
| **Modal** | `TutorialModalPanel` | Full blocking dialog with dim background |
| **Overlay** | `TutorialOverlayController` | Dimmer with cutout + tooltip near a target |
| **Tooltip** | `TutorialOverlayController` | Currently same renderer as overlay |
| **Hint** | `TutorialHintIndicator` | Pulsing dot only (stub) |
| **None** | (invisible) | Waits for a completion event |
Panels can be anchored via `TutorialPanelAnchor` (`Center` / `Left` / `Right` / `Top` / `Bottom`).
---
## Trigger Catalog
Most event-style triggers are raised via `TutorialTriggerRegistry`. Durable strategic dialogue beats are also recomputed by `TutorialDialogueCoordinator` on each model update and sent directly to `DialogueManager.TriggerDialogue()`. Both paths match against the dialogue JSON; if step-UI sequences are re-registered, only the registry path will route to them. File:line citations are for the registry unless noted; line numbers may drift.
### Bootstrap / first-session
| Trigger | Fires from | When |
|---------|-----------|------|
| `game_started` | `TutorialDialogueCoordinator.EligibleTriggers()` | Any tutorial strategic model update; dialogue progress keeps it from replaying |
| `first_battle_available` | `OnModelUpdated()` ~L204 | A `RunningShardokGameModel` first appears |
| `battle_entered` | `OnBattleEntered()` ~L793 | User clicks **Fight!** |
| `shardok_battle_reset` | `ShardokGameController.cs:627` (fired *directly* to `DialogueManager`, bypassing the registry) | Battle retry; the registry's battle flags and the battle dialogue scripts' completed-set are reset alongside, so per-battle tutorials re-fire |
### One-shot semantics
Most contextual triggers are guarded by booleans on the registry (e.g., a `_diplomacyShown` flag). These are session-local — they don't persist via `TutorialState`, but the registry has `ResetBattleTriggerFlags()` for replays. The strategic completion list *does* persist across sessions.
---
## Highlight Targets
Steps reference UI by string ID through `TutorialTargetRegistry`:
1. Static targets are wired in the Inspector on the registry.
2. Dynamic targets register at runtime (`RegisterTarget(id, rectTransform)`).
3. Lookup falls back to `GameObject.Find()` if not registered.
Step options:
- `TargetGameObjectPath` — primary highlight
- `AdditionalHighlightTargets[]` — multiple at once
- `HighlightBoundsFromChildren` — use children's bounding box
- `HighlightPulsing` — strobe animation
Dialogue scripts use the same registry via `highlightTarget` (and `persistHighlight: true` to keep the highlight after the dialogue closes).
---
## Sequences vs. Contextual Dispatch
- **Onboarding sequence** (`IsOnboarding = true`) — Single linear sequence, started by `StartOnboarding()`. Resumes from `OnboardingStepReached`. Counts only visible steps for progress.
- **Contextual** (`IsOnboarding = false`) — Single- or multi-step, dispatched on demand by `TriggerContextualTutorial()`.
- Dispatch rules in `TriggerContextualTutorial()`:
1. No active sequence → show immediately.
2. Active step is hidden (`None`) → interrupt.
3. Both are command tutorials → interrupt.
4. Otherwise → queue.
---
## Dialogue System (Currently Live)
`DialogueManager` is a parallel system, not a step-UI subclass. It loads every `TextAsset` in `Resources/Dialogues/` at startup, parses them as `DialogueScript` objects, and indexes by `trigger`.
"instructionText": "Click <b>Fight!</b> to enter tactical combat.",
"highlightTarget": "FightButton",
"persistHighlight": true,
"highlightProvince": "Onmaa",
"completionEvent": null
}]
}]
}
```
When a registry trigger fires, `OnGameEvent()` calls `DialogueManager.TriggerDialogue(triggerId)`. If a script matches and hasn't completed, it queues (or shows immediately) and the panel renders speaker headshot + body + instruction. Steps can pause for a `completionEvent` (e.g., wait for the player to click the highlighted button), allowing the dialogue to chain across game state changes.
Dialogue panel position defaults sensibly per scene (`top` during combat) and can be overridden per script.
---
## Persistence
`TutorialState` (PlayerPrefs JSON):
- `OnboardingCompleted` (bool)
- `OnboardingStepReached` (int)
- `CompletedTutorials` (List → HashSet at load)
- `DismissedHints` (List → HashSet at load)
- `AllTutorialsDisabled` (bool)
Reset paths:
- `TutorialState.Reset()` clears everything.
- Entering a tutorial game (`GameType.Tutorial`) resets `TutorialState`; dialogue replay is controlled separately by the per-game dialogue progress key.
- Settings → "Reset Tutorials" calls `TutorialManager.ResetAllProgress()`, which also calls `DialogueManager.ResetCompletedScripts()`.
`TutorialDialogueProgressStore` separately stores completed dialogue script IDs per game under PlayerPrefs keys named `Eagle0_TutorialDialogueProgress_<gameId>`.
`DialogueManager` keeps the active game's completed script IDs in memory and saves them through `TutorialDialogueProgressStore`. `ResetCompletedScripts()` clears the current game's dialogue progress; `ClearCurrentGameProgress()` deletes the current game's persisted dialogue key.
---
## Bootstrap
1. `TutorialManager` is in the scene as a singleton; `Awake` loads state and creates the trigger registry. `RegisterAll()` is called here but currently no-ops.
2. `ConnectionHandler` sets `IsTutorialGame` and `IsMultiplayerGame` when the game is created/joined.
3. `EagleGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(this, null)`. If `IsTutorialGame && !OnboardingCompleted`, it would call `StartOnboarding()` — currently a no-op because `OnboardingSequence` is null.
4. The first model update raises `game_started`. `DialogueManager` matches `tutorial_opening` from `tutorial_strategic.json` and the dialogue runs.
5. `ShardokGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(null, this)` when battle starts; raises `battle_entered` and battle-only triggers from there.
---
## Expected Tutorial Flow (today)
Driven by `tutorial_strategic.json` + `tutorial_battle.json`. Old Marek the Learned narrates almost everything.
### Narrative arc
You play **Sadar Rakon**, formerly Ikhaan Tarn's most trusted lieutenant. Tarn's behavior grew erratic, the King wouldn't listen, so Sadar broke off and raised the **Reclamation**. He's been pushed back to **Onmaa** for a last stand against Tarn's superior royal army. After surviving, the real twist arrives: a new invasion — **The Fracture Covenant**, led by a mysterious figure called *The Eagle* — lands ships on the western coast and starts taking provinces. Heroes from the King's own service mysteriously begin to desert. The Reclamation's framing pivots from "rebellion" to "the only force that can stop the actual invasion."
| 1 | `game_started` | **Opening monologue** (3 steps): Sadar's backstory, the Reclamation, last stand at Onmaa. Ends highlighting the **Fight!** button (`FightButton`, `persistHighlight`) |
| 2 | `tutorial_battle_ended` | **Aftermath**: Tarn has *vanished*; you've captured his lieutenants. Recruit them or hold them |
| 3 | `tutorial_rebuild_support` | **Rebuild Onmaa** (3 steps): Marek frames it; **John Ranil** introduces *Improve* (engineer bonus); **Elena Fyar** introduces *Give Alms* (paladin bonus). Goal: 40 support by January |
| 4 | `tutorial_loyalty_warning` | November-ish, low-loyalty hero may leave at year end → use *Give Gold* / *Feast* |
| 5 | `tutorial_support_deadline` | December nag if support < 40: have Elena give alms now |
| 6 | `guidance_expand` (script `tutorial_expansion`) | Once support is stable: keep developing, taxes coming in January |
| 7 | `tutorial_taxes_collected` | January: gold/food collected. Now expand — March your warlord + most heroes to a neighbor, leave 1–2 behind |
| 8 | `tutorial_ready_to_join` | A free hero in `{provinceName}` is ready to recruit → Travel + Recruit |
| 9 | `tutorial_in_town` | When you Travel: explains Trade / Arm Troops / Divine / Recruit / Manage Prisoners; *Return* ends the turn |
| 10 | `tutorial_faction_appears` | **The twist**: Fracture Covenant lands at Ingia and Soria. *The Eagle* commands them. Tarn may be with them |
| 11 | `tutorial_hero_departed` | First King's hero abandons service — "something deeper is at work" |
| 12 | `tutorial_hero_departed_again` | Second one in a different month — "something is wrong, I can feel it" (foreshadowing) |
| 13 | `tutorial_hero_faction_appears` | John Ranil and Elena Fyar leave Sadar's service and reappear as independent tutorial factions |
### Tactical battle flow (`tutorial_battle.json`)
| # | Trigger | What happens |
|---|---------|--------------|
| 1 | `shardok_placement_started` | **Placement** (3 steps): identify Tarn's units (knights, heavy infantry, dragoons), color legend (red/blue/black), placement instructions, highlight Commit |
| 2 | `shardok_battle_running` | **Turn 1 strategy** (2 steps): victory conditions for both sides, advice to hold castles and End Turn |
| 3 | `archery_available` | First archery opportunity → purple outlines, longbows vs. armor |
| 14 | `shardok_battle_reset` | If you lose and replay: Marek "had the strangest sensation… as though we'd already fought this battle, and lost" — fourth-wall-adjacent retry framing |
Most remaining "Tutorial / first-session onboarding" items in `SMALL_EAGLE_TODO.md` (Shardok tutorial, narrative hook, first-session goal, early small victory, guided first scenario vs. sandbox) map to *adding new dialogue scripts* against existing trigger IDs, or — if the step-UI is revived — to populating `TutorialContentDefinitions.RegisterAll()` and removing the early return.
- **Combat tutorials are deliberately paced.** Don't expect every available-ability trigger to fire on its first eligible turn — the registry intentionally spaces them.
- **One-shot flags are session-local.** A trigger that "already fired" will not re-fire even if `TutorialState` is reset, until the registry is recreated.
- **`TutorialTargetRegistry` lookups silently fall back to `GameObject.Find`.** Typos in target IDs will render with no highlight rather than throwing.
- **`TutorialContentDefinitions.RegisterAll()` returning early is load-bearing.** If the step UI is revived without auditing trigger overlap, dialogue and step-UI tutorials may double-fire on the same trigger.
- **`Tutorial/TUTORIAL_PLAN.md`** in the Unity project is an older implementation plan and partially overlaps this doc.
The riskiest third-party shader set is Polytope Studio. Do not hand-convert package
shader sets speculatively.
`docs/URP_THIRD_PARTY_ASSET_AUDIT.md` records the current production GUID-reference
scan. The important result is that production assets use Polytope character prefabs
for Eagle beast effects, but the Polytope environment/water assets that still contain
`GrabPass` were not found on the production path.
If a future visible regression or inventory update makes third-party conversion
necessary, record:
- current installed version
- whether a URP-compatible version exists
- whether upgrading is license/account-accessible
- whether the package is still used in production scenes
### 6. Keep Probe Guidance For Future Pipeline Experiments
The original disposable probe has served its main purpose: URP is enabled on main and
the biggest visual regressions were fixed in focused PRs. Keep the probe playbook as
a template for future risky pipeline experiments, not as current migration guidance.
Use `docs/URP_PROBE_PLAYBOOK.md` when we need to measure a future Unity or render
pipeline change cheaply before committing to a mergeable branch.
## Known Maintenance Risks
| Risk | Why It Matters | Right-Now Action |
|---|---|---|
| `UnityUI.cginc` usage in package shaders | URP does not provide the same include path/semantics for hand-authored shaders | Leave package-managed TMP shaders alone unless a visible regression appears. |
| `GrabPass` in unused package shaders | No direct URP equivalent | Ignore until production starts referencing those assets. |
| Surface shaders in third-party packages | URP does not support Built-in surface shader generation | Covered by beast material compatibility on current production paths; convert only for a visible regression. |
| Post Processing Stack v2 | Replaced by URP Volume system | Keep package and runtime/project-setting references from returning. |
| Scene lighting | URP lighting/shadow settings differ from BiRP | Baseline and rebake only for visible regressions or intentional lighting work. |
| Render ordering | Shardok and Eagle overlays depend on careful layering | Include affected overlays in baseline whenever render order changes. |
## Follow-Up Strategy
Main now carries the completed URP switch. Future rendering work should be split by
observable behavior:
- **Compatibility fixes**: small PRs for specific pink/missing/dark assets.
- **Render ordering fixes**: small PRs for Shardok/Eagle layer order regressions.
- **Inventory/docs updates**: docs-only or editor-tool PRs that do not alter runtime
visuals.
- **Pipeline experiments**: isolated branches using the probe playbook, with findings
copied into a mergeable follow-up.
This keeps the project from accumulating another broad, hard-to-revert rendering PR.
## Completed Phases
### Phase 0: Preparation And Switch
- Visual baseline checklist/screenshots: complete, keep current
- Repeatable shader/material inventory: complete, keep current
- Post Processing Stack usage decision: complete, keep removed
- Drought visual decision: complete, use non-GrabPass visual signal
- Third-party URP availability check: documented
- Disposable URP probe findings: superseded by the merged URP switch
- CI guardrails for pipeline settings, runtime shader lookup, project shader
patterns, and beast-material compatibility: complete
### Phase 1: Pipeline Setup
- Install/configure URP: complete
- Create URP Pipeline Asset and Renderer Asset: complete
Project-owned runtime-created materials now use serialized shader or material
references for Eagle weather, Eagle fireworks, Shardok foreground UI/effects, and
Shardok foreground text. Remaining production `Shader.Find` usage is limited to
Unity/URP-provided shader names inside `BeastMaterialCompatibility`, plus
third-party package code outside our ownership.
## Completed Project-Owned Shader Hardening
These production shaders have been converted to URP-compatible HLSL and passed the
Windows Unity build after merge.
| Shader | Production References | Completion Notes |
|---|---|---|
| `Assets/Eagle/Shaders/ProvinceMapShader.shader` | `Assets/Eagle/Materials/ProvinceMapMaterial.mat` | Converted with province color, border, ocean, and UI clipping behavior preserved. |
| `Assets/Eagle/Shaders/ProvinceWeatherMapShader.shader` | `Assets/Eagle/Materials/ProvinceWeatherMapMaterial.mat` | Converted with weather lookup, overlay alignment, clipping, and initialized effect/tint values. |
| `Assets/Eagle/Shaders/ProvinceWeatherShader.shader` | `Assets/Eagle/Weather/BlizzardEffect.mat`, `Assets/Eagle/Weather/DroughtEffect.mat`, `Assets/Eagle/Weather/FloodEffect.mat` | Converted for the live drought/flood/blizzard material path. |
| `Assets/Eagle/Shaders/ProvinceParticleShader.shader` | `Assets/Eagle/Effects/ProvinceParticleMaterial.mat` | Converted for Eagle map province particles. Weather effect prefabs serialize the shared particle material instead of relying on shader-name fallback. |
| `Assets/Eagle/Shaders/ClipRectParticleUnlit.shader` | `Assets/Eagle/Effects/Fireworks.prefab`, `Assets/Eagle/Effects/ParticleAlphaBlendMaterial.mat`, `Assets/Eagle/Effects/ParticleStandardUnlitMaterial.mat` | Converted for Eagle one-shot effects and clipped particle UI paths; Fireworks serializes this shader instead of using shader-name fallback. |
| `Assets/Eagle/maskShader.shader` | Formerly `Assets/Eagle/Materials/map_color_nolabels.mat`, `Assets/Eagle/Materials/map_color_whitened.mat` | Deleted after confirming the serialized material path was stale. |
| `Assets/Hex Mesh Shader.shader` | None | Deleted after confirming no serialized or code references to the asset, shader name, or GUID. |
| `Assets/Shardok/ShardokFireOverlay.shader` | `Assets/Shardok/ShardokFireOverlay.mat` | Converted with the Shardok fire overlay layering path preserved. |
| `Assets/Shardok/Shaders/ForegroundUIOverlay.shader` | Serialized Shardok foreground UI/effect shader references | URP pass is serialized on the Shardok scene/container and included in `ProjectSettings/GraphicsSettings.asset`; the stale Built-in fallback pass has been removed. |
| `Assets/TextMesh Pro/Resources/Shaders/TMP_SDF Overlay.shader` | Serialized Shardok foreground text shader references | Serialized on the Shardok scene/container and included in `ProjectSettings/GraphicsSettings.asset` for required foreground text overlay material. |
## CI Guardrails
- `URPPipelineSettingsTests` keeps Graphics and Quality settings on the same URP
| Polytope Studio characters/weapons/props | Modular NPC, armor, weapons, props shaders | `#pragma surface`, `UnityCG.cginc` | Keep runtime beast material compatibility in place; only import/replace vendor URP materials if we intentionally remove the compatibility shim. |
| Polytope Studio environment/water | Water, vegetation, rock shaders | `GrabPass`, `#pragma surface`, `UnityCG.cginc`, tessellation | Ignore unless production starts referencing these assets. Water shaders are the only remaining `GrabPass` hits. |
| RRFreelance Orc/Ogre | Ogre, armor, weapon shaders | `#pragma surface`, `UnityCG.cginc` | Current production references are handled by compatibility/material fixes; do not hand-convert unless a visible ogre regression returns. |
This audit scopes third-party visual packages after the completed URP migration. It
is based on a GUID reference scan from production Unity assets under:
- `Assets/Scenes`
- `Assets/Eagle`
- `Assets/Shardok`
- `Assets/ConnectionHandler`
- `Assets/UI`
- `Assets/common`
- `Assets/Tutorial`
- `Assets/Resources`
- `Assets/Materials`
The scan maps GUIDs from each package folder, then finds production scenes,
prefabs, materials, controllers, assets, and scripts that reference those GUIDs.
It does not prove runtime code never loads assets by path, but it catches the
serialized references that matter most for future render-pipeline maintenance.
## Summary
| Package | Production Use | URP Risk | Notes |
|---|---:|---|---|
| Polytope Studio | 20 refs / 18 assets | Medium | Used by Eagle human-type beast effects. Runtime beast-material compatibility converts active renderers to URP materials; production refs are character prefabs, not environment/water assets. |
| GUI Pro Kit Fantasy RPG | 138 refs / 58 assets | Low | Production use is UI sprites/icons. No package shader dependency found in production refs. |
| Modern UI Pack v4.2.0 | 23 refs / 6 assets | Low | Production use is UI textures/icons. |
| RRFreelance Orc/Ogre | 2 refs / 2 assets | Medium | Ogre effect uses custom Built-in surface shaders in source assets, but active renderers are covered by runtime beast-material compatibility under URP. |
| DungeonMonsters2D | 12 refs / 12 assets | Medium | 2D monster prefabs for Eagle beast effects. Expected to be mostly SpriteRenderer/Animator; keep covered in beast-effect visual checks. |
@@ -4,17 +4,36 @@ This document describes which quests the AI attempts to complete proactively to
## Overview
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
The AI attempts to complete most quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`.
Important scope limits:
- The selector only considers quests from unaffiliated heroes in provinces owned by the acting faction.
- It further filters to provinces ruled by that faction's leader.
- It only emits a command when the corresponding command is currently available.
- Handler order is fixed. The first handler that produces a valid command wins.
## Quests the AI Actively Completes
This section lists quests with direct `QuestCommandChooser` handlers in `FulfillQuestsCommandSelector`.
### Diplomacy Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `BetrayAllyQuest` | `BetrayAllyQuestCommandChooser` | Break alliance with target faction. Only if factions don't share a border. Sends weakest non-leader hero. |
### Province Development Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
| `TotalDevelopmentQuest` | `TotalDevelopmentQuestCommandChooser` | Improve the lowest stat in the target province |
### Resource Giving Quests
@@ -24,72 +43,99 @@ The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
| `SpendOnFeastsInProvinceQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on feasts in the quest holder's province |
| `SpendOnFeastsAcrossRealmQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on the most expensive currently available feast |
| `SendSuppliesQuest` | `SendSuppliesQuestCommandChooser` | Send food to the target province |
### Province Development Quests
### Prisoner Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
| `ReleasePrisonerQuest` | `ReleasePrisonerQuestCommandChooser` | Release a specific prisoner |
| `ExilePrisonerQuest` | `ExilePrisonerQuestCommandChooser` | Exile a specific prisoner |
| `ExecutePrisonerQuest` | `ExecutePrisonerQuestCommandChooser` | Execute a specific prisoner |
| `ReturnPrisonerQuest` | `ReturnPrisonerQuestCommandChooser` | Return a prisoner to their faction |
| `ReleaseAllPrisonersQuest` | `ReleaseAllPrisonersQuestCommandChooser` | Release all prisoners |
### Province Order Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `DevelopProvincesQuest` | `DevelopProvincesQuestCommandChooser` | Switches provinces to Develop order. Prefers provinces without hostile neighbors. |
| `MobilizeProvincesQuest` | `MobilizeProvincesQuestCommandChooser` | Switches provinces to Mobilize order. Prefers provinces with hostile neighbors. Skipped if a DevelopProvincesQuest also exists (conflict avoidance). |
### Weather/Epidemic Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `StartEpidemicQuest` | `StartEpidemicQuestCommandChooser` | Start an epidemic in a province. Skipped if the target province belongs to the acting faction. |
| `StartBlizzardQuest` | `ControlWeatherQuestCommandChooser` | Start a blizzard via ControlWeather command. Skipped if the target province belongs to the acting faction. |
| `StartDroughtQuest` | `ControlWeatherQuestCommandChooser` | Start a drought via ControlWeather command. Skipped if the target province belongs to the acting faction. |
### Military Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `GrandArmyQuest` | `GrandArmyQuestCommandChooser` | Uses OrganizeTroops to top off existing battalions and hire Light Infantry. Only executes if resources are sufficient to fully meet the troop target. |
| `BattalionDiversityQuest` | `BattalionDiversityQuestCommandChooser` | Hires one battalion of a missing type to reach 3+ distinct types. Only attempts if the province already has 2+ existing types, has sufficient development (`meetsRequirements`), and gold/food surplus. |
| `UpgradeBattalionQuest` | `UpgradeBattalionQuestCommandChooser` | Walks a priority tree per turn: arm-completes (with optional travel and/or food sale setup) → train-completes → march in a pre-qualified neighbor battalion → organize/top-off of the target type → march in an unqualified neighbor battalion → develop Economy/Agriculture → train progress → develop Infrastructure → arm progress. Only toggles Travel when it directly enables an arm-completes finisher. |
| `ReconProvincesQuest` | `ReconProvincesQuestCommandChooser` | Issues a Recon command to reconnoiter a province |
### Beast Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `FightBeastsAloneQuest` | `FightBeastsAloneQuestCommandChooser` | Send an expendable non-leader hero to fight beasts without a battalion. Hero must have power at most `MaxExpendableHeroPowerRatio` (default 0.75) times the quest-giving hero's power. Picks the weakest eligible hero. |
### Special Event Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ApprehendOutlawQuest` | `ApprehendOutlawQuestCommandChooser` | Apprehend a specific outlaw hero when present in the province |
### Other Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `RestProvinceQuest` | `RestProvinceQuestCommandChooser` | Use the Rest command in a specific province |
| `SwearBrotherhoodWithHeroQuest` | `SwearBrotherhoodQuestCommandChooser` | Swear brotherhood with a specific hero |
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
## Quests the AI Does Not Attempt to Complete
## Quests the AI Handles Indirectly
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
These are not in `FulfillQuestsCommandSelector`, but other AI command-selection code can still bias toward completing them.
### Combat/Military Quests
- `DefeatFactionQuest` - Defeat a specific faction
-`GrandArmyQuest` - Accumulate a large number of troops
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
- `WinBattlesQuest` - Win a number of battles
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
| Quest | Handler | Notes |
|-------|---------|-------|
|`SuppressRiotByForceQuest` | `CommandChoiceHelpers.handleRiotSelectedCommand` | When this quest exists and the faction has battalions, the AI prefers CrackDown over Give when handling riots. |
### Expansion Quests
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
- `SpecificExpansionQuest` - Conquer a specific province
- `BorderSecurityQuest` - Have troops in a border province
## Quests the AI Does Not Yet Attempt to Complete
### Prisoner Quests
- `ExecutePrisonerQuest` - Execute a specific prisoner
- `ExilePrisonerQuest` - Exile a specific prisoner
- `ReleasePrisonerQuest` - Release a specific prisoner
- `ReturnPrisonerQuest` - Return a prisoner to their faction
- `ReleaseAllPrisonersQuest` - Release all prisoners
The following quests have no handler and must be completed naturally through gameplay. They are listed in rough priority order for future implementation.
### Province Order Quests
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
- `RestProvinceQuest` - Use the Rest command in a specific province
### Planned for Implementation
### Reconnaissance Quests
- `ReconProvincesQuest` - Reconnoiter a number of provinces
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
None currently planned.
### Economic Quests
- `TotalDevelopmentQuest` - Achieve total development level in a province
- `WealthQuest` - Accumulate gold and food
- `SpendOnFeastsQuest` - Spend gold on feasts
- `SendSuppliesQuest` - Send food to a specific province
- `RepairDevastationQuest` - Repair devastation
### Not Planned
### Special Event Quests
- `SuppressRiotByForceQuest` - Suppress a riot by force
- `FightBeastsAloneQuest` - Fight beasts alone
- `StartBlizzardQuest` - Start a blizzard in a province
- `StartEpidemicQuest` - Start an epidemic in a province
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
These quests are too situational, passive, or risky to actively pursue. They may be completed naturally through gameplay.
### Miscellaneous
- `BattalionDiversityQuest` - Have diverse battalion types
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
- `BetrayAllyQuest` - Betray an allied faction
- `BorderSecurityQuest` - Have troops in a border province. Involves taking provinces; better left to organic expansion.
- `DefeatFactionQuest` - Defeat a specific faction. Too risky to pursue proactively.
- `SpecificExpansionQuest` - Conquer a specific province. Too risky.
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces. Too risky.
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered. Can't reliably engineer.
- `WinBattlesQuest` - Win a number of battles. Passive.
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader. Complex multi-step.
- `WealthQuest` - Accumulate gold and food. Happens passively.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.