Commit Graph
100 Commits
Author SHA1 Message Date
adminandGitHub ceed22d6de Prioritize remaining small Eagle alpha work (#6795) 2026-05-29 08:31:41 -07:00
adminandGitHub 75f35e981e Unpin Bazel workflows from specific runners (#6792) 2026-05-29 08:29:29 -07:00
adminandGitHub 76d8f07827 Keep command factory test flatbuffers alive (#6794) 2026-05-29 08:23:27 -07:00
adminandGitHub 705a36e901 Update Unity 6000.4.9 project files (#6793) 2026-05-29 08:21:19 -07:00
adminandGitHub 758ae6d44f Ensure Bazel is available in self-hosted workflows (#6785)
* Ensure Bazel is available in self-hosted workflows

* Allow Bazel CI to use Command Line Tools

* Set macOS C++ deployment target for Bazel

* Use Bazel macOS minimum OS flags

* Raise macOS Bazel deployment target for filesystem

* Ensure git-lfs is available for CI fetches

* Authenticate CI LFS fetches with workflow token

* Disable stale LFS hooks during CI checkout

* Disable LFS filters during CI checkout

* Prepare Git LFS before persistent checkout

* Harden generated Bazel rc and LFS auth

* Let Xcode sync skip without full Xcode

* Harden Unity build cache markers

* Require full Xcode for mactools sync
2026-05-29 07:52:17 -07:00
899162bbc5 Phase 4.5i: delete v15-v20 payload-parsing backfills (#6791)
After #6787 (upload-on-load) deployed and the admin-load pass pushed
every persisted game.db to v ≥ 21 in S3, the v15-v20 migration
backfills that re-derived their new columns/tables from
`action_results.payload` are unreachable on any healthy game. This PR
deletes that code so the proto dependency surface shrinks and the
"only used by backfill" file headers stop misleading future readers.

Removed:
- `backfill(Connection)` (and supporting `bindOpt*` helpers / `select`
  loops) from each of the 6 writers that populated the 4.5h.1–4
  scalars/vectors:
    - ActionResultEntityRefColumnsWriter (v15)
    - ActionResultEnumColumnsWriter (v16)
    - ActionResultNumericColumnsWriter (v17)
    - ActionResultDateBattleColumnsWriter (v18)
    - ActionResultStructuredBlobColumnsWriter (v19)
    - ActionResultVectorTablesWriter (v20)
- `applyV15`–`applyV20` collapse to a single line each — just
  `execEach(connection, ...schemaStatements)`. Doc note added that
  schema v < 15 is no longer supported (if `applyV15` ever runs on a
  non-empty `action_results` table, the new columns are left NULL and
  state diverges silently; the migration runner's `schema_version`
  gate is the only protection).
- 6 corresponding `"action_results vN backfill"` tests in
  `SqliteHistoryTest` (one per migration). The matching `vN live
  write` tests stay — those still exercise the live-write column
  binding.

BUILD dep cleanup falls out: each of those writers no longer needs
`internal:action_result_scala_proto` or
`proto_converters:action_result_proto_converter` (the
payload-parse + full-converter round-trip is gone). The remaining
narrow deps (`tutorial_phase_scala_proto`, `game_type_scala_proto`,
etc.) cover the proto-enum `.value` reads that `bindColumns` still
needs.

Verification: `sqlite_history_test` 90/90 green (96 - 6 backfill
tests deleted); full Scala suite 226/226 green.

The per-entity table backfills (v3-v14) still parse `payload` and are
out of scope here — they kept ChangedHero/Faction/Battalion games
alive across the .5c cutover, and removing them needs the same
"prove all games are at v ≥ 21 in S3" gate. Future cleanup.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 07:51:34 -07:00
adminandGitHub d174c06ddb Reduce Unity LFS asset footprint (#6789)
* Delete unused Animal pack deluxe v2 assets

* Delete unused preview and demo LFS assets

* Normalize bridge FBX assets into LFS
2026-05-29 07:51:00 -07:00
4935b81037 Upload migrated game.db to persister on load (red+green) (#6787)
* Upload migrated game.db to persister on load (red)

Prep for 4.5i (deleting v15-v20 backfill payload-parsing). Before we
can delete migration code, every persisted `game.db` needs to be at the
current schema in S3 — otherwise a server restart re-downloads a stale
blob and re-walks the old migrations.

Today `SqliteHistory.loaded` runs migrations on the local file but
nothing pushes the migrated form back to the persister until an action
is actually committed (`withNewResults` is the only upload trigger).
Result: admin-loading every game upgrades the local server's copy but
leaves S3 at the old schema, so the upgrade unwinds on restart.

This commit adds two red tests:

  1. Loading a v1 DB out of the persister with a wiped local copy must
     bump the persister's `saveCount` — i.e., the migrated bytes get
     pushed back. Today this fails because nothing in the load path
     uploads.

  2. Loading an already-current DB must NOT bump `saveCount`. Today
     this passes vacuously (the load path never uploads), so it's a
     forward-guarantee that the fix won't spam uploads on every restart
     of a healthy server.

GREEN will thread an "any pending migrations applied?" boolean out of
`Migrations.applyPending` and, when true, do a one-shot
checkpoint+upload after constructing the `SqliteHistory`.

Holding for review of the test design before I land GREEN on the same
branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Upload migrated game.db to persister on load (green)

Thread an "any pending migrations applied?" boolean out of
`Migrations.applyPending` and through `initializeSchema`. In `loaded`,
when migrations actually ran and a persister is set, do a one-shot
checkpoint+upload so the migrated bytes propagate to S3.

Factored the WAL-checkpoint + readAllBytes + persister.save block out
of `maybeUploadToPersister` into a shared
`uploadCurrentBytesToPersister(p, nowMillis)` helper; `loaded` calls
the public no-arg variant. The `lastUploadAtMillis` field is anchored
inside the upload so the subsequent withNewResults time-based trigger
stays correct.

`create` discards the migration boolean explicitly: a fresh game has
no stale S3 form to compete with, and the first `withNewResults`
upload will be the v-current bytes anyway.

Verification: sqlite_history_test 95/95 green; full Scala suite
226/226 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 06:58:50 -07:00
503801e7c4 Add PerEntityWriteDispatcher with compile-time + runtime coverage guards (#6790)
Background. We just hit a production state-loss bug on a saved game:
`Province 40 has an army that should have already arrived`. Root cause:
`ChangedProvinceTableWriter` was added in 4.5b (#6725, 5/17) but its call
into `withNewResults` wasn't wired until 4.5h.5c (#6779, 5/28). For the
11 days between, every action committed had `payload` populated but
zero rows in `action_changed_provinces`. When 4.5h.5d (#6784) dropped
`payload`, those actions' changedProvinces became permanently lost.
On the affected game that's ~24k actions out of 28k.

ChangedHero and ChangedFaction had similar (hours-long) wiring gaps, but
v6 (#6741) included a re-backfill that rescued them. Every other
decomposition (4.5e/f/g/h.4) shipped writer + migration in a single PR,
so no orphan window. Only ChangedProvince bled data in production.

This PR adds two complementary guards so the same shape of bug can't
ship again:

1) New `PerEntityWriteDispatcher` object owns the fan-out to every
   per-entity decomposition writer. Its `writeAll` matches `ar` against
   the full `ActionResultC` case-class constructor pattern, binding
   every field (Vector ones by name, scalars as `_`). Adding any new
   field to `ActionResultC` breaks the constructor arity at compile
   time, forcing whoever adds the field to come here and decide
   "scalar → new column on `action_results`" or "vector → new
   per-entity table + writer + wiring".

2) New test ("withNewResults populates every per-entity decomposition
   table when given an ActionResult with one of each vector")
   constructs an ActionResult with a non-empty entry for every 17
   `Vector[_]` field on `ActionResultC`, runs `withNewResults`, and
   asserts every per-entity table has >= 1 row. Catches "wrote a
   writer but didn't call it" — which is the second variant of the
   same class of bug.

`SqliteHistory.withNewResults` now delegates to
`PerEntityWriteDispatcher.writeAll`. The previous inline foreach blocks
plus the `ChangedProvinceConverter` import move to the dispatcher.
Visibility of `model/action_result/changed_province/concrete` widened to
`//src/test/scala/net/eagle0/eagle/service:__pkg__` so the new test
can build the source `ChangedProvinceC` directly.

Memory updated: `changed_entity_decomposition_pitfalls.md` now lists
this as a third recurring pitfall with the guard documented.

Verification: `sqlite_history_test` 94/94 green; full Scala suite
226/226 green.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 06:53:56 -07:00
adminandGitHub 646c47259c Upgrade Unity project to 6000.4.9f1 (#6788) 2026-05-29 06:52:18 -07:00
4e411695f2 Phase 4.5h.5d (red+green): drop action_results.payload column (#6784)
* Phase 4.5h.5d (red): assert action_results.payload column is gone

Final 4.5h chunk: stop writing the `payload` blob and drop the column from
the SQLite schema. After .5c the read path no longer touches `payload`, so
it's now dead weight on every write. .5d closes that loop.

Two red tests:
  1. Fresh DB: `PRAGMA table_info(action_results)` must not include `payload`.
  2. v20 → v21 upgrade: a DB that has a populated `payload` column gets the
     column dropped on `SqliteHistory.loaded`, and a subsequent read still
     returns the action via `ActionResultDbReader`.

Both fail RED because the column is still present (v20 schema). GREEN will:
  - Stop binding `payload` in the `withNewResults` INSERT (and drop
    `ActionResultProtoConverter.toProto` from the write path).
  - Add migration v21 to `ALTER TABLE action_results DROP COLUMN payload`.

Holding for review of the test design before I land GREEN on the same branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.5d (green): stop writing payload and drop the column

Final step of the 4.5x decomposition. After this commit every
ActionResultC field on disk lives in a dedicated column or per-entity
table; `action_results.payload` is gone.

Changes:
- Add migration v21: ALTER TABLE action_results DROP COLUMN payload.
  SQLite >= 3.35.0 supports DROP COLUMN; the xerial sqlite-jdbc driver
  bundles a new-enough engine.
- Remove `payload` from the withNewResults INSERT (drops the bind at
  position 6 and the ActionResultProtoConverter.toProto call) and
  renumber the trailing column positions.
- Drop the ActionResultProtoConverter import from SqliteHistory.scala
  and the corresponding sqlite_history Bazel deps
  (action_result_proto_converter, action_result_scala_proto); add
  changed_province_scala_proto, which had been pulled in transitively
  via the action_result proto.
- Delete the .5b parity test and the .5c "read path doesn't touch
  payload" test. Both queried `payload` directly to make their
  assertions; with the column gone, the assertions are moot. Their
  intent — that ActionResultDbReader assembles a faithful
  ActionResultC from the decomposed tables — is now covered implicitly
  by every test that round-trips through withNewResults + last/all.

Verification: `sqlite_history_test` 93/93 green; full Scala suite
226/226 green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 22:09:38 -07:00
adminandGitHub 237efb9c0c Temporarily pin self-hosted CI to halfdan (#6786) 2026-05-28 22:05:35 -07:00
0368a9f24a Phase 4.5h.5c (red+green): swap read path off action_results.payload (#6779)
* Phase 4.5h.5c (red): assert read path no longer depends on payload

Continues 4.5h.5 after .5b (the ActionResultDbReader orchestrator).
This sub-PR is the read-path cutover: swap replayFrom + readActionAt +
stateAfter to use ActionResultDbReader instead of
ActionResultProtoConverter.fromProto(payload), and wire
ChangedProvinceTableWriter into withNewResults so the cutover is
correctness-complete for changedProvinces.

The cleanest single test for "read path doesn't touch payload" is to
persist an action, then corrupt its payload column (overwrite with a
1-byte garbage blob), then re-load and read. If anything in the read
path still calls parseFrom on payload, that parse throws
InvalidProtocolBufferException; the test fails. If the read path uses
ActionResultDbReader (which doesn't touch payload), the test sees the
populated fields and passes.

The test exercises both stateAfter (which replays actions to advance
state) and readActionAt (which returns the last action) via a single
SqliteHistory.last call — both must avoid touching the corrupted
payload.

This commit (red):
- No code changes to SqliteHistory; the read path still parses payload.
- One red test: persist an action with 4 populated scalar/Option fields,
  wipe payload column to x'00', reload, call last, assert the fields
  come back populated.

The GREEN commit will:
  - Swap replayFrom + readActionAt + the stateAfter inner loop to
    ActionResultDbReader.read (no more parseFrom + fromProto).
  - Wire ChangedProvinceTableWriter into withNewResults (so live actions
    populate action_changed_provinces — needed for the read path to
    return changedProvinces correctly).
  - Note: payload is still written by withNewResults (kept around as a
    safety net for one PR); .5d drops the column entirely.

Verification: sqlite_history_test 92/93 pass, 1 RED for the right
reason (parseFrom throws "Protocol message contained an invalid tag
(zero)" on the corrupted bytes).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.5c (green): swap read path to ActionResultDbReader, wire ChangedProvinceTableWriter

stateAfter / replayFrom / readActionAt now go through ActionResultDbReader.read,
which assembles ActionResultC from the decomposed columns + per-entity tables
(4.5b–4.5h.4) instead of `parseFrom(payload)`. Also wires
ChangedProvinceTableWriter into withNewResults so the action_changed_provinces
table is populated by live writes (it was already populated by the 4.5b
backfill; this closes the gap for new actions). The `payload` column is still
written; it gets dropped in 4.5h.5d.

The RED test added in 6b6134afef passes now that the corrupted payload is
no longer touched on read; gave it a `newDate` so the applier (which runs
during stateAfter's replay) has a date.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:49:06 -07:00
adminandGitHub 39b8c3360e Tidy Bazel rc marker and Mac deploy runner label (#6782) 2026-05-28 20:05:11 -07:00
adminandGitHub c2b04c2109 Pass iOS TestFlight build between jobs (#6781) 2026-05-28 06:25:48 -07:00
adminandGitHub 875f34a624 Harden GitHub Actions workflows (#6780) 2026-05-27 22:04:56 -07:00
7f2cdbd111 Phase 4.5h.5b: ActionResultDbReader (#6777)
* Phase 4.5h.5b (red): ActionResultDbReader stub + parity test

Continues 4.5h.5 after .5a (the two missing per-entity readers). This
sub-PR adds the top-level orchestrator that will assemble a domain
ActionResultC from the 24 action_results columns + the eleven per-entity
table readers — without touching the legacy payload BLOB.

The 24 columns now on action_results (added by 4.5h.1, .2a, .2b, .2c,
.3) cover every top-level scalar/Option field of ActionResultC plus the
three structured-Option blobs (new_battle_proto / new_chronicle_entry_proto
/ new_eagle_map_proto). The eleven per-entity vectors are read via the
existing per-entity table readers:

  - ChangedBattalionTableReader, ChangedFactionTableReader,
    ChangedHeroTableReader, ChangedProvinceTableReader (last from .5a)
  - NewBattalionTableReader, NewFactionTableReader, NewHeroTableReader,
    NewProvinceTableReader
  - NotificationTableReader (readNew + readRemoved)
  - GeneratedTextRequestTableReader, ClientTextVisibilityExtensionTableReader
  - ActionResultVectorTablesReader (the five v20 vector tables, from .5a)

Parity contract with the converter-based read:
  - For every field both paths handle, this reader produces an
    ActionResultC equal to ActionResultProtoConverter.fromProto(payload).
  - newGeneratedTextRequests is the one intentional asymmetry: the
    converter drops it ("LLM text requests are not used by the
    applier"), this reader returns the populated vector from the
    decomposed table. Strictly better.
  - changedProvinces is excluded from this PR's parity test:
    ChangedProvinceTableWriter is not yet wired into withNewResults
    (that's a .5c concern). Adding it back to parity is straightforward
    once the wiring lands.

Reader is unused by production — replayFrom still goes through
ActionResultProtoConverter.fromProto(payload). The cutover lands in
.5c; the payload column gets dropped in .5d.

This commit (red):
- ActionResultDbReader stub: read returns a near-empty ActionResultC
  (only actionResultType = UnknownActionUnspecified). GREEN lands the
  real read.
- One parity test in SqliteHistoryTest: builds a rich source
  ActionResultC (one of each shape — scalar/Option columns, structured
  blobs, v20 vector tables, per-entity vectors except changedProvinces
  and newGeneratedTextRequests), persists via withNewResults, then
  asserts ActionResultDbReader.read produces the same ActionResultC as
  the converter-based read of the payload.
- Visibility widening:
  model/action_result/concrete:action_result_concrete adds
  service:__pkg__; model/proto_converters:action_result_proto_converter
  adds test/.../service:__pkg__.

Verification: sqlite_history_test 91/92 pass, 1 RED for the right reason
(stub returns near-empty AR; parity assertion fails the long structural
comparison).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.5b (green): implement ActionResultDbReader

Fills in the ActionResultDbReader stub from the red commit with the
real read implementation. The parity test added in red now passes:
ActionResultDbReader.read produces an ActionResultC equal to
ActionResultProtoConverter.fromProto(payload) for every field both
paths handle.

Implementation:
- Single action_results SELECT for the 24 column values (action_result_type,
  the 4 entity-ref columns, 4 enum columns, 4 numeric Option columns,
  date pair + resolved_battle, 3 blob columns).
- Eleven per-entity reader calls (ChangedBattalion/Faction/Hero/Province,
  NewBattalion/Faction/Hero/Province, NotificationTableReader.readNew +
  readRemoved, GeneratedTextRequest, ClientTextVisibilityExtension,
  ActionResultVectorTables — readDestroyedBattalionIds/etc).
- All thirty fields plug straight into ActionResultC's case-class
  constructor — single allocation, no .copy() (the custom copy method
  uses different parameter names like `actingFaction` vs `actingFactionId`
  and `removedHeroes` vs `removedHeroIds`).

Decoding helpers:
- getOptInt / getOptLong: nullable INTEGER via JDBC's wasNull() pattern
  (getInt returns 0 for SQL NULL, indistinguishable without wasNull).
- decodeGameType: handles the (new_game_type, new_tutorial_phase) pair.
  Writer never stores UNKNOWN_GAME_TYPE (stores NULL instead), so
  fromProto's UNKNOWN→throw path is unreachable; defensive throw on
  None from CommandTypeConverter.fromProto for the same reason.
- decodeDate: handles the (new_date_year, new_date_month) pair.
  Inconsistent (one NULL) would throw IllegalArgumentException from
  Date.Month.fromInt(0), surfacing writer bugs loudly.
- Blobs use Option(rs.getBytes(...)).map(parseFrom + Converter.fromProto).

BUILD additions (green-only):
- action_result_db_reader deps: all 11 per-entity readers + 6 enum/blob
  converters + their proto packages (chronicle_entry, command_type,
  eagle_map_info, game_type, round_phase, tutorial_phase, shardok_battle)
  + Date domain type + eagle_internal_exception.

The read path is NOT swapped here — replayFrom still parses payload.
The reader is unused by production until .5c.

Verification: sqlite_history_test 92/92 pass; full Scala test suite
226/226.

Next: 4.5h.5c — swap replayFrom to use ActionResultDbReader, stop
writing payload, wire ChangedProvinceTableWriter into withNewResults.
Then .5d drops the payload column.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 08:14:40 -07:00
a466c42cd8 Phase 4.5h.5a: missing per-entity readers (#6776)
* Phase 4.5h.5a (red): missing per-entity readers (stub) + round-trip tests

Begins Phase 4.5h.5 (the final read-path-swap chunk). Per the agreed
four-slice plan:
  .5a (this PR): the two missing per-entity readers + round-trip tests
                 with their writers. Readers exist but are unused by
                 production.
  .5b:           the top-level ActionResultDbReader orchestrator +
                 parity tests asserting it produces the same ActionResultC
                 as the converter-based read path.
  .5c:           swap replayFrom to use the new reader, stop writing
                 payload.
  .5d:           drop the payload column.

The 4.5b–g per-entity readers were created alongside their writers
(paired RED+GREEN PRs). Two gaps remain:

- ChangedProvinceTableReader: ChangedProvince was the first option-B
  entity (4.5b) and its reader was deferred because nothing needed it
  until 4.5h.
- ActionResultVectorTablesReader: the v20 vector tables (4.5h.4) were
  built with only a writer because the read path hadn't started yet.

Both readers are needed by .5b's top-level orchestrator. Adding them
now closes the gap.

This commit (red):
- ChangedProvinceTableReader stub: read returns Vector.empty. Reads
  from action_changed_provinces' changed_province_proto BLOB (the
  scalar denorm columns are ignored on read; the blob is authoritative
  per the writer's contract).
- ActionResultVectorTablesReader stub: five public read methods (one
  per v20 table), all returning Vector.empty. The four primitive-ID
  methods will share an internal readIds helper in GREEN;
  readNewBattalionTypes will parse battalion_type_proto via
  BattalionTypeConverter.fromProto.
- Two red tests in SqliteHistoryTest: round-trip ChangedProvinces via
  the writer + new reader (two protos, exercises n ordering); round-trip
  all five vectors via withNewResults + new reader.
- Visibility widening: model/action_result/changed_province:changed_province
  and model/proto_converters:changed_province_converter to add the
  service main + test service packages (GREEN reader will need the
  converter).

Verification: sqlite_history_test 89/91 pass, 2 RED for the right reason
(stub readers return Vector.empty → "Vector() was not equal to Vector(17,
23)").

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.5a (green): implement missing per-entity readers

Fills in the two reader stubs from the red commit with real
implementations. No new tests — the round-trip tests added in the red
commit now turn green.

ChangedProvinceTableReader:
- read(connection, actionSeq): SELECT changed_province_proto ORDER BY n,
  parse each blob via ChangedProvinceProto.parseFrom, convert to domain
  via ChangedProvinceConverter.fromProto.

ActionResultVectorTablesReader:
- readDestroyedBattalionIds / readRemovedHeroIds / readRemovedFactionIds
  / readAffectedFactionIds: all delegate to the shared private readIds
  helper which does SELECT $idColumn FROM $table WHERE action_seq = ?
  ORDER BY n. Table and column names are internal constants, never user
  input.
- readNewBattalionTypes: SELECT battalion_type_proto ORDER BY n, parse
  each blob via BattalionTypeProto.parseFrom, convert via
  BattalionTypeConverter.fromProto. The type_id column is ignored on
  read — the blob is authoritative.

BUILD additions (green-only):
- changed_province_table_reader deps:
  internal:changed_province_scala_proto (for parseFrom),
  proto_converters:changed_province_converter (for fromProto).
- action_result_vector_tables_reader deps:
  common:battalion_type_scala_proto (for parseFrom),
  proto_converters:battalion_type_converter (for fromProto).

Verification: sqlite_history_test 91/91 pass; full Scala test suite
226/226.

Next: 4.5h.5b — top-level ActionResultDbReader orchestrator that uses
all the per-entity readers (plus the action_results column reads) to
assemble an ActionResultC. Parity tests assert it matches the
converter-based read.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:30:36 -07:00
adminandGitHub 40dd82e638 Print Shardok binary hash during deploy (#6775) 2026-05-27 07:12:51 -07:00
824748d88f Phase 4.5h.4: action_results vector tables (#6773)
* Phase 4.5h.4 (red): action_results vector tables schema + tests

Continues 4.5h after .3 (structured-Option blob columns). Five remaining
top-level ActionResultC vectors decomposed into child tables (one row
per element), matching the per-entity table precedent from 4.5e–g:

  - action_destroyed_battalion_ids (destroyedBattalionIds: Vector[BattalionId])
  - action_removed_hero_ids        (removedHeroIds: Vector[HeroId])
  - action_removed_faction_ids     (removedFactionIds: Vector[FactionId])
  - action_affected_faction_ids    (affectedFactionIds: Vector[FactionId])
  - action_new_battalion_types     (newBattalionTypes: Vector[BattalionType]
                                    — quest-hybrid: type_id queryable +
                                    whole battalion_type_proto BLOB)

All five share the (action_seq, n) PK pattern (n = position in the
source vector — the #6735/#6737/#6738 disambiguator). The four
primitive-ID tables index the id column for the obvious "actions
affecting entity X" lookups. BattalionType is a flat 12-field struct
that's only ever looked up by type_id, so it gets the quest-hybrid
shape (type_id column + whole battalion_type_proto BLOB) rather than
fully columnizing — consistent with how flat-but-deep entities are
handled elsewhere in this package.

All FK back to action_results(action_seq) ON DELETE CASCADE.

Unified writer (ActionResultVectorTablesWriter) with a facade
write(connection, actionSeq, ar: ActionResultT) that iterates all five
vectors internally. SqliteHistory.withNewResults calls it once per
action; backfill calls the same facade per row after fromProto. Single
source of truth, no risk of drift between live + backfill.

Read path is NOT swapped here — replayFrom still parses payload. The
rows are "shadow" until 4.5h.5 drops payload and switches the read
path.

This commit (red):
- ActionResultVectorTablesWriter stub: real schemaStatements (5 CREATE
  TABLE + 5 CREATE INDEX), no-op write + no-op backfill.
- Migration v20 registered.
- Two red tests in SqliteHistoryTest, each asserting all 5 tables: live-
  write via withNewResults, and backfill from an existing payload.
  Shared assertVectorTablesPopulated helper checks every row of every
  table.

After this lands, the only remaining 4.5h work is .5 — drop payload +
read-path swap.

Verification: sqlite_history_test 87/89 pass, 2 RED for the right reason
(tables exist per migration, no rows because stubs are no-ops →
rs.next() returns false in withRow).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.4 (green): implement action_results vector tables

Fills in the ActionResultVectorTablesWriter stub from the red commit
and wires SqliteHistory.withNewResults to call the writer's facade
once per action. v20 backfill turns from a no-op into a real per-row
decomposition of every existing payload.

Writer (ActionResultVectorTablesWriter):
- write(connection, actionSeq, ar): facade that dispatches to four
  insertIdVector calls (one per primitive-ID vector, each batched) plus
  insertNewBattalionTypes. Single source of truth for "what gets
  inserted from a domain ActionResultT" across live + backfill.
- insertIdVector(connection, table, idColumn, actionSeq, ids): batched
  INSERT into one of the four primitive-ID join tables. No-op when ids
  is empty. (table/idColumn are internal constants, never user input.)
- insertNewBattalionTypes: batched INSERT into action_new_battalion_types
  binding type_id + BattalionTypeConverter.toProto(bt).toByteArray.
  Domain → proto happens here at the storage edge.
- backfill(connection): drains every action_results.payload (closing
  the SELECT first per the xerial quirk), then per row goes through
  ActionResultProtoConverter.fromProto and delegates to write.

SqliteHistory.withNewResults:
- Single new line after the existing per-vector loops:
  ActionResultVectorTablesWriter.write(connection, seq, awrs.actionResult).
  The facade iterates all five vectors internally.

BUILD additions (green-only):
- action_result_vector_tables_writer deps:
  action_result_scala_proto (for parseFrom),
  action_result_proto_converter (for fromProto in backfill),
  battalion_type_converter (for toProto + transitively pulls in the
  BattalionType proto for .toByteArray).

The read path is NOT touched here — replayFrom still parses payload.
The rows are "shadow" until 4.5h.5 drops payload and switches the
read path.

Verification: sqlite_history_test 89/89 pass; full Scala test suite
226/226.

Next: 4.5h.5 — drop payload BLOB, swap replayFrom to assemble from
the columns + per-entity blobs. The last 4.5h chunk.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 07:08:41 -07:00
adminandGitHub 0b70e6e9bf Load Shardok image tarball on Hetzner deploy (#6774) 2026-05-27 07:02:20 -07:00
adminandGitHub 0ad09f4067 Delete first unused Unity asset batch (#6770) 2026-05-27 06:56:53 -07:00
adminandGitHub 3c23afecfa Downscale oversized Unity texture sources (#6769) 2026-05-27 06:53:29 -07:00
adminandGitHub 596143f54d Make DragonEffect tolerate child animators (#6768) 2026-05-27 06:46:58 -07:00
adminandGitHub 6cbc99ae73 Disable checkout credential persistence (#6767) 2026-05-27 06:46:18 -07:00
2497e7114d Phase 4.5h.3: action_results structured-Option blob columns (#6772)
* Phase 4.5h.3 (red): action_results structured-Option blob columns schema + tests

Continues 4.5h after .2 (the nine scalar/Option columns split across .2a/.2b/.2c).
Three structured-Option top-level ActionResultC fields promoted as
nullable BLOB columns on action_results — small nested aggregates that
aren't analytics-queried at this level, so per the design doc's stopping
rule (same as ChangedProvince / newProvinces / etc.) each becomes a
single whole-entity BLOB column rather than being further decomposed:

  - new_battle_proto         (newBattle: Option[ShardokBattle])
  - new_chronicle_entry_proto (newChronicleEntry: Option[ChronicleEntry])
  - new_eagle_map_proto      (newEagleMap: Option[EagleMapInfo])

No indexes — these are BLOBs whose internals aren't queried at the SQL
level; the analytical handles for "which actions started a battle /
advanced chronicle / loaded a map" come from per-entity tables and
scalar columns elsewhere on action_results.

Same shared-bindColumns-helper pattern as .2a / .2b / .2c — backfill
will go through ActionResultProtoConverter.fromProto and delegate to
bindColumns; live INSERT in withNewResults calls the same helper. Single
source of truth, no risk of drift between live + backfill.

Read path is NOT swapped here — replayFrom still parses payload. The
columns are "shadow" until the final 4.5h sub-PR drops payload and
switches the read path.

This commit (red):
- ActionResultStructuredBlobColumnsWriter stub: real schemaStatements
  (3 ALTER TABLE ADD COLUMN ... BLOB), no-op backfill.
- Migration v19 registered.
- Two red tests in SqliteHistoryTest: live-write asserts the three BLOB
  columns populated from the ActionResultC fields (assertions parse the
  stored bytes via the per-entity converter and compare to the domain
  instance — robust to serialization-order differences); backfill asserts
  the same from an existing payload.
- Visibility widening: model/state/shardok_battle:shardok_battle,
  model/state/chronicle_entry:chronicle_entry,
  model/proto_converters/shardok_battle:shardok_battle_converter,
  model/proto_converters/chronicle_entry:chronicle_entry_converter,
  model/proto_converters:eagle_map_info_converter — all to add the
  test (and where relevant, service) packages.

After this lands, the remaining 4.5h work is the primitive-ID vectors +
the small newBattalionTypes vector, then the payload drop + read-path
swap.

Verification: sqlite_history_test 85/87 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getBytes returns null and
parseFrom(null) throws NPE).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.3 (green): implement action_results structured-Option blob columns

Fills in the ActionResultStructuredBlobColumnsWriter stub from the red
commit and wires the live INSERT in SqliteHistory.withNewResults to call
the writer's bindColumns helper. v19 backfill turns from a no-op into a
real per-row UPDATE through the full ActionResultProtoConverter.fromProto.

Writer (ActionResultStructuredBlobColumnsWriter):
- bindColumns(ps, startPosition, ar): single source of truth for the
  three blob column bindings — each is the per-entity converter's
  toProto.toByteArray (or SQL NULL for None).
    * new_battle_proto         ← ShardokBattleConverter.toProto(...).toByteArray
    * new_chronicle_entry_proto ← ChronicleEntryConverter.toProto(...).toByteArray
    * new_eagle_map_proto      ← EagleMapInfoConverter.toProto(...).toByteArray
- backfill(connection): drains every action_results.payload (closing
  the SELECT first per the xerial quirk), then per row goes through
  ActionResultProtoConverter.fromProto and delegates to bindColumns.
- bindOptBytes helper via .fold (no match-on-Option).

SqliteHistory.withNewResults:
- INSERT extended from 21 to 24 columns. The three new bindings are a
  single call:
  ActionResultStructuredBlobColumnsWriter.bindColumns(insertStmt,
  startPosition = 22, awrs.actionResult).

BUILD additions (green-only):
- action_result_structured_blob_columns_writer deps:
  action_result_scala_proto, eagle_pkg, action_result_trait,
  action_result_proto_converter, eagle_map_info_converter,
  chronicle_entry_converter, shardok_battle_converter.
- Widened eagle_map_info_converter visibility to include service:__pkg__
  (chronicle_entry_converter and shardok_battle_converter already had
  service visibility from the red commit / prior phases).

The read path is NOT touched here — replayFrom still parses payload.
These columns are "shadow" until the final 4.5h sub-PR drops payload
and switches the read path.

Verification: sqlite_history_test 87/87 pass; full Scala test suite
226/226.

Next: 4.5h.4 — primitive-ID vectors (destroyedBattalionIds,
removedHeroIds, removedFactionIds, affectedFactionIds) + the small
newBattalionTypes vector. Then 4.5h.5 = drop payload + read-path swap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 06:45:39 -07:00
adminandGitHub fbe7f9606b Update Unity Rider package (#6771) 2026-05-27 06:44:48 -07:00
adminandGitHub 1573e73419 Add Unity LFS asset audit tooling (#6766) 2026-05-26 21:42:31 -07:00
5ea7908db0 Phase 4.5h.2c: action_results date + resolvedBattle columns (#6765)
* Phase 4.5h.2c (red): action_results date + resolvedBattle columns schema + tests

Closes the 4.5h.2 family (enums → numerics → date+text). The last two
scalar/Option top-level ActionResultC fields:

Three nullable columns + two indexes:
  - new_date_year   (newDate.year)
  - new_date_month  (newDate.month.value, 1..12)
  - resolved_battle (resolvedBattle: Option[String] — shardok game id)

The year/month pair mirrors the v1 date_year/date_month pattern already
used for the resulting state's currentDate — newDate is a distinct field
(the change-of-date event), but storing it identically keeps the table
shape uniform. A composite (new_date_year, new_date_month) index matches
the v1 composite on the resulting-state date columns; resolved_battle
is independently indexed.

Same shared-bindColumns-helper pattern as .2a / .2b — backfill goes
through ActionResultProtoConverter.fromProto, then delegates to
bindColumns; live write calls the same bindColumns from the existing
INSERT in withNewResults. Single source of truth, no risk of drift.

Read path is NOT swapped here — replayFrom still parses payload. The
columns are "shadow" until the final 4.5h sub-PR drops payload and
switches the read path.

This commit (red):
- ActionResultDateBattleColumnsWriter stub: real schemaStatements
  (3 ALTER TABLE ADD COLUMN + 2 CREATE INDEX, including the composite
  on (year, month)), no-op backfill.
- Migration v18 registered.
- Two red tests: live-write asserts all three columns populated; backfill
  asserts the same from an existing payload.

After this lands, 4.5h.2 is complete. The remaining 4.5h work is
structured Options (newBattle / newChronicleEntry / newEagleMap),
primitive-ID vectors, the small newBattalionTypes vector, and finally
the payload drop + read-path swap.

Verification: sqlite_history_test 83/85 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getInt returns 0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.2c (green): implement action_results date + resolvedBattle columns

Fills in the ActionResultDateBattleColumnsWriter stub from the red
commit and wires the live INSERT in SqliteHistory.withNewResults to call
the writer's bindColumns helper. v18 backfill turns from a no-op into a
real per-row UPDATE through the full ActionResultProtoConverter.fromProto.

This commit closes the 4.5h.2 family — all 9 scalar/Option top-level
ActionResultC fields are now promoted onto action_results columns. The
remaining 4.5h work is structured Options, primitive-ID vectors, and the
payload drop + read-path swap.

Writer (ActionResultDateBattleColumnsWriter):
- bindColumns(ps, startPosition, ar): single source of truth for the
  three column bindings — delegates to bindOptDate (year/month pair)
  and bindOptString (resolved_battle).
- backfill(connection): drains every action_results.payload, then per
  row goes through ActionResultProtoConverter.fromProto and delegates to
  bindColumns. Same pattern as .2a / .2b.
- bindOptDate(ps, yearIdx, monthIdx, opt): Option[Date] → (setInt year,
  setInt month.value) or (setNull, setNull), via .fold.
- bindOptString(ps, idx, opt): Option[String] → setString or setNull,
  via .fold.

SqliteHistory.withNewResults:
- INSERT extended from 18 to 21 columns. The three new bindings are a
  single call:
  ActionResultDateBattleColumnsWriter.bindColumns(insertStmt,
  startPosition = 19, awrs.actionResult).

BUILD additions (green-only):
- action_result_date_battle_columns_writer deps:
  action_result_scala_proto, eagle_pkg (for type aliases referenced
  through ActionResultT), action_result_trait,
  action_result_proto_converter, model/state/date.

The read path is NOT touched here — replayFrom still parses payload.
These columns are "shadow" until the final 4.5h sub-PR drops payload
and switches the read path.

Verification: sqlite_history_test 85/85 pass; full Scala test suite
226/226.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 21:16:26 -07:00
090ce2b1f6 Phase 4.5h.2b: action_results numeric columns (#6763)
* Phase 4.5h.2b (red): action_results numeric columns schema + tests

Continues 4.5h after .2a (enum columns). The second of the three 4.5h.2
sub-PRs (enums → numerics → date+text).

Four numeric-primitive top-level ActionResultC fields promoted as
nullable INTEGER columns + indexes:

  - new_round_id           (newRoundId: Option[RoundId]   — RoundId = Int)
  - new_victor_faction_id  (newVictorFactionId: Option[FactionId] — FactionId = Int)
  - game_ended             (gameEnded: Option[Boolean] — stored as 0/1)
  - new_random_seed        (newRandomSeed: Option[Long]   — SQLite INTEGER takes int64)

Note new_round_id is distinct from the v1 round_id column: the latter is
the resulting state's current round id (always set), this is the action's
change-of-round event (sparse, only set when an action advances the round).

Unlike .2a's enums, these four are direct Option-unwrap from the proto
(no UNKNOWN→None or back-compat semantics), so the GREEN backfill is
simpler — though it'll still go through ActionResultProtoConverter.fromProto
to keep the bindColumns helper as the single source of truth for "what
gets bound and from what" across live INSERT + backfill UPDATE.

The shared-bindColumns-helper pattern from .2a continues. Live-write
path is the existing INSERT INTO action_results (...) in withNewResults,
extended in GREEN to bind these four additional columns positionally via
ActionResultNumericColumnsWriter.bindColumns(insertStmt, position, ar).

Read path is NOT swapped here — replayFrom still parses payload. The
columns are "shadow" until the final 4.5h sub-PR drops payload and
switches the read path.

This commit (red):
- ActionResultNumericColumnsWriter stub: real schemaStatements
  (4 ALTER TABLE ADD COLUMN + 4 CREATE INDEX), no-op backfill (no
  bindColumns yet — that lands in green alongside the impl).
- Migration v17 registered.
- Two red tests in SqliteHistoryTest: live-write asserts the four
  columns populated from the ActionResultC fields; backfill asserts
  the four columns populated from an existing payload.

Verification: sqlite_history_test 81/83 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getInt returns 0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.2b (green): implement action_results numeric columns

Fills in the ActionResultNumericColumnsWriter stub from the red commit
and wires the live INSERT in SqliteHistory.withNewResults to call the
writer's bindColumns helper. v17 backfill turns from a no-op into a
real per-row UPDATE through the full ActionResultProtoConverter.fromProto.

Writer (ActionResultNumericColumnsWriter):
- bindColumns(ps, startPosition, ar): single source of truth for the
  four numeric-column bindings.
    * new_round_id          ← ar.newRoundId             (Option[Int])
    * new_victor_faction_id ← ar.newVictorFactionId     (Option[Int])
    * game_ended            ← ar.gameEnded.map(_→0/1)   (Option[Boolean])
    * new_random_seed       ← ar.newRandomSeed          (Option[Long], setLong)
- backfill(connection): drains every action_results.payload (closing
  the SELECT first per the xerial quirk), then issues a batched UPDATE
  per row. Each row goes through ActionResultProtoConverter.fromProto
  to get the domain ActionResultC, then delegates to bindColumns at
  position 1. Same pattern as .2a — keeps live + backfill on a single
  source of truth.
- bindOptInt / bindOptLong helpers via .fold (no match-on-Option).

SqliteHistory.withNewResults:
- INSERT extended from 14 to 18 columns. The four new bindings are a
  single call: ActionResultNumericColumnsWriter.bindColumns(insertStmt,
  startPosition = 15, awrs.actionResult).

BUILD additions (green-only):
- action_result_numeric_columns_writer deps: action_result_scala_proto,
  eagle_pkg (for type aliases referenced through ActionResultT),
  action_result_trait, action_result_proto_converter.

The read path is NOT touched here — replayFrom still parses payload.
These columns are "shadow" until the final 4.5h sub-PR drops payload
and switches the read path.

Verification: sqlite_history_test 83/83 pass; full Scala test suite
226/226.

Next in 4.5h.2: .2c — newDate (year+month columns) + resolvedBattle
(text column) — the last scalar/Option chunk before the structured
Options + primitive-ID vectors + the payload drop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:52:32 -07:00
9d114b89a7 Phase 4.5h.2a: action_results enum columns (#6761)
* Phase 4.5h.2a (red): action_results enum columns schema + tests

Continues 4.5h after .1 (entity-ref columns). The 4.5h.2 family is the
remaining 9 scalar/Option ActionResultC fields, split per the three-group
plan: .2a = enums (this PR), .2b = numeric primitives, .2c = Date +
String.

The three enum-typed fields become four columns — GameType is a sum type
(Normal vs Tutorial(phase: TutorialPhase)) so it needs a kind discriminator
plus a wrapped TutorialPhase column. All nullable INTEGER, all indexed:

  - new_round_phase     (newRoundPhase: Option[RoundPhase])
  - last_command_type   (lastCommandTypeForActingProvince: Option[CommandType])
  - new_game_type       (newGameType: Option[GameType]) — kind discriminator
  - new_tutorial_phase  (TutorialPhase wrapped inside GameType.Tutorial;
                         NULL when Normal or None)

Storage value is the **proto enum number** (Converter.toProto(domain).value)
for all three — the proto enum's wire-format number is intentionally
stable across schema evolutions, unlike e.g. Scala enum .ordinal. The
domain RoundPhase.value happens to match the proto enum number, but going
through the converter for all three keeps the pattern uniform (and forces
any drift into one place — the converter).

GREEN backfill will go through the full ActionResultProtoConverter.fromProto
rather than reading proto fields directly, because the domain
lastCommandTypeForActingProvince has a back-compat fallback path to a
deprecated SelectedCommand field. The converter is the single source of
truth for "domain Option-value from this payload"; reusing it eliminates
drift risk for the cost of a one-time per-row conversion on the migration
path.

Live-write path is the existing INSERT INTO action_results (...) in
withNewResults, extended in GREEN to bind these four additional columns
positionally. Read path is NOT swapped here — replayFrom still goes
through the full converter on the payload blob; the columns are "shadow"
until the final 4.5h sub-PR drops payload and switches the read path.

This commit (red):
- ActionResultEnumColumnsWriter stub: real schemaStatements
  (4 ALTER TABLE ADD COLUMN + 4 CREATE INDEX), no-op backfill.
- Migration v16 registered.
- Two red tests in SqliteHistoryTest: live-write exercises the Tutorial
  branch (both new_game_type AND new_tutorial_phase populated); backfill
  exercises the Normal branch (new_game_type set, new_tutorial_phase
  NULL) so the two tests together cover both GameType branches.

Verification: sqlite_history_test 79/81 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getInt returns 0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.2a (green): implement action_results enum columns

Fills in the ActionResultEnumColumnsWriter stub from the red commit and
wires the live INSERT in SqliteHistory.withNewResults to call the writer's
new bindColumns helper. v16 backfill turns from a no-op into a real
per-row UPDATE through the full ActionResultProtoConverter.fromProto.

Writer (ActionResultEnumColumnsWriter):
- bindColumns(ps, startPosition, ar): single source of truth for "what
  the four enum columns get bound to from a domain ActionResultT".
  Returns nothing; binds 4 positional parameters via the shared
  bindOptInt helper. Used by both the live INSERT and the migration
  UPDATE — eliminates drift risk between the two paths.
- backfill(connection): drains every action_results.payload (closing the
  SELECT first per the xerial quirk), then issues a batched UPDATE per
  row. Each row goes through the full ActionResultProtoConverter.fromProto
  to get the domain ActionResultC (which handles the deprecated
  SelectedCommand fallback for lastCommandTypeForActingProvince), then
  delegates to bindColumns at position 1.
- GameType handling: GameType.Tutorial(phase) populates BOTH
  new_game_type AND new_tutorial_phase; GameType.Normal populates only
  new_game_type, leaving new_tutorial_phase NULL (rather than the proto
  sentinel TUTORIAL_PHASE_UNKNOWN = 0).

SqliteHistory.withNewResults:
- INSERT extended from 10 to 14 columns. The four new bindings are a
  single call: ActionResultEnumColumnsWriter.bindColumns(insertStmt,
  startPosition = 11, awrs.actionResult). The 4.5h.1 entity-ref columns
  stay inline (their inline-vs-helper choice can be revisited later).

BUILD additions (green-only):
- action_result_enum_columns_writer deps: tutorial_phase_scala_proto,
  action_result_scala_proto, eagle_pkg (for the BattalionId etc. types
  referenced through ActionResultT), action_result_trait,
  action_result_proto_converter, command_type_converter,
  game_type_converter, round_phase_converter.
- Widened visibility of command_type_converter and round_phase_converter
  to include service:__pkg__.

The read path is NOT touched here — replayFrom still parses payload and
goes through ActionResultProtoConverter.fromProto. These columns are
"shadow" until the final 4.5h sub-PR drops payload and switches the
read path.

Verification: sqlite_history_test 81/81 pass; full Scala test suite
226/226.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:01:56 -07:00
adminandGitHub 9112e60378 Queue client update notifications until UI is ready (#6762) 2026-05-26 16:46:19 -07:00
37fa64bdad Phase 4.5h.1: action_results entity-ref columns (#6760)
* Phase 4.5h.1 (red): action_results entity-ref columns schema + tests

Begins Phase 4.5h. The first chunk: four top-level entity-ref Option fields
from ActionResultC promoted onto the existing action_results table as
nullable INTEGER columns, indexed for the obvious query shapes
("show all actions by hero X"):

  - acting_hero_id      (domain actingHeroId     / proto leader)
  - acting_faction_id   (domain actingFactionId  / proto player)
  - province_id         (domain provinceId       / proto province)
  - province_id_acted   (domain provinceIdActed  / proto province_acted)

Proto field names don't match the domain — the column names follow the
domain since that's what the read path will ultimately expose.

Distinct from the per-entity table writers: there's no new table here,
and no per-row write helper. The live-write path is the existing
INSERT INTO action_results (...) statement in withNewResults, which the
green commit extends to bind these four extra columns positionally
(RED: INSERT unchanged, new columns default to NULL, so getInt returns 0
and the test fails 0 != 9).

The read path is NOT swapped here — replayFrom still parses the payload
blob and goes through ActionResultProtoConverter.fromProto. The columns
are "shadow" until the final 4.5h sub-PR drops payload and switches the
read path.

This commit (red):
- ActionResultEntityRefColumnsWriter stub: real schemaStatements
  (4 ALTER TABLE ADD COLUMN + 4 CREATE INDEX), no-op backfill.
- Migration v15 registered (runs the schema; backfill is the stub no-op).
- Two red tests in SqliteHistoryTest: live-write asserts the four
  columns are populated from the ActionResultC fields, and backfill
  asserts the four columns are populated from an existing payload.

Verification: sqlite_history_test 77/79 pass, 2 RED for the right reason
(columns exist per migration, default NULL → getInt returns 0). The impl
that turns them green is the next commit on this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5h.1 (green): implement action_results entity-ref columns

Fills in the ActionResultEntityRefColumnsWriter.backfill stub from the
red commit and extends the existing INSERT in SqliteHistory.withNewResults
to populate the four new columns. v15 backfill turns from a no-op into
a real one-row-per-existing-payload UPDATE.

Writer (ActionResultEntityRefColumnsWriter):
- backfill(connection): drains every action_results.payload (fully
  closing the SELECT ResultSet before any UPDATE — the xerial
  sqlite-jdbc quirk), then issues one batched UPDATE per row binding
  leader/player/province/provinceActed from the parsed proto. The
  UPDATE is unconditional: rows whose four proto fields are all unset
  bind NULL × 4, identical to the post-ALTER default — keeps the logic
  uniform with no extra branching.
- bindOptInt helper: Option[Int] → setInt or setNull, via .fold (no
  match-on-Option).

SqliteHistory.withNewResults:
- The INSERT INTO action_results (...) statement is extended from 6
  columns to 10 — adding acting_hero_id, acting_faction_id, province_id,
  province_id_acted at positions 7–10. The four new bindings each use
  .fold to handle the Option[Int] domain field. The existing
  date_year/date_month pattern-match stays as-is (local convention).

BUILD addition (green-only):
- action_result_entity_ref_columns_writer: action_result_scala_proto
  (for ActionResultProto.parseFrom in backfill).

The read path is NOT touched here — replayFrom still parses payload and
goes through ActionResultProtoConverter.fromProto. These columns are
"shadow" until the final 4.5h sub-PR drops payload and switches the
read path.

Verification: sqlite_history_test 79/79 pass; full Scala test suite
226/226.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:53:08 -07:00
adminandGitHub 9eca1f4a91 Clarify expired OAuth session login state (#6759) 2026-05-26 15:09:36 -07:00
5d807ef268 Phase 4.5g.3: client text visibility extensions decomposition (#6758)
* Phase 4.5g.3 (red): client text visibility extensions decomposition tests + schema

Final 4.5g chunk. ClientTextVisibilityExtensionT is small and all-scalar
(textId: ClientTextId + recipientFactionIds: Vector[FactionId]) with no
sealed-hierarchy variants and no nested aggregates — so it's a **refined**
entity, fully columnized with no *_proto BLOB. Two tables, mirroring the
ChangedFaction primitive-ID-join precedent:

  - action_client_text_visibility_extensions (parent): one row per
    extension, (action_seq, n) PK, text_id queryable column (indexed).
  - action_client_text_visibility_extension_recipients (child): one row
    per (extension, faction_id), PK (action_seq, n, faction_id),
    faction_id indexed. Set semantics: the reader sorts by faction_id on
    read, matching the existing ChangedFaction _*_ids children.

Both FK back to action_results(action_seq) ON DELETE CASCADE directly
(matching the existing convention) — cascading from the parent action
keeps both tables in sync without an explicit parent-to-child FK.

There is no ClientTextVisibilityExtensionConverter file — conversion is
inlined in ActionResultProtoConverter. Backfill will build a
ClientTextVisibilityExtensionC directly from the proto's nested
ClientTextVisibilityExtension fields.

This commit (red):
- ClientTextVisibilityExtensionTableWriter stub: real schemaStatements
  (both tables + indexes), no-op write/backfill.
- ClientTextVisibilityExtensionTableReader stub: read returns
  Vector.empty.
- Migration v14 registered (runs the schema; backfill is the stub
  no-op).
- Two red tests in SqliteHistoryTest: live-write/reader round-trip
  preserving text_id and recipients (including an extension with empty
  recipients, to exercise the no-child-rows case), and backfill from an
  existing payload (asserts parent rows + recipients child rows for both
  the populated and empty-recipients case).
- client_text_visibility_extension_trait visibility widened to the
  service main + test packages; client_text_visibility_extension_concrete
  visibility widened to the service main + test service package.

Verification: sqlite_history_test 75/77 pass, 2 RED for the right reason
(schema exists, rows absent). The impl that turns them green is the next
commit on this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5g.3 (green): implement client text visibility extensions decomposition

Fills in the ClientTextVisibilityExtensionTableWriter / Reader stubs from
the red commit and wires the write into withNewResults. v14 backfill
turns from a no-op into the real per-extension decomposition of every
existing action_results.payload.

Writer (ClientTextVisibilityExtensionTableWriter):
- write(connection, actionSeq, n, extension): one INSERT into
  action_client_text_visibility_extensions (parent row with text_id),
  then a batched INSERT into action_client_text_visibility_extension_recipients
  (one row per faction_id). Skips the child INSERT entirely when
  recipientFactionIds is empty.
- backfill(connection): reads every action_results.payload (fully
  draining the SELECT ResultSet before any INSERT), parses ActionResult
  proto, iterates clientTextVisibilityExtensions and builds a
  ClientTextVisibilityExtensionC directly from the nested proto fields
  (the same inline conversion ActionResultProtoConverter does), then
  delegates to write.

Reader (ClientTextVisibilityExtensionTableReader):
- read(connection, actionSeq): two-pass — first drain parent rows
  (n, text_id) ordered by n, then per-parent SELECT of the recipient
  faction_ids ordered ascending (set semantics, matching
  ChangedFactionTableReader's _*_ids children).

SqliteHistory:
- withNewResults: after the action_results executeBatch (so the FK
  target rows exist), for each (awrs, i) iterate
  awrs.actionResult.clientTextVisibilityExtensions.zipWithIndex and call
  ClientTextVisibilityExtensionTableWriter.write — same loop shape as
  the other per-vector writes already in place.

BUILD additions (green-only):
- client_text_visibility_extension_table_writer: action_result_scala_proto
  (for ActionResultProto.parseFrom) + client_text_visibility_extension_concrete
  (for the ClientTextVisibilityExtensionC constructor in backfill).
- client_text_visibility_extension_table_reader:
  client_text_visibility_extension_concrete (same — assembling each
  read row).

Verification: sqlite_history_test 77/77 pass; full Scala test suite
226/226.

This closes Phase 4.5g. Next is 4.5h: top-level ActionResultC scalar
columns on action_results + drop the payload BLOB, swapping the read
path to scalar columns + per-entity blobs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:06:34 -07:00
c9b045cd78 Phase 4.5g.2: generated text requests decomposition (#6756)
* Phase 4.5g.2 (red): generated text requests decomposition tests + schema

Continues Phase 4.5g. After notifications (4.5g.1), this is the second
chunk: the newGeneratedTextRequests "new entity" vector.

GeneratedTextRequestT is a sealed trait (FixedHeroName, GeneratedHeroName,
and the ~45-case LlmRequestT enum), so per the quest-hybrid stopping rule
each request is stored as a whole-entity generated_text_request_proto BLOB
plus a queryable `kind` discriminator column — the same shape as
notifications, minus the notification-only `deferred` column (a
GeneratedTextRequest carries no field beside the proto).

One table, action_new_generated_text_requests (there is no "removed"
counterpart): (action_seq, n, kind, generated_text_request_proto), PK
(action_seq, n), kind indexed.

A wrinkle worth noting: the production read path
(ActionResultProtoConverter.fromProto) deliberately drops
newGeneratedTextRequests ("not used by the applier"), so the proto blob in
action_results.payload is the only place old requests survive. Backfill
therefore reads the proto's repeated new_generated_text_requests field
directly, and the decomposed table becomes the first relational home for
this data.

This commit (red):
- GeneratedTextRequestTableWriter stub: real schemaStatements (table +
  kind index), no-op write/backfill.
- GeneratedTextRequestTableReader stub: read returns Vector.empty.
- Migration v13 registered (runs the schema; backfill is the stub no-op).
- Two red tests in SqliteHistoryTest: newGeneratedTextRequests round-trip
  preserving kind + order via withNewResults, and backfill from an
  existing payload (asserting the kind discriminator in n order).

The converter and domain-type targets were already visible to the service
and test packages, so no visibility widening was needed.

Verification: sqlite_history_test 73/75 pass, 2 RED for the right reason
(schema exists, rows absent). The impl that turns them green is the next
commit on this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5g.2 (green): implement generated text requests decomposition

Fills in the GeneratedTextRequestTableWriter / Reader stubs from the red
commit and wires the write into withNewResults. v13 backfill turns from a
no-op into the real per-request decomposition of every existing
action_results.payload.

Writer (GeneratedTextRequestTableWriter):
- write(connection, actionSeq, n, request): INSERT one row with
  (action_seq, n, kind, generated_text_request_proto). Converts the
  domain request to proto exactly at the storage edge via
  GeneratedTextRequestConverter.toProto.
- kindOf(request): productPrefix-based discriminator. Every subtype —
  FixedHeroName, GeneratedHeroName, and each LlmRequestT enum case — is
  a Product, so the compiler proves the match exhaustive (no fallback,
  which would be unreachable under -Werror).
- backfill(connection): reads every action_results.payload (fully
  draining the SELECT ResultSet before any INSERT, the xerial
  sqlite-jdbc quirk), parses ActionResult proto, then iterates the proto's
  repeated new_generated_text_requests field directly — because
  ActionResultProtoConverter.fromProto deliberately drops this vector
  ("not used by the applier"), the blob is the only place these
  requests survive. Each is converted to the domain type via
  GeneratedTextRequestConverter.fromProto and delegated to write.

Reader (GeneratedTextRequestTableReader):
- read(connection, actionSeq): SELECT generated_text_request_proto in n
  order, parse each blob, convert via fromProto.

SqliteHistory:
- withNewResults: after the action_results executeBatch (so the FK target
  rows exist), for each (awrs, i) iterate
  awrs.actionResult.newGeneratedTextRequests.zipWithIndex and call
  GeneratedTextRequestTableWriter.write — same loop shape as the other
  per-vector writes already in place.

BUILD additions (green-only):
- generated_text_request_table_writer: action_result_scala_proto (for
  ActionResultProto.parseFrom) + generated_text_request_converter (for
  toProto / fromProto at the storage edge).
- generated_text_request_table_reader: llm_request_scala_proto (for
  GeneratedTextRequestProto.parseFrom — the target name is historical
  baggage; the proto is generated_text_request.proto) +
  generated_text_request_converter.

Verification: sqlite_history_test 75/75 pass; full Scala test suite
225/225.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:49:48 -07:00
adminandGitHub f2c3772304 Preserve Shardok setup placements on reconnect (#6757) 2026-05-26 13:37:54 -07:00
adminandGitHub 7ab1b44989 Apply faction name diffs on client (#6755) 2026-05-25 07:26:23 -07:00
adminandGitHub e584e85642 Handle factionless profession gain notifications (#6754) 2026-05-24 13:18:53 -07:00
adminandGitHub e543d7b53f Run scalafmt hook through Bazel (#6753) 2026-05-24 13:01:56 -07:00
adminandGitHub c08ed8c170 Suppress ready-to-join notification after province ownership change (#6751) 2026-05-24 14:56:58 -04:00
adminandGitHub 86d396c5f5 Suppress ready-to-join notification after quest fulfillment (#6752) 2026-05-24 11:14:08 -04:00
adminandGitHub e635e2d5cf Preserve tutorial generated text requests (#6750) 2026-05-23 22:42:24 -04:00
266039df60 Phase 4.5g.1: notifications decomposition (#6749)
* Phase 4.5g.1 (red): notifications decomposition tests + schema

Starts Phase 4.5g — notifications + generated text requests. This is the
first chunk: the newNotifications / removedNotifications vectors.

NotificationT's details is a ~45-subtype sealed hierarchy, so per the
quest-hybrid stopping rule each notification is stored as a whole-entity
notification_proto BLOB plus a queryable `kind` discriminator column.
Two wrinkles unique to notifications:

  - deferred is NOT in the proto. NotificationConverter.toProto returns
    (NotificationProto, Boolean) — the Boolean is deferred, carried
    beside the message. So deferred is its own column.
  - the proto representation of newNotifications is lossy: it splits into
    a single new_deferred_notification + a repeated notifications_to_deliver,
    so multiple deferred new notifications and original order across the
    split don't survive a proto round-trip. The decomposed tables store
    the domain vector faithfully — one row per element, in n order, each
    with its own deferred — which is strictly better than the proto.

Both action_new_notifications and action_removed_notifications share the
shape (action_seq, n, kind, deferred, notification_proto), PK
(action_seq, n), kind indexed.

This commit (red):
- NotificationTableWriter stub: real schemaStatements (both tables +
  kind indexes), no-op writeNew/writeRemoved/backfill.
- NotificationTableReader stub: readNew/readRemoved return Vector.empty.
- Migration v12 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: newNotifications round-trip
  preserving deferred + order (the proto-can't-do-this case),
  removedNotifications round-trip, and backfill from an existing payload
  (asserting the kind discriminator).
- notification_trait / notification_concrete / notification_converter
  visibility widened to the service main and/or test packages.

Verification: sqlite_history_test 70/73 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5g.1 (green): implement notifications decomposition

Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.

- NotificationTableWriter.writeNew / writeRemoved: INSERT one row per
  notification at (action_seq, n) — kind discriminator + deferred column
  + the Notification proto bytes. kind = details.productPrefix (clean
  subtype name; the compiler proves the Product case exhaustive over the
  sealed NotificationDetails hierarchy, so no reflection and no
  45-case map). deferred comes from NotificationConverter.toProto's
  second tuple element (it lives beside the proto, not in it).
- NotificationTableWriter.backfill: for every existing payload,
  reconstruct newNotifications exactly as ActionResultProtoConverter
  does (single new_deferred_notification ++ notifications_to_deliver),
  plus removedNotifications; write each. Reads all payloads before any
  INSERT (closes the SELECT ResultSet first).
- NotificationTableReader.readNew / readRemoved: inverse — parse each
  blob and combine with the deferred column via
  NotificationConverter.fromProto(proto, deferred), ordered by n.
- SqliteHistory.withNewResults: live-write loop now also writes
  newNotifications + removedNotifications.
- BUILD: writer/reader gain action_result_scala_proto /
  action_result_notification_details_scala_proto + notification_converter
  deps.

Verification: sqlite_history_test 73/73 pass; full //src/test/scala/...
225/225 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 07:51:55 -07:00
ec8223fb4f Phase 4.5f.4: newProvinces decomposition (#6747)
* Phase 4.5f.4 (red): newProvinces decomposition tests + schema

Last and deepest of the "new entity" vectors (after newBattalions
4.5f.1, newFactions 4.5f.2, newHeroes 4.5f.3): newProvinces. A
ProvinceT creation snapshot transitively embeds armies, units, heroes,
battalions, events, deferred changes — exactly the nesting 4.5b's
ChangedProvince kept as a proto blob by the same stopping rule. So it
takes the whole-entity province_proto BLOB shape + a queryable
province_id index.

action_new_provinces is a brand-new table, distinct from 4.5b's
action_changed_provinces (deltas). PK is (action_seq, n) from the
start — the disambiguator the #6735/#6737/#6738 outages taught us — so
multiple new provinces in one action all land; province_id is indexed
but non-unique.

This commit (red):
- NewProvinceTableWriter stub: real schemaStatements (CREATE the
  (action_seq, n) table + index), no-op write and backfill.
- NewProvinceTableReader stub: returns Vector.empty.
- Migration v11 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: live write + reader round-trip,
  two new provinces preserved in n order, and backfill from an existing
  payload.
- ProvinceC concrete visibility widened to the service test package
  (ProvinceT and ProvinceConverter are already //visibility:public).

Verification: sqlite_history_test 67/70 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5f.4 (green): implement newProvinces decomposition

Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring. Completes Phase 4.5f
(all four "new entity" vectors decomposed).

- NewProvinceTableWriter.write: INSERT one row per new province at
  (action_seq, n) — province_id column (denormalized, indexed) plus the
  Province proto bytes in province_proto. Domain type in;
  ProvinceConverter.toProto only at the blob storage edge.
- NewProvinceTableWriter.backfill: decompose every newProvinces entry in
  every existing action_results.payload. Reads all payloads before any
  INSERT (closes the SELECT ResultSet first); the proto field is a
  Province per entry, converted via fromProto before write.
- NewProvinceTableReader.read: inverse — parse each blob back to a
  ProvinceT, ordered by n. (Round-trip test confirms ProvinceConverter
  is lossless for the test province.)
- SqliteHistory.withNewResults: live-write loop now also writes
  newProvinces (same n-disambiguator pattern as the other new-entity
  vectors).
- BUILD: writer/reader gain action_result_scala_proto /
  province_scala_proto + province converter deps.

Verification: sqlite_history_test 70/70 pass; full //src/test/scala/...
225/225 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 16:40:06 -07:00
adminandGitHub 76d3d25d05 Account for incoming food in low food warnings (#6748) 2026-05-22 08:30:44 -07:00
adminandGitHub 6672683289 Display ready-to-join hero notifications (#6745)
* Show ready-to-join hero notifications

* Update Unity project file
2026-05-22 08:16:15 -07:00
91ee65f7d0 Phase 4.5f.3: newHeroes decomposition (#6746)
* Phase 4.5f.3 (red): newHeroes decomposition tests + schema

Third of the "new entity" vectors (after newBattalions 4.5f.1 and
newFactions 4.5f.2): newHeroes. Full HeroT creation snapshots (no
Changed* wrapper), embedding backstory versions, backstory events, and
faction biases — a deep entity, so it takes the whole-entity hero_proto
BLOB shape + a queryable hero_id index.

action_new_heroes is a brand-new table (the "new entity" vectors had no
v2 scaffolding). PK is (action_seq, n) from the start — the
disambiguator the #6735/#6737/#6738 outages taught us — so multiple new
heroes in one action all land; hero_id is indexed but non-unique.

This commit (red):
- NewHeroTableWriter stub: real schemaStatements (CREATE the
  (action_seq, n) table + index), no-op write and backfill.
- NewHeroTableReader stub: returns Vector.empty.
- Migration v10 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: live write + reader round-trip,
  two new heroes preserved in n order, and backfill from an existing
  payload.
- hero converter target visibility widened to the service main and test
  packages (HeroT and HeroC are already visible to service).

Verification: sqlite_history_test 64/67 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5f.3 (green): implement newHeroes decomposition

Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.

- NewHeroTableWriter.write: INSERT one row per new hero at (action_seq,
  n) — hero_id column (denormalized, indexed) plus the Hero proto bytes
  in hero_proto. Domain type in; HeroConverter.toProto only at the blob
  storage edge.
- NewHeroTableWriter.backfill: decompose every newHeroes entry in every
  existing action_results.payload. Reads all payloads before any INSERT
  (closes the SELECT ResultSet first); the proto field is a Hero per
  entry, converted via fromProto before write.
- NewHeroTableReader.read: inverse — parse each blob back to a HeroT,
  ordered by n. (Round-trip test confirms HeroConverter is lossless over
  all HeroC fields.)
- SqliteHistory.withNewResults: live-write loop now also writes
  newHeroes (same n-disambiguator pattern as newBattalions/newFactions).
- BUILD: writer/reader gain action_result_scala_proto / hero_scala_proto
  + hero converter deps.

Verification: sqlite_history_test 67/67 pass; full //src/test/scala/...
225/225 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 08:14:18 -07:00
7d3dee125a Phase 4.5f.2: newFactions decomposition (#6744)
* Phase 4.5f.2 (red): newFactions decomposition tests + schema

Second of the "new entity" vectors (after newBattalions, 4.5f.1):
newFactions. Full FactionT creation snapshots (no Changed* wrapper),
which transitively embed diplomacy offers, faction relationships, and
reconned province views — a deep entity, so it takes the whole-entity
faction_proto BLOB shape + a queryable faction_id index.

action_new_factions is a brand-new table (the "new entity" vectors had
no v2 scaffolding). PK is (action_seq, n) from the start — the
disambiguator the #6735/#6737/#6738 outages taught us — so multiple new
factions in one action all land; faction_id is indexed but non-unique.

This commit (red):
- NewFactionTableWriter stub: real schemaStatements (CREATE the
  (action_seq, n) table + index), no-op write and backfill.
- NewFactionTableReader stub: returns Vector.empty.
- Migration v9 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: live write + reader round-trip,
  two new factions preserved in n order, and backfill from an existing
  payload.
- faction (FactionT) / faction/concrete (FactionC) / faction converter
  target visibility widened to the service main and/or test packages.

Verification: sqlite_history_test 61/64 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5f.2 (green): implement newFactions decomposition

Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.

- NewFactionTableWriter.write: INSERT one row per new faction at
  (action_seq, n) — faction_id column (denormalized, indexed) plus the
  Faction proto bytes in faction_proto. Domain type in;
  FactionConverter.toProto only at the blob storage edge.
- NewFactionTableWriter.backfill: decompose every newFactions entry in
  every existing action_results.payload. Reads all payloads before any
  INSERT (closes the SELECT ResultSet first); the proto field is a
  Faction per entry, converted via fromProto before write.
- NewFactionTableReader.read: inverse — parse each blob back to a
  FactionT, ordered by n.
- SqliteHistory.withNewResults: live-write loop now also writes
  newFactions (same n-disambiguator pattern as newBattalions).
- BUILD: writer/reader gain action_result_scala_proto /
  faction_scala_proto + faction converter deps.

Verification: sqlite_history_test 64/64 pass; full //src/test/scala/...
225/225 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:32:49 -07:00
adminandGitHub 8aedd945cf Notify when heroes are ready to join (#6743) 2026-05-22 07:25:21 -07:00
5d537db8c8 Phase 4.5f.1: newBattalions decomposition (#6742)
* Phase 4.5f.1 (red): newBattalions decomposition tests + schema

Starts Phase 4.5f — decomposing the "new entity" vectors
(newProvinces/newHeroes/newFactions/newBattalions) out of the
ActionResult payload blob. Per the session cadence, 4.5f is split into
one PR per vector; this is the first, newBattalions — the lowest-risk
because it reuses BattalionConverter and the deep-blob pattern just
landed for ChangedBattalion (4.5e), establishing the action_new_*
table convention.

newBattalions holds full BattalionT creation snapshots (no Changed*
wrapper). It's a deep entity: one whole-entity battalion_proto BLOB
(the Battalion proto bytes) + a queryable battalion_id index.

action_new_battalions is a brand-new table (the "new entity" vectors
had no v2 scaffolding). Its PK is (action_seq, n) from the start — the
disambiguator the #6735/#6737/#6738 outages taught us to use — so
multiple new battalions in one action all land; battalion_id is indexed
but non-unique.

This commit (red):
- NewBattalionTableWriter stub: real schemaStatements (CREATE the
  (action_seq, n) table + index), no-op write and backfill.
- NewBattalionTableReader stub: returns Vector.empty.
- Migration v8 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest: live write + reader round-trip,
  two new battalions preserved in n order, and backfill from an existing
  payload.
- battalion (BattalionT) target visibility widened to the service main
  package.

Verification: sqlite_history_test 58/61 pass, 3 RED for the right
reason (schema exists, rows absent). The impl that turns them green is
the next commit on this branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5f.1 (green): implement newBattalions decomposition

Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.

- NewBattalionTableWriter.write: INSERT one row per new battalion at
  (action_seq, n) — battalion_id column (denormalized, indexed) plus the
  Battalion proto bytes in battalion_proto. Domain type in;
  BattalionConverter.toProto only at the blob storage edge.
- NewBattalionTableWriter.backfill: decompose every newBattalions entry
  in every existing action_results.payload. Reads all payloads before
  any INSERT (closes the SELECT ResultSet first); the proto field is a
  Battalion per entry, converted via fromProto before write.
- NewBattalionTableReader.read: inverse — parse each blob back to a
  BattalionT, ordered by n.
- SqliteHistory.withNewResults: live-write loop now also writes
  newBattalions (same n-disambiguator pattern as the changed-* family).
- BUILD: writer/reader gain action_result_scala_proto /
  battalion_scala_proto + battalion_converter deps.

Verification: sqlite_history_test 61/61 pass; full //src/test/scala/...
225/225 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:18:13 -07:00
14100fcb50 Phase 4.5e: ChangedBattalion decomposition (#6741)
* Phase 4.5e (red): ChangedBattalion decomposition tests + schema

Sets up Phase 4.5e — decomposing ChangedBattalion. It's a deep entity
(ChangedBattalionC's sole field is the contained BattalionT), so it
takes the whole-entity blob shape like ChangedProvince: one
changed_battalion_proto BLOB (the Battalion proto bytes) plus a
queryable battalion_id index.

Critically, the v2 action_changed_battalions scaffolding (from 4.5a)
carried the same latent (action_seq, battalion_id) PK bug that caused
the #6735/#6737/#6738 outages for the other entities. v7 fixes it up
front: PK is (action_seq, n), battalion_id is indexed but non-unique,
so two ChangedBattalions for the same battalion in one action both
land.

This commit (red):
- ChangedBattalionTableWriter stub: real schemaStatements (DROP the
  v2 shape + CREATE the (action_seq, n) table + index), no-op write
  and backfill.
- ChangedBattalionTableReader stub: returns Vector.empty.
- Migration v7 registered (runs the schema; backfill is the stub no-op).
- Three red tests in SqliteHistoryTest:
  - live write + reader round-trip of a domain ChangedBattalionC.
  - two ChangedBattalions with the same battalion_id preserved in n
    order (the dup-entity case).
  - backfill from an existing payload (the backfill-on-load path that
    crashed the other entities in prod).
- changed_battalion_concrete visibility widened to the service main +
  test packages; battalion_converter / battalion_type_id /
  battalion/concrete widened to the service test package for the
  fixtures.

All three battalion tests are RED on the stubs with clean assertion
mismatches (schema exists, rows absent). The impl that turns them
green is the next commit on the same branch.

Verification: sqlite_history_test 55/58 pass, 3 RED for the right
reason.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5e (green): implement ChangedBattalion decomposition

Turns the three RED tests from the previous commit green by filling in
the writer, reader, and live write-path wiring.

- ChangedBattalionTableWriter.write: INSERT one row per ChangedBattalion
  at (action_seq, n) — battalion_id column (denormalized, indexed) plus
  the Battalion proto bytes in changed_battalion_proto. Domain type in;
  BattalionConverter.toProto only at the blob storage edge.
- ChangedBattalionTableWriter.backfill: decompose every ChangedBattalion
  in every existing action_results.payload. Reads all payloads before any
  INSERT (closes the SELECT ResultSet first — xerial dislikes interleaving
  writes with an open ResultSet); the proto field is a Battalion per entry,
  converted via fromProto and wrapped in ChangedBattalionC before write.
- ChangedBattalionTableReader.read: inverse — parse each blob back to a
  ChangedBattalionC, ordered by n.
- SqliteHistory.withNewResults: live-write loop now also writes
  changedBattalions (same n-disambiguator pattern as ChangedHero /
  ChangedFaction).
- BUILD: writer/reader gain action_result_scala_proto / battalion_scala_proto
  + battalion_converter deps; battalion_converter visibility widened to the
  service main package.

Verification: sqlite_history_test 58/58 pass; full //src/test/scala/...
225/225 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 07:06:39 -07:00
dd0eba3f48 Test: round-trip coverage for ChangedFactionTableReader's non-trivial decoders (#6740)
Fast-follow to #6739, which shipped ChangedFactionTableReader with
round-trip tests covering only Tier-1 scalars, a primitive-ID join
field, the empty case, and the same-faction_id n disambiguation. The
small-struct and Tier-3 leaf-blob decoders were left untested (deferred
because the reader has no production consumer yet). Adding that coverage
now per request.

New test "round-trip the small-struct and Tier-3 leaf-blob decoders"
builds a ChangedFactionC exercising every remaining decoder and asserts
the reader reconstructs it exactly:

- PrestigeModifier (new + removed): kind TEXT <-> Type enum
  (Festival / SuppressedBeasts / TutorialHandicap), ordered by i.
- FactionRelationship: relationship_level TEXT <-> RelationshipLevel
  (Ally / Hostile), nullable reset_date year/month <-> Option[Date]
  (one Some, one None).
- TrustLevelUpdate: explicit columns, two entries ordered by i.
- DiplomacyOffer (AllianceOffer): Tier-3 leaf blob round-tripped via
  DiplomacyOfferConverter.
- ProvinceView: Tier-3 leaf blob round-tripped via ProvinceViewConverter.
- One OutgoingTruceOfferFactionId for the wrapper path.

Test-only change plus three additive visibility entries exposing the
model targets the fixture constructs to the service test package
(state/faction:prestige_modifier, state/faction:faction_relationship,
state/diplomacy_offer:diplomacy_offer); view/province was already
test-visible. No production logic change.

Verification:
- sqlite_history_test - 55/55 pass.
- //src/test/scala/... - 225/225 pass.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 06:36:01 -07:00
ff5e6bd461 Phase 4.5d.2 (read path): ChangedFactionTableReader (#6739)
* Phase 4.5d.2 (red): ChangedFactionTableReader round-trip tests

Adds the failing tests for the 4.5d.2 read path — the inverse of
ChangedFactionTableWriter, reconstructing ChangedFactionT for an
action from the v5 decomposed tables (parent + 15 child tables).
Parallels ChangedHeroTableReader (#6733). Nothing in production
consumes it yet; it verifies the v5 columns round-trip and seeds
4.5h.

Three test cases:

- Round-trip a ChangedFactionC (Tier-1 scalars + a primitive-ID
  join field) through withNewResults and assert the reader returns
  it. Focused fixture — the writer's per-table-type behavior is
  covered in detail by the v5 backfill tests; the reader test only
  needs to prove it inverts the writer end-to-end.
- Return Vector.empty for an action with no ChangedFactions (vacuous
  on the stub; kept for the green-side guarantee).
- Preserve two ChangedFactions with the same faction_id in their
  original (n) order — exercises the (action_seq, n) PK from the
  #6737 hotfix.

Reader returns rows in `n` order (not faction_id), matching the
post-hotfix ChangedHeroTableReader contract. Stub returns
Vector.empty so the two non-vacuous cases are RED with clean
assertion mismatches. The impl that turns them green is the next
commit on the same branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Phase 4.5d.2: implement ChangedFactionTableReader (turns red tests green)

Fills in the stub. Inverse of ChangedFactionTableWriter:

- Pass 1: SELECT the parent rows from action_changed_factions
  WHERE action_seq = ? ORDER BY n, decoding Tier-1 scalars into a
  ChangedFactionC with empty Vector fields.
- Pass 2: for each parent (keyed by its n), read the 15 child tables
  filtered by (action_seq, n) and .copy() the Vector fields in:
  - 9 primitive-ID join tables → Vector[Int] (sorted by id; these
    are set-semantics fields with no `i`, so original order isn't
    preserved — equivalent for the applier). The four Outgoing*
    ones wrap the ids back in their type-safety case classes.
  - 4 small-struct tables ordered by i: PrestigeModifier (kind
    string → Type via prestigeTypeFromString), FactionRelationship
    (relationship_level string → RelationshipLevel, nullable
    reset_date year/month → Option[Date]), TrustLevelUpdate.
  - 2 Tier-3 leaf-blob tables ordered by i: parse the proto bytes
    and convert via DiplomacyOfferConverter.fromProto /
    ProvinceViewConverter.fromProto.

Returns ChangedFactionT in n order, matching the post-#6737
ChangedHeroTableReader contract; multiple entries with the same
faction_id come back in their original positions.

Test coverage scope: the three reader tests cover Tier-1 scalars, a
primitive-ID join field, the empty case, and the same-faction_id n
disambiguation. The small-struct and leaf-blob decoders are not
round-tripped here — exercising them needs Scala model imports
(PrestigeModifier / FactionRelationship / DiplomacyOffer / ProvinceView)
and test-package visibility widenings on four model targets. Deferred
because the reader has no production consumer yet (4.5h will wire it up
and bring its own broader coverage); the writer side that those
columns come from is already validated end-to-end by the v5 backfill
test. A reader decoder bug therefore can't cause a live outage today.

Verification:
- sqlite_history_test - 54/54 pass, including the 3 reader tests.
- //src/test/scala/... - 225/225 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:27:11 -07:00
adminandGitHub c9268d61c8 Hotfix v2 for #6737: v6 migration brings already-migrated games onto the new schema (#6738) 2026-05-21 12:15:11 -04: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