mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 22:55:41 +00:00
lfs-asset-cleanup
10840
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7b5e616006 | Clean up Unity LFS asset footprint | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |