mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 22:35:42 +00:00
ceed22d6de09e95bfa003035c57b4665eec90e22
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ceed22d6de | Prioritize remaining small Eagle alpha work (#6795) | ||
|
|
75f35e981e | Unpin Bazel workflows from specific runners (#6792) | ||
|
|
76d8f07827 | Keep command factory test flatbuffers alive (#6794) | ||
|
|
705a36e901 | Update Unity 6000.4.9 project files (#6793) | ||
|
|
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 |
||
|
|
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> |
||
|
|
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 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
646c47259c | Upgrade Unity project to 6000.4.9f1 (#6788) | ||
|
|
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>
|
||
|
|
237efb9c0c | Temporarily pin self-hosted CI to halfdan (#6786) | ||
|
|
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>
|
||
|
|
39b8c3360e | Tidy Bazel rc marker and Mac deploy runner label (#6782) | ||
|
|
c2b04c2109 | Pass iOS TestFlight build between jobs (#6781) | ||
|
|
875f34a624 | Harden GitHub Actions workflows (#6780) | ||
|
|
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>
|
||
|
|
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>
|
||
|
|
40dd82e638 | Print Shardok binary hash during deploy (#6775) | ||
|
|
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>
|
||
|
|
0b70e6e9bf | Load Shardok image tarball on Hetzner deploy (#6774) | ||
|
|
0ad09f4067 | Delete first unused Unity asset batch (#6770) | ||
|
|
3c23afecfa | Downscale oversized Unity texture sources (#6769) | ||
|
|
596143f54d | Make DragonEffect tolerate child animators (#6768) | ||
|
|
6cbc99ae73 | Disable checkout credential persistence (#6767) | ||
|
|
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> |
||
|
|
fbe7f9606b | Update Unity Rider package (#6771) | ||
|
|
1573e73419 | Add Unity LFS asset audit tooling (#6766) | ||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
9112e60378 | Queue client update notifications until UI is ready (#6762) | ||
|
|
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>
|
||
|
|
9eca1f4a91 | Clarify expired OAuth session login state (#6759) | ||
|
|
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>
|
||
|
|
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>
|
||
|
|
f2c3772304 | Preserve Shardok setup placements on reconnect (#6757) | ||
|
|
7ab1b44989 | Apply faction name diffs on client (#6755) | ||
|
|
e584e85642 | Handle factionless profession gain notifications (#6754) | ||
|
|
e543d7b53f | Run scalafmt hook through Bazel (#6753) | ||
|
|
c08ed8c170 | Suppress ready-to-join notification after province ownership change (#6751) | ||
|
|
86d396c5f5 | Suppress ready-to-join notification after quest fulfillment (#6752) | ||
|
|
e635e2d5cf | Preserve tutorial generated text requests (#6750) | ||
|
|
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>
|
||
|
|
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> |
||
|
|
76d3d25d05 | Account for incoming food in low food warnings (#6748) | ||
|
|
6672683289 |
Display ready-to-join hero notifications (#6745)
* Show ready-to-join hero notifications * Update Unity project file |
||
|
|
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> |
||
|
|
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> |
||
|
|
8aedd945cf | Notify when heroes are ready to join (#6743) | ||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
c9268d61c8 | Hotfix v2 for #6737: v6 migration brings already-migrated games onto the new schema (#6738) | ||
|
|
8bb45a57af |
Unity client: clear deprecation/unused warnings (#6732)
- Replace deprecated FindObjectsByType(FindObjectsSortMode) and FindFirstObjectByType with non-deprecated overloads. - Replace deprecated GetInstanceID() with GetEntityId() in three files that use the value as a dictionary key or for equality. - Prefix fire-and-forget async calls with `_ =` to silence CS4014 in ConnectionHandler, CustomBattleHandler, ErrorHandler, ShardokGameModel, and ResourceFetcher. - Remove the unused `listen` field from ConnectionHandler. - Suppress CS0162 around tutorial content intentionally preserved after an unconditional return, and CS0067 on SingleInstanceEnforcer.OnDeepLinkReceived (declared cross-platform but only invoked on UNITY_STANDALONE_WIN). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
307ea245ee |
Hotfix: same-entity duplicate PK bug for both ChangedFaction and ChangedHero (#6737)
Same shape as the ChangedProvince hotfix in #6735 (PR title "Hotfix: v3 backfill crash on duplicate-province ChangedProvinces"), applied preemptively to the other two decomposed entity tables. The crash: !!! CRITICAL: Failed to load game 2798739292a60a5f: SQLITE_CONSTRAINT_PRIMARYKEY ... action_changed_factions.action_seq, action_changed_factions.faction_id at ChangedFactionTableWriter.writeParentRow at ChangedFactionTableWriter.backfill at SqliteHistory$Migrations$.applyV5ChangedFactionDecomposition The root cause is identical to the ChangedProvince case I'd just hot-fixed. The v2 / v4 / v5 schemas for action_changed_heroes and action_changed_factions used (action_seq, entity_id) as the PK, assuming at most one Changed<Entity> per entity per action. That invariant doesn't hold: ActionResultApplierImpl folds through the changed<Entity> Vector sequentially and applies each entry as its own delta, so two entries for the same entity_id are two valid sequential deltas, both applied. I failed to apply the ChangedProvince lesson when fixing the analogous ChangedFaction crash, and the structurally-identical bug was sitting in ChangedHero too (no production game has hit it yet, but it's the same landmine — fixing preemptively in this PR per Dan's request). Fix shape (mirrors the ChangedProvince hotfix): - Parent table PK becomes (action_seq, n), where n is the position within the source ActionResult's changed<Entity> vector. entity_id becomes an indexed column but no longer unique. - write() takes n as a parameter; backfill iterates with changed<Entity>.zipWithIndex and passes n. - SqliteHistory.withNewResults call sites updated to use zipWithIndex for both entities. - Schema rewrite is via DROP + CREATE in the same migration (applyV4ChangedHeroColumns and applyV5ChangedFactionDecomposition edited in place, same justification as the ChangedProvince hotfix: affected games never successfully ran these migrations, so editing isn't retroactively rewriting their history). - For ChangedFaction's 15 child tables (4.5d.1): all gain n column, FK on (action_seq, n) → parent, keep faction_id denormalized for query convenience. Child tables that had their own within-vector index (renamed from n to i to avoid colliding with the parent's n). PK becomes (action_seq, n, i) for indexed children or (action_seq, n, <id>) for join tables. - For ChangedHero's events child (4.5c.1): same treatment — add n for parent FK, rename internal n → i, keep hero_id denormalized, PK (action_seq, n, i). - ChangedHeroTableReader updated to order by n (preserves the source changedHeroes vector order — multiple entries with the same hero_id come back in their original positions) and to filter events by (action_seq, n) so same-hero_id entries get their own event sets. Tests: - Existing tests that query "WHERE entity_id = X" on parent / child rows still work (entity_id remains a column, denormalized on children; single-row fixtures still match uniquely). - Updated v4 backfill test's events ORDER BY n → ORDER BY i. - Updated ChangedHeroTableReader "ordered by hero_id" test → now asserts insertion order (by n) instead of sorted-by-hero_id. - Added two regression tests: - ChangedHero: two ChangedHeroes with the same hero_id in one action round-trip via the reader in their original order. - ChangedFaction: two ChangedFactions with the same faction_id in one action both land in the parent + child tables at n=0 and n=1. Out of scope: games on other deployments that successfully ran the old (buggy) v4/v5 (because their history lacked duplicate-entity actions) keep the old PK shape. They're not currently exercising the bug. A separate v6 migration for those can land when 4.5h needs the decomposed read path; same trade-off as the ChangedProvince hotfix. Verification: - sqlite_history_test - 50/50 pass, including both new regressions and the updated reader ordering test. - //src/test/scala/... - 225/225 pass. No regressions. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
c0d00d4b81 |
Phase 4.5d.2 (live write): wire ChangedFactionTableWriter into withNewResults (#6736)
* Phase 4.5d.2 (red): live ChangedFaction decomposition test Adds a failing test for the 4.5d.2 live-write contract. 4.5d.1 (#6734) added v5 schema + backfill but left SqliteHistory.with- NewResults unchanged — it only writes the action_results payload blob and (since 4.5c.2) ChangedHero. On a freshly-created game the v5 backfill is a no-op, so action_changed_factions and its 15 child tables stay empty forever. That gap is what 4.5d.2 closes. The new test builds a Scala ChangedFactionC directly (factionId 7, newFactionHeadHeroId 11, newName "foo", newLeaderHeroIds [10, 20]), hands it through withNewResults on a fresh DB, and asserts the parent row plus the new_leader_hero_ids join table populated. Scope: parent row + one join-table assertion. The writer's full per-table behavior is already exercised by the v5 backfill tests landed in 4.5d.1; the live-path test only needs to prove withNewResults invokes ChangedFactionTableWriter.write at all. RED on the current code with a clean assertion mismatch (false was not equal to true at withRow's rs.next() — no parent row exists). Adds changed_faction_concrete to the test BUILD deps (for the Scala ChangedFactionC import). The impl that turns this green is the next commit on the same branch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Phase 4.5d.2: wire ChangedFactionTableWriter into withNewResults Turns the red test from the previous commit green. The existing ChangedHero decomposition loop in withNewResults now also iterates awrs.actionResult.changedFactions and calls ChangedFactionTableWriter. write for each. Same shape as the ChangedHero live-path loop landed in #6730. Sits inside the same transaction, after the action_results executeBatch (FK target rows must exist), so a failure rolls back the action_results inserts too. Updates the comment to reflect both 4.5c.2 (hero) and 4.5d.2 (faction) as the live write paths sharing this loop. Verification: - sqlite_history_test - 48/48 pass, including the previously-red 4.5d.2 live write path test. - //src/test/scala/... - 225/225 pass. No regressions on the critical write path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
b8cb342bc5 |
Phase 4.5d.1: ChangedFaction refined per-field decomposition (schema + backfill) (#6734)
* Phase 4.5d.1 (red): ChangedFaction schema v5 + backfill red tests Sets up the refined per-field decomposition of ChangedFaction (Decisions #2 in the design doc: classified `refined`, no whole-entity blob). ChangedFactionC has 15 non-scalar Vector fields. Per the design doc's refined-option-B tiers, each gets a child table sized to its shape: - 9 primitive-ID join tables for Vector[HeroId/FactionId/ProvinceId] and the four Outgoing*OfferFactionId wrappers. Keyed by (action_seq, faction_id, value) so the join carries set semantics with no n column. - 4 small-struct tables for PrestigeModifier (new + removed), FactionRelationship (changed), and TrustLevelUpdate. Explicit columns + n for ordering. - 2 Tier-3 leaf-blob tables for DiplomacyOffer (sealed with 5 subtypes including the deeper RansomOffer) and ProvinceView (deep aggregate with FullProvinceInfo plus nested vectors). Leaf-blob avoids re-incurring the child-table explosion option B exists to prevent. All 15 tables FK (action_seq, faction_id) into the existing v2 action_changed_factions parent table ON DELETE CASCADE. Tier-1 scalars stay on that parent table (v2). Backfill populates parent row + every applicable child row from each ChangedFaction in each existing action_results.payload. This commit (red): - Writer stub (ChangedFactionTableWriter) with the real 15-table schemaStatements (the contract under review) and no-op write + backfill methods. - Migration v5 hooked into SqliteHistory.Migrations. - Two red tests in SqliteHistoryTest. The first exercises a fully- populated ChangedFaction through seedV1WithPayloads + load and asserts the parent row and every child table got the expected rows. The second asserts a minimal ChangedFaction leaves child tables empty but still emits a parent row. Both RED on the stub: schema exists (no "no such table" errors) but rows are absent, so the assertions fail cleanly. - changed_faction_concrete visibility widened to the production service package (and the service test package) so the writer can consume ChangedFactionT. Same precedent as for ChangedHero in #6730. The impl commit (next, same branch) fills in `write` and `backfill` to turn the tests green. Verification: - sqlite_history_test - 44/46 pass, 2 RED for the right reason (clean assertion mismatches at the row-count and column checks, not exceptions or compile errors). - //src/test/scala/... - 224/225 targets green elsewhere. No regressions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Phase 4.5d.1: implement ChangedFactionTableWriter (turns red tests green) Fills in write + backfill so the two v5 backfill tests from the previous commit pass. write(connection, actionSeq, ChangedFactionT) pattern-matches to ChangedFactionC and fans out: - Parent row: INSERT into v2 action_changed_factions with the Tier-1 scalar / entity-reference columns. - 9 primitive-ID join tables via a shared insertIds helper: (action_seq, faction_id, <id>) batched inserts. Covers newLeaderHeroIds, removedLeaderHeroIds, removedFactionRelationshipFactionIds, removedIncomingDiplomacyOfferFactionIds, removedReconnedProvinceIds, and the four newOutgoing{Truce,Alliance,Invitation,Ransom}OfferFactionIds (mapped through .fid to unwrap the type-safety wrappers). - 4 small-struct tables: PrestigeModifier kind column uses prestigeModifierType.toString (matches case-object names like "Festival", "SuppressedBeasts"); FactionRelationship's reset_date becomes a year/month nullable pair, relationship_level uses toString of the sealed RelationshipLevel. - 2 Tier-3 leaf-blob tables: DiplomacyOffer and ProvinceView convert per-element via DiplomacyOfferConverter.toProto(...).toByteArray and ProvinceViewConverter.toProto(...).toByteArray respectively, exactly at the storage edge. backfill parses each existing payload, converts each ChangedFaction proto to the domain type via ChangedFactionConverter.fromProto, then delegates to the shared write path - one code path for live and backfill. Test fixture fix: - DiplomacyOfferProto in the v5 backfill fixture now sets both offer_details (AllianceOfferDetails) AND status (UNRESOLVED). Real payloads have both; the converter rejects "Empty offer details" and "Unknown diplomacy offer status". BUILD: - changed_faction_table_writer gains deps on the model concrete / converter / state targets it now references: action_result_- concrete:changed_faction_concrete, proto_converters:changed_- faction_converter, proto_converters/diplomacy_offer, proto_converters/view/province:province_view, state/diplomacy_- offer, state/faction:faction_relationship + :prestige_modifier, state/date, view/province:province_view, internal:action_result_- scala_proto. - 5 visibility widenings on production targets to expose them to //src/main/scala/.../service:__pkg__: changed_hero_concrete's precedent, applied here to changed_faction_concrete, proto_converters:changed_faction_converter, proto_converters/- diplomacy_offer:diplomacy_offer, state/diplomacy_offer:diplomacy_- offer, state/faction:faction_relationship + :prestige_modifier. - Test BUILD gains diplomacy_offer_status_scala_proto for the fixture status field. Verification: - sqlite_history_test - 46/46 pass, including both v5 backfill tests. - //src/test/scala/... - 225/225 pass. No regressions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
cf70169e3c |
Hotfix: v3 backfill crashes on ActionResults with duplicate-province ChangedProvinces (#6735)
Production games failed to load with SQLITE_CONSTRAINT_PRIMARYKEY during the v3 ChangedProvince migration: UNIQUE constraint failed: action_changed_provinces.action_seq, action_changed_provinces.province_id The v2 schema PK on action_changed_provinces was (action_seq, province_id), which assumes at most one ChangedProvince per province per action. That invariant doesn't hold: ActionResultApplierImpl folds through result.changedProvinces sequentially and applies each as a delta, so two entries for the same province in one action are two valid sequential deltas, both applied. The v3 backfill iterated .changedProvinces.foreach and INSERTed each, hitting the PK constraint on the second entry. Confirmed in production logs for games cf27e3e7... and 2798739... Fix: change v3 to drop the v2 action_changed_provinces table and recreate it with PK (action_seq, n), where n is the position within the source ActionResult's changedProvinces vector. province_id stays as an indexed column but no longer constrains uniqueness. Two ChangedProvinces with the same province_id now land as two separate rows with different n values. Why edit v3 instead of adding a new migration: - The games crashing are at schema_version less than 3 (or sitting at v2 with v3 never run). v3 itself crashes before any new migration could fix things. Editing v3 is the only path that lets affected games load. - The CLAUDE.md "migrations are append-only" rule exists to prevent silently changing semantics for games that have already migrated. Here, the affected games never successfully ran v3, so editing it isn't retroactively rewriting their history. - Nothing reads action_changed_provinces yet (4.5h hasn't shipped), so dropping and recreating the table is safe. v2 created it empty for crashing games; the drop is a no-op there. - For games on other deployments that did successfully run the old buggy v3 (no duplicate-province actions in their history), their schema stays with the old shape. They're not currently exercising the bug. A separate v6 migration can drop+recreate for them when 4.5h needs the read path. Out of scope for this hotfix. Changes: - ChangedProvinceTableWriter.schemaStatements now drops the v2 table and recreates it with the new PK shape plus the n column and the changed_province_proto BLOB. CREATE INDEX on province_id recreated (drop took it). - ChangedProvinceTableWriter.write takes (connection, actionSeq, n, cp); INSERT includes n. - backfill iterates changedProvinces.zipWithIndex.foreach and passes n to write. - Regression test: an ActionResult with two ChangedProvinces sharing province_id (id=7, goldDelta 5 and 10) lands as both rows ordered by n. Pre-fix this crashes loaded() with SQLITE_CONSTRAINT_PRIMARYKEY. Verification: - sqlite_history_test - 45/45 pass, including the new regression. - Full Scala suite - 225/225 pass. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
e96a86f1a6 |
Phase 4.5c.2 (read path): ChangedHeroTableReader (#6733)
* Phase 4.5c.2 (red): ChangedHeroTableReader round-trip tests Adds the failing tests for the 4.5c.2 read path. ChangedHeroTableReader is the inverse of ChangedHeroTableWriter — it reconstructs the ChangedHeroT domain values for a given action_seq from the v4 tables (action_changed_heroes plus action_changed_heroes_new_backstory_events). Nothing in production consumes it yet; replayFrom and friends still parse action_results.payload. The reader exists to verify the v4 columns are a faithful round-trip representation (paired with the writer landed in #6730) and to seed 4.5h, which will swap the production read path off the payload blob entirely. Three test cases for the read path contract: - Round-trip a single ChangedHeroC with all three tiers populated (Tier-1 scalars, Tier-2 loyalty/vigor pairs, Tier-3 backstory event) through withNewResults and assert the reader reconstructs the exact same Scala value. - Return Vector.empty for an action with no ChangedHeroes (the vacuous case — currently passes because the stub also returns Vector.empty, kept for the green-side guarantee). - Return multiple ChangedHeroT ordered by hero_id (the writer does not preserve insertion order, so the contract is sort by id). Stub returns Vector.empty so the red signal is a clean assertion mismatch (parallel to the red test in #6730). Two of three cases are RED on this commit. The impl will land as a follow-up commit on the same branch after review. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Phase 4.5c.2: implement ChangedHeroTableReader (turns red tests green) Fills in the stub from the previous commit so the round-trip tests pass. Inverse of ChangedHeroTableWriter: - Pass 1: SELECT every row from action_changed_heroes WHERE action_seq = ? ORDER BY hero_id. Decode Tier-1 columns directly into ChangedHeroC fields. Tier-2 loyalty/vigor pairs decode to StatChange via column-name discrimination (delta column non-NULL means StatDelta, absolute column non-NULL means StatAbsolute, both NULL means StatNoChange, both non-NULL throws — that would be a violated schema invariant). newProfession reverses the writer: column holds the proto enum ordinal, ProfessionProto.fromValue then ProfessionConverter.fromProto. Backstory text id is a nullable String. - Pass 2: for each hero, SELECT event_proto FROM action_changed_heroes_new_backstory_events WHERE action_seq = ? AND hero_id = ? ORDER BY n, parse each blob via EventForHeroBackstoryProto.parseFrom plus EventForHeroBackstoryConverter.fromProto. Reuses one PreparedStatement across all heroes. Two passes (not a JOIN) because xerial sqlite-jdbc dislikes interleaving statements with an open ResultSet, and the per-hero event cardinality is small. The hero ResultSet is drained and closed before any event query runs. Verification: - sqlite_history_test — 44/44 pass, including the three reader cases. - Full Scala suite — 225/225 pass. No regressions. BUILD deps added to the reader target: profession + event_for_hero_- backstory proto targets (for parseFrom and fromValue), the two proto_converters/hero targets (for the converters), and state/unit_- status (transitively referenced via the EventForHeroBackstory converter signature — same as the writer needs). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
a07b679a5a |
Phase 4.5c.2: live ChangedHero decomposition in withNewResults (#6730)
* Phase 4.5c.2 (red): failing test for live ChangedHero decomposition Red-test PR, no production fix — committed for review before the implementation, per the established workflow. 4.5c.1 (#6727) added the v4 schema plus a backfill that decomposes ChangedHero out of *existing* action_results.payload during the migration. It did not wire the decomposition into the live write path: SqliteHistory.withNewResults still writes only the whole-ActionResult payload blob (plus snapshots) and never calls ChangedHeroTableWriter. On a freshly-created game the backfill is a no-op, so action_changed_heroes and action_changed_heroes_new_backstory_events stay empty forever. That gap is what 4.5c.2 closes. This adds one failing test, "4.5c.2 live write path", asserting that a ChangedHero pushed through withNewResults on a fresh DB lands in the v4 tables (tier-1/2 columns plus the tier-3 per-event blob rows) — symmetric to the existing v4 backfill test but via the live path. It fails today on the missing-row assertion (the action_results row is written, the decomposed rows are not). It will go green when withNewResults invokes ChangedHeroTableWriter.write inside the same transaction. Scope notes: - The test bypasses the applier deliberately. InMemoryHistory.fromResults runs ActionResultApplierImpl, which looks up hero 80 in an empty state and throws — not the behavior under test. The awrs is built directly from ActionResultProtoConverter.fromProto plus a trivial resulting state, exactly the shape withNewResults consumes. - That needs two test deps (action_result_applier, action_result_proto_converter) and one additive visibility entry on the proto_converters target for the service test package — the Bazel-recommended, precedented fix (the list already grants other test packages). No production behavior change. - Only the live-write half of 4.5c.2 is pinned here. The read-path reconstruction depends on an API not yet designed and gets its own red assertion when that lands. - Full //src/test/scala/... is intentionally not run: a red-test PR makes it nonzero by design, so it yields no clean signal. The touched service test package was run instead — 18/19 targets green, only the intended new test red. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Address review: build the Scala ActionResult directly, no converter Review feedback: the test built a throwaway proto ActionResult only to run it through ActionResultProtoConverter.fromProto. Construct the domain ChangedHeroC / ActionResultC directly instead and hand withNewResults exactly what it consumes. Net changes vs the first commit: - SqliteHistoryTest: the 4.5c.2 fixture is now ChangedHeroC (loyalty/ vigor as StatDelta/StatAbsolute, newProfession Profession.Mage, one HeroDeparted backstory event) wrapped in ActionResultC. The tier-3 read-back still parses the stored EventForHeroBackstory proto bytes and compares to an expected proto literal — that is verifying the stored format, not converting the input. - Reverts the proto_converters visibility entry from commit 1 (the converter is no longer referenced), so that target is untouched vs origin/main. - Test deps: drop action_result_proto_converter, add the model targets the direct construction needs (action_result_concrete, changed_hero_concrete, action_result/types, the two state/hero targets). action_result_applier stays (ActionResultWithResultingState). - Widens three internal model targets to the service test package (changed_hero_concrete, event_for_hero_backstory_trait, profession). This is the cost of the direct approach over the converter (1 vs 3 visibility entries) and was chosen deliberately on review. Still a red-test PR. The new test fails for the same reason as before (no action_changed_heroes row after a live withNewResults on a fresh DB). Verified: 40/41 in the suite pass, 18/19 service targets green, only the intended new test red. scalafmt + gazelle pre-commit pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Phase 4.5c.2: wire ChangedHeroTableWriter into withNewResults (live path) Implementation that turns the red test in #6730 green. 4.5c.1 (#6727) added the v4 schema + a backfill that decomposed ChangedHero out of existing action_results.payload during the migration, but left the live write path untouched. On a freshly-created game the backfill is a no-op, so action_changed_heroes and action_changed_heroes_new_- backstory_events stayed empty forever. This wires the decomposition into withNewResults so every new action's ChangedHero is written to the v4 tables in the same transaction as the action_results row. Mechanics: - Hoist ActionResultProtoConverter.toProto out of the per-action loop to compute each protoResult once. It is both the stored blob and the source for the decomposed rows, so doing it twice was wasted work. - After the action_results batch executeBatch (so the FK target rows exist), iterate protoResults again and call ChangedHeroTableWriter.write for each ChangedHero. Still inside the outer try, so a failure here rolls back the action_results inserts too — write atomicity preserved. Verification: - //src/test/scala/net/eagle0/eagle/service:sqlite_history_test — 41/41 pass, including the previously-red 4.5c.2 live write path. - //src/test/scala/net/eagle0/eagle/service/... — 19/19 targets green. - //src/test/scala/... — 225/225 green. No regressions. Read path is still the proto blob (replayFrom etc. unchanged). The decomposed-table read path comes later in 4.5c.2 or in 4.5h when action_results.payload is dropped. This PR is stacked on #6730 (the red test). Merge #6730 first, then this PR will rebase cleanly onto main. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Address review: writer takes Scala domain types, not proto The 4.5c.1 writer took ChangedHeroProto because its only caller was the backfill (whose source is proto bytes parsed from existing payloads). With the 4.5c.2 live caller in place, that interface is wrong — the live path has Scala domain types and was being forced to hand them through a proto round-trip that the writer then re-pattern- matched. Refactors the writer to take the domain type and converts only at the actual storage boundaries. Writer changes (ChangedHeroTableWriter): - write(connection, actionSeq, ChangedHeroT) instead of write(connection, actionSeq, ChangedHeroProto). Pattern-matches on ChangedHeroC (sole concrete impl). - Tier-1/2 columns read Scala fields directly. Loyalty/vigor branches match on StatChange (StatDelta / StatAbsolute / StatNoChange) instead of the proto Loyalty.LoyaltyDelta / Vigor.VigorAbsolute / Empty cases. - Tier-3 is the only proto edge: each EventForHeroBackstory is converted via EventForHeroBackstoryConverter.toProto(event).toByteArray inside the per-event INSERT loop, where the bytes actually go into the BLOB column. - Profession stored as the proto enum ordinal via ProfessionConverter.toProto(p).value (None means no change means NULL), symmetric with ChangedHeroConverter. - backfill stays the entry point for migrating existing payloads: parses ActionResult proto, converts each ChangedHero to ChangedHeroC via ChangedHeroConverter.fromProto, then hands it to the shared write. Live and backfill share one code path. Caller changes (SqliteHistory.withNewResults): - Drops the protoResults hoist that the previous impl introduced. payload is back to a single inline ActionResultProtoConverter.toProto in the action_results insert loop — exactly the pre-4.5c.2 shape. - Decomposition loop iterates newResults.zipWithIndex and passes awrs.actionResult.changedHeroes (Vector[ChangedHeroT]) straight to the writer. No proto conversion in this loop at all. The whole-action ActionResultProtoConverter.toProto at line 83 stays, because the action_results.payload BLOB column is still proto bytes until 4.5h drops it. That conversion is no longer load-bearing for the decomposed writes though. BUILD: - changed_hero_table_writer drops common:profession_scala_proto (the ProfessionConverter handles it), adds the model concrete and converter targets it now references (changed_hero_concrete, changed_hero_converter, hero:event_for_hero_backstory, hero:profession), plus eagle_pkg (HeroId / ClientTextId aliases) and state/unit_status (transitively referenced through the EventForHeroBackstory converter signature). - Three additive visibility entries on production targets, exposing them to the production service package: changed_hero_concrete, hero:event_for_hero_backstory, and changed_hero_converter all gain //src/main/scala/net/eagle0/eagle/service:__pkg__. This is the cost of the domain-types-at-the-edge approach and was chosen on review. Test fixture fix: - The v4 backfill test fixture now sets a date on each backstory event. Real EventForHeroBackstory protos always carry a date (the converter requires it). The previous fixture got away with date-less events because the old writer was a direct proto-bytes passthrough and never went through the converter. The new backfill does, so the fixture now matches what real payloads look like in the wild. Verification: - //src/test/scala/net/eagle0/eagle/service:sqlite_history_test — 41/41 pass, including both v4 backfill tests and the 4.5c.2 live write path. - //src/test/scala/... — 225/225 pass. No regressions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
c27715904c |
Design doc: record refined option B (ChangedHero 4.5c.1 reality) (#6729)
The 4.5c.1 PR (#6727) shipped a refinement of option B for ChangedHero that the design doc never recorded, so the doc and the merged code diverged. What actually merged: loyalty/vigor StatChange oneofs as typed nullable column pairs (column name discriminates), and newBackstoryEvents as per-event EventForHeroBackstory blobs in a child table. No whole-entity changed_hero_proto blob. changed_hero.proto and ChangedHeroConverter become deletable, only event_for_hero_backstory.proto is retained as a leaf blob format. Reconciles the doc with that reality: - New authoritative "Refinement" subsection under Status defining the three-tier pattern (scalar columns, typed columns for sealed value-oneofs, per-row leaf-proto blobs in child tables) and the per-entity classification all-scalar / refined / deep. - Scope, "Deleting the proto", ChangedHero section, Decisions #1-#3, and Sequencing rows 4.5c and 4.5i updated to match. 4.5c is split into 4.5c.1 (merged) and 4.5c.2 (live write/read path, pending). - 4.5i is no longer "near-nothing": refined entities' protos and converters get deleted there, scope scales with how many entities went refined. Documentation only. No code or schema change. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
7830c42cd2 |
CLAUDE.md: forbid cd-prefixed commands; fix contradictory git rule (#6728)
Adds a no-`cd` rule to the CRITICAL BASH RULES section: run git/gh/bazel directly from the cwd (repo/workspace discovery walks up from a subdir; the shell resets cwd after every command so a cd never persists; and `cd <dir> && <cmd>` also trips the no-chaining rule and forces an approval prompt every time). Also fixes the no-`git -C` rule, which previously said "Instead, cd to the repo root" — directly contradicting the new rule. It now points at the no-`cd` rule instead. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
f4ef9d83c4 |
Phase 4.5c.1: ChangedHero scalar columns + proto blob (option B) (#6727)
* Phase 4.5c.1: ChangedHero scalar columns + proto blob (option B) Same pattern as 4.5b/#6725, applied to ChangedHero. v4 migration: ALTER TABLE action_changed_heroes ADD COLUMN changed_hero_proto BLOB + a one-time backfill over existing action_results.payload that populates the 4.5a scalar columns (faction change, XP/stat deltas, profession, backstory text id, clear-events flag) and the full proto blob. Inside the migration transaction; no-op on fresh games. ChangedHero is shallow: only the sealed loyalty/vigor StatChange oneofs and the newBackstoryEvents vector are non-scalar, and those stay in the blob (the design doc flagged per-stat StatChange columns as overkill — option B blobs them). new_profession stores the proto enum ordinal, UNKNOWN_PROFESSION -> NULL, mirroring ChangedHeroConverter and the improvement_type/action_result_type precedent. Scalars are query denormalization; the blob is authoritative. Not in this PR (4.5c.2): live withNewResults write path + read path. ~95-line writer, mirrors ChangedProvinceTableWriter. New changed_hero_table_writer Bazel target. Tests: scalar promotion + ChangedHero.parseFrom(blob) == original (covers the loyalty/vigor oneofs that live only in the blob) + minimal-hero NULL case. Full Scala suite green (225/225). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Phase 4.5c.1: refined option B for ChangedHero — full columns + per-event blobs Implements the two refinements suggested on #6727: - Tier 2: store the loyalty/vigor StatChange oneofs as two nullable REAL columns each (loyalty_delta/loyalty_absolute, vigor_delta/vigor_absolute). The column name is the discriminator; at most one of each pair is non-NULL; both NULL ⇒ StatNoChange. No kind column. - Tier 3: keep the EventForHeroBackstory proto and store just those bytes, one row per event, in action_changed_heroes_new_backstory_events. ChangedHero now has no whole-entity blob: it round-trips losslessly from columns + per-event blobs, so changed_hero.proto/ChangedHeroConverter become deletable later while event_for_hero_backstory.proto is retained as the blob format. Renames the v4 migration to applyV4ChangedHeroColumns and rewrites the v4 tests for the new schema (loyalty/vigor columns + events child table). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
f76d4b785c |
Design doc: record option B + re-plan 4.5c-i per-entity (#6726)
ChangedProvince shipped via option B (scalars + proto blob, #6725), not the full-relational "decision locked" plan. Update the doc so it stops contradicting what's on main and so 4.5c-i has a coherent plan: - New "Status — option B adopted" section at the top; it supersedes the body, which is kept as historical context / the migration target if a structured-query need on deep aggregates ever appears. - Rescind "Decisions locked #1" (fully relational, no blob fallback); rewrite the locked decisions around option B + per-entity blob choice. - Re-plan the Sequencing table per-entity: each gets 4.5a scalars + an <entity>_proto BLOB, split .1 (schema+backfill) / .2 (live+read). Estimate drops from ~3-4 weeks to ~1. - Revise principle 7 (proto blob is the retained authoritative store), the Scope (per-entity protos are NOT deleted), and "Deleting the proto" (superseded). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
7d6961fc86 |
Phase 4.5b (option B): ChangedProvince scalar columns + proto blob (#6725)
Alternative to PR #6721. Applies the design doc's "stopping rule" consistently: instead of exploding ChangedProvince's ~25 nested aggregates into ~37 child tables, keep the v2 top-level action_changed_provinces table (per-(action,province) scalar deltas + the province_id index — the part anyone actually queries) and store the full ChangedProvince proto in one changed_province_proto BLOB for lossless round-trip / replay. This is the same reasoning the doc already applies to Quest, applied to the deep army/unit/hero nesting that is likewise never analytics- queried. ~110 lines of writer vs ~1100 in the full-relational approach. The scalar columns are pure query denormalization; the blob is authoritative. A future fully-relational pass (if a structured-query need on the deep aggregates ever materializes) is a pure read-blob -> write-columns migration — which is why the blob must survive any later proto deletion. This deliberately re-opens the doc's "new-entity vectors are fully relational, no blob fallback" lock; the quest hybrid is the precedent. Not in this PR (would be 4.5b.2): the live withNewResults write path and the read path. Nothing reads these columns yet. v3 = ALTER TABLE action_changed_provinces ADD COLUMN changed_province_proto BLOB + a one-time backfill over existing action_results.payload (scalars + blob), inside the migration transaction; a no-op on freshly-created games. Tests: a ChangedProvince with a genuinely nested aggregate (defending army -> combat unit) + a repeated field, asserting scalar promotion and that ChangedProvince.parseFrom(blob) == original (proves the deep structure round-trips), plus that no child tables are created. Full Scala suite green (225/225). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
9b6cbe6455 |
Fix tutorial chronicle crash: tutorial creation must use SqliteClientTextStore (#6724)
* Red test: tutorial game text store diverges from reload path createTutorialGameForUser builds its ClientTextStore as the legacy file-based ClientTextStoreImpl (persists to completeText.txt), but every reload reconstructs the store from text_store.db via SqliteClientTextStore.loaded. Texts generated during a tutorial session (e.g. yearly chronicle updates) are therefore unrecoverable after a save+reload, surfacing in production as: EagleInternalException: Unknown dependency for LLM request chronicle_update_373_11: chronicle_update_371_11 This is a failing regression test only — no fix yet. It creates a tutorial game, persists a chronicle text through the game's live store, then reads it back via the production reload path and asserts it resolves. Fails today (TextGenerationDependencyUnknown). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fix: tutorial creation uses SqliteClientTextStore (text_store.db) createTutorialGameForUser built its ClientTextStore as the legacy file-based ClientTextStoreImpl (completeText.txt), while every reload reconstructs the store from text_store.db via SqliteClientTextStore.loaded and normal game creation already uses SqliteClientTextStore. Texts generated during a tutorial session (e.g. yearly chronicle updates) were written to completeText.txt and lost on the first save+reload, causing: EagleInternalException: Unknown dependency for LLM request chronicle_update_373_11: chronicle_update_371_11 Switch tutorial creation to SqliteClientTextStore.createWithData, matching the normal-creation path, so create and reload agree on text_store.db. Behavior-preserving otherwise (unrequestedTexts/accessibleTo were already empty here). Turns the regression test from the previous commit green; full Scala suite (225 tests) passes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
9eb90c2cb4 |
Vary maxWindMph per map by terrain composition (#6723)
All maps shipped with maxWindMph: 25 for every month, giving them no geographic windiness identity. This sets maxWindMph based on each map's terrain mix — open terrain (plains, still water) pushes the ceiling up, dense terrain (forest, swamp) pulls it down — with a small additional bump on winter months (proxied by snow/blizzard chance). Spread across maps now ranges roughly 20–29 mph (median 24). Forest-heavy maps like Nikemi land at 20–22; open-water maps like Chia and field maps like MeetThemInTheField land at 27–29. Note: the existing exploding-roll mechanic in NewWeather amplifies the tail proportionally, so a 28 mph cap produces ~55 mph outliers where a 22 mph cap produces ~44 mph outliers (same ~5% explosion frequency). That's intended texture but worth flagging if any of the downstream wind consumers want a tighter cap. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
48a44e126a |
Couple wind speed to weather conditions (#6722)
Wind speed was previously a uniform-then-exploding roll against a per-map maxWindMph, with no awareness of the rolled weather conditions. Stormy days were as likely to be calm as sunny days. This adds six per-condition multipliers (settings.tsv) applied on top of the existing wind-speed formula. Defaults: 1.0× for sun/clouds, 1.2× for rain/snow, 1.5× for thunderstorms, 1.8× for blizzards. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
b8605a3662 |
Fix free-hero backstory visibility (placement hook + load migration) (#6719)
* Red test: Traveler move does not extend backstory visibility to dest ruler
Reproduces the [BACKSTORY-VISIBILITY] production diagnostic. When a Traveler
unaffiliated hero with a backstory moves into a province, the destination
province's ruling faction should gain visibility of the hero's backstory text
(as ProvinceConqueredAction and TravelCommand already do for their cases).
PerformUnaffiliatedHeroesAction.heroMovedResult builds the HeroMoved result
with no clientTextVisibilityExtensions, so this test FAILS until the fix:
Vector() did not contain element
ClientTextVisibilityExtensionC("Leah_backstory 0", Vector(2))
The move + relocation sanity assertions pass; only the visibility assertion
fails, isolating the root cause. Committed test-only for review before the fix.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Red test: stationary free hero's ruler change does not extend backstory visibility
Adds the broader case behind the [BACKSTORY-VISIBILITY] diagnostic: a free
hero with a backstory sitting still in a province whose current ruler is not
in the backstory's visibility set. Conquest already extends visibility via
ProvinceConqueredAction, but nothing heals a stationary hero whose ruler
changed by other means (faction split, vassal independence, inheritance).
The per-round unaffiliated-hero phase emits no clientTextVisibilityExtensions
for this case, so the test FAILS until the fix:
Vector() did not contain element
ClientTextVisibilityExtensionC("Leah_backstory 0", Vector(2))
14 pre-existing tests still pass; the 2 new red tests isolate the two gaps
(Traveler move-in, and stationary ruler-change). Test-only for review.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Fix: extend free-hero backstory visibility to current province ruler
Two complementary fixes for the [BACKSTORY-VISIBILITY] diagnostic; turns the
two red tests green (16/16 in the class, full Scala suite 224/224).
1. heroMovedResult: the HeroMoved ActionResult now emits a
ClientTextVisibilityExtensionC for the destination province's ruling
faction (guarded by backstoryVersions.nonEmpty, since backstoryTextId
throws on empty). Mirrors ProvinceConqueredAction and TravelCommand,
keeping visibility extension atomic with the move event.
2. EndUnaffiliatedHeroActionsPhaseAction: a per-round self-healing sweep
emits visibility extensions for every free hero with a backstory to its
province's current ruler. Covers ruler changes by non-conquest means
(faction split, vassal independence, inheritance) where the hero is
stationary, and retroactively heals already-stuck heroes. Visibility is
additive and de-duplicated downstream, so re-emitting each round is safe;
the engine has no visibility state to dedupe against, so this redundancy
is the deliberate tradeoff.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Redesign: fix backstory visibility at placement + one-time load migration
Replaces the per-round sweep with the true root-cause fix. Pool heroes arrive
with a pre-authored backstory and previously had NO visibility set at
appearance (the all-faction request fires only for spawned heroes with an
empty backstory), so they were legible only to whatever faction a later narrow
event happened to extend to.
- UnaffiliatedHeroAppearedAction: for the pre-existing-backstory branch, emit
a ClientTextVisibilityExtensionC to all factions, matching the spawn-time
policy. This is the missing event-driven hook.
- Reverted the EndUnaffiliatedHeroActionsPhaseAction per-round sweep: the only
engine path that changes a province's ruler is ProvinceConqueredAction
(already handled), so no perpetual sweep is needed for correctness.
- heroMovedResult fix (Fix 1) retained: extension stays atomic with the move.
- FreeHeroBackstoryVisibilityRecovery: one-time on-load repair (mirrors
MissingHeroNameRecovery) widening any free-hero backstory not visible to its
current province ruler. No-op for healthy games; heals already-stuck heroes
like prod hero 184. Wired into GamesManager.gameController load path.
Tests: placement-hook tests in UnaffiliatedHeroAppearedActionTest, migration
tests in new FreeHeroBackstoryVisibilityRecoveryTest, Test 1 (move-in) kept,
obsolete sweep test removed. Full Scala suite 225/225.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
2d3779448c |
Draw faction-target province highlights as a single grouped outline (#6720)
When the Diplomacy command selector targets a faction, MapController was drawing TargetedProvinces with per-province borders, so each of the faction's provinces got its own outline (including the internal borders between adjacent same-faction provinces). The visual the user wanted is the unified outline used by notifications and popups: one boundary around the faction's territory as a whole. The mechanism already exists — ProvinceBorderManager.SetHighlightGroup fills a per-province lookup texture that the shader consults to suppress borders between provinces in the same group, and OverrideTargetedProvinces uses it. TargetedProvinces was calling ClearHighlightGroup unconditionally. Add MapController.TargetedProvincesAsGroup. When true, the no-override branch calls SetHighlightGroup(TargetedProvinces) before the existing per-province strobe/border loop, producing the unified outline. EagleGameController.Update sets the flag to selector.TargetedFactionIds.Count > 0 alongside the existing mapController.TargetedProvinces assignment, so it stays in sync with the selector each frame. DiplomacyCommandSelector itself needs no change — its TargetedFactionIds is already non-empty when a target faction is picked, and the base CommandSelector.TargetedProvinces getter already expands a non-empty TargetedFactionIds into the faction's provinces. Other selectors (AttackDecision, BattleAftermath) don't populate TargetedFactionIds and so render per-province as before. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
7eb5894a57 |
Phase 4.5a: schema migration scaffolding + changed-entity top-level tables (#6716)
Introduces an append-only migration system for SqliteHistory and adds the four "changed entity" top-level tables (scalar columns only). No write or read paths target these tables yet — they're scaffolding for the per-aggregate decomposition that lands across Phase 4.5b–e. See docs/SQLITE_ACTION_RESULT_DECOMPOSITION.md. Migration runner - `SqliteHistory.Migrations` holds a Vector[Migration(version, apply)], append-only. Versions must be strictly ascending; new schema work is always a *new* migration, never an edit of an existing one. - `applyPending` reads `metadata.schema_version`, runs every migration with a higher version, bumps the stored version after each — the whole sequence inside a single transaction so a partial failure rolls back to the pre-migration version. - `metadata.value` is now TEXT (was BLOB) to hold the schema_version as a stringified int. The column had no readers before this PR. v1 — baseline The existing tables (action_results, state_snapshots, shardok_*), unchanged, repackaged into the migration runner. Idempotent (CREATE TABLE IF NOT EXISTS) so any pre-migration DB that already has these tables ends up at v1 without an error. v2 — changed-entity top-level tables - action_changed_provinces (PK action_seq + province_id) with the scalar fields of ChangedProvinceC. - action_changed_heroes (PK action_seq + hero_id) with the scalar fields of ChangedHeroC. StatChange fields (loyalty/vigor) and newEventsForHeroBackstory are deferred — they belong in child tables per the design doc. - action_changed_factions (PK action_seq + faction_id) with the scalar fields of ChangedFactionC. - action_changed_battalions (PK action_seq + battalion_id only). ChangedBattalionC has no scalar fields at the top level — its sole field is the contained BattalionT, which gets its own decomposition in Phase 4.5e. - Per-entity-id indexes on each of the four tables so cross-game queries like "all actions touching hero X" are single-index lookups. - FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE on each, so truncateTo only needs to drop from action_results and the rest cascades. Tests - Fresh DB ends up at `latestSchemaVersion`. - All four new tables exist after create. - Re-loading is a no-op (no migrations re-run, no schema_version change). - A simulated pre-v2 DB (v1 baseline + schema_version='1') upgrades to the latest version and gains the new tables on load. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
e9f10ee540 |
Phase 4 fix: wire SqliteHistory through the cloud persister (#6718)
Phase 4's cutover wrote game.db only to local disk. PersistedHistory had
shipped every chunk through the Persister, so .e0a files landed in S3
(DigitalOcean Spaces in prod) automatically; SqliteHistory dropped that
because the dbFile-based factories never accepted a Persister.
Result: a Hetzner host crash would lose every active game; only
text_store.db survived in cloud storage. This restores parity with the
SQLITE_HISTORY_DESIGN.md plan (lines 43, 244–245, 271).
Mechanism
- `SqliteHistory` constructor now takes `dbFile` + `Option[Persister]`.
After each successful `withNewResults` commit, it calls
`maybeUploadToPersister(previousCount)`, which uploads the whole
game.db blob when either (a) the batch crossed at least one 25-action
snapshot boundary or (b) more than 60s has elapsed since the last
upload.
- Before reading bytes off disk, it issues
`PRAGMA wal_checkpoint(TRUNCATE)` so the uploaded file is a
self-consistent snapshot — without the checkpoint the upload would
miss whatever's still in game.db-wal.
- Upload failures are caught + logged so a transient cloud-storage
outage does not break local game progress.
- `AsyncS3Persister` (the production wiring) already coalesces saves for
the same key on a 500ms tick and has a JVM shutdown-hook flush, so
uploading more often than that costs at most a memcopy.
Load path
- New `SqliteHistory.loadedOrDownloaded(dbFile, persister)` first tries
the local file, otherwise tries `persister.retrieveAsStream("game.db")`
and writes it locally. Returns `None` when both are absent (the game
simply does not exist).
- `GamesManager.gameHistoryFromFiles` uses this so a wiped local saves
directory recovers games from cloud storage on next boot.
Plumbing
- `NewGameCreation.createEngine` and
`TutorialGameCreation.createTutorialGame` pass their `persister` to
`SqliteHistory.createWithScalaResults` (un-`@annotation.unused`).
- BUILD: sqlite_history depends on the persister target; the
SqliteHistoryTest target depends on it as well.
Tests
- An in-memory `FakePersister` records save calls.
- `withNewResults` triggers an upload at the 25-action boundary but not
before; a single batch spanning a boundary also triggers exactly one.
- No persister configured → no observable behavior change.
- `loadedOrDownloaded` opens the local file when present; downloads from
the persister when the local file is missing; returns `None` when
both are absent.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
58030a56a7 |
Split Eagle server fat JAR into deps + app Docker layers (#6717)
The Docker Build and Push workflow spends more time pushing than building because the entire ~115MB Eagle classpath ships as one pkg_tar layer, so every Scala commit re-uploads the whole blob to DOCR. Add a jar_split rule that partitions the scala_binary's transitive runtime jars by workspace into a stable third-party layer (76MB, 137 jars) and a first-party layer (38MB, 989 jars). crane skips the unchanged deps blob, so typical Scala-only commits re-upload ~38MB instead of ~115MB. Entrypoint switches from -jar fat.jar to a -cp classpath glob over both layer dirs. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
3a45347549 |
Phase 4: cutover game creation from PersistedHistory to SqliteHistory (#6715)
GamesManager, NewGameCreation, and TutorialGameCreation now open per-game game.db SQLite files via SqliteHistory instead of chunked .e0a blobs via PersistedHistory. PersistedHistory remains in the tree so the existing unit tests still build; it is no longer reachable from the running server. - SqliteHistory.createWithScalaResults / emptyStartingState convenience constructors mirror the PersistedHistory new-game shape (raw scala ActionResultTs folded through the applier). - GamesManager.gameHistoryFromFiles looks up saveDirectoryForGame(gameId)/game.db and opens it with SqliteHistory.loaded; the persister is now unused on that path. - TutorialGameCreationTest's "successfully creates" case forces the lazy val instead of calling createTutorialGame a second time with the same gameId — duplicate creates would collide on snapshot PK. - Tests that exercise game creation point eagle.save.dir at a temp dir so the bazel sandbox can write game.db. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
660d349e50 |
Design doc: full decomposition of ActionResult into relational tables (#6714)
* Design doc: full decomposition of ActionResult into relational tables Follow-up to docs/SQLITE_HISTORY_DESIGN.md. Plans replacing the proto-blob action_results.payload with native relational tables for every field of ActionResultC, recursively. Key facts that motivate full decomposition (vs blob middle ground): - ActionResult-family protos are storage-only (one file imports them, ChangedProvinceConverter; no RPC consumers). - ActionResultC has no top-level polymorphism — 34 flat fields with no sealed-trait discriminator at the action level. - Pre-alpha is the right window: we can nuke save dirs as part of the rollout, and we never have to migrate real user data. Doc covers principles, top-level schema, the four changed-entity families (~25 tables each under the ChangedProvince family), notifications and generated-text-requests, the "new entity" vectors with a fallback flag, schema migration scaffolding (now load-bearing), read/write/replay path changes, and a 9-step sub-phase plan (4.5a–4.5i) starting after the Phase 4 cutover lands. Three open questions for sign-off at the bottom. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Lock in the three open questions - "New entity" vectors get full relational decomposition (no blob fallback). - Sub-phase ordering stays as proposed. - Phase 4 cutover lands separately, before 4.5a starts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
57c2862a57 |
Phase 3: SqliteHistory shardok integration (#6713)
All seven FullGameHistory shardok methods land, plus truncateTo cleanup of orphaned battles. Pure foundation — no production caller yet. Schema: - shardok_results: (shardok_game_id, action_seq) -> ShardokActionResult bytes - shardok_state: shardok_game_id -> (game_state bytes, last_eagle_round_id) - shardok_player_results: (shardok_game_id, faction_id, seq) -> ActionResultView bytes - shardok_player_commands: (shardok_game_id, faction_id) -> AvailableCommands bytes (nullable) Behavioral semantics mirror PersistedHistory: - shardokPlayerCount / shardokPlayerResultsSince fall back to faction -1 if the requested faction has no registered presence. - shardokPlayerAvailableCommands does NOT fall back to -1 (exact match only). - withNewShardokResults: append results and per-faction views, upsert per-faction commands (replacing previous value, NULL if not in the new availableCommands), upsert per-battle game_state. Touched factions = old participants union newPlayerResults factions. - withResetShardokResults: drop all rows for the given battle. truncateTo now uses stateAfter(target).outstandingBattles to determine which shardok rows to preserve; rows for any other battle are deleted in the same transaction. Mirrors deleteOrphanedShardokFiles. 8 new tests covering each method plus the full battle reset case. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
3923ae8c11 |
Phase 2: SqliteHistory read paths (all / since / sinceDate / recentResultsForRound) (#6712)
All four trait methods land via a single replayFrom helper: find the latest snapshot at or before the emit boundary, replay forward to the end of the range, emit ActionResultWithResultingState only for action_seq >= emitFrom. - all: replayFrom(0, count) - since: clamp start to [0, count], replayFrom(start, count) - sinceDate: SQL SELECT MIN(action_seq) WHERE date_year IS NOT NULL AND (date_year, date_month) >= (?, ?), then replayFrom(thatSeq, count). Matches PersistedHistory semantics: actions with NULL date are filtered out (they're pre-game-start; once currentDate is Some it stays Some). - recentResultsForRound: SQL SELECT MIN/MAX(action_seq) WHERE round_id = ?, replay that contiguous range, filter by the Scala predicate. stateAfter is refactored to share the new readLatestSnapshotAtOrBefore helper. Behavior unchanged. Tests cover all four methods including snapshot-boundary crossings and out-of-range clamping. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
d0e39bfc02 |
Phase 1: SqliteHistory skeleton — schema, write paths, stateAfter (#6711)
Adds SqliteHistory implementing FullGameHistory. Phase 1 scope: - Schema (action_results, state_snapshots, metadata) with WAL, NORMAL synchronous, FK pragmas at connection init. - Lifecycle: create(dbFile, startingState), createWithResults(...), loaded(dbFile), close(). - Write paths: withNewResults (transactional batch insert + snapshot at every 25-action boundary), truncateTo, saveNow (no-op). - count cached in-memory, refreshed from DB on construction. - last and stateAfter implemented via snapshot+replay (latest snapshot <= N, replay forward via ActionResultApplier). - Other read paths (all, since, sinceDate, recentResultsForRound) and all shardok methods throw NotImplementedError with phase markers; filled in in Phase 2 and 3. Tests cover: empty create, withNewResults sequencing, snapshot writes at the 25-boundary, stateAfter at and between boundaries, truncateTo, saveNow no-op semantics, reload-and-continue. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
78ed12052b |
Phase 0: SQLite game history design doc (#6710)
* Phase 0: SQLite game history design doc Per-game game.db replaces .e0a / .e0s / .e0i file-based persistence. SqliteHistory implements the existing FullGameHistory trait so the cutover at the call site is a single constructor swap. Schema, connection lifecycle, read/write paths, snapshot strategy, shardok results layout, migration trigger, rollback policy, and four open questions called out for sign-off before Phase 1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Phase 0: fold in user feedback — drop migration, separate DBs rationale - Remove the migration importer entirely. At cutover we nuke save directories one time; pre-alpha trade-off accepted explicitly. - Add the writer-lock-contention rationale for keeping game.db and text_store.db as separate files instead of one combined DB. - Lock in the four open questions with the agreed defaults. - Note the GameAdminServiceImpl.getActionDetail follow-up to move off history.all; not blocking. - Phase plan drops from 5-6 weeks to 3-4 weeks with migration gone. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
abdb91b8d9 |
Add direct domain ↔ Common-proto enum converters (#6709)
CommonConverters and ShardokInterfaceGrpcClient.fromCommon no longer round-trip through the Eagle proto enums to harvest .value: Int just to feed CommonProfession.fromValue / CommonBattalionTypeId.fromValue. New CommonEnumConverters defines CommonProfessionConverter and CommonBattalionTypeIdConverter with toCommon / fromCommon. The shardok interface boundary now only touches domain types and the Common proto it's actually emitting; the Eagle proto enums are out of this path. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
2debfe546c |
Drop proto conversion in ShardokInterfaceGrpcClient.resolveBattle (#6708)
EagleUnit now holds domain HeroT and Option[BattalionT], and ExpandUnit takes the domain GameState. That removes the per-battle GameStateConverter.toProto call and the dual-arg playerInfo signature. CommonConverters.toCommon reads domain fields and routes Profession / BattalionTypeId through the existing proto converters to keep the Common* enum values stable. fromCommon now builds HeroC / BattalionC directly instead of building proto types only to convert them back to domain at the call site. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
5d81972093 |
Use domain GameState in ShardokInterfaceProxy.resolveBattle (#6707)
* Use domain GameState in ShardokInterfaceProxy.resolveBattle The Shardok gRPC client used to take a proto GameState directly, which forced GamesManager to convert domain → proto right before the call and forced the client to inline its own proto-relationship-level filtering. Now the trait accepts the domain GameState; the gRPC client uses FactionUtils.alliedFactions for the watcher derivation and reads the relationship enum via the domain Hostile case object. Proto conversion still happens once inside the client where the unit- construction helpers (toCommon, EagleUnit.ExpandUnit) require it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Use FactionUtils.nonHostileFactions and flag the dual-arg as TODO Add a nonHostileFactions helper to FactionUtils paralleling alliedFactions, covering Ally + Truce (and defensively Unknown). playerInfo now uses it instead of the inline relationshipLevel != Hostile filter. Add a TODO at the GameStateConverter.toProto call inside resolveBattle so the dual-arg playerInfo signature (domain + proto) doesn't calcify -- it's a transition state until toCommon and EagleUnit.ExpandUnit are moved to take the domain GameState. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
c8aa26058b |
Send watcher faction IDs in Shardok NewGameRequest (#6705)
Populates the watcher_eagle_faction_ids field added in the prior Shardok-side change. The watcher set is the union of each participant's non-hostile faction relationships, minus the participants themselves. Once both this and the Shardok-side change are deployed, factions allied to a battle's participants but not themselves participating receive filtered views that include hero stats and hidden actions for their allies' units. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
386d99d553 |
Cache parsed games.e0es to skip per-lobby-tick re-parse (#6706)
* Cache parsed games.e0es to skip per-lobby-tick re-parse
games.e0es was being read from disk and parsed by six call sites:
ensureGameLoaded, gamesFor, gamesForWithoutBlocking, loadGamesInBackground,
save (for the merge), and getAllRunningGamesSummary. Combined with the
EagleServiceImpl path that fans lobby updates out to every connected user
on every game-affecting action, this produced repeated "Read N ongoing
games from games.e0es" log lines per lobby tick and a parse-and-allocate
churn that grew with both lobby user count and games count.
The freshness justification ("called fresh each time to handle race
conditions during blue-green deployment") is already covered by the
existing flush-marker invalidation: when the active server flushes
state, the new server detects the flush-marker mod-time bump and clears
its caches.
Add an in-process cache (cachedRunningGames) populated lazily by the
read paths and refreshed at the end of save() from the just-written
state. The flush-marker invalidation path also clears the cache so
blue-green semantics are preserved.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* Use Option.getOrElse instead of match in runningGamesFromStorage
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
b3d99c6f85 |
Generate ally-aware Shardok views for non-participant watchers (#6704)
When a faction watches an ally's battle, the Eagle layer falls back to Shardok's generic observer (factionId == -1) view because Shardok only emits filtered views for battle participants. The observer view treats every unit as an enemy, so visibility is knowledge-gated: battalion stats (threshold 50) usually creep above the floor while hero stats (threshold 75) lag behind, leaving watchers with battalion data but no hero stats for their own allies' heroes. This change adds a watcher_eagle_faction_ids field to NewGameRequest and has Shardok emit one filtered view per watcher in GetUpdates. Each watcher's view treats their allied participants' units as fully visible (hero stats, hidden actions like meteor casts, hidden-target coords) while non-allied units fall back to observer-style knowledge gating. The watcher path is implemented via new GameStateFilteredForPlayerWithAllies and ActionResultFilteredForPlayerWithAllies overloads that take alliedPids explicitly, plus a new ShardokEngine::FilterNewResultsForWatcher that drives them. The IsHiddenFromOpponents predicate now treats the actor's allies as self for visibility purposes. The Eagle side that populates watcher_eagle_faction_ids is in a follow-up PR; this change is a no-op until that lands. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
afb143645a |
AI: imprison the messenger when accepting a truce would box us in (#6702)
If the AI's only neighbor is the proposing faction (every one of our
provinces' neighbors is owned by us or them), accepting a truce locks
us out of attacking the only faction we could expand into for the
duration of the truce. The existing alliance-acceptance guard already
catches this for alliances; truces had no such guard and the AI would
happily accept and stagnate.
When boxed-in:
- choose Imprisoned if the offer's eligible statuses include it
(which requires us to have at least one province, per
EligibleDiplomacyStatuses.maybeImprisonStatus)
- otherwise fall back to Rejected.
Factor the boxed-in predicate into a shared helper
(acceptingWouldBoxUsIn) and have allianceOfferAcceptanceChance use it
so the two paths stay in lockstep.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
ba160b9669 |
AI: break alliance with weakest bordering ally when boxed in (#6700)
When the midgame AI has no neutral or hostile neighbors anywhere on its border, it has no path to growth and gets stuck in the chosenNoNeutralNeighborsCommand fallback resting and improving forever. The upstream allianceOfferAcceptanceChance guard refuses alliances that would immediately box us in, but that state can still emerge later -- e.g., a shared hostile neighbor gets conquered by our ally. Add a chooser that, in that state, finds the weakest bordering ally (measured by province count, since post-truce conquest is the goal) and emits a DiplomacyAvailable BreakAlliance command targeting them. Inserted just before chosenNoNeutralNeighborsCommand so the recovery path runs ahead of the catch-all rest/improve. Truces are intentionally not treated as boxing-in: they expire, and breaking would burn trust unnecessarily -- waiting them out is cheaper. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
5664f15dac |
Push locally-resolved hero names to connected clients (#6698)
Hero names from the local heroNameCache (and FixedHeroName text) are resolved synchronously inside UnrequestedTextHandler and stored as complete in the ClientTextStore -- but unlike LLM-generated text, they never flow through the streaming pipeline that pushes updates to connected clients. Mid-session UIs sit on the "Hero" fallback until the user reconnects (which triggers the on-subscribe bulk-send of every accessible complete text). Surface the resolved CompleteClientTexts from clientTextStoreWithHandledUnrequestedTexts via a new HandledUnrequestedTextsResult, and have GamesManager push them through GameController.withResolvedTextsPushedToClients (which routes through the existing humanClientsAfterUpdatingLlmStream path used for LLM streaming responses). Initial-load callers continue to ignore the list since the on-subscribe bulk-send already covers that case. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
8009ed62d2 |
Auto-fire End Turn after AI action when next player has no options (#6699)
The AI thread's post path didn't run the only-one-option auto-loop, so when an AI player ended their turn and control passed to a player whose only available command was End Turn (e.g., a player with one stunned unit), that player had to click End Turn manually. The human-driven PostCommand path already called PostWhileCurrentPlayerHasOnlyOneOption. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
8abd355883 |
Suppress FMOD output-switch error in ErrorHandler (#6696)
Unity's internal FMOD wrapper logs an Error whenever the macOS audio output device changes (headphones plug/unplug, AirPods, system output switch): the wrapper tries to call setOutput on FMOD after System::init and FMOD rejects with "Cannot call this command after System::init" (error 32). The audio continues to play on whichever device was active at app launch; nothing actionable for us, but the message was reaching ErrorHandler and surfacing the in-game error panel. Filter the specific message in ErrorHandler.HandleLog. Match both halves of the string so a genuine FMOD failure (different cause) still surfaces. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
fb8408fd64 |
Gate Observe battle button on ShardokGameModel readiness (#6697)
ShardokBattles is populated when a NewBattle eagle update arrives, but the matching ShardokGameModel isn't created until the first ShardokActionResultResponse for that battle reaches the client (in EagleGameModel.MakeGameModel via the ReceiveGameUpdate handler). In that gap, the Observe button on an allied battle was unconditionally interactable, and clicking it threw KeyNotFoundException out of EagleGameController.GoToBattle's Model.ShardokGameModels[gameId] indexer access. Gate the Observe button on Model.ShardokGameModels.ContainsKey(gameId). Show "Loading..." until the matching shardok model arrives, then flip to interactable "Observe". The Self/Fight! path was already implicitly gated via fightableGameId (derived from RunningShardokGameModels), so no change needed there. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
9f155a359b |
Centralize popup-driven province highlight in PopupCanvasController (#6693)
* Centralize popup-driven province highlight in PopupCanvasController Multiple PopupPanelController subclasses (PleaseRecruitMeCommandSelector, NotificationPanel, ResolveDiplomacyCommandSelector, ResolveRansomOfferCommandSelector) all wrote directly to MapController.OverrideTargetedProvinces from their SetUpPanel hooks, and SetUpPanel only ran when state changed. So when a notification arrived or was dismissed while the Please Recruit Me popup was open, the notification panel's SetUpPanel would overwrite the recruit popup's province highlight, and the recruit popup would never re-assert. Result: the requesting hero's province often wasn't highlighted even though the popup said it should be. Switch to a single-writer model: - PopupPanelController exposes CurrentAffectedProvinceIds (the current popup's affectedProvinceIds), and no longer writes to MapController itself. - PopupCanvasController gains a MapController reference and, in Update, applies the topmost active panel's CurrentAffectedProvinceIds every frame, so popups can't clobber one another. On the transition from "popup active" to "no popup", we clear the override once and then leave the field alone so non-popup writers (table hovers etc.) can drive it normally. - ResolveDiplomacyCommandSelector and ResolveRansomOfferCommandSelector used to compute their highlight inline in SetUpPanel; that logic moves into the PopupInfo construction so each offer carries the right affectedProvinceIds, and their SetUpPanel overrides shrink accordingly. PleaseRecruitMeCommandSelector and NotificationPanel already populated affectedProvinceIds correctly and didn't need changes. PopupCanvasController has a new public MapController field. Wire it in the prefab/scene where the PopupCanvasController lives (drag the MapController object onto the new "Map Controller" Inspector field) before testing — without it, opening a popup will throw a NullReferenceException, which is intentional per the project's no-defensive-checks policy on Inspector fields. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Wire MapController into both PopupCanvasController instances in Gameplay scene Companion to the previous commit. Two PopupCanvasController instances exist in Gameplay.unity (narrow + wide layout); both now reference the MapController so the new single-writer Update path can drive OverrideTargetedProvinces from any active popup. Also includes auto-saved DialogueManager unitHighlight default colors that Unity wrote into the scene when loading after the prior tutorial unit-highlight PR landed — incidental, but moot to revert. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
19cf9c592a |
Add failing test for backstory visibility on hero imprisonment (#6695)
* Add failing test for backstory visibility on hero imprisonment When a hero is captured via battle aftermath and imprisoned, the captor faction has no visibility on the prisoner's backstory text. This test asserts the imprisonment ActionResult should emit a ClientTextVisibilityExtensionC granting the imprisoning faction visibility on the captured hero's backstory text -- matching the parallel pattern in ProvinceConqueredAction.afterUpdatingUnaffiliatedHeroes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Extend backstory visibility to captor in convertToUnaffiliated When a captured hero with an existing backstory is converted into an unaffiliated hero (imprisoned or exiled), emit a ClientTextVisibilityExtensionC granting the captor faction visibility on the prisoner's backstory text. Without this the captor sees blank text for their own prisoner -- the same gap ProvinceConqueredAction already closes for inherited unaffiliated heroes when a province changes hands. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
537f846fee |
March marks the acting (menu) province as acted (#6694)
* Add failing test: March should mark the acting (menu) province as acted Currently, MarchCommand.make takes only originProvinceId and marks that as the only acted province (via provinceIdActed). If the player opens the command menu on province A and chooses to march units from a different origin province B, only B is marked acted. Province A retains its full command menu and can issue another command in the same round — the player can effectively shuffle units around without spending province A's action. Plumbing changes (necessary for the test to express the desired behavior, behavior unchanged): - Add actingProvinceId: ProvinceId parameter to MarchCommand.make. The inner MarchCommand class accepts it but currently marks it @scala.annotation.unused — immediateExecute does not yet emit a hasActed change for the acting province. - CommandFactory passes ac.actingProvinceId from the MarchAvailable pair when constructing the MarchCommand. The wire protocol already carries the menu province on EagleCommand.province_id, so no proto or client changes are required. - Existing MarchCommandTest cases pass actingProvinceId = originProvinceId to preserve current behavior; they all still pass. New failing test: - "should mark the acting (menu) province as having acted, even when marching from a different province" — sets actingProvinceId=14, originProvinceId=7, asserts the result includes a ChangedProvinceC for province 14 with setHasActed = Some(true). Currently fails: "None was not equal to Some(true)" — no ChangedProvinceC for 14 exists in the result. Implementation fix to follow. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Mark the acting (menu) province as acted on March Implementation for the test added in the previous commit. When actingProvinceId == originProvinceId (the typical case where the player issued March from the same province their units march from), provinceIdActed = Some(originProvinceId) already marks that single province as acted. No extra change needed. When actingProvinceId != originProvinceId (the player opened the menu on A, chose origin B), append a ChangedProvinceC for the acting province with setHasActed = Some(true) so it can no longer issue commands this round. The origin still gets hasActed via provinceIdActed. Drops @scala.annotation.unused on actingProvinceId since it's now used. All 8 MarchCommandTest cases pass, full Scala suite (223 tests) green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
2bd9fb4022 |
Compute PersistedActionResult.gameState lazily (#6692)
formAwrs (called on game load) and withNewResults (called per applied result at runtime) were eagerly running GameStateConverter.toProto on every Scala game state to populate PersistedActionResult.gameState — 3 seconds of the 4.5-second game-load time per the timing logs and ongoing per-result cost during play. In practice, the proto game-state is only ever read at "last entry of batch" boundaries: - PersistedHistory.scala:214 — last entry of a loaded chunk during orphan recovery. - PersistedHistory.scala:471 — invariant check on recentHistory.headOption.gameState.actionResultCount. - PersistedHistory.scala:592 — results.last.gameState in saveNow, passed to the next batch as starting state. - PersistedHistory.scala:664 — toSave.last.gameState in withNewResults chunk save, passed to next batch as starting state. So at most ~1 in resultsPerSaveFile (25) entries ever needs the proto form; for the others the conversion was pure waste. Make PersistedActionResult.gameState a lazy val derived from scalaGameState — entries that never become a "last in batch" never pay for toProto. (Also drops the unused fromProto factory.) formAwrsFromScala still eagerly converts the action result to proto since saveIndividualResult writes individual ActionResult bytes for crash recovery — that conversion happens regardless. 223 Scala tests pass. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
21eb10d2e5 |
Skip proto conversion in PersistedHistory.stateAfter replay (#6691)
The disk-load branch of stateAfter went through formAwrs, which
unconditionally calls GameStateConverter.toProto on every replayed
intermediate state — wasted work for stateAfter callers, since the
replayed entries are transient and never persisted. With a 19k-action
game, a single stateAfter(14790) call (e.g. from
UnrequestedTextHandler processing a stale incomplete LLM request)
materialized ~4500 PersistedActionResult instances, each holding both
proto and Scala forms of the game state, allocating an estimated
1.1-1.5 GB and OOMing the 2g heap on Province.apply during one of
those toProto calls.
Add a private replayScalaOnlyToState helper that replays in Scala and
returns just the final state, with no proto conversion. Use it in the
stateAfter disk-load branch, and short-circuit the replay at the
target index by taking only `count - initialIndex` actions instead of
replaying through persistedCount.
Behavior preserved:
- formAwrs is unchanged (the save flow still gets eager proto for
in-memory entries, avoiding repeated toProto cost across save
accumulations).
- partialGames load range is unchanged (chunk-range optimization
intentionally deferred).
- recentHistory branch is unchanged.
Per-call impact on the failing stateAfter(14790) path:
before: replay 4575 actions, run toProto 4575 times,
retain Vector[PersistedActionResult] of length 4575 with
proto + Scala state on every entry — ~1.1-1.5 GB transient.
after: replay 15 actions, no toProto calls,
allocate one GameState — a few MB.
223 Scala tests pass.
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
1d99eefbd6 |
Fix CombatUnitSelector hero/battalion matching (#6688)
* Add failing tests for CombatUnitSelector hero/battalion suitability Documents three desired behaviors that the current greedy selectedCombatBattalions() does not satisfy: 1. Engineer should be taken over a no-profession hero on a Longbowmen battalion when only one slot is available — Suboptimal pairing is preferable to leaving the much more capable Engineer behind. 2. Same for a Mage: a Suboptimal Longbowmen pairing is preferable to leaving the Mage behind. 3. A Mage with only Restrictive options (e.g. only a non-casting Heavy Cavalry battalion) should be skipped entirely, not glued to the Restrictive battalion via the getOrElse fallback. The current code: - Uses a lexicographic (suitability.ordinal, -power) sort, so any Optimal hero beats any Suboptimal hero regardless of how much more capable the Suboptimal hero is. This causes (1) and (2). - Has a getOrElse fallback that pairs the strongest hero with the strongest battalion when every (hero, battalion) pair is Restrictive, ignoring suitability. This causes (3). These tests fail today and will pass after the planned fix. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Fix CombatUnitSelector matching: soft Suboptimal penalty, skip Restrictive Three changes to selectedCombatBattalions's chooseOne: 1. Replace the lexicographic (suitability.ordinal, -power) sort with a soft scoring scheme: power minus SuboptimalPairingPenalty (50) when the pairing is Suboptimal. An Engineer's +200 profession bonus still outweighs a no-profession hero on a Suboptimal Longbowmen pairing, but an equally-strong Optimal hero still wins the slot. 2. Filter Restrictive heroes out of the candidate pool entirely for the chosen battalion rather than relying on suitability ordering — so a Mage can never win a non-casting battalion via lexicographic ranking. 3. Replace the getOrElse fallback (which previously glued any hero to any battalion regardless of suitability) with a three-way match: valid pairing exists -> take it; no battalions left -> strongest hero marches alone (preserves prior behavior); battalions remain but every pairing is Restrictive -> return None and stop adding heroes. The five tests in CombatUnitSelectorTest now all pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Take Restrictive pairings rather than abandon a battalion Earlier in this branch we treated "all options for a hero are Restrictive" as a signal to skip the hero. That was wrong: a Restrictive pairing (e.g. Mage leading Heavy Cavalry) is still better than leaving the battalion behind entirely. The correct rule is "prefer non-Restrictive heroes when any are available, but fall back to Restrictive rather than losing the unit." - Replace test 3 ("leave a mage behind ... if every battalion is Restrictive") with the inverse: "take a mage on a Restrictive battalion if that's the only way to take the battalion at all". - Add a counterpart test confirming a no-profession hero is still preferred over a mage on a Restrictive Heavy Cavalry when both are available. - Simplify chooseOne: always pick the strongest battalion, prefer non-Restrictive heroes for it, fall back to Restrictive heroes if none are non-Restrictive. Drop the early-return / skip path. All six CombatUnitSelector tests pass; full Scala suite (223 tests) still green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ebc29a75d4 |
Default Shardok server status to WAITING_FOR_AI when no faction is queued (#6689)
In Eagle battles, computeShardokServerStatus checks each faction's currentCommand to decide between YOUR_TURN, WAITING_FOR_AI, WAITING_FOR_HUMAN_PLAYER, and PROCESSING. The AI's currentCommand, however, isn't populated during the window when the AI is computing its move — so the typical post-player-turn state had no faction with a current command and fell through to PROCESSING. Clients never observed WAITING_FOR_AI in practice; they bounced between YOUR_TURN and "Processing...". When no faction has a current command, infer the most useful status from which other factions are present in the battle: WAITING_FOR_AI if any AI faction remains, WAITING_FOR_HUMAN_PLAYER if any other human remains, and PROCESSING only when no other factions are present (an edge case at battle boundaries). The custom-battle path (CustomBattleManager.customBattleStatus) was already binary YOUR_TURN/WAITING_FOR_AI and not affected. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |