Commit Graph
10840 Commits
Author SHA1 Message Date
admin 7b5e616006 Clean up Unity LFS asset footprint 2026-05-26 17:06:46 -07:00
8bb45a57af Unity client: clear deprecation/unused warnings (#6732)
- 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>
2026-05-20 21:51:05 -07:00
307ea245ee Hotfix: same-entity duplicate PK bug for both ChangedFaction and ChangedHero (#6737)
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>
2026-05-20 21:45:37 -07:00
c0d00d4b81 Phase 4.5d.2 (live write): wire ChangedFactionTableWriter into withNewResults (#6736)
* 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>
2026-05-20 21:24:40 -07:00
b8cb342bc5 Phase 4.5d.1: ChangedFaction refined per-field decomposition (schema + backfill) (#6734)
* 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>
2026-05-20 16:44:37 -07:00
cf70169e3c Hotfix: v3 backfill crashes on ActionResults with duplicate-province ChangedProvinces (#6735)
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>
2026-05-20 08:09:52 -07:00
e96a86f1a6 Phase 4.5c.2 (read path): ChangedHeroTableReader (#6733)
* 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>
2026-05-20 06:51:39 -07:00
a07b679a5a Phase 4.5c.2: live ChangedHero decomposition in withNewResults (#6730)
* 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>
2026-05-19 22:22:40 -07:00
c27715904c Design doc: record refined option B (ChangedHero 4.5c.1 reality) (#6729)
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>
2026-05-19 07:16:36 -07:00
7830c42cd2 CLAUDE.md: forbid cd-prefixed commands; fix contradictory git rule (#6728)
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>
2026-05-19 07:03:29 -07:00
f4ef9d83c4 Phase 4.5c.1: ChangedHero scalar columns + proto blob (option B) (#6727)
* 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>
2026-05-19 06:48:53 -07:00
f76d4b785c Design doc: record option B + re-plan 4.5c-i per-entity (#6726)
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>
2026-05-18 07:21:20 -07:00
7d6961fc86 Phase 4.5b (option B): ChangedProvince scalar columns + proto blob (#6725)
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>
2026-05-17 11:17:09 -07:00
9b6cbe6455 Fix tutorial chronicle crash: tutorial creation must use SqliteClientTextStore (#6724)
* 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>
2026-05-16 23:17:16 -07:00
9eb90c2cb4 Vary maxWindMph per map by terrain composition (#6723)
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>
2026-05-16 10:15:25 -07:00
48a44e126a Couple wind speed to weather conditions (#6722)
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>
2026-05-16 10:14:49 -07:00
b8605a3662 Fix free-hero backstory visibility (placement hook + load migration) (#6719)
* 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>
2026-05-15 20:02:25 -07:00
2d3779448c Draw faction-target province highlights as a single grouped outline (#6720)
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>
2026-05-15 20:01:15 -07:00
7eb5894a57 Phase 4.5a: schema migration scaffolding + changed-entity top-level tables (#6716)
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>
2026-05-15 16:36:34 -07:00
e9f10ee540 Phase 4 fix: wire SqliteHistory through the cloud persister (#6718)
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>
2026-05-15 16:31:34 -07:00
58030a56a7 Split Eagle server fat JAR into deps + app Docker layers (#6717)
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>
2026-05-15 16:19:47 -07:00
3a45347549 Phase 4: cutover game creation from PersistedHistory to SqliteHistory (#6715)
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>
2026-05-14 06:29:49 -07:00
660d349e50 Design doc: full decomposition of ActionResult into relational tables (#6714)
* 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>
2026-05-12 06:51:34 -07:00
57c2862a57 Phase 3: SqliteHistory shardok integration (#6713)
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>
2026-05-12 06:30:39 -07:00
3923ae8c11 Phase 2: SqliteHistory read paths (all / since / sinceDate / recentResultsForRound) (#6712)
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>
2026-05-11 21:10:53 -07:00
d0e39bfc02 Phase 1: SqliteHistory skeleton — schema, write paths, stateAfter (#6711)
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>
2026-05-11 21:03:43 -07:00
78ed12052b Phase 0: SQLite game history design doc (#6710)
* 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>
2026-05-11 17:19:04 -07:00
abdb91b8d9 Add direct domain ↔ Common-proto enum converters (#6709)
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>
2026-05-10 16:01:44 -07:00
2debfe546c Drop proto conversion in ShardokInterfaceGrpcClient.resolveBattle (#6708)
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>
2026-05-10 15:36:40 -07:00
5d81972093 Use domain GameState in ShardokInterfaceProxy.resolveBattle (#6707)
* 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>
2026-05-10 15:05:45 -07:00
c8aa26058b Send watcher faction IDs in Shardok NewGameRequest (#6705)
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>
2026-05-09 13:55:06 -07:00
386d99d553 Cache parsed games.e0es to skip per-lobby-tick re-parse (#6706)
* 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>
2026-05-09 13:54:02 -07:00
b3d99c6f85 Generate ally-aware Shardok views for non-participant watchers (#6704)
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>
2026-05-09 13:49:11 -07:00
afb143645a AI: imprison the messenger when accepting a truce would box us in (#6702)
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>
2026-05-08 20:31:59 -07:00
ba160b9669 AI: break alliance with weakest bordering ally when boxed in (#6700)
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>
2026-05-08 19:51:38 -07:00
5664f15dac Push locally-resolved hero names to connected clients (#6698)
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>
2026-05-08 19:24:06 -07:00
8009ed62d2 Auto-fire End Turn after AI action when next player has no options (#6699)
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>
2026-05-08 19:21:26 -07:00
8abd355883 Suppress FMOD output-switch error in ErrorHandler (#6696)
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>
2026-05-08 19:11:22 -07:00
fb8408fd64 Gate Observe battle button on ShardokGameModel readiness (#6697)
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>
2026-05-08 18:44:29 -07:00
9f155a359b Centralize popup-driven province highlight in PopupCanvasController (#6693)
* 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>
2026-05-08 18:28:22 -07:00
19cf9c592a Add failing test for backstory visibility on hero imprisonment (#6695)
* 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>
2026-05-08 18:27:56 -07:00
537f846fee March marks the acting (menu) province as acted (#6694)
* 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>
2026-05-08 17:19:12 -07:00
2bd9fb4022 Compute PersistedActionResult.gameState lazily (#6692)
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>
2026-05-08 15:03:18 -07:00
21eb10d2e5 Skip proto conversion in PersistedHistory.stateAfter replay (#6691)
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>
2026-05-08 12:39:27 -07:00
1d99eefbd6 Fix CombatUnitSelector hero/battalion matching (#6688)
* 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>
2026-05-08 07:16:41 -07:00
ebc29a75d4 Default Shardok server status to WAITING_FOR_AI when no faction is queued (#6689)
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>
2026-05-08 07:05:47 -07:00
f0d7067cc9 Wire ShardokServerStatus into the connection-status indicator (#6686)
* 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>
2026-05-08 06:58:32 -07:00
e05ea92c46 Populate ShardokServerStatus in remaining response paths (#6687)
* 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>
2026-05-05 15:49:01 -07:00
b0639dc8d0 Add ShardokServerStatus proto and server-side population (#6685)
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>
2026-04-30 06:07:54 -07:00
9eb8f7f086 Highlight March button in post-taxes tutorial dialogue (#6683)
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>
2026-04-29 21:49:53 -07:00