Compare commits

..
Author SHA1 Message Date
admin 5c874f3e53 Relax warmup command availability check 2026-06-07 19:05:35 -07:00
adminandGitHub d210cbb820 Add Connection scene migration tool (#6957)
* Add Connection scene migration tool

* Copy connection UI into split scene
2026-06-07 18:59:42 -07:00
adminandGitHub 50948bccae Persist SQLite history with base and deltas (#6956) 2026-06-07 18:52:12 -07:00
adminandGitHub 98a1c2b361 Add Connection scene readiness marker (#6955) 2026-06-07 18:35:47 -07:00
adminandGitHub 38608bd3e4 Keep shared error handler scene-owned (#6954) 2026-06-07 18:24:23 -07:00
adminandGitHub e4e43292df Default Main scene to split startup (#6952) 2026-06-07 18:16:07 -07:00
adminandGitHub 351cc6f658 Use remote persister for SQLite database uploads (#6953) 2026-06-07 18:15:10 -07:00
adminandGitHub 603e7fb071 Stop tracking Unity editor user settings (#6951) 2026-06-07 17:24:13 -07:00
adminandGitHub 0ccf0539ba Centralize connection mode visibility (#6939) 2026-06-07 17:16:34 -07:00
adminandGitHub 033e1f4675 Serialize SQLite history access internally (#6950) 2026-06-07 17:11:11 -07:00
adminandGitHub c9aa6df721 Fix Shardok model lifecycle cleanup (#6948) 2026-06-07 16:54:42 -07:00
adminandGitHub 734ea7c62e Validate SQLite downloads before loading (#6949) 2026-06-07 16:53:59 -07:00
adminandGitHub 23f29249ef Upload SQLite history from snapshots (#6947) 2026-06-07 16:14:31 -07:00
adminandGitHub eeab47e480 Persist Shardok SQLite deltas to S3 (#6944) 2026-06-07 15:48:59 -07:00
adminandGitHub 22b52f498a Use passive SQLite checkpoints for uploads (#6945) 2026-06-07 15:42:47 -07:00
adminandGitHub f403980853 Fallback build warmup binary during deploy (#6946) 2026-06-07 15:39:33 -07:00
adminandGitHub eb6fcb5ce6 Move sqlite history uploads off command path (#6943) 2026-06-07 15:01:26 -07:00
adminandGitHub 299f4625cd Map Shardok clicks through screen space (#6942) 2026-06-07 13:45:11 -07:00
adminandGitHub 84788452b3 Add JFR tracing for SQLite persistence (#6941) 2026-06-07 11:41:24 -07:00
adminandGitHub 5a81a203e8 Bootstrap Unity client from split scenes (#6935) 2026-06-07 09:11:31 -07:00
adminandGitHub 9c60652eaa Instrument Shardok battle latency (#6938) 2026-06-07 09:08:06 -07:00
adminandGitHub 9b384234d8 Clean up AI quest completion docs (#6937) 2026-06-06 18:51:15 -07:00
adminandGitHub ef591539d3 Add prisoner quest invariant coverage (#6936) 2026-06-06 17:10:32 -07:00
adminandGitHub 1db0c6b282 Clarify failed quest notification source hero (#6932) 2026-06-06 05:48:54 -07:00
adminandGitHub 61da5c1e92 Include recon counts in quest prompts (#6934) 2026-06-06 05:11:56 -07:00
adminandGitHub 2d713f4e57 Use release wording for prisoner quests (#6933) 2026-06-06 05:11:14 -07:00
adminandGitHub fab32ae871 Normalize quest LLM request ids (#6931) 2026-06-06 05:10:51 -07:00
adminandGitHub bc762f3b45 Fix quest outcome prompt name grammar (#6930) 2026-06-06 05:10:21 -07:00
adminandGitHub 7e5c5af876 Document Unity scene separation plan (#6929) 2026-06-06 04:43:26 -07:00
adminandGitHub c71c846a81 Allow text listeners to change during updates (#6928) 2026-06-05 05:39:57 -07:00
adminandGitHub cfc218ba30 Regenerate Shardok AI baselines after timeout fix (#6927) 2026-06-04 13:15:08 -07:00
adminandGitHub 29a481f7b4 Show sworn hero faction in Free Heroes panel (#6926) 2026-06-04 10:47:17 -07:00
adminandGitHub fce2a104e8 Fix break alliance imprisonment notification text (#6925) 2026-06-04 10:40:22 -07:00
adminandGitHub 0fb419cbd8 Suppress invited faction destroyed notifications (#6924) 2026-06-04 10:03:13 -07:00
adminandGitHub bf83e7bde3 Preserve Shardok selection during command updates (#6921) 2026-06-04 08:21:30 -07:00
adminandGitHub 54bfea07d4 Fix ID timeout score pollution (#6923) 2026-06-04 06:23:52 -07:00
adminandGitHub ab74172744 Only generate fulfillable prisoner quests (#6922) 2026-06-03 16:15:41 -07:00
adminandGitHub 80cbe1b60f Stream command results before phase advancement (#6920) 2026-06-03 14:17:05 -07:00
adminandGitHub 488ffbf559 Acknowledge commands before AI continuation (#6919) 2026-06-03 09:45:35 -07:00
adminandGitHub 50f9ccb28c Discover dynamic text editor tests (#6918) 2026-06-03 09:44:59 -07:00
adminandGitHub 34cce82cb0 Reject null dynamic render values (#6917) 2026-06-03 09:43:07 -07:00
adminandGitHub f0baa8bdba Require client text provider inputs (#6916) 2026-06-03 09:41:37 -07:00
adminandGitHub 52fe13325b Require generated text listener inputs (#6915) 2026-06-03 09:41:13 -07:00
adminandGitHub de15e2a66a Reject null dynamic placeholder entries (#6914) 2026-06-03 09:33:41 -07:00
adminandGitHub a506abae98 Send human command results before AI continuation (#6912) 2026-06-03 09:26:23 -07:00
adminandGitHub b43406b7c7 Require dynamic text binding inputs (#6913) 2026-06-03 09:25:13 -07:00
adminandGitHub 919990a95c Require parsed dynamic template text (#6911) 2026-06-03 09:18:36 -07:00
adminandGitHub 48162b20c0 Require dynamic placeholder fallback text (#6910) 2026-06-03 09:07:42 -07:00
adminandGitHub 88433e481e Clarify prisoner quest divine prompts (#6909) 2026-06-03 09:07:16 -07:00
adminandGitHub cc5eb01dba Preserve dynamic placeholder discovery order (#6908) 2026-06-03 09:03:11 -07:00
adminandGitHub d5dda77a47 Render dynamic text templates in one pass (#6907) 2026-06-03 08:47:16 -07:00
adminandGitHub 663b2b34f7 Seal dynamic text carrier types (#6906) 2026-06-03 08:17:18 -07:00
adminandGitHub 1e936374b0 Clean up stale AI scoring experiments (#6905) 2026-06-03 08:10:42 -07:00
adminandGitHub 8adc8953cf Centralize dynamic placeholder token parsing (#6904) 2026-06-03 08:08:52 -07:00
adminandGitHub ea5a16ba6b Promote expected-impact archery scoring (#6902) 2026-06-03 07:45:50 -07:00
adminandGitHub b4fae126ef Run all dynamic text editor tests (#6903) 2026-06-03 07:42:58 -07:00
adminandGitHub d468037358 Avoid ascii identifier assumption in placeholder audit (#6901) 2026-06-03 07:17:25 -07:00
adminandGitHub 49f9e53be8 Use unicode placeholder validation in audits (#6900) 2026-06-03 07:17:12 -07:00
adminandGitHub 2c2e405ebc Validate dynamic text placeholder names (#6899) 2026-06-03 07:01:37 -07:00
adminandGitHub b49132e3de Evaluate target-aware archery scoring (#6876)
* Evaluate target-aware archery scoring

* Refine target-aware archery experiment

* Record broader archery scoring evaluation

* Record future-position archery experiment

* Evaluate expected-impact archery scoring
2026-06-03 06:54:58 -07:00
adminandGitHub 340bd806fa Type dynamic text binding keys (#6898) 2026-06-03 06:52:44 -07:00
adminandGitHub 31e6277d87 Centralize indexed riot placeholders (#6897) 2026-06-03 06:52:10 -07:00
adminandGitHub e17115ee07 Type faction display placeholders (#6896) 2026-06-03 05:35:52 -07:00
adminandGitHub 09023e9f1f Remove unused faction leader template helper (#6895) 2026-06-02 22:05:40 -07:00
adminandGitHub b82c6a5dd8 Share ARN victim hero descriptions (#6894) 2026-06-02 21:54:20 -07:00
adminandGitHub b702f1f8bd Audit notification dynamic text placeholders (#6893) 2026-06-02 19:43:47 -07:00
adminandGitHub 9beb308b80 Type dynamic text placeholder names (#6892) 2026-06-02 19:28:35 -07:00
adminandGitHub 7f3b3b65a3 Use typed captured hero executed placeholders (#6891) 2026-06-02 19:04:33 -07:00
adminandGitHub 902513d0b5 Use typed captured hero imprisoned placeholders (#6890) 2026-06-02 19:04:17 -07:00
adminandGitHub 0ddfeff937 Use typed ransom paid notification placeholders (#6882) 2026-06-02 18:58:33 -07:00
adminandGitHub d540ed425f Use typed ransom rejected notification placeholders (#6883) 2026-06-02 18:58:18 -07:00
adminandGitHub ed636ef358 Use typed invitation ambassador notification placeholders (#6884) 2026-06-02 18:58:02 -07:00
adminandGitHub e5590bc848 Use typed truce ambassador notification placeholders (#6885) 2026-06-02 18:57:48 -07:00
adminandGitHub 5eb4ca38c9 Use typed alliance ambassador notification placeholders (#6886) 2026-06-02 18:57:29 -07:00
adminandGitHub f65e03137f Use typed break alliance ambassador placeholders (#6887) 2026-06-02 18:57:13 -07:00
adminandGitHub 35144f95fb Use typed captured hero returned placeholders (#6888) 2026-06-02 18:56:52 -07:00
adminandGitHub 5fd4182245 Use typed captured hero exiled placeholders (#6889) 2026-06-02 18:56:34 -07:00
adminandGitHub ffbdb5d5c7 Use typed prisoner exchange notification placeholders (#6881) 2026-06-02 16:28:59 -07:00
adminandGitHub b9b4d5254f Use typed vassal rise notification placeholders (#6880) 2026-06-02 16:28:39 -07:00
adminandGitHub e2a2a64204 Use typed profession notification placeholders (#6879) 2026-06-02 16:28:16 -07:00
adminandGitHub 30d8f8575a Use typed hero departure placeholders (#6878) 2026-06-02 16:14:55 -07:00
adminandGitHub 4cdd9371c6 Use typed faction head placeholders (#6877) 2026-06-02 16:10:16 -07:00
adminandGitHub f23e81dc52 Use typed prisoner status placeholders (#6875) 2026-06-02 16:06:12 -07:00
adminandGitHub 3de29fd23e Use typed outlaw apprehension placeholders (#6874) 2026-06-02 15:54:16 -07:00
adminandGitHub 52143a9481 Use typed food warning placeholders (#6873) 2026-06-02 15:44:05 -07:00
adminandGitHub 7a5185f6bf Use typed brotherhood notification placeholders (#6872) 2026-06-02 15:32:41 -07:00
adminandGitHub 8ac97cda58 Use typed placeholders in simple notifications (#6871) 2026-06-02 15:11:14 -07:00
adminandGitHub 06e82a84cd Systematize Shardok AI battle experiments (#6870) 2026-06-02 14:59:28 -07:00
adminandGitHub abfd27b495 Use typed prisoner notification placeholders (#6869) 2026-06-02 14:57:21 -07:00
adminandGitHub abb4504355 Use typed quest notification placeholders (#6868) 2026-06-02 14:38:34 -07:00
adminandGitHub 5cb1b155cd Fix riot killed hero placeholders (#6867) 2026-06-02 14:31:04 -07:00
adminandGitHub d258e65a6f Audit inline dynamic text template parsing (#6866) 2026-06-02 14:21:01 -07:00
adminandGitHub 9005ddf00c Type ambassador imprisonment templates (#6865) 2026-06-02 14:08:41 -07:00
adminandGitHub fd66a5d4dd Type ransom dynamic text templates (#6864) 2026-06-02 14:00:21 -07:00
adminandGitHub 1d06fa4368 Type riot dynamic text template construction (#6863) 2026-06-02 13:43:43 -07:00
adminandGitHub 9fdeb07d9c Type branch-selected dynamic text templates (#6862) 2026-06-02 13:33:24 -07:00
adminandGitHub 9893a79296 Type simple dynamic text template locals (#6860) 2026-06-02 12:48:17 -07:00
adminandGitHub 05f17ca569 Fix registry cleanup workflow (#6861) 2026-06-02 12:36:59 -07:00
adminandGitHub 7652b798a8 Regenerate AI battle baseline data (#6859) 2026-06-02 12:36:02 -07:00
adminandGitHub e27cbf1143 Require streaming dynamic notifications to use templates (#6858) 2026-06-02 12:10:26 -07:00
adminandGitHub 4276a211a0 Fix AI battle simulator unit capabilities (#6857) 2026-06-02 12:07:09 -07:00
adminandGitHub 0ebb240f6f Require dynamic notification constructors to use templates (#6856) 2026-06-02 12:00:34 -07:00
adminandGitHub 07bf5fc72f Add dynamic text binding validation tests (#6855) 2026-06-02 11:25:12 -07:00
adminandGitHub 09fb31fad4 Require dynamic text bindings to use templates (#6854) 2026-06-01 21:52:50 -07:00
adminandGitHub 989915a62d Remove unused ARN notification LLM message locals (#6853) 2026-06-01 21:52:24 -07:00
adminandGitHub 1f6ba6ff1f Fix AI random command sampling (#6852) 2026-06-01 19:58:01 -07:00
adminandGitHub f7942f67fd Separate dynamic template literals (#6850) 2026-06-01 17:04:10 -07:00
adminandGitHub a01935d1f7 Redo Shardok scoring experiments with hero-only units (#6845) 2026-06-01 08:40:14 -07:00
adminandGitHub be673810f6 Fix ransom placeholder validation (#6851) 2026-06-01 06:43:14 -07:00
adminandGitHub fcc21b1d5c Validate dynamic text placeholders (#6848) 2026-06-01 06:32:27 -07:00
adminandGitHub 234979c2f6 Avoid duplicate ready-to-join notifications (#6849) 2026-05-31 22:01:00 -07:00
adminandGitHub 806cdf4d68 Centralize dynamic placeholder factories (#6847) 2026-05-31 21:49:04 -07:00
adminandGitHub befd7df406 Require typed dynamic placeholders (#6846)
* Require typed dynamic placeholders

* Fix captured hero placeholder migration
2026-05-31 21:38:52 -07:00
adminandGitHub fc3c88e6e6 Extract dynamic text binding (#6844) 2026-05-31 18:56:01 -07:00
adminandGitHub 94a7f01b35 Avoid freezing placeholder notification text (#6843) 2026-05-31 18:43:50 -07:00
adminandGitHub c1dbebb60b Keep notification names live until text loads (#6842) 2026-05-31 15:22:36 -07:00
adminandGitHub 9fa873ed53 Fix Shardok thunderstorm weather icon import (#6841) 2026-05-31 15:09:26 -07:00
adminandGitHub 816417931e Document worktree boundary rule (#6840) 2026-05-31 15:01:24 -07:00
adminandGitHub ba1b62c792 Fix zero-size units in AI battle configs (#6839) 2026-05-31 15:00:56 -07:00
adminandGitHub 4066996d77 Face beast visuals toward camera on spawn (#6838) 2026-05-31 14:30:52 -07:00
adminandGitHub 1651c362fa Fix standard AI timeout and scatter scoring (#6837) 2026-05-31 13:11:36 -07:00
adminandGitHub 10552ac843 Record ID scoring benchmark baseline (#6832)
* Record ID scoring benchmark baseline

* Add real battle benchmark subset

* Record all real battle scoring matrix

* Record close battle scoring experiments

* Correct real battle benchmark round-limit results

* Document Shardok scoring benchmark baseline
2026-05-31 08:12:52 -07:00
adminandGitHub b93bfc745a Remove obsolete MCTS integration assertions (#6836) 2026-05-31 08:03:16 -07:00
adminandGitHub a539740046 Remove normalized Shardok AI scoring (#6835) 2026-05-31 07:51:34 -07:00
adminandGitHub f0f22b7818 Document PR timing guidance (#6834) 2026-05-31 07:46:07 -07:00
adminandGitHub 0f7e8d201d Fix artifact storage check for expired artifacts (#6833) 2026-05-31 04:43:55 -07:00
adminandGitHub 9dc6a691f0 Update agent workspace guidance (#6830) 2026-05-30 19:21:45 -07:00
adminandGitHub cae2cf4e07 Avoid json4s reflection for admin history (#6829) 2026-05-30 19:21:20 -07:00
adminandGitHub 3e58063303 Add saved-state AI battle benchmark support (#6831) 2026-05-30 19:19:46 -07:00
adminandGitHub 1f0d70ccb2 Add AI battle scoring benchmark summaries (#6828) 2026-05-30 18:41:31 -07:00
adminandGitHub 38cf715f86 Add Shardok weather condition icon (#6817) 2026-05-30 18:36:55 -07:00
adminandGitHub 8bd118759a Fix AI battle simulator config handling (#6827)
* Fix AI battle simulator config handling

* Stabilize MCTS setup reserve filtering
2026-05-30 14:36:40 -07:00
adminandGitHub 04d2e2d0cc Add repository agent instructions (#6826) 2026-05-30 14:35:56 -07:00
adminandGitHub 454d4a4e12 Fix low food warnings with incoming supplies (#6825)
* Fix low food warning with incoming supplies

* Fix low food shipment forecast invariants
2026-05-30 12:47:58 -07:00
adminandGitHub 6ee0485a05 Normalize MCTS UCB action priors (#6824) 2026-05-30 10:25:56 -07:00
adminandGitHub cffc11ef5c Report terminal winners to MCTS (#6823) 2026-05-30 10:20:36 -07:00
adminandGitHub 81f0eca49a Account for unvisited MCTS chance outcomes (#6822) 2026-05-30 10:19:08 -07:00
adminandGitHub 7a99609383 Expand all MCTS chance outcomes (#6821) 2026-05-30 10:14:45 -07:00
adminandGitHub cf72e50e49 Key MCTS legal action cache by root role (#6820) 2026-05-30 10:12:19 -07:00
adminandGitHub baf2dd184d Fix MCTS filtering for opponent turns (#6819) 2026-05-30 09:56:04 -07:00
adminandGitHub f60bcc4fa1 Delete iOS handoff artifact after extraction (#6818) 2026-05-30 09:45:24 -07:00
adminandGitHub b7e6b6a0e7 Double notification beast visual scale (#6816) 2026-05-29 20:51:33 -07:00
adminandGitHub 7101b83166 Label scoped feast quests in Unity (#6815) 2026-05-29 20:27:18 -07:00
adminandGitHub 3052b65282 Implement scoped feast quests (#6814) 2026-05-29 20:11:43 -07:00
adminandGitHub bc0b85e86a Add scoped feast quest proto fields (#6813)
* Add scoped feast quest proto fields

* Decode scoped feast quest proto fields
2026-05-29 20:07:03 -07:00
adminandGitHub 408979fd80 Highlight ransom participant factions (#6811) 2026-05-29 19:54:34 -07:00
adminandGitHub 0bc84928c7 Add Scala staging runtime to Eagle server (#6810) 2026-05-29 18:44:28 -07:00
adminandGitHub b475e806bd Use new hero data for ready-to-join notifications (#6809) 2026-05-29 18:44:05 -07:00
adminandGitHub b987277f8b Skip stale ready-to-join notifications (#6808) 2026-05-29 18:29:10 -07:00
adminandGitHub 2536e43e56 Apply Xcode sync to all macOS Bazel builds (#6807) 2026-05-29 18:10:24 -07:00
adminandGitHub ff7fcbcac8 Harden SQLite persistence edge cases (#6804) 2026-05-29 17:17:03 -07:00
adminandGitHub a28d4d6631 Defer notification beast loading until panel is active (#6806) 2026-05-29 17:16:11 -07:00
6cb4591c4e Store tutorial results as a SQLite .db instead of a Scala literal (#6803)
Follow-up to #6802 that swapped the proto-shape JSON resources for a
generated `ExpansionTutorialResults.scala` (820-line `Vector` literal).
Replaces that with the originally-intended shape from the design
discussion: a `tutorial_expansion.db` resource in the same format as a
live `game.db`, read back via `SqliteHistory`.

Changes:

- New `tutorial_expansion.db` (~500KB binary resource) generated from
  the previous `ExpansionTutorialResults.results` by running them
  through `withNewResults` with a placeholder resulting state. (The
  placeholder is recorded but never read — `allActionResults`
  reconstructs each `ActionResultC` from the decomposed per-entity
  tables only.)
- New `SqliteHistory.allActionResults`: public method that returns
  every `ActionResultT` in the db without folding through the applier.
  Skips replay/snapshots, so it's safe on a db whose resulting-state
  columns are placeholders.
- `TutorialPhaseResultsLoader` rewritten as 10 lines: copy resource
  to tempfile, `SqliteHistory.loaded(...).allActionResults`. Same
  public surface as before.
- Deleted: `ExpansionTutorialResults.scala` (820 generated lines),
  `TutorialDbGenerator.scala` (one-shot tool, job done).
- BUILD deps on the loader collapse from ~30 to 3 — the loader no
  longer references domain types directly.

The generator stayed alive long enough to cross-validate the .db
round-trip against the Scala source (the test asserted
`expansionResults shouldBe ExpansionTutorialResults.results`); that
passed before this PR was finalized, so the round-trip is faithful.
The cross-validation assertion goes away with the Scala source.

Net: 4 source files changed (+540 / -3050 lines), plus the new
binary resource. `TutorialPhaseResultsLoaderTest` 1/1 green; full
Scala suite 225/225 green.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 16:37:08 -07:00
adminandGitHub 9629bfd9de Show beast visuals on appearance notifications (#6800)
* Show beast visuals on beast appearance notifications

* Wire beast visual container in gameplay scene

* Let notification beast previews render inside popups

* Tune notification beast preview framing
2026-05-29 16:08:21 -07:00
e11ddf3e21 Retire ActionResultProtoConverter (#6802)
Closes the converter-chain retirement that PRs #6798, #6799, and #6801
chipped away at. The last remaining caller of
`ActionResultProtoConverter.fromProto` was
`TutorialPhaseResultsLoader`, which read proto-shape JSON tutorial
resource files and walked them through the converter.

Replaced the resource-based loader with compile-time-baked Scala
constants:

- New `ExpansionTutorialResults.scala` (820 lines, generated) contains
  the same `Vector[ActionResultT]` that the JSON used to produce, but
  as a literal expression.
- New `TutorialResultsCodegen` was the one-shot generator that walked
  an `ActionResultC` via `Product` introspection + the Scala 3 enum's
  superclass reflection, emitting fully-qualified Scala source. Used
  once to convert the two existing resource files; deleted in this
  commit since its job is done.
- `TutorialPhaseResultsLoader` is now ~10 lines: just references
  `ExpansionTutorialResults.results` and an empty mobilization vector
  (the original mobilization JSON was `{}`).

Deleted:

- `ActionResultProtoConverter.scala` + BUILD target
- `ChangedHeroConverter.scala` + BUILD target (only caller was the
  parent converter)
- `ChangedFactionConverter.scala` + BUILD target (same)
- `TutorialPhaseResultsExtractor.scala` (broken anyway — it read
  `.e0a` chunk files through `PersistedHistory` machinery that #6798
  deleted)
- `expansion_results.json`, `mobilization_results.json`, and their
  resource BUILD target

BUILD churn: visibility widened on ~10 `model/state` and
`model/action_result/concrete` targets so the new
`tutorial_phase_results_loader` can see them. Two unused
`action_result_proto_converter` deps fell out of `service` and
`service/new_game_creation`. Dependency cleanup is minor compared to
the deletions; net ~4,000 lines removed.

`json4s` reflection on Scala 3 case classes is broken in this
codebase (the `ScalaSigReader` is Scala 2-only and throws
`NoClassDefFoundError: scala/quoted/staging/package$`), which is why
the codegen used `Product` introspection + JVM-class-name parsing
instead of a runtime JSON serializer.

Verification: full Scala suite 225/225 green;
`TutorialPhaseResultsLoaderTest` still passes against the new path
(asserts the expected `generatedTextRequest.requestId` values are
preserved through the round-trip).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:45:05 -07:00
bf48be685d Serialize admin action JSON from the domain ActionResultC directly (#6801)
Drops the only remaining production caller of `ActionResultProtoConverter.toProto`.

The admin history endpoint had been doing `domain → proto → JSON`:
```
val arProto    = ActionResultProtoConverter.toProto(ar)
val actionJson = writePretty(JsonFormat.toJson(arProto))
```

Switched to `writePretty(ar)` — json4s reflects on the case class
directly. The Go admin server passes `action_json` straight through to
an HTML template (no parsing), so a format change is tolerable; the
field's proto-comment "serialized as JSON" describes the previous
implementation, not a contract.

After this:
- The `getRawGameHistory` flow still uses scalapb's `JsonFormat` —
  the inputs there are raw `ActionResult` protos (loaded from the
  legacy `.e0r` admin-debug path), not domain types.
- The `getCurrentGameState` flow still uses `JsonFormat` on the
  `GameState` proto — out of scope here (it's `GameStateConverter`,
  not `ActionResultProtoConverter`).
- `game_admin_service` BUILD target drops its
  `:action_result_proto_converter` dep.

One ActionResultProtoConverter caller remains:
`TutorialPhaseResultsLoader` (reads bundled tutorial JSON resources
via `fromProto`); retiring it needs the tutorial JSON files
regenerated in a domain-shape format.

Verification: full Scala suite 225/225 green; `game_admin_service`
builds cleanly. The new JSON output format should be visually
checked against the existing admin UI history view — json4s
DefaultFormats reflection on Scala 3 enums and sum types may produce
slightly different shape than scalapb's JsonFormat did. No code
parses this JSON; the admin UI just renders it as a string.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:49:38 -07:00
adminandGitHub ded1939106 Persist tutorial dialogue progress per game (#6778) 2026-05-29 11:20:25 -07:00
3b378a6d02 Delete InMemoryHistory.fromResults (#6799)
Removes one of the three remaining ActionResultProtoConverter callers
left after #6798. `fromResults(Vector[ActionResult])` only existed as a
test convenience that walked proto results through
`ActionResultProtoConverter.fromProto`. Its 17 call sites — across
`InMemoryHistoryTest`, `SqliteHistoryTest`, and `GameControllerTest` —
have been switched to the existing `fromScalaResults(Vector[ActionResultT])`
sibling, with each test's local `makeActionResult` helper now returning
domain `ActionResultC` instead of the proto.

After this:
- `InMemoryHistory.scala` no longer imports `ActionResultProtoConverter`
  or the internal `ActionResult` proto.
- `in_memory_history` BUILD target drops the proto + converter deps;
  three test targets drop the proto deps that fell to unused under
  strict-deps.
- The two remaining ActionResultProtoConverter callers are
  `GameAdminServiceImpl` (admin JSON serialization) and
  `TutorialPhaseResultsLoader` (tutorial resource loading), both of
  which need bigger replacements before they go.

Verification: full Scala suite 225/225 green.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:19:27 -07:00
786608a6ec Retire PersistedHistory (#6798)
Closes out the legacy non-SQLite history implementation. SqliteHistory
has been the sole runtime backend since 4.5h.5d shipped; the only
remaining caller of PersistedHistory was the RemoveActions admin tool,
whose BUILD target was already commented out.

Changes:
- RemoveActions rewritten on SqliteHistory: open game.db directly,
  call truncateTo(count - countToTruncate), close. Persister-based
  chunk-deletion logic falls away. BUILD target re-enabled with the
  new (much smaller) dep set.
- PersistedHistory.scala, PersistedActionResult.scala, and
  PersistedHistoryTest.scala deleted; their BUILD targets removed.
- games_manager's transitive `:persisted_history` dep removed (was
  unused even before this PR).
- Stale doc-comment references to PersistedHistory in SqliteHistory
  and FullGameHistory rephrased to stand on their own.

Out of scope: ActionResultProtoConverter (and the per-entity converter
chain) still exists. It's no longer used by any history path, but
remains alive for GameAdminServiceImpl's admin JSON serialization and
TutorialPhaseResultsLoader's resource loading. Retiring those is a
separate effort with bigger blast radius (custom JSON serializer for
ActionResultC, regenerated tutorial JSON resources).

Verification: full Scala suite 225/225 green (was 226 with the
deleted PersistedHistoryTest); RemoveActions builds.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 10:20:07 -07:00
7526817a3b Phase 4.5i (cont.): delete v3-v14 per-entity backfills (#6797)
Continues #6791 (which removed the v15-v20 column/vector-table
backfills). Same predicate: after #6787 every persisted game.db is at
v >= 21 in S3, and #6784 dropped the `payload` column. The v3-v14
per-entity backfills (which read each existing `action_results.payload`
to populate the decomposed tables) are unreachable on any healthy game
and cannot run anyway with `payload` gone.

Removed:
- `def backfill(connection: Connection): Unit` from each of the 11
  per-entity table writers (ChangedHero/Faction/Battalion/Province +
  NewBattalion/Faction/Hero/Province + Notification +
  GeneratedTextRequest + ClientTextVisibilityExtension).
- The corresponding `<Writer>.backfill(connection)` calls from
  applyV3-V14 in SqliteHistory. Each migration collapses to a single
  `execEach(connection, schemaStatements)`.
- applyV6ReapplyChangedEntityDecomposition (#6738 hotfix) keeps its
  DROP+CREATE schema reset for the ChangedHero/Faction tables; the
  re-backfill step is gone. For pre-#6737 games it would still fix
  the table shape but leave them empty — same "schema v < 15 is no
  longer supported" caveat as for v15-v20.
- 15 `"<entity> vN backfill"` tests in SqliteHistoryTest and 3
  orphaned section banners (v3, v4, v5).
- Unused proto imports in 11 writer files; unused private helpers
  (`factionRowCount`) and unused imports in SqliteHistoryTest.
- ~40 BUILD deps that fall out as unused: each writer no longer needs
  `internal:action_result_scala_proto` or
  `proto_converters:action_result_proto_converter`. Several writers
  needed their per-entity proto target added directly (e.g.
  `battalion_scala_proto`, `hero_scala_proto`, `faction_scala_proto`)
  because they used `.toByteArray` on the proto type — previously
  available transitively via `action_result_scala_proto`.

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

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 08:46:13 -07:00
adminandGitHub 46891abb5b Normalize remaining Unity LFS assets (#6796) 2026-05-29 08:43:01 -07:00
adminandGitHub ceed22d6de Prioritize remaining small Eagle alpha work (#6795) 2026-05-29 08:31:41 -07:00
adminandGitHub 75f35e981e Unpin Bazel workflows from specific runners (#6792) 2026-05-29 08:29:29 -07:00
adminandGitHub 76d8f07827 Keep command factory test flatbuffers alive (#6794) 2026-05-29 08:23:27 -07:00
adminandGitHub 705a36e901 Update Unity 6000.4.9 project files (#6793) 2026-05-29 08:21:19 -07:00
adminandGitHub 758ae6d44f Ensure Bazel is available in self-hosted workflows (#6785)
* Ensure Bazel is available in self-hosted workflows

* Allow Bazel CI to use Command Line Tools

* Set macOS C++ deployment target for Bazel

* Use Bazel macOS minimum OS flags

* Raise macOS Bazel deployment target for filesystem

* Ensure git-lfs is available for CI fetches

* Authenticate CI LFS fetches with workflow token

* Disable stale LFS hooks during CI checkout

* Disable LFS filters during CI checkout

* Prepare Git LFS before persistent checkout

* Harden generated Bazel rc and LFS auth

* Let Xcode sync skip without full Xcode

* Harden Unity build cache markers

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

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

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

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

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

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

* Delete unused preview and demo LFS assets

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

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

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

This commit adds two red tests:

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* Phase 4.5e (green): implement ChangedBattalion decomposition

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

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

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

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

---------

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

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

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

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

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

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

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

Three test cases:

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

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

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

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

Fills in the stub. Inverse of ChangedFactionTableWriter:

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 21:27:11 -07:00
adminandGitHub c9268d61c8 Hotfix v2 for #6737: v6 migration brings already-migrated games onto the new schema (#6738) 2026-05-21 12:15:11 -04:00
8bb45a57af Unity client: clear deprecation/unused warnings (#6732)
- Replace deprecated FindObjectsByType(FindObjectsSortMode) and
  FindFirstObjectByType with non-deprecated overloads.
- Replace deprecated GetInstanceID() with GetEntityId() in three
  files that use the value as a dictionary key or for equality.
- Prefix fire-and-forget async calls with `_ =` to silence CS4014
  in ConnectionHandler, CustomBattleHandler, ErrorHandler,
  ShardokGameModel, and ResourceFetcher.
- Remove the unused `listen` field from ConnectionHandler.
- Suppress CS0162 around tutorial content intentionally preserved
  after an unconditional return, and CS0067 on
  SingleInstanceEnforcer.OnDeepLinkReceived (declared cross-platform
  but only invoked on UNITY_STANDALONE_WIN).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:51:05 -07:00
307ea245ee Hotfix: same-entity duplicate PK bug for both ChangedFaction and ChangedHero (#6737)
Same shape as the ChangedProvince hotfix in #6735 (PR title "Hotfix:
v3 backfill crash on duplicate-province ChangedProvinces"), applied
preemptively to the other two decomposed entity tables.

The crash:

  !!! CRITICAL: Failed to load game 2798739292a60a5f:
  SQLITE_CONSTRAINT_PRIMARYKEY ... action_changed_factions.action_seq,
  action_changed_factions.faction_id
    at ChangedFactionTableWriter.writeParentRow
    at ChangedFactionTableWriter.backfill
    at SqliteHistory$Migrations$.applyV5ChangedFactionDecomposition

The root cause is identical to the ChangedProvince case I'd just
hot-fixed. The v2 / v4 / v5 schemas for action_changed_heroes and
action_changed_factions used (action_seq, entity_id) as the PK,
assuming at most one Changed<Entity> per entity per action. That
invariant doesn't hold: ActionResultApplierImpl folds through the
changed<Entity> Vector sequentially and applies each entry as its
own delta, so two entries for the same entity_id are two valid
sequential deltas, both applied.

I failed to apply the ChangedProvince lesson when fixing the analogous
ChangedFaction crash, and the structurally-identical bug was sitting
in ChangedHero too (no production game has hit it yet, but it's the
same landmine — fixing preemptively in this PR per Dan's request).

Fix shape (mirrors the ChangedProvince hotfix):

- Parent table PK becomes (action_seq, n), where n is the position
  within the source ActionResult's changed<Entity> vector. entity_id
  becomes an indexed column but no longer unique.
- write() takes n as a parameter; backfill iterates with
  changed<Entity>.zipWithIndex and passes n.
- SqliteHistory.withNewResults call sites updated to use
  zipWithIndex for both entities.
- Schema rewrite is via DROP + CREATE in the same migration
  (applyV4ChangedHeroColumns and applyV5ChangedFactionDecomposition
  edited in place, same justification as the ChangedProvince hotfix:
  affected games never successfully ran these migrations, so editing
  isn't retroactively rewriting their history).
- For ChangedFaction's 15 child tables (4.5d.1): all gain n column,
  FK on (action_seq, n) → parent, keep faction_id denormalized for
  query convenience. Child tables that had their own within-vector
  index (renamed from n to i to avoid colliding with the parent's
  n). PK becomes (action_seq, n, i) for indexed children or
  (action_seq, n, <id>) for join tables.
- For ChangedHero's events child (4.5c.1): same treatment — add n
  for parent FK, rename internal n → i, keep hero_id denormalized,
  PK (action_seq, n, i).
- ChangedHeroTableReader updated to order by n (preserves the source
  changedHeroes vector order — multiple entries with the same
  hero_id come back in their original positions) and to filter
  events by (action_seq, n) so same-hero_id entries get their own
  event sets.

Tests:

- Existing tests that query "WHERE entity_id = X" on parent / child
  rows still work (entity_id remains a column, denormalized on
  children; single-row fixtures still match uniquely).
- Updated v4 backfill test's events ORDER BY n → ORDER BY i.
- Updated ChangedHeroTableReader "ordered by hero_id" test → now
  asserts insertion order (by n) instead of sorted-by-hero_id.
- Added two regression tests:
  - ChangedHero: two ChangedHeroes with the same hero_id in one
    action round-trip via the reader in their original order.
  - ChangedFaction: two ChangedFactions with the same faction_id
    in one action both land in the parent + child tables at n=0
    and n=1.

Out of scope: games on other deployments that successfully ran the
old (buggy) v4/v5 (because their history lacked duplicate-entity
actions) keep the old PK shape. They're not currently exercising
the bug. A separate v6 migration for those can land when 4.5h needs
the decomposed read path; same trade-off as the ChangedProvince
hotfix.

Verification:

- sqlite_history_test - 50/50 pass, including both new regressions
  and the updated reader ordering test.
- //src/test/scala/... - 225/225 pass. No regressions.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:45:37 -07:00
c0d00d4b81 Phase 4.5d.2 (live write): wire ChangedFactionTableWriter into withNewResults (#6736)
* Phase 4.5d.2 (red): live ChangedFaction decomposition test

Adds a failing test for the 4.5d.2 live-write contract. 4.5d.1
(#6734) added v5 schema + backfill but left SqliteHistory.with-
NewResults unchanged — it only writes the action_results payload
blob and (since 4.5c.2) ChangedHero. On a freshly-created game the
v5 backfill is a no-op, so action_changed_factions and its 15
child tables stay empty forever. That gap is what 4.5d.2 closes.

The new test builds a Scala ChangedFactionC directly (factionId 7,
newFactionHeadHeroId 11, newName "foo", newLeaderHeroIds [10, 20]),
hands it through withNewResults on a fresh DB, and asserts the
parent row plus the new_leader_hero_ids join table populated.

Scope: parent row + one join-table assertion. The writer's full
per-table behavior is already exercised by the v5 backfill tests
landed in 4.5d.1; the live-path test only needs to prove
withNewResults invokes ChangedFactionTableWriter.write at all. RED
on the current code with a clean assertion mismatch (false was not
equal to true at withRow's rs.next() — no parent row exists).

Adds changed_faction_concrete to the test BUILD deps (for the
Scala ChangedFactionC import). The impl that turns this green is
the next commit on the same branch.

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

* Phase 4.5d.2: wire ChangedFactionTableWriter into withNewResults

Turns the red test from the previous commit green. The existing
ChangedHero decomposition loop in withNewResults now also iterates
awrs.actionResult.changedFactions and calls ChangedFactionTableWriter.
write for each. Same shape as the ChangedHero live-path loop landed
in #6730. Sits inside the same transaction, after the action_results
executeBatch (FK target rows must exist), so a failure rolls back the
action_results inserts too.

Updates the comment to reflect both 4.5c.2 (hero) and 4.5d.2 (faction)
as the live write paths sharing this loop.

Verification:

- sqlite_history_test - 48/48 pass, including the previously-red
  4.5d.2 live write path test.
- //src/test/scala/... - 225/225 pass. No regressions on the critical
  write path.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 21:24:40 -07:00
b8cb342bc5 Phase 4.5d.1: ChangedFaction refined per-field decomposition (schema + backfill) (#6734)
* Phase 4.5d.1 (red): ChangedFaction schema v5 + backfill red tests

Sets up the refined per-field decomposition of ChangedFaction (Decisions
#2 in the design doc: classified `refined`, no whole-entity blob).

ChangedFactionC has 15 non-scalar Vector fields. Per the design doc's
refined-option-B tiers, each gets a child table sized to its shape:

- 9 primitive-ID join tables for Vector[HeroId/FactionId/ProvinceId]
  and the four Outgoing*OfferFactionId wrappers. Keyed by
  (action_seq, faction_id, value) so the join carries set semantics
  with no n column.
- 4 small-struct tables for PrestigeModifier (new + removed),
  FactionRelationship (changed), and TrustLevelUpdate. Explicit
  columns + n for ordering.
- 2 Tier-3 leaf-blob tables for DiplomacyOffer (sealed with 5
  subtypes including the deeper RansomOffer) and ProvinceView (deep
  aggregate with FullProvinceInfo plus nested vectors). Leaf-blob
  avoids re-incurring the child-table explosion option B exists to
  prevent.

All 15 tables FK (action_seq, faction_id) into the existing v2
action_changed_factions parent table ON DELETE CASCADE. Tier-1
scalars stay on that parent table (v2). Backfill populates parent
row + every applicable child row from each ChangedFaction in each
existing action_results.payload.

This commit (red):
- Writer stub (ChangedFactionTableWriter) with the real 15-table
  schemaStatements (the contract under review) and no-op write +
  backfill methods.
- Migration v5 hooked into SqliteHistory.Migrations.
- Two red tests in SqliteHistoryTest. The first exercises a fully-
  populated ChangedFaction through seedV1WithPayloads + load and
  asserts the parent row and every child table got the expected
  rows. The second asserts a minimal ChangedFaction leaves child
  tables empty but still emits a parent row. Both RED on the stub:
  schema exists (no "no such table" errors) but rows are absent, so
  the assertions fail cleanly.
- changed_faction_concrete visibility widened to the production
  service package (and the service test package) so the writer can
  consume ChangedFactionT. Same precedent as for ChangedHero in
  #6730.

The impl commit (next, same branch) fills in `write` and `backfill`
to turn the tests green.

Verification:
- sqlite_history_test - 44/46 pass, 2 RED for the right reason
  (clean assertion mismatches at the row-count and column checks,
  not exceptions or compile errors).
- //src/test/scala/... - 224/225 targets green elsewhere. No
  regressions.

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

* Phase 4.5d.1: implement ChangedFactionTableWriter (turns red tests green)

Fills in write + backfill so the two v5 backfill tests from the
previous commit pass.

write(connection, actionSeq, ChangedFactionT) pattern-matches to
ChangedFactionC and fans out:

- Parent row: INSERT into v2 action_changed_factions with the
  Tier-1 scalar / entity-reference columns.
- 9 primitive-ID join tables via a shared insertIds helper:
  (action_seq, faction_id, <id>) batched inserts. Covers
  newLeaderHeroIds, removedLeaderHeroIds,
  removedFactionRelationshipFactionIds,
  removedIncomingDiplomacyOfferFactionIds,
  removedReconnedProvinceIds, and the four
  newOutgoing{Truce,Alliance,Invitation,Ransom}OfferFactionIds
  (mapped through .fid to unwrap the type-safety wrappers).
- 4 small-struct tables: PrestigeModifier kind column uses
  prestigeModifierType.toString (matches case-object names like
  "Festival", "SuppressedBeasts"); FactionRelationship's reset_date
  becomes a year/month nullable pair, relationship_level uses
  toString of the sealed RelationshipLevel.
- 2 Tier-3 leaf-blob tables: DiplomacyOffer and ProvinceView convert
  per-element via DiplomacyOfferConverter.toProto(...).toByteArray
  and ProvinceViewConverter.toProto(...).toByteArray respectively,
  exactly at the storage edge.

backfill parses each existing payload, converts each ChangedFaction
proto to the domain type via ChangedFactionConverter.fromProto, then
delegates to the shared write path - one code path for live and
backfill.

Test fixture fix:

- DiplomacyOfferProto in the v5 backfill fixture now sets both
  offer_details (AllianceOfferDetails) AND status (UNRESOLVED). Real
  payloads have both; the converter rejects "Empty offer details"
  and "Unknown diplomacy offer status".

BUILD:

- changed_faction_table_writer gains deps on the model concrete /
  converter / state targets it now references: action_result_-
  concrete:changed_faction_concrete, proto_converters:changed_-
  faction_converter, proto_converters/diplomacy_offer,
  proto_converters/view/province:province_view, state/diplomacy_-
  offer, state/faction:faction_relationship + :prestige_modifier,
  state/date, view/province:province_view, internal:action_result_-
  scala_proto.
- 5 visibility widenings on production targets to expose them to
  //src/main/scala/.../service:__pkg__: changed_hero_concrete's
  precedent, applied here to changed_faction_concrete,
  proto_converters:changed_faction_converter, proto_converters/-
  diplomacy_offer:diplomacy_offer, state/diplomacy_offer:diplomacy_-
  offer, state/faction:faction_relationship + :prestige_modifier.
- Test BUILD gains diplomacy_offer_status_scala_proto for the
  fixture status field.

Verification:

- sqlite_history_test - 46/46 pass, including both v5 backfill
  tests.
- //src/test/scala/... - 225/225 pass. No regressions.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 16:44:37 -07:00
cf70169e3c Hotfix: v3 backfill crashes on ActionResults with duplicate-province ChangedProvinces (#6735)
Production games failed to load with SQLITE_CONSTRAINT_PRIMARYKEY
during the v3 ChangedProvince migration:

  UNIQUE constraint failed: action_changed_provinces.action_seq,
  action_changed_provinces.province_id

The v2 schema PK on action_changed_provinces was (action_seq,
province_id), which assumes at most one ChangedProvince per province
per action. That invariant doesn't hold: ActionResultApplierImpl folds
through result.changedProvinces sequentially and applies each as a
delta, so two entries for the same province in one action are two
valid sequential deltas, both applied. The v3 backfill iterated
.changedProvinces.foreach and INSERTed each, hitting the PK constraint
on the second entry. Confirmed in production logs for games
cf27e3e7... and 2798739...

Fix: change v3 to drop the v2 action_changed_provinces table and
recreate it with PK (action_seq, n), where n is the position within
the source ActionResult's changedProvinces vector. province_id stays
as an indexed column but no longer constrains uniqueness. Two
ChangedProvinces with the same province_id now land as two separate
rows with different n values.

Why edit v3 instead of adding a new migration:
- The games crashing are at schema_version less than 3 (or sitting at
  v2 with v3 never run). v3 itself crashes before any new migration
  could fix things. Editing v3 is the only path that lets affected
  games load.
- The CLAUDE.md "migrations are append-only" rule exists to prevent
  silently changing semantics for games that have already migrated.
  Here, the affected games never successfully ran v3, so editing it
  isn't retroactively rewriting their history.
- Nothing reads action_changed_provinces yet (4.5h hasn't shipped),
  so dropping and recreating the table is safe. v2 created it empty
  for crashing games; the drop is a no-op there.
- For games on other deployments that did successfully run the old
  buggy v3 (no duplicate-province actions in their history), their
  schema stays with the old shape. They're not currently exercising
  the bug. A separate v6 migration can drop+recreate for them when
  4.5h needs the read path. Out of scope for this hotfix.

Changes:
- ChangedProvinceTableWriter.schemaStatements now drops the v2 table
  and recreates it with the new PK shape plus the n column and the
  changed_province_proto BLOB. CREATE INDEX on province_id recreated
  (drop took it).
- ChangedProvinceTableWriter.write takes (connection, actionSeq, n,
  cp); INSERT includes n.
- backfill iterates changedProvinces.zipWithIndex.foreach and passes
  n to write.
- Regression test: an ActionResult with two ChangedProvinces sharing
  province_id (id=7, goldDelta 5 and 10) lands as both rows ordered
  by n. Pre-fix this crashes loaded() with SQLITE_CONSTRAINT_PRIMARYKEY.

Verification:
- sqlite_history_test - 45/45 pass, including the new regression.
- Full Scala suite - 225/225 pass.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 08:09:52 -07:00
e96a86f1a6 Phase 4.5c.2 (read path): ChangedHeroTableReader (#6733)
* Phase 4.5c.2 (red): ChangedHeroTableReader round-trip tests

Adds the failing tests for the 4.5c.2 read path. ChangedHeroTableReader
is the inverse of ChangedHeroTableWriter — it reconstructs the
ChangedHeroT domain values for a given action_seq from the v4 tables
(action_changed_heroes plus action_changed_heroes_new_backstory_events).
Nothing in production consumes it yet; replayFrom and friends still
parse action_results.payload. The reader exists to verify the v4
columns are a faithful round-trip representation (paired with the
writer landed in #6730) and to seed 4.5h, which will swap the production
read path off the payload blob entirely.

Three test cases for the read path contract:

- Round-trip a single ChangedHeroC with all three tiers populated
  (Tier-1 scalars, Tier-2 loyalty/vigor pairs, Tier-3 backstory
  event) through withNewResults and assert the reader reconstructs
  the exact same Scala value.
- Return Vector.empty for an action with no ChangedHeroes (the
  vacuous case — currently passes because the stub also returns
  Vector.empty, kept for the green-side guarantee).
- Return multiple ChangedHeroT ordered by hero_id (the writer does
  not preserve insertion order, so the contract is sort by id).

Stub returns Vector.empty so the red signal is a clean assertion
mismatch (parallel to the red test in #6730). Two of three cases are
RED on this commit. The impl will land as a follow-up commit on the
same branch after review.

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

* Phase 4.5c.2: implement ChangedHeroTableReader (turns red tests green)

Fills in the stub from the previous commit so the round-trip tests
pass. Inverse of ChangedHeroTableWriter:

- Pass 1: SELECT every row from action_changed_heroes WHERE action_seq
  = ? ORDER BY hero_id. Decode Tier-1 columns directly into
  ChangedHeroC fields. Tier-2 loyalty/vigor pairs decode to StatChange
  via column-name discrimination (delta column non-NULL means
  StatDelta, absolute column non-NULL means StatAbsolute, both NULL
  means StatNoChange, both non-NULL throws — that would be a violated
  schema invariant). newProfession reverses the writer: column holds
  the proto enum ordinal, ProfessionProto.fromValue then
  ProfessionConverter.fromProto. Backstory text id is a nullable
  String.
- Pass 2: for each hero, SELECT event_proto FROM
  action_changed_heroes_new_backstory_events WHERE action_seq = ? AND
  hero_id = ? ORDER BY n, parse each blob via
  EventForHeroBackstoryProto.parseFrom plus
  EventForHeroBackstoryConverter.fromProto. Reuses one
  PreparedStatement across all heroes.

Two passes (not a JOIN) because xerial sqlite-jdbc dislikes
interleaving statements with an open ResultSet, and the per-hero
event cardinality is small. The hero ResultSet is drained and closed
before any event query runs.

Verification:

- sqlite_history_test — 44/44 pass, including the three reader cases.
- Full Scala suite — 225/225 pass. No regressions.

BUILD deps added to the reader target: profession + event_for_hero_-
backstory proto targets (for parseFrom and fromValue), the two
proto_converters/hero targets (for the converters), and state/unit_-
status (transitively referenced via the EventForHeroBackstory
converter signature — same as the writer needs).

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 06:51:39 -07:00
a07b679a5a Phase 4.5c.2: live ChangedHero decomposition in withNewResults (#6730)
* Phase 4.5c.2 (red): failing test for live ChangedHero decomposition

Red-test PR, no production fix — committed for review before the
implementation, per the established workflow.

4.5c.1 (#6727) added the v4 schema plus a backfill that decomposes
ChangedHero out of *existing* action_results.payload during the
migration. It did not wire the decomposition into the live write path:
SqliteHistory.withNewResults still writes only the whole-ActionResult
payload blob (plus snapshots) and never calls ChangedHeroTableWriter.
On a freshly-created game the backfill is a no-op, so action_changed_heroes
and action_changed_heroes_new_backstory_events stay empty forever. That
gap is what 4.5c.2 closes.

This adds one failing test, "4.5c.2 live write path", asserting that a
ChangedHero pushed through withNewResults on a fresh DB lands in the v4
tables (tier-1/2 columns plus the tier-3 per-event blob rows) — symmetric
to the existing v4 backfill test but via the live path. It fails today on
the missing-row assertion (the action_results row is written, the
decomposed rows are not). It will go green when withNewResults invokes
ChangedHeroTableWriter.write inside the same transaction.

Scope notes:
- The test bypasses the applier deliberately. InMemoryHistory.fromResults
  runs ActionResultApplierImpl, which looks up hero 80 in an empty state
  and throws — not the behavior under test. The awrs is built directly
  from ActionResultProtoConverter.fromProto plus a trivial resulting
  state, exactly the shape withNewResults consumes.
- That needs two test deps (action_result_applier,
  action_result_proto_converter) and one additive visibility entry on
  the proto_converters target for the service test package — the
  Bazel-recommended, precedented fix (the list already grants other test
  packages). No production behavior change.
- Only the live-write half of 4.5c.2 is pinned here. The read-path
  reconstruction depends on an API not yet designed and gets its own red
  assertion when that lands.
- Full //src/test/scala/... is intentionally not run: a red-test PR makes
  it nonzero by design, so it yields no clean signal. The touched service
  test package was run instead — 18/19 targets green, only the intended
  new test red.

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

* Address review: build the Scala ActionResult directly, no converter

Review feedback: the test built a throwaway proto ActionResult only to
run it through ActionResultProtoConverter.fromProto. Construct the
domain ChangedHeroC / ActionResultC directly instead and hand
withNewResults exactly what it consumes.

Net changes vs the first commit:
- SqliteHistoryTest: the 4.5c.2 fixture is now ChangedHeroC (loyalty/
  vigor as StatDelta/StatAbsolute, newProfession Profession.Mage, one
  HeroDeparted backstory event) wrapped in ActionResultC. The tier-3
  read-back still parses the stored EventForHeroBackstory proto bytes
  and compares to an expected proto literal — that is verifying the
  stored format, not converting the input.
- Reverts the proto_converters visibility entry from commit 1 (the
  converter is no longer referenced), so that target is untouched vs
  origin/main.
- Test deps: drop action_result_proto_converter, add the model targets
  the direct construction needs (action_result_concrete,
  changed_hero_concrete, action_result/types, the two state/hero
  targets). action_result_applier stays (ActionResultWithResultingState).
- Widens three internal model targets to the service test package
  (changed_hero_concrete, event_for_hero_backstory_trait, profession).
  This is the cost of the direct approach over the converter (1 vs 3
  visibility entries) and was chosen deliberately on review.

Still a red-test PR. The new test fails for the same reason as before
(no action_changed_heroes row after a live withNewResults on a fresh
DB). Verified: 40/41 in the suite pass, 18/19 service targets green,
only the intended new test red. scalafmt + gazelle pre-commit pass.

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

* Phase 4.5c.2: wire ChangedHeroTableWriter into withNewResults (live path)

Implementation that turns the red test in #6730 green. 4.5c.1 (#6727)
added the v4 schema + a backfill that decomposed ChangedHero out of
existing action_results.payload during the migration, but left the
live write path untouched. On a freshly-created game the backfill is
a no-op, so action_changed_heroes and action_changed_heroes_new_-
backstory_events stayed empty forever. This wires the decomposition
into withNewResults so every new action's ChangedHero is written to
the v4 tables in the same transaction as the action_results row.

Mechanics:

- Hoist ActionResultProtoConverter.toProto out of the per-action loop
  to compute each protoResult once. It is both the stored blob and
  the source for the decomposed rows, so doing it twice was wasted
  work.
- After the action_results batch executeBatch (so the FK target rows
  exist), iterate protoResults again and call
  ChangedHeroTableWriter.write for each ChangedHero. Still inside the
  outer try, so a failure here rolls back the action_results inserts
  too — write atomicity preserved.

Verification:

- //src/test/scala/net/eagle0/eagle/service:sqlite_history_test —
  41/41 pass, including the previously-red 4.5c.2 live write path.
- //src/test/scala/net/eagle0/eagle/service/... — 19/19 targets green.
- //src/test/scala/... — 225/225 green. No regressions.

Read path is still the proto blob (replayFrom etc. unchanged). The
decomposed-table read path comes later in 4.5c.2 or in 4.5h when
action_results.payload is dropped.

This PR is stacked on #6730 (the red test). Merge #6730 first, then
this PR will rebase cleanly onto main.

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

* Address review: writer takes Scala domain types, not proto

The 4.5c.1 writer took ChangedHeroProto because its only caller was
the backfill (whose source is proto bytes parsed from existing
payloads). With the 4.5c.2 live caller in place, that interface is
wrong — the live path has Scala domain types and was being forced to
hand them through a proto round-trip that the writer then re-pattern-
matched. Refactors the writer to take the domain type and converts
only at the actual storage boundaries.

Writer changes (ChangedHeroTableWriter):

- write(connection, actionSeq, ChangedHeroT) instead of
  write(connection, actionSeq, ChangedHeroProto). Pattern-matches on
  ChangedHeroC (sole concrete impl).
- Tier-1/2 columns read Scala fields directly. Loyalty/vigor branches
  match on StatChange (StatDelta / StatAbsolute / StatNoChange)
  instead of the proto Loyalty.LoyaltyDelta / Vigor.VigorAbsolute /
  Empty cases.
- Tier-3 is the only proto edge: each EventForHeroBackstory is
  converted via EventForHeroBackstoryConverter.toProto(event).toByteArray
  inside the per-event INSERT loop, where the bytes actually go into
  the BLOB column.
- Profession stored as the proto enum ordinal via
  ProfessionConverter.toProto(p).value (None means no change means
  NULL), symmetric with ChangedHeroConverter.
- backfill stays the entry point for migrating existing payloads:
  parses ActionResult proto, converts each ChangedHero to ChangedHeroC
  via ChangedHeroConverter.fromProto, then hands it to the shared
  write. Live and backfill share one code path.

Caller changes (SqliteHistory.withNewResults):

- Drops the protoResults hoist that the previous impl introduced.
  payload is back to a single inline ActionResultProtoConverter.toProto
  in the action_results insert loop — exactly the pre-4.5c.2 shape.
- Decomposition loop iterates newResults.zipWithIndex and passes
  awrs.actionResult.changedHeroes (Vector[ChangedHeroT]) straight to
  the writer. No proto conversion in this loop at all.

The whole-action ActionResultProtoConverter.toProto at line 83 stays,
because the action_results.payload BLOB column is still proto bytes
until 4.5h drops it. That conversion is no longer load-bearing for the
decomposed writes though.

BUILD:

- changed_hero_table_writer drops common:profession_scala_proto (the
  ProfessionConverter handles it), adds the model concrete and
  converter targets it now references (changed_hero_concrete,
  changed_hero_converter, hero:event_for_hero_backstory,
  hero:profession), plus eagle_pkg (HeroId / ClientTextId aliases) and
  state/unit_status (transitively referenced through the
  EventForHeroBackstory converter signature).
- Three additive visibility entries on production targets, exposing
  them to the production service package:
  changed_hero_concrete, hero:event_for_hero_backstory, and
  changed_hero_converter all gain
  //src/main/scala/net/eagle0/eagle/service:__pkg__. This is the cost
  of the domain-types-at-the-edge approach and was chosen on review.

Test fixture fix:

- The v4 backfill test fixture now sets a date on each backstory
  event. Real EventForHeroBackstory protos always carry a date (the
  converter requires it). The previous fixture got away with date-less
  events because the old writer was a direct proto-bytes passthrough
  and never went through the converter. The new backfill does, so the
  fixture now matches what real payloads look like in the wild.

Verification:

- //src/test/scala/net/eagle0/eagle/service:sqlite_history_test —
  41/41 pass, including both v4 backfill tests and the 4.5c.2 live
  write path.
- //src/test/scala/... — 225/225 pass. No regressions.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:22:40 -07:00
c27715904c Design doc: record refined option B (ChangedHero 4.5c.1 reality) (#6729)
The 4.5c.1 PR (#6727) shipped a refinement of option B for ChangedHero
that the design doc never recorded, so the doc and the merged code
diverged. What actually merged: loyalty/vigor StatChange oneofs as
typed nullable column pairs (column name discriminates), and
newBackstoryEvents as per-event EventForHeroBackstory blobs in a child
table. No whole-entity changed_hero_proto blob. changed_hero.proto and
ChangedHeroConverter become deletable, only event_for_hero_backstory.proto
is retained as a leaf blob format.

Reconciles the doc with that reality:

- New authoritative "Refinement" subsection under Status defining the
  three-tier pattern (scalar columns, typed columns for sealed
  value-oneofs, per-row leaf-proto blobs in child tables) and the
  per-entity classification all-scalar / refined / deep.
- Scope, "Deleting the proto", ChangedHero section, Decisions #1-#3,
  and Sequencing rows 4.5c and 4.5i updated to match. 4.5c is split
  into 4.5c.1 (merged) and 4.5c.2 (live write/read path, pending).
- 4.5i is no longer "near-nothing": refined entities' protos and
  converters get deleted there, scope scales with how many entities
  went refined.

Documentation only. No code or schema change.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:16:36 -07:00
7830c42cd2 CLAUDE.md: forbid cd-prefixed commands; fix contradictory git rule (#6728)
Adds a no-`cd` rule to the CRITICAL BASH RULES section: run git/gh/bazel
directly from the cwd (repo/workspace discovery walks up from a subdir;
the shell resets cwd after every command so a cd never persists; and
`cd <dir> && <cmd>` also trips the no-chaining rule and forces an
approval prompt every time).

Also fixes the no-`git -C` rule, which previously said "Instead, cd to
the repo root" — directly contradicting the new rule. It now points at
the no-`cd` rule instead.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 07:03:29 -07:00
f4ef9d83c4 Phase 4.5c.1: ChangedHero scalar columns + proto blob (option B) (#6727)
* Phase 4.5c.1: ChangedHero scalar columns + proto blob (option B)

Same pattern as 4.5b/#6725, applied to ChangedHero. v4 migration:
ALTER TABLE action_changed_heroes ADD COLUMN changed_hero_proto BLOB +
a one-time backfill over existing action_results.payload that
populates the 4.5a scalar columns (faction change, XP/stat deltas,
profession, backstory text id, clear-events flag) and the full proto
blob. Inside the migration transaction; no-op on fresh games.

ChangedHero is shallow: only the sealed loyalty/vigor StatChange
oneofs and the newBackstoryEvents vector are non-scalar, and those
stay in the blob (the design doc flagged per-stat StatChange columns
as overkill — option B blobs them). new_profession stores the proto
enum ordinal, UNKNOWN_PROFESSION -> NULL, mirroring ChangedHeroConverter
and the improvement_type/action_result_type precedent.

Scalars are query denormalization; the blob is authoritative. Not in
this PR (4.5c.2): live withNewResults write path + read path.

~95-line writer, mirrors ChangedProvinceTableWriter. New
changed_hero_table_writer Bazel target. Tests: scalar promotion +
ChangedHero.parseFrom(blob) == original (covers the loyalty/vigor
oneofs that live only in the blob) + minimal-hero NULL case. Full
Scala suite green (225/225).

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

* Phase 4.5c.1: refined option B for ChangedHero — full columns + per-event blobs

Implements the two refinements suggested on #6727:

- Tier 2: store the loyalty/vigor StatChange oneofs as two nullable REAL
  columns each (loyalty_delta/loyalty_absolute, vigor_delta/vigor_absolute).
  The column name is the discriminator; at most one of each pair is non-NULL;
  both NULL ⇒ StatNoChange. No kind column.
- Tier 3: keep the EventForHeroBackstory proto and store just those bytes,
  one row per event, in action_changed_heroes_new_backstory_events.

ChangedHero now has no whole-entity blob: it round-trips losslessly from
columns + per-event blobs, so changed_hero.proto/ChangedHeroConverter become
deletable later while event_for_hero_backstory.proto is retained as the blob
format. Renames the v4 migration to applyV4ChangedHeroColumns and rewrites the
v4 tests for the new schema (loyalty/vigor columns + events child table).

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 06:48:53 -07:00
f76d4b785c Design doc: record option B + re-plan 4.5c-i per-entity (#6726)
ChangedProvince shipped via option B (scalars + proto blob, #6725), not
the full-relational "decision locked" plan. Update the doc so it stops
contradicting what's on main and so 4.5c-i has a coherent plan:

- New "Status — option B adopted" section at the top; it supersedes the
  body, which is kept as historical context / the migration target if a
  structured-query need on deep aggregates ever appears.
- Rescind "Decisions locked #1" (fully relational, no blob fallback);
  rewrite the locked decisions around option B + per-entity blob choice.
- Re-plan the Sequencing table per-entity: each gets 4.5a scalars + an
  <entity>_proto BLOB, split .1 (schema+backfill) / .2 (live+read).
  Estimate drops from ~3-4 weeks to ~1.
- Revise principle 7 (proto blob is the retained authoritative store),
  the Scope (per-entity protos are NOT deleted), and "Deleting the
  proto" (superseded).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 07:21:20 -07:00
7d6961fc86 Phase 4.5b (option B): ChangedProvince scalar columns + proto blob (#6725)
Alternative to PR #6721. Applies the design doc's "stopping rule"
consistently: instead of exploding ChangedProvince's ~25 nested
aggregates into ~37 child tables, keep the v2 top-level
action_changed_provinces table (per-(action,province) scalar deltas +
the province_id index — the part anyone actually queries) and store the
full ChangedProvince proto in one changed_province_proto BLOB for
lossless round-trip / replay.

This is the same reasoning the doc already applies to Quest, applied to
the deep army/unit/hero nesting that is likewise never analytics-
queried. ~110 lines of writer vs ~1100 in the full-relational approach.

The scalar columns are pure query denormalization; the blob is
authoritative. A future fully-relational pass (if a structured-query
need on the deep aggregates ever materializes) is a pure
read-blob -> write-columns migration — which is why the blob must
survive any later proto deletion. This deliberately re-opens the doc's
"new-entity vectors are fully relational, no blob fallback" lock; the
quest hybrid is the precedent.

Not in this PR (would be 4.5b.2): the live withNewResults write path
and the read path. Nothing reads these columns yet.

v3 = ALTER TABLE action_changed_provinces ADD COLUMN
changed_province_proto BLOB + a one-time backfill over existing
action_results.payload (scalars + blob), inside the migration
transaction; a no-op on freshly-created games.

Tests: a ChangedProvince with a genuinely nested aggregate (defending
army -> combat unit) + a repeated field, asserting scalar promotion and
that ChangedProvince.parseFrom(blob) == original (proves the deep
structure round-trips), plus that no child tables are created. Full
Scala suite green (225/225).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 11:17:09 -07:00
9b6cbe6455 Fix tutorial chronicle crash: tutorial creation must use SqliteClientTextStore (#6724)
* Red test: tutorial game text store diverges from reload path

createTutorialGameForUser builds its ClientTextStore as the legacy
file-based ClientTextStoreImpl (persists to completeText.txt), but every
reload reconstructs the store from text_store.db via
SqliteClientTextStore.loaded. Texts generated during a tutorial session
(e.g. yearly chronicle updates) are therefore unrecoverable after a
save+reload, surfacing in production as:

  EagleInternalException: Unknown dependency for LLM request
  chronicle_update_373_11: chronicle_update_371_11

This is a failing regression test only — no fix yet. It creates a
tutorial game, persists a chronicle text through the game's live store,
then reads it back via the production reload path and asserts it
resolves. Fails today (TextGenerationDependencyUnknown).

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

* Fix: tutorial creation uses SqliteClientTextStore (text_store.db)

createTutorialGameForUser built its ClientTextStore as the legacy
file-based ClientTextStoreImpl (completeText.txt), while every reload
reconstructs the store from text_store.db via SqliteClientTextStore.loaded
and normal game creation already uses SqliteClientTextStore. Texts
generated during a tutorial session (e.g. yearly chronicle updates) were
written to completeText.txt and lost on the first save+reload, causing:

  EagleInternalException: Unknown dependency for LLM request
  chronicle_update_373_11: chronicle_update_371_11

Switch tutorial creation to SqliteClientTextStore.createWithData, matching
the normal-creation path, so create and reload agree on text_store.db.
Behavior-preserving otherwise (unrequestedTexts/accessibleTo were already
empty here). Turns the regression test from the previous commit green;
full Scala suite (225 tests) passes.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:17:16 -07:00
9eb90c2cb4 Vary maxWindMph per map by terrain composition (#6723)
All maps shipped with maxWindMph: 25 for every month, giving them no
geographic windiness identity. This sets maxWindMph based on each
map's terrain mix — open terrain (plains, still water) pushes the
ceiling up, dense terrain (forest, swamp) pulls it down — with a
small additional bump on winter months (proxied by snow/blizzard
chance).

Spread across maps now ranges roughly 20–29 mph (median 24).
Forest-heavy maps like Nikemi land at 20–22; open-water maps like
Chia and field maps like MeetThemInTheField land at 27–29.

Note: the existing exploding-roll mechanic in NewWeather amplifies
the tail proportionally, so a 28 mph cap produces ~55 mph outliers
where a 22 mph cap produces ~44 mph outliers (same ~5% explosion
frequency). That's intended texture but worth flagging if any of the
downstream wind consumers want a tighter cap.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 10:15:25 -07:00
48a44e126a Couple wind speed to weather conditions (#6722)
Wind speed was previously a uniform-then-exploding roll against a
per-map maxWindMph, with no awareness of the rolled weather
conditions. Stormy days were as likely to be calm as sunny days.

This adds six per-condition multipliers (settings.tsv) applied on top
of the existing wind-speed formula. Defaults: 1.0× for sun/clouds,
1.2× for rain/snow, 1.5× for thunderstorms, 1.8× for blizzards.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 10:14:49 -07:00
b8605a3662 Fix free-hero backstory visibility (placement hook + load migration) (#6719)
* Red test: Traveler move does not extend backstory visibility to dest ruler

Reproduces the [BACKSTORY-VISIBILITY] production diagnostic. When a Traveler
unaffiliated hero with a backstory moves into a province, the destination
province's ruling faction should gain visibility of the hero's backstory text
(as ProvinceConqueredAction and TravelCommand already do for their cases).

PerformUnaffiliatedHeroesAction.heroMovedResult builds the HeroMoved result
with no clientTextVisibilityExtensions, so this test FAILS until the fix:

  Vector() did not contain element
  ClientTextVisibilityExtensionC("Leah_backstory 0", Vector(2))

The move + relocation sanity assertions pass; only the visibility assertion
fails, isolating the root cause. Committed test-only for review before the fix.

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

* Red test: stationary free hero's ruler change does not extend backstory visibility

Adds the broader case behind the [BACKSTORY-VISIBILITY] diagnostic: a free
hero with a backstory sitting still in a province whose current ruler is not
in the backstory's visibility set. Conquest already extends visibility via
ProvinceConqueredAction, but nothing heals a stationary hero whose ruler
changed by other means (faction split, vassal independence, inheritance).

The per-round unaffiliated-hero phase emits no clientTextVisibilityExtensions
for this case, so the test FAILS until the fix:

  Vector() did not contain element
  ClientTextVisibilityExtensionC("Leah_backstory 0", Vector(2))

14 pre-existing tests still pass; the 2 new red tests isolate the two gaps
(Traveler move-in, and stationary ruler-change). Test-only for review.

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

* Fix: extend free-hero backstory visibility to current province ruler

Two complementary fixes for the [BACKSTORY-VISIBILITY] diagnostic; turns the
two red tests green (16/16 in the class, full Scala suite 224/224).

1. heroMovedResult: the HeroMoved ActionResult now emits a
   ClientTextVisibilityExtensionC for the destination province's ruling
   faction (guarded by backstoryVersions.nonEmpty, since backstoryTextId
   throws on empty). Mirrors ProvinceConqueredAction and TravelCommand,
   keeping visibility extension atomic with the move event.

2. EndUnaffiliatedHeroActionsPhaseAction: a per-round self-healing sweep
   emits visibility extensions for every free hero with a backstory to its
   province's current ruler. Covers ruler changes by non-conquest means
   (faction split, vassal independence, inheritance) where the hero is
   stationary, and retroactively heals already-stuck heroes. Visibility is
   additive and de-duplicated downstream, so re-emitting each round is safe;
   the engine has no visibility state to dedupe against, so this redundancy
   is the deliberate tradeoff.

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

* Redesign: fix backstory visibility at placement + one-time load migration

Replaces the per-round sweep with the true root-cause fix. Pool heroes arrive
with a pre-authored backstory and previously had NO visibility set at
appearance (the all-faction request fires only for spawned heroes with an
empty backstory), so they were legible only to whatever faction a later narrow
event happened to extend to.

- UnaffiliatedHeroAppearedAction: for the pre-existing-backstory branch, emit
  a ClientTextVisibilityExtensionC to all factions, matching the spawn-time
  policy. This is the missing event-driven hook.
- Reverted the EndUnaffiliatedHeroActionsPhaseAction per-round sweep: the only
  engine path that changes a province's ruler is ProvinceConqueredAction
  (already handled), so no perpetual sweep is needed for correctness.
- heroMovedResult fix (Fix 1) retained: extension stays atomic with the move.
- FreeHeroBackstoryVisibilityRecovery: one-time on-load repair (mirrors
  MissingHeroNameRecovery) widening any free-hero backstory not visible to its
  current province ruler. No-op for healthy games; heals already-stuck heroes
  like prod hero 184. Wired into GamesManager.gameController load path.

Tests: placement-hook tests in UnaffiliatedHeroAppearedActionTest, migration
tests in new FreeHeroBackstoryVisibilityRecoveryTest, Test 1 (move-in) kept,
obsolete sweep test removed. Full Scala suite 225/225.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:02:25 -07:00
2d3779448c Draw faction-target province highlights as a single grouped outline (#6720)
When the Diplomacy command selector targets a faction, MapController
was drawing TargetedProvinces with per-province borders, so each of
the faction's provinces got its own outline (including the internal
borders between adjacent same-faction provinces). The visual the
user wanted is the unified outline used by notifications and popups:
one boundary around the faction's territory as a whole.

The mechanism already exists — ProvinceBorderManager.SetHighlightGroup
fills a per-province lookup texture that the shader consults to
suppress borders between provinces in the same group, and
OverrideTargetedProvinces uses it. TargetedProvinces was calling
ClearHighlightGroup unconditionally.

Add MapController.TargetedProvincesAsGroup. When true, the no-override
branch calls SetHighlightGroup(TargetedProvinces) before the existing
per-province strobe/border loop, producing the unified outline.
EagleGameController.Update sets the flag to
selector.TargetedFactionIds.Count > 0 alongside the existing
mapController.TargetedProvinces assignment, so it stays in sync with
the selector each frame.

DiplomacyCommandSelector itself needs no change — its TargetedFactionIds
is already non-empty when a target faction is picked, and the base
CommandSelector.TargetedProvinces getter already expands a non-empty
TargetedFactionIds into the faction's provinces. Other selectors
(AttackDecision, BattleAftermath) don't populate TargetedFactionIds
and so render per-province as before.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:01:15 -07:00
7eb5894a57 Phase 4.5a: schema migration scaffolding + changed-entity top-level tables (#6716)
Introduces an append-only migration system for SqliteHistory and adds
the four "changed entity" top-level tables (scalar columns only). No
write or read paths target these tables yet — they're scaffolding for
the per-aggregate decomposition that lands across Phase 4.5b–e. See
docs/SQLITE_ACTION_RESULT_DECOMPOSITION.md.

Migration runner
- `SqliteHistory.Migrations` holds a Vector[Migration(version, apply)],
  append-only. Versions must be strictly ascending; new schema work is
  always a *new* migration, never an edit of an existing one.
- `applyPending` reads `metadata.schema_version`, runs every migration
  with a higher version, bumps the stored version after each — the whole
  sequence inside a single transaction so a partial failure rolls back
  to the pre-migration version.
- `metadata.value` is now TEXT (was BLOB) to hold the schema_version as
  a stringified int. The column had no readers before this PR.

v1 — baseline
The existing tables (action_results, state_snapshots, shardok_*),
unchanged, repackaged into the migration runner. Idempotent (CREATE
TABLE IF NOT EXISTS) so any pre-migration DB that already has these
tables ends up at v1 without an error.

v2 — changed-entity top-level tables
- action_changed_provinces (PK action_seq + province_id) with the
  scalar fields of ChangedProvinceC.
- action_changed_heroes (PK action_seq + hero_id) with the scalar fields
  of ChangedHeroC. StatChange fields (loyalty/vigor) and
  newEventsForHeroBackstory are deferred — they belong in child tables
  per the design doc.
- action_changed_factions (PK action_seq + faction_id) with the scalar
  fields of ChangedFactionC.
- action_changed_battalions (PK action_seq + battalion_id only).
  ChangedBattalionC has no scalar fields at the top level — its sole
  field is the contained BattalionT, which gets its own decomposition
  in Phase 4.5e.
- Per-entity-id indexes on each of the four tables so cross-game queries
  like "all actions touching hero X" are single-index lookups.
- FOREIGN KEY (action_seq) REFERENCES action_results(action_seq)
  ON DELETE CASCADE on each, so truncateTo only needs to drop from
  action_results and the rest cascades.

Tests
- Fresh DB ends up at `latestSchemaVersion`.
- All four new tables exist after create.
- Re-loading is a no-op (no migrations re-run, no schema_version change).
- A simulated pre-v2 DB (v1 baseline + schema_version='1') upgrades to
  the latest version and gains the new tables on load.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:36:34 -07:00
e9f10ee540 Phase 4 fix: wire SqliteHistory through the cloud persister (#6718)
Phase 4's cutover wrote game.db only to local disk. PersistedHistory had
shipped every chunk through the Persister, so .e0a files landed in S3
(DigitalOcean Spaces in prod) automatically; SqliteHistory dropped that
because the dbFile-based factories never accepted a Persister.
Result: a Hetzner host crash would lose every active game; only
text_store.db survived in cloud storage. This restores parity with the
SQLITE_HISTORY_DESIGN.md plan (lines 43, 244–245, 271).

Mechanism
- `SqliteHistory` constructor now takes `dbFile` + `Option[Persister]`.
  After each successful `withNewResults` commit, it calls
  `maybeUploadToPersister(previousCount)`, which uploads the whole
  game.db blob when either (a) the batch crossed at least one 25-action
  snapshot boundary or (b) more than 60s has elapsed since the last
  upload.
- Before reading bytes off disk, it issues
  `PRAGMA wal_checkpoint(TRUNCATE)` so the uploaded file is a
  self-consistent snapshot — without the checkpoint the upload would
  miss whatever's still in game.db-wal.
- Upload failures are caught + logged so a transient cloud-storage
  outage does not break local game progress.
- `AsyncS3Persister` (the production wiring) already coalesces saves for
  the same key on a 500ms tick and has a JVM shutdown-hook flush, so
  uploading more often than that costs at most a memcopy.

Load path
- New `SqliteHistory.loadedOrDownloaded(dbFile, persister)` first tries
  the local file, otherwise tries `persister.retrieveAsStream("game.db")`
  and writes it locally. Returns `None` when both are absent (the game
  simply does not exist).
- `GamesManager.gameHistoryFromFiles` uses this so a wiped local saves
  directory recovers games from cloud storage on next boot.

Plumbing
- `NewGameCreation.createEngine` and
  `TutorialGameCreation.createTutorialGame` pass their `persister` to
  `SqliteHistory.createWithScalaResults` (un-`@annotation.unused`).
- BUILD: sqlite_history depends on the persister target; the
  SqliteHistoryTest target depends on it as well.

Tests
- An in-memory `FakePersister` records save calls.
- `withNewResults` triggers an upload at the 25-action boundary but not
  before; a single batch spanning a boundary also triggers exactly one.
- No persister configured → no observable behavior change.
- `loadedOrDownloaded` opens the local file when present; downloads from
  the persister when the local file is missing; returns `None` when
  both are absent.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:31:34 -07:00
58030a56a7 Split Eagle server fat JAR into deps + app Docker layers (#6717)
The Docker Build and Push workflow spends more time pushing than building
because the entire ~115MB Eagle classpath ships as one pkg_tar layer, so
every Scala commit re-uploads the whole blob to DOCR.

Add a jar_split rule that partitions the scala_binary's transitive runtime
jars by workspace into a stable third-party layer (76MB, 137 jars) and a
first-party layer (38MB, 989 jars). crane skips the unchanged deps blob, so
typical Scala-only commits re-upload ~38MB instead of ~115MB. Entrypoint
switches from -jar fat.jar to a -cp classpath glob over both layer dirs.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 16:19:47 -07:00
3a45347549 Phase 4: cutover game creation from PersistedHistory to SqliteHistory (#6715)
GamesManager, NewGameCreation, and TutorialGameCreation now open per-game
game.db SQLite files via SqliteHistory instead of chunked .e0a blobs via
PersistedHistory. PersistedHistory remains in the tree so the existing
unit tests still build; it is no longer reachable from the running server.

- SqliteHistory.createWithScalaResults / emptyStartingState convenience
  constructors mirror the PersistedHistory new-game shape (raw scala
  ActionResultTs folded through the applier).
- GamesManager.gameHistoryFromFiles looks up
  saveDirectoryForGame(gameId)/game.db and opens it with
  SqliteHistory.loaded; the persister is now unused on that path.
- TutorialGameCreationTest's "successfully creates" case forces the lazy
  val instead of calling createTutorialGame a second time with the same
  gameId — duplicate creates would collide on snapshot PK.
- Tests that exercise game creation point eagle.save.dir at a temp dir so
  the bazel sandbox can write game.db.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 06:29:49 -07:00
660d349e50 Design doc: full decomposition of ActionResult into relational tables (#6714)
* Design doc: full decomposition of ActionResult into relational tables

Follow-up to docs/SQLITE_HISTORY_DESIGN.md. Plans replacing the
proto-blob action_results.payload with native relational tables for
every field of ActionResultC, recursively.

Key facts that motivate full decomposition (vs blob middle ground):
- ActionResult-family protos are storage-only (one file imports them,
  ChangedProvinceConverter; no RPC consumers).
- ActionResultC has no top-level polymorphism — 34 flat fields with no
  sealed-trait discriminator at the action level.
- Pre-alpha is the right window: we can nuke save dirs as part of the
  rollout, and we never have to migrate real user data.

Doc covers principles, top-level schema, the four changed-entity
families (~25 tables each under the ChangedProvince family), notifications
and generated-text-requests, the "new entity" vectors with a fallback
flag, schema migration scaffolding (now load-bearing), read/write/replay
path changes, and a 9-step sub-phase plan (4.5a–4.5i) starting after the
Phase 4 cutover lands. Three open questions for sign-off at the bottom.

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

* Lock in the three open questions

- "New entity" vectors get full relational decomposition (no blob fallback).
- Sub-phase ordering stays as proposed.
- Phase 4 cutover lands separately, before 4.5a starts.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 06:51:34 -07:00
57c2862a57 Phase 3: SqliteHistory shardok integration (#6713)
All seven FullGameHistory shardok methods land, plus truncateTo cleanup
of orphaned battles. Pure foundation — no production caller yet.

Schema:
- shardok_results: (shardok_game_id, action_seq) -> ShardokActionResult bytes
- shardok_state: shardok_game_id -> (game_state bytes, last_eagle_round_id)
- shardok_player_results: (shardok_game_id, faction_id, seq) -> ActionResultView bytes
- shardok_player_commands: (shardok_game_id, faction_id) -> AvailableCommands bytes (nullable)

Behavioral semantics mirror PersistedHistory:
- shardokPlayerCount / shardokPlayerResultsSince fall back to faction -1
  if the requested faction has no registered presence.
- shardokPlayerAvailableCommands does NOT fall back to -1 (exact match only).
- withNewShardokResults: append results and per-faction views, upsert
  per-faction commands (replacing previous value, NULL if not in the new
  availableCommands), upsert per-battle game_state. Touched factions =
  old participants union newPlayerResults factions.
- withResetShardokResults: drop all rows for the given battle.

truncateTo now uses stateAfter(target).outstandingBattles to determine
which shardok rows to preserve; rows for any other battle are deleted in
the same transaction. Mirrors deleteOrphanedShardokFiles.

8 new tests covering each method plus the full battle reset case.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 06:30:39 -07:00
3923ae8c11 Phase 2: SqliteHistory read paths (all / since / sinceDate / recentResultsForRound) (#6712)
All four trait methods land via a single replayFrom helper: find the latest
snapshot at or before the emit boundary, replay forward to the end of the
range, emit ActionResultWithResultingState only for action_seq >= emitFrom.

- all: replayFrom(0, count)
- since: clamp start to [0, count], replayFrom(start, count)
- sinceDate: SQL SELECT MIN(action_seq) WHERE date_year IS NOT NULL AND
  (date_year, date_month) >= (?, ?), then replayFrom(thatSeq, count).
  Matches PersistedHistory semantics: actions with NULL date are filtered
  out (they're pre-game-start; once currentDate is Some it stays Some).
- recentResultsForRound: SQL SELECT MIN/MAX(action_seq) WHERE round_id = ?,
  replay that contiguous range, filter by the Scala predicate.

stateAfter is refactored to share the new readLatestSnapshotAtOrBefore
helper. Behavior unchanged.

Tests cover all four methods including snapshot-boundary crossings and
out-of-range clamping.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:10:53 -07:00
d0e39bfc02 Phase 1: SqliteHistory skeleton — schema, write paths, stateAfter (#6711)
Adds SqliteHistory implementing FullGameHistory. Phase 1 scope:

- Schema (action_results, state_snapshots, metadata) with WAL, NORMAL
  synchronous, FK pragmas at connection init.
- Lifecycle: create(dbFile, startingState), createWithResults(...),
  loaded(dbFile), close().
- Write paths: withNewResults (transactional batch insert + snapshot at
  every 25-action boundary), truncateTo, saveNow (no-op).
- count cached in-memory, refreshed from DB on construction.
- last and stateAfter implemented via snapshot+replay (latest snapshot
  <= N, replay forward via ActionResultApplier).
- Other read paths (all, since, sinceDate, recentResultsForRound) and
  all shardok methods throw NotImplementedError with phase markers;
  filled in in Phase 2 and 3.

Tests cover: empty create, withNewResults sequencing, snapshot writes
at the 25-boundary, stateAfter at and between boundaries, truncateTo,
saveNow no-op semantics, reload-and-continue.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:03:43 -07:00
78ed12052b Phase 0: SQLite game history design doc (#6710)
* Phase 0: SQLite game history design doc

Per-game game.db replaces .e0a / .e0s / .e0i file-based persistence.
SqliteHistory implements the existing FullGameHistory trait so the
cutover at the call site is a single constructor swap.

Schema, connection lifecycle, read/write paths, snapshot strategy,
shardok results layout, migration trigger, rollback policy, and four
open questions called out for sign-off before Phase 1.

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

* Phase 0: fold in user feedback — drop migration, separate DBs rationale

- Remove the migration importer entirely. At cutover we nuke save
  directories one time; pre-alpha trade-off accepted explicitly.
- Add the writer-lock-contention rationale for keeping game.db and
  text_store.db as separate files instead of one combined DB.
- Lock in the four open questions with the agreed defaults.
- Note the GameAdminServiceImpl.getActionDetail follow-up to move off
  history.all; not blocking.
- Phase plan drops from 5-6 weeks to 3-4 weeks with migration gone.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 17:19:04 -07:00
abdb91b8d9 Add direct domain ↔ Common-proto enum converters (#6709)
CommonConverters and ShardokInterfaceGrpcClient.fromCommon no longer
round-trip through the Eagle proto enums to harvest .value: Int just to
feed CommonProfession.fromValue / CommonBattalionTypeId.fromValue.

New CommonEnumConverters defines CommonProfessionConverter and
CommonBattalionTypeIdConverter with toCommon / fromCommon. The shardok
interface boundary now only touches domain types and the Common proto
it's actually emitting; the Eagle proto enums are out of this path.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 16:01:44 -07:00
2debfe546c Drop proto conversion in ShardokInterfaceGrpcClient.resolveBattle (#6708)
EagleUnit now holds domain HeroT and Option[BattalionT], and ExpandUnit
takes the domain GameState. That removes the per-battle
GameStateConverter.toProto call and the dual-arg playerInfo signature.

CommonConverters.toCommon reads domain fields and routes Profession /
BattalionTypeId through the existing proto converters to keep the
Common* enum values stable.

fromCommon now builds HeroC / BattalionC directly instead of building
proto types only to convert them back to domain at the call site.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 15:36:40 -07:00
5d81972093 Use domain GameState in ShardokInterfaceProxy.resolveBattle (#6707)
* Use domain GameState in ShardokInterfaceProxy.resolveBattle

The Shardok gRPC client used to take a proto GameState directly, which
forced GamesManager to convert domain → proto right before the call
and forced the client to inline its own proto-relationship-level
filtering. Now the trait accepts the domain GameState; the gRPC client
uses FactionUtils.alliedFactions for the watcher derivation and reads
the relationship enum via the domain Hostile case object. Proto
conversion still happens once inside the client where the unit-
construction helpers (toCommon, EagleUnit.ExpandUnit) require it.

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

* Use FactionUtils.nonHostileFactions and flag the dual-arg as TODO

Add a nonHostileFactions helper to FactionUtils paralleling alliedFactions,
covering Ally + Truce (and defensively Unknown). playerInfo now uses it
instead of the inline relationshipLevel != Hostile filter.

Add a TODO at the GameStateConverter.toProto call inside resolveBattle so
the dual-arg playerInfo signature (domain + proto) doesn't calcify -- it's
a transition state until toCommon and EagleUnit.ExpandUnit are moved to
take the domain GameState.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 15:05:45 -07:00
c8aa26058b Send watcher faction IDs in Shardok NewGameRequest (#6705)
Populates the watcher_eagle_faction_ids field added in the prior
Shardok-side change. The watcher set is the union of each
participant's non-hostile faction relationships, minus the
participants themselves.

Once both this and the Shardok-side change are deployed, factions
allied to a battle's participants but not themselves participating
receive filtered views that include hero stats and hidden actions for
their allies' units.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:55:06 -07:00
386d99d553 Cache parsed games.e0es to skip per-lobby-tick re-parse (#6706)
* Cache parsed games.e0es to skip per-lobby-tick re-parse

games.e0es was being read from disk and parsed by six call sites:
ensureGameLoaded, gamesFor, gamesForWithoutBlocking, loadGamesInBackground,
save (for the merge), and getAllRunningGamesSummary. Combined with the
EagleServiceImpl path that fans lobby updates out to every connected user
on every game-affecting action, this produced repeated "Read N ongoing
games from games.e0es" log lines per lobby tick and a parse-and-allocate
churn that grew with both lobby user count and games count.

The freshness justification ("called fresh each time to handle race
conditions during blue-green deployment") is already covered by the
existing flush-marker invalidation: when the active server flushes
state, the new server detects the flush-marker mod-time bump and clears
its caches.

Add an in-process cache (cachedRunningGames) populated lazily by the
read paths and refreshed at the end of save() from the just-written
state. The flush-marker invalidation path also clears the cache so
blue-green semantics are preserved.

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

* Use Option.getOrElse instead of match in runningGamesFromStorage

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:54:02 -07:00
b3d99c6f85 Generate ally-aware Shardok views for non-participant watchers (#6704)
When a faction watches an ally's battle, the Eagle layer falls back to
Shardok's generic observer (factionId == -1) view because Shardok only
emits filtered views for battle participants. The observer view treats
every unit as an enemy, so visibility is knowledge-gated: battalion
stats (threshold 50) usually creep above the floor while hero stats
(threshold 75) lag behind, leaving watchers with battalion data but no
hero stats for their own allies' heroes.

This change adds a watcher_eagle_faction_ids field to NewGameRequest
and has Shardok emit one filtered view per watcher in GetUpdates. Each
watcher's view treats their allied participants' units as fully
visible (hero stats, hidden actions like meteor casts, hidden-target
coords) while non-allied units fall back to observer-style knowledge
gating.

The watcher path is implemented via new
GameStateFilteredForPlayerWithAllies and
ActionResultFilteredForPlayerWithAllies overloads that take alliedPids
explicitly, plus a new ShardokEngine::FilterNewResultsForWatcher that
drives them. The IsHiddenFromOpponents predicate now treats the
actor's allies as self for visibility purposes.

The Eagle side that populates watcher_eagle_faction_ids is in a
follow-up PR; this change is a no-op until that lands.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:49:11 -07:00
afb143645a AI: imprison the messenger when accepting a truce would box us in (#6702)
If the AI's only neighbor is the proposing faction (every one of our
provinces' neighbors is owned by us or them), accepting a truce locks
us out of attacking the only faction we could expand into for the
duration of the truce. The existing alliance-acceptance guard already
catches this for alliances; truces had no such guard and the AI would
happily accept and stagnate.

When boxed-in:
  - choose Imprisoned if the offer's eligible statuses include it
    (which requires us to have at least one province, per
    EligibleDiplomacyStatuses.maybeImprisonStatus)
  - otherwise fall back to Rejected.

Factor the boxed-in predicate into a shared helper
(acceptingWouldBoxUsIn) and have allianceOfferAcceptanceChance use it
so the two paths stay in lockstep.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 20:31:59 -07:00
ba160b9669 AI: break alliance with weakest bordering ally when boxed in (#6700)
When the midgame AI has no neutral or hostile neighbors anywhere on its
border, it has no path to growth and gets stuck in the
chosenNoNeutralNeighborsCommand fallback resting and improving forever.
The upstream allianceOfferAcceptanceChance guard refuses alliances that
would immediately box us in, but that state can still emerge later --
e.g., a shared hostile neighbor gets conquered by our ally.

Add a chooser that, in that state, finds the weakest bordering ally
(measured by province count, since post-truce conquest is the goal)
and emits a DiplomacyAvailable BreakAlliance command targeting them.
Inserted just before chosenNoNeutralNeighborsCommand so the recovery
path runs ahead of the catch-all rest/improve.

Truces are intentionally not treated as boxing-in: they expire, and
breaking would burn trust unnecessarily -- waiting them out is cheaper.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 19:51:38 -07:00
5664f15dac Push locally-resolved hero names to connected clients (#6698)
Hero names from the local heroNameCache (and FixedHeroName text) are
resolved synchronously inside UnrequestedTextHandler and stored as
complete in the ClientTextStore -- but unlike LLM-generated text, they
never flow through the streaming pipeline that pushes updates to
connected clients. Mid-session UIs sit on the "Hero" fallback until the
user reconnects (which triggers the on-subscribe bulk-send of every
accessible complete text).

Surface the resolved CompleteClientTexts from
clientTextStoreWithHandledUnrequestedTexts via a new
HandledUnrequestedTextsResult, and have GamesManager push them through
GameController.withResolvedTextsPushedToClients (which routes through
the existing humanClientsAfterUpdatingLlmStream path used for LLM
streaming responses). Initial-load callers continue to ignore the
list since the on-subscribe bulk-send already covers that case.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 19:24:06 -07:00
8009ed62d2 Auto-fire End Turn after AI action when next player has no options (#6699)
The AI thread's post path didn't run the only-one-option auto-loop, so
when an AI player ended their turn and control passed to a player whose
only available command was End Turn (e.g., a player with one stunned
unit), that player had to click End Turn manually. The human-driven
PostCommand path already called PostWhileCurrentPlayerHasOnlyOneOption.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 19:21:26 -07:00
8abd355883 Suppress FMOD output-switch error in ErrorHandler (#6696)
Unity's internal FMOD wrapper logs an Error whenever the macOS audio
output device changes (headphones plug/unplug, AirPods, system output
switch): the wrapper tries to call setOutput on FMOD after System::init
and FMOD rejects with "Cannot call this command after System::init"
(error 32). The audio continues to play on whichever device was active
at app launch; nothing actionable for us, but the message was reaching
ErrorHandler and surfacing the in-game error panel.

Filter the specific message in ErrorHandler.HandleLog. Match both
halves of the string so a genuine FMOD failure (different cause) still
surfaces.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 19:11:22 -07:00
fb8408fd64 Gate Observe battle button on ShardokGameModel readiness (#6697)
ShardokBattles is populated when a NewBattle eagle update arrives, but
the matching ShardokGameModel isn't created until the first
ShardokActionResultResponse for that battle reaches the client (in
EagleGameModel.MakeGameModel via the ReceiveGameUpdate handler). In
that gap, the Observe button on an allied battle was unconditionally
interactable, and clicking it threw KeyNotFoundException out of
EagleGameController.GoToBattle's Model.ShardokGameModels[gameId]
indexer access.

Gate the Observe button on Model.ShardokGameModels.ContainsKey(gameId).
Show "Loading..." until the matching shardok model arrives, then flip
to interactable "Observe". The Self/Fight! path was already implicitly
gated via fightableGameId (derived from RunningShardokGameModels), so
no change needed there.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:44:29 -07:00
9f155a359b Centralize popup-driven province highlight in PopupCanvasController (#6693)
* Centralize popup-driven province highlight in PopupCanvasController

Multiple PopupPanelController subclasses (PleaseRecruitMeCommandSelector,
NotificationPanel, ResolveDiplomacyCommandSelector,
ResolveRansomOfferCommandSelector) all wrote directly to
MapController.OverrideTargetedProvinces from their SetUpPanel hooks,
and SetUpPanel only ran when state changed. So when a notification
arrived or was dismissed while the Please Recruit Me popup was open,
the notification panel's SetUpPanel would overwrite the recruit
popup's province highlight, and the recruit popup would never
re-assert. Result: the requesting hero's province often wasn't
highlighted even though the popup said it should be.

Switch to a single-writer model:

- PopupPanelController exposes CurrentAffectedProvinceIds (the current
  popup's affectedProvinceIds), and no longer writes to MapController
  itself.
- PopupCanvasController gains a MapController reference and, in
  Update, applies the topmost active panel's CurrentAffectedProvinceIds
  every frame, so popups can't clobber one another. On the transition
  from "popup active" to "no popup", we clear the override once and
  then leave the field alone so non-popup writers (table hovers etc.)
  can drive it normally.
- ResolveDiplomacyCommandSelector and ResolveRansomOfferCommandSelector
  used to compute their highlight inline in SetUpPanel; that logic
  moves into the PopupInfo construction so each offer carries the
  right affectedProvinceIds, and their SetUpPanel overrides shrink
  accordingly. PleaseRecruitMeCommandSelector and NotificationPanel
  already populated affectedProvinceIds correctly and didn't need
  changes.

PopupCanvasController has a new public MapController field. Wire it in
the prefab/scene where the PopupCanvasController lives (drag the
MapController object onto the new "Map Controller" Inspector field)
before testing — without it, opening a popup will throw a
NullReferenceException, which is intentional per the project's
no-defensive-checks policy on Inspector fields.

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

* Wire MapController into both PopupCanvasController instances in Gameplay scene

Companion to the previous commit. Two PopupCanvasController instances
exist in Gameplay.unity (narrow + wide layout); both now reference the
MapController so the new single-writer Update path can drive
OverrideTargetedProvinces from any active popup.

Also includes auto-saved DialogueManager unitHighlight default colors
that Unity wrote into the scene when loading after the prior tutorial
unit-highlight PR landed — incidental, but moot to revert.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:28:22 -07:00
19cf9c592a Add failing test for backstory visibility on hero imprisonment (#6695)
* Add failing test for backstory visibility on hero imprisonment

When a hero is captured via battle aftermath and imprisoned, the captor
faction has no visibility on the prisoner's backstory text. This test
asserts the imprisonment ActionResult should emit a
ClientTextVisibilityExtensionC granting the imprisoning faction
visibility on the captured hero's backstory text -- matching the
parallel pattern in ProvinceConqueredAction.afterUpdatingUnaffiliatedHeroes.

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

* Extend backstory visibility to captor in convertToUnaffiliated

When a captured hero with an existing backstory is converted into an
unaffiliated hero (imprisoned or exiled), emit a
ClientTextVisibilityExtensionC granting the captor faction visibility
on the prisoner's backstory text. Without this the captor sees blank
text for their own prisoner -- the same gap ProvinceConqueredAction
already closes for inherited unaffiliated heroes when a province changes
hands.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 18:27:56 -07:00
537f846fee March marks the acting (menu) province as acted (#6694)
* Add failing test: March should mark the acting (menu) province as acted

Currently, MarchCommand.make takes only originProvinceId and marks
that as the only acted province (via provinceIdActed). If the player
opens the command menu on province A and chooses to march units from
a different origin province B, only B is marked acted. Province A
retains its full command menu and can issue another command in the
same round — the player can effectively shuffle units around without
spending province A's action.

Plumbing changes (necessary for the test to express the desired
behavior, behavior unchanged):

- Add actingProvinceId: ProvinceId parameter to MarchCommand.make.
  The inner MarchCommand class accepts it but currently marks it
  @scala.annotation.unused — immediateExecute does not yet emit a
  hasActed change for the acting province.
- CommandFactory passes ac.actingProvinceId from the MarchAvailable
  pair when constructing the MarchCommand. The wire protocol already
  carries the menu province on EagleCommand.province_id, so no proto
  or client changes are required.
- Existing MarchCommandTest cases pass actingProvinceId =
  originProvinceId to preserve current behavior; they all still pass.

New failing test:

- "should mark the acting (menu) province as having acted, even when
  marching from a different province" — sets actingProvinceId=14,
  originProvinceId=7, asserts the result includes a ChangedProvinceC
  for province 14 with setHasActed = Some(true). Currently fails:
  "None was not equal to Some(true)" — no ChangedProvinceC for 14
  exists in the result.

Implementation fix to follow.

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

* Mark the acting (menu) province as acted on March

Implementation for the test added in the previous commit.

When actingProvinceId == originProvinceId (the typical case where the
player issued March from the same province their units march from),
provinceIdActed = Some(originProvinceId) already marks that single
province as acted. No extra change needed.

When actingProvinceId != originProvinceId (the player opened the
menu on A, chose origin B), append a ChangedProvinceC for the acting
province with setHasActed = Some(true) so it can no longer issue
commands this round. The origin still gets hasActed via
provinceIdActed.

Drops @scala.annotation.unused on actingProvinceId since it's now
used.

All 8 MarchCommandTest cases pass, full Scala suite (223 tests) green.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:19:12 -07:00
2bd9fb4022 Compute PersistedActionResult.gameState lazily (#6692)
formAwrs (called on game load) and withNewResults (called per applied
result at runtime) were eagerly running GameStateConverter.toProto on
every Scala game state to populate PersistedActionResult.gameState —
3 seconds of the 4.5-second game-load time per the timing logs and
ongoing per-result cost during play.

In practice, the proto game-state is only ever read at "last entry of
batch" boundaries:

- PersistedHistory.scala:214 — last entry of a loaded chunk during
  orphan recovery.
- PersistedHistory.scala:471 — invariant check on
  recentHistory.headOption.gameState.actionResultCount.
- PersistedHistory.scala:592 — results.last.gameState in saveNow,
  passed to the next batch as starting state.
- PersistedHistory.scala:664 — toSave.last.gameState in withNewResults
  chunk save, passed to next batch as starting state.

So at most ~1 in resultsPerSaveFile (25) entries ever needs the proto
form; for the others the conversion was pure waste. Make
PersistedActionResult.gameState a lazy val derived from
scalaGameState — entries that never become a "last in batch" never
pay for toProto. (Also drops the unused fromProto factory.)

formAwrsFromScala still eagerly converts the action result to proto
since saveIndividualResult writes individual ActionResult bytes for
crash recovery — that conversion happens regardless.

223 Scala tests pass.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 15:03:18 -07:00
21eb10d2e5 Skip proto conversion in PersistedHistory.stateAfter replay (#6691)
The disk-load branch of stateAfter went through formAwrs, which
unconditionally calls GameStateConverter.toProto on every replayed
intermediate state — wasted work for stateAfter callers, since the
replayed entries are transient and never persisted. With a 19k-action
game, a single stateAfter(14790) call (e.g. from
UnrequestedTextHandler processing a stale incomplete LLM request)
materialized ~4500 PersistedActionResult instances, each holding both
proto and Scala forms of the game state, allocating an estimated
1.1-1.5 GB and OOMing the 2g heap on Province.apply during one of
those toProto calls.

Add a private replayScalaOnlyToState helper that replays in Scala and
returns just the final state, with no proto conversion. Use it in the
stateAfter disk-load branch, and short-circuit the replay at the
target index by taking only `count - initialIndex` actions instead of
replaying through persistedCount.

Behavior preserved:

- formAwrs is unchanged (the save flow still gets eager proto for
  in-memory entries, avoiding repeated toProto cost across save
  accumulations).
- partialGames load range is unchanged (chunk-range optimization
  intentionally deferred).
- recentHistory branch is unchanged.

Per-call impact on the failing stateAfter(14790) path:

  before: replay 4575 actions, run toProto 4575 times,
          retain Vector[PersistedActionResult] of length 4575 with
          proto + Scala state on every entry — ~1.1-1.5 GB transient.
  after:  replay 15 actions, no toProto calls,
          allocate one GameState — a few MB.

223 Scala tests pass.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:39:27 -07:00
1d99eefbd6 Fix CombatUnitSelector hero/battalion matching (#6688)
* Add failing tests for CombatUnitSelector hero/battalion suitability

Documents three desired behaviors that the current greedy
selectedCombatBattalions() does not satisfy:

1. Engineer should be taken over a no-profession hero on a Longbowmen
   battalion when only one slot is available — Suboptimal pairing is
   preferable to leaving the much more capable Engineer behind.
2. Same for a Mage: a Suboptimal Longbowmen pairing is preferable to
   leaving the Mage behind.
3. A Mage with only Restrictive options (e.g. only a non-casting
   Heavy Cavalry battalion) should be skipped entirely, not glued to
   the Restrictive battalion via the getOrElse fallback.

The current code:

- Uses a lexicographic (suitability.ordinal, -power) sort, so any
  Optimal hero beats any Suboptimal hero regardless of how much more
  capable the Suboptimal hero is. This causes (1) and (2).
- Has a getOrElse fallback that pairs the strongest hero with the
  strongest battalion when every (hero, battalion) pair is Restrictive,
  ignoring suitability. This causes (3).

These tests fail today and will pass after the planned fix.

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

* Fix CombatUnitSelector matching: soft Suboptimal penalty, skip Restrictive

Three changes to selectedCombatBattalions's chooseOne:

1. Replace the lexicographic (suitability.ordinal, -power) sort with a
   soft scoring scheme: power minus SuboptimalPairingPenalty (50) when
   the pairing is Suboptimal. An Engineer's +200 profession bonus still
   outweighs a no-profession hero on a Suboptimal Longbowmen pairing,
   but an equally-strong Optimal hero still wins the slot.
2. Filter Restrictive heroes out of the candidate pool entirely for the
   chosen battalion rather than relying on suitability ordering — so a
   Mage can never win a non-casting battalion via lexicographic ranking.
3. Replace the getOrElse fallback (which previously glued any hero to
   any battalion regardless of suitability) with a three-way match:
   valid pairing exists -> take it; no battalions left -> strongest
   hero marches alone (preserves prior behavior); battalions remain but
   every pairing is Restrictive -> return None and stop adding heroes.

The five tests in CombatUnitSelectorTest now all pass.

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

* Take Restrictive pairings rather than abandon a battalion

Earlier in this branch we treated "all options for a hero are
Restrictive" as a signal to skip the hero. That was wrong: a Restrictive
pairing (e.g. Mage leading Heavy Cavalry) is still better than leaving
the battalion behind entirely. The correct rule is "prefer non-Restrictive
heroes when any are available, but fall back to Restrictive rather than
losing the unit."

- Replace test 3 ("leave a mage behind ... if every battalion is
  Restrictive") with the inverse: "take a mage on a Restrictive
  battalion if that's the only way to take the battalion at all".
- Add a counterpart test confirming a no-profession hero is still
  preferred over a mage on a Restrictive Heavy Cavalry when both are
  available.
- Simplify chooseOne: always pick the strongest battalion, prefer
  non-Restrictive heroes for it, fall back to Restrictive heroes if
  none are non-Restrictive. Drop the early-return / skip path.

All six CombatUnitSelector tests pass; full Scala suite (223 tests)
still green.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 07:16:41 -07:00
ebc29a75d4 Default Shardok server status to WAITING_FOR_AI when no faction is queued (#6689)
In Eagle battles, computeShardokServerStatus checks each faction's
currentCommand to decide between YOUR_TURN, WAITING_FOR_AI,
WAITING_FOR_HUMAN_PLAYER, and PROCESSING. The AI's currentCommand,
however, isn't populated during the window when the AI is computing
its move — so the typical post-player-turn state had no faction with
a current command and fell through to PROCESSING. Clients never
observed WAITING_FOR_AI in practice; they bounced between YOUR_TURN
and "Processing...".

When no faction has a current command, infer the most useful status
from which other factions are present in the battle: WAITING_FOR_AI
if any AI faction remains, WAITING_FOR_HUMAN_PLAYER if any other
human remains, and PROCESSING only when no other factions are present
(an edge case at battle boundaries).

The custom-battle path (CustomBattleManager.customBattleStatus) was
already binary YOUR_TURN/WAITING_FOR_AI and not affected.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 07:05:47 -07:00
f0d7067cc9 Wire ShardokServerStatus into the connection-status indicator (#6686)
* Wire ShardokServerStatus into the connection-status indicator

Client half of the Shardok-side connection-status work. The server
already populates ShardokServerStatus on SingleShardokGameResultResponse
(see #6685); this hooks it up to the persistent ConnectionStatusUI so
players can see whether they're up, the AI is thinking, or another human
is about to act while a battle is on screen.

- ShardokGameModel implements IShardokGameStateProvider and caches the
  latest ShardokServerStatus, updated as new responses arrive in
  EagleGameModel.
- ConnectionStatusUI gains a Shardok provider slot; when set, it renders
  Your Turn / Waiting for AI / Waiting for Other Player / Processing
  instead of the strategic-layer status.
- EagleGameController binds the UI to the Shardok model when entering a
  battle; ShardokGameController clears the binding on exit.

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

* Fix PersistentUIManager namespace in ShardokGameController

CI build failed with:
  error CS0234: The type or namespace name 'PersistentUIManager' does
  not exist in the namespace 'eagle'

PersistentUIManager lives in the `common` namespace, and Shardok already
has `using common;`, so the `eagle.` qualifier was both wrong and
unnecessary.

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

* Wire ShardokServerStatus through CustomBattleHandler

Custom battles use CustomBattleHandler as the gRPC subscriber instead
of GameModelUpdater, so the existing wiring on EagleGameController +
EagleGameModel didn't reach them — the indicator stayed on "Connected"
in custom battles even after the server began populating
ShardokServerStatus correctly.

- Set the ShardokGameModel as the connection-status UI's Shardok game
  state provider when entering a custom battle. Cleanup is already
  handled by ShardokGameController.EndTurn for both battle paths.
- Forward resp.ShardokServerStatus to the model from
  CustomBattleHandler.ReceiveGameUpdate, mirroring the per-response
  handling that GameModelUpdater already does for Eagle battles.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 06:58:32 -07:00
e05ea92c46 Populate ShardokServerStatus in remaining response paths (#6687)
* Add doc describing server-side work for Shardok status indicator

Hand-off note for the Scala worktree: client wiring on
shardok-status-client is complete, but the indicator only ever shows
"Connected" in battle because the server attaches a ShardokServerStatus
proto with status left at the default UNKNOWN value.

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

* Populate ShardokServerStatus in remaining response paths

PR #6685 added the ShardokServerStatus proto and populated it in
GameController.humanClientsAfterPostingResults, but three other
SingleShardokGameResultResponse construction sites left the field
unset, defaulting to UNKNOWN on the wire and producing the symptom
flagged in docs/SHARDOK_SERVER_STATUS_SERVER_WORK.md.

Fix:

- HumanPlayerClientConnectionState.streamUpdates / apply factory now
  take a shardokServerStatusFor: ShardokGameId => ShardokServerStatus
  callback and set the field on each per-battle response. Avoids a
  Bazel dep cycle into game_controller by accepting the precomputed
  function rather than calling computeShardokServerStatus directly.
- GameController.streamUpdates wires
  GameController.computeShardokServerStatus into that callback.
- CustomBattleManager populates the field at both construction sites
  via a small helper (YOUR_TURN if the human has a current command,
  WAITING_FOR_AI otherwise) — custom battles have only one human
  faction and the AI on -1, so the full computeShardokServerStatus
  machinery is overkill.

Existing test sites in HumanPlayerClientConnectionStateTest pass a
stub shardokServerStatusFor since they don't exercise active battles.

Removes the now-stale TODO doc — its diagnosis (enum never set) no
longer applies.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 15:49:01 -07:00
b0639dc8d0 Add ShardokServerStatus proto and server-side population (#6685)
Introduces a new ShardokServerStatus message on
SingleShardokGameResultResponse so the client can render a tactical-side
connection-status indicator (Your Turn / Waiting for AI / Waiting for
Other Player / Processing).

ShardokServerStatus is intentionally separate from ServerGameStatus so
the strategic and tactical layers don't have to evolve in lockstep.

GameController.computeShardokServerStatus picks the status from the
receiving player's perspective by checking shardokPlayerAvailableCommands
across factions and classifying them via aiClientFactionIds. The two
humanClientsAfterPostingResults overloads now thread an aiFactionIds set
through and populate the new field on every per-battle response.

This is the server half of a two-PR split. The client wiring lands
separately; until then the new field is emitted but unread, which is
safe.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 06:07:54 -07:00
9eb8f7f086 Highlight March button in post-taxes tutorial dialogue (#6683)
Matches the existing pattern used for the Improve Province and Give Alms
buttons earlier in the strategic tutorial.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:49:53 -07:00
9f18322013 Increase Handle Captured Heroes panel font size (#6682)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:36:31 -07:00
7306c9cb06 Don't block dropGame on archive + battle cancellation (#6681)
When the last human drops a running game, the previous order was:
cancel-battles → archiveGame → mutate state → save() → return. Both
cancelBattlesForGame (gRPC roundtrip) and archiveGame (per-S3-file
copy + delete, all serialized) ran inside this.synchronized AND inside
the lock that gates the lobby push, so users could wait several
seconds to see the dropped game disappear from the lobby list.

Now we mutate state and save() first so the lobby update reflects the
removal immediately, then run cancelBattles + archive on a background
Future. Failures are logged.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:28:12 -07:00
3ff012806f Reliably highlight reinforcement heroes in battle tutorial (#6679)
* Highlight Elena (and other reinforcements) reliably in tutorial dialogues

Elena Fyar's hex wasn't pulsing during her arrival dialogue even though
the trigger fired. The most likely causes are: she's matched in
ReserveUnitsById rather than UnitsById at the moment the dialogue plays,
the SelfHostility check rejects an allied-rendered reinforcement, or her
ProfessionInfo isn't populated on whichever diff happens to be the
latest UnitView in UnitsById when ApplyUnitHighlight runs.

Three changes, defense in depth:

- DialogueManager.ApplyUnitHighlight now scans UnitsById ∪ ReserveUnitsById
  (still requires a placed Location), and treats any non-enemy unit as
  highlightable rather than requiring SelfHostility.
- Elena's and Ranil's reinforcement dialogues, plus engineer_bombard
  (which is Ranil speaking about himself), now match by heroName instead
  of profession. Each dialogue is about a specific hero by name, and
  heroName goes through unit.Name (text-id-based) which doesn't depend
  on ProfessionInfo being present on the latest diff.
- duel keeps profession matching since the dialogue is about Champions
  in general.

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

* Re-apply tutorial unit highlight every frame

The Shardok controller's ModelUpdated() calls hexGrid.ClearOverlays() on
every model update, which wiped the tutorial unit highlight applied
during step 1 of a multi-step dialogue: by the time the player was
reading Marek's lead-in line about Elena (or Ranil), a follow-up server
update had cleared the overlay. Step 2 happened to survive because no
further model updates arrived between the player advancing the dialogue
and the render.

DialogueManager now stores the current step's highlight criteria and
re-applies it every frame from Update() so it survives any number of
ClearOverlays calls. ApplyUnitHighlight is now idempotent (clears its
tracked cells before re-scanning).

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:15:39 -07:00
904dadf170 Bump Luke the Prank-tricker tutorial bias to 300 to outpace decay (#6680)
The previous 130 setting netted +30 effective bias after
FactionBiasFromImprisonment (-100), which then decayed to 0 within
3 rounds via NewRoundAction's per-round bias decay (additive -10/round
below |100|, multiplicative *0.9 above). By the time prison-round odds
had accumulated, the bias contribution was already gone.

Setting the config to 300 yields +200 effective bias, putting it in the
multiplicative-decay regime: ~+131 by round 4 (Nov) and ~+96 by round 7
(Feb). That keeps Luke comfortably over MinOddsForRecruitment (80)
through the intended early-tutorial recruitment window.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:05:36 -07:00
579d04eafd Rename knights in tutorial opening dialogue to Doomriders (#6678)
Old Marek's first tutorial-battle line previously named Shardok's Guard
as "the finest soldiers in the realm" and referred to "knights besides"
as a separate unit. Since the heavy-cavalry battalion in the attacking
army is named Doomriders, attribute the knights line to them directly
and drop the implicit second unit reference.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:46:48 -07:00
acdea65632 Highlight referenced units during battle tutorial dialogues (#6676)
Several battle tutorial dialogues call out specific units by role
(longbows for archery, the champion for dueling, Marek for charging,
the engineer/paladin reinforcements, Hedrick for hiding) or by an
available action (melee, start fire). The dialogue had no way to point
at the unit on the battlefield, so the player had to find it themselves.

Adds an optional `highlightUnit` block to dialogue steps with criteria
fields (battalionType, profession, heroName, canStartFire, canMelee)
that DialogueManager resolves against the current Shardok model. Each
matching friendly unit gets a strobing border via HexGrid.OverlayCell
— the same primitive already used for archery range and movement
overlays — so the visual language matches existing combat highlights.
The overlay is cleared on step change and dialogue end.

Wires up the criteria for all relevant battle dialogues:
- archery_available → Longbowmen
- ability_charge_available → Old Marek the Learned
- melee_available → friendly unit with a MeleeCommand available
- start_fire_available → friendly unit with CanStartFire
- duel_available → Champion
- tutorial_reinforcement_paladin → Paladin (Elena)
- tutorial_reinforcement_engineer → Engineer (Ranil)
- engineer_near_enemy → Engineer
- hide_available → Hedrick the Hedge-Merchant

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:38:18 -07:00
b4bb2f6d7f Bump Luke the Prank-tricker tutorial recruitment bias to 130 (#6677)
The tutorial configures +110 faction-3 bias on Luke so that he becomes
recruitable shortly after capture, but EndBattleAftermathPhaseAction
applies FactionBiasFromImprisonment (-100) to the imprisoning faction
and merges in initialFactionBiases on top of it. The effective bias was
only +10, which left him needing several extra rounds of prison-time
odds adjustment before reaching MinOddsForRecruitment (~Feb/Mar 372 in
practice). Bumping to 130 raises effective bias to +30 (~4 rounds
earlier), pulling first recruitability to ~Nov 371 as intended.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:30:58 -07:00
44546ac3e0 Mutually distrust the three tutorial NPC factions (#6675)
Bregos, Bridget, and Hedrick now all start at -500 trust toward each
other. With trust drift at +1/round, that's effectively permanent for
tutorial timescales. The invitation gate (TrustForDiplomacy.scala) only
checks the inviter's trust toward the target, so previously Bregos's
trust toward the smaller factions was an unset 0 and he could try to
absorb them after his earliestRoundForInvitation passed.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:27:26 -07:00
ab44d53b60 Point tutorial highlight at the Fight button on the battle progress panel (#6674)
The opening tutorial's pre-battle step instructed the player to click
"Battle!" and highlighted "GoToBattleButton", but that UI was replaced
by per-battle Fight buttons on the BattleProgressPanel. The dialogue now
references "Fight!" and BattleProgressRowController dynamically registers
its action button as "FightButton" with the tutorial target registry while
the row is in the fightable state, unregistering otherwise (and on
destroy) to avoid stale highlight targets.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:03:09 -07:00
02cffbdccb Check off completed tutorial items (#6673)
The Shardok tutorial, narrative hook, and early-victory items are
already delivered by the dialogue system: tutorial_battle.json covers
combat mechanics start to finish, tutorial_opening provides Sadar's
backstory in the first scene, and surviving Tarn's attack is the
early-victory beat.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:52:07 -07:00
152b72d691 Load Eagle/Tarn province setup from game_parameters.json (#6671)
EagleAppearsAction had Province32Stats / Province6Stats hardcoded
alongside hardcoded battalion definitions and random hero counts —
all duplicating the corresponding entries in game_parameters.json.
Any tuning of the normal game's Ingia/Soria starting state would
silently diverge in the tutorial.

Replace the hardcoded constants with a lazy load from
GameParametersUtils.defaultExpandedGameParameters: find the
SetFaction whose head is The Eagle, derive ProvinceConfig (stats,
random hero count, battalion templates) from each occupied province,
and look up which province belongs to the Eagle vs. Tarn by primary
ruling hero name. Battalions come through BattalionConverter.fromProto
and have IDs assigned at result time.

Behavior is preserved (verified by EagleAppearsActionTest).

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:23:03 -07:00
ea7677e351 Add dialogue for Ranil and Elena forming their own factions (#6672)
* Add dialogue for Ranil and Elena forming their own factions

The tutorial_hero_faction_appears trigger fires when John Ranil and
Elena Fyar break off from Sadar's service to form their own factions
(Builders of a Better World, Champions of Justice) seven rounds after
The Eagle appears. The trigger had no dialogue script, so a major
narrative beat — losing two of the player's signature allies — fired
silently.

Add a 4-step dialogue: Marek frames the departures as the same
mysterious force that has been claiming the King's heroes; Ranil and
Elena each speak their reason for leaving; Marek closes by noting they
are not enemies and an alliance may be possible later.

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

* Sharpen Marek's closing line to convey ambition

Old Marek now frames Ranil and Elena as obstacles the kingdom may need
to go through, not just former allies on different paths.

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

* Don't blame Bregos Fyar as the realm's arsonist

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:15:45 -07:00
d57003e144 Add tests for EagleAppearsAction (#6670)
The TutorialStrategicEventsTest comment claimed EagleAppearsAction's
result-creation was tested in TutorialGameCreationTest, but no such
tests existed — leaving the Eagle/Tarn faction setup uncovered.

Add EagleAppearsActionTest covering: faction creation and hostility,
The Eagle hero, Tarn's faction switch and loyalty, province 32 and 6
ownership / hero placement / stats, and battalion creation. Run
against a real bootstrapped tutorial game state via
TutorialGameCreation.createTutorialGame.

Also fix the stale comment in TutorialStrategicEventsTest.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:10:04 -07:00
4c720983b7 Fix stale tutorial-battle origin province reference (#6669)
Doc said Tarn's attacking army originates from province 32; the
config in tutorial_parameters.json:102 has originProvinceId: 31.
Province 32 (Ingia) is unoccupied at game start and only becomes
relevant later when The Eagle appears.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 17:58:05 -07:00
5831dc6e7a Tutorial system doc: add missing triggers and full expected flow (#6668)
Catalog four triggers missing from the original doc:
tutorial_faction_appears, tutorial_hero_faction_appears,
tutorial_hero_departed, and tutorial_hero_departed_again (all from
OnStrategicActionResult), plus shardok_battle_reset (fired directly
from ShardokGameController, bypassing the registry).

Replace the brief expected-flow stub with the full strategic-map and
tactical-battle dialogue flow tables, plus the underlying narrative
arc, so future tutorial work has a concrete map of what fires and
when.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 17:12:42 -07:00
79ee1e4e28 Docs: update Small Eagle TODO and add tutorial system architecture doc (#6667)
* Update Small Eagle TODO: lobby fixes done, merge tutorial + onboarding

Mark lobby fixes complete and consolidate the tutorial and
first-session onboarding bullets into a single section, since they
overlap heavily (a guided first scenario is essentially a tutorial).

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

* Add tutorial system architecture doc

Document the Unity tutorial system's class map, step lifecycle,
trigger catalog (with file:line citations), highlight targets,
persistence, and expected first-session flow. Calls out that the
step-based UI is currently dormant (RegisterAll early-returns) and
the dialogue system is the live path today.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 17:01:59 -07:00
26100acbe5 Show a proper page with a back link when a game URL is not found (#6666)
Previously /games/<id> returned a plain-text 404 with no way back to
the games list. Now renders an HTML error page through the shared
layout with a link back to /games.

Adds a generic error.html template and a renderError helper so other
handlers can use the same pattern.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:11:09 -07:00
0af9a11bc0 Commit missing .meta files for tracked scripts (#6665)
AddressableLoader.cs and AssetUsageAuditor.cs were committed without
their .meta files, so each dev's Unity was generating a different
local GUID. Commit the metas to lock the GUIDs so any future
prefab/ScriptableObject reference will be stable across clones.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 13:45:17 -07:00
a9ebe48a76 Align battle thermometer rows regardless of troop-count visibility (#6664)
Rows where the viewer sees troop counts (allied) render non-empty
AttackerLabel / DefenderLabel, while other rows render them empty.
The labels' LayoutElement had MinWidth=-1, so Unity fell through to
TMP's content-dependent minWidth, making the bar area's inner edges
land at different X positions across rows whenever available width
was tight.

Pin MinWidth=100 (== PreferredWidth) on both labels so their width is
content-agnostic and the bar areas line up.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 21:19:48 -07:00
a582af560d Update Gameplay scene layout (#6663)
Rebakes many UI RectTransforms in the Gameplay scene: several panels
switch to layout-driven sizing (anchors/position/size zeroed to let a
parent LayoutGroup/ContentSizeFitter drive them) and a few panels get
explicit resizes.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 20:15:15 -07:00
adminandGitHub 28f0081a86 Fix turn-mismatch reconnect during chronicle text generation (#6662)
* Report WAITING_FOR_PLAYERS while an AI is taking its turn

computeServerGameStatus only considered other human factions when
checking whether to report WAITING_FOR_PLAYERS, so when a human player
finished their turn and the server transitioned to AI play it returned
None. The client keeps its previous YOUR_TURN status when the server
sends a None update (ServerStatus is only overwritten when non-null)
while HandleAvailableCommands clears commands on the same update, so
CheckForTurnMismatch fired a spurious reconnect after its 3s grace.

Pass all other faction IDs (human + AI) into computeServerGameStatus
so WAITING_FOR_PLAYERS is reported whenever any other player has
commands to act on.

* Count unrequested (queued) texts in hasIncompleteTextsAccessibleTo

Chronicle texts begin their life in unrequestedTexts and only move to
incompleteTexts once withMarkedRequested fires as the LLM request
actually starts. If computeServerGameStatus ran during that window -
after the player's commands had already cleared but before the LLM
request had been kicked off - hasIncompleteTextsAccessibleTo returned
false, the status fell through to None, and the client kept its stale
YOUR_TURN while its commands were cleared on the same update. That is
the actual trigger for the 3s turn-mismatch reconnect we kept chasing:
waiting for the chronicle text to start streaming, not waiting for an
AI turn.

Include 'unrequested' alongside 'incomplete' in both the in-memory
default implementation and the SQLite EXISTS query so we report
GENERATING_TEXT for the full queued + generating window.
2026-04-22 19:08:41 -07:00
adminandGitHub fe6b50b055 Unify ServerGameStatus reported on re-subscribe with post-result path (#6661)
The re-subscribe path in HumanPlayerClientConnectionState.streamUpdates
was computing ServerGameStatus using simpler logic than
humanClientsAfterPostingResults: it only reported YOUR_TURN (when the
player had commands) or None otherwise. When a client re-subscribed
while another player was taking their turn, a battle was in progress, or
chronicle text was generating, the server sent serverGameStatus=None and
empty availableCommands. The client cleared its command state but kept
the stale YOUR_TURN status from the previous update (ServerStatus is
only overwritten when non-null), which tripped CheckForTurnMismatch
after its 3s grace and triggered a spurious reconnect.

Extract GameController.computeServerGameStatus as a single source of
truth, and route it through HumanPlayerClientConnectionState.apply and
through rewindTo so all three paths agree on
BATTLE_IN_PROGRESS / YOUR_TURN / GENERATING_TEXT / WAITING_FOR_PLAYERS.
2026-04-22 07:26:10 -07:00
4ee0230b18 Add failing test for fleeing army at hostile province (#6660)
* Add failing test: fleeing army at hostile province should not shatter

A defender that fled a battle lands at its pre-selected flee province
with fleeProvinceId cleared. If that province has since been conquered
by the enemy, shatterStrandedArmies destroys the army outright — but the
army still has combat power and should get a chance at battle via the
normal HostileArmySetup → AttackDecision flow.

This commit flips the prior "should shatter" test to the behavior we
actually want. Fix to follow.

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

* Remove shatterStrandedArmies so fleeing armies can trigger battle

Fleeing armies arrive at their pre-selected flee province with
fleeProvinceId cleared (by ResolveBattleAction) so they can't flee
again. If the province has been conquered by the enemy in the meantime,
shatterStrandedArmies was destroying the retreating force outright
during ProvinceMoveResolution — even though the army still had combat
power and should get a chance at battle.

Removing the block lets these incoming armies fall through to
HostileArmySetup later in the same round, where they become hostile
army groups and trigger the normal AttackDecision → battle flow.

Also drops the now-unused ShatteredArmyUtils.shatteredStrandedArmyResult
helper.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 08:20:27 -07:00
af75f7af74 Add UpgradeBattalionQuest AI quest handler (#6659)
Adds a deterministic chooser that walks a priority tree per turn toward
fulfilling an UpgradeBattalionQuest: arm-completes (optionally preceded by
travel and/or a safe food sale to cover the gold cost) -> train-completes
-> march in a pre-qualified neighbor battalion -> organize/top-off of the
quest battalion type -> march in an unqualified neighbor battalion ->
develop Economy/Agriculture -> train progress -> develop Infrastructure
-> arm progress. Travel is only toggled when it directly enables an
arm-completes finisher, to avoid fragile multi-turn plans.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-16 22:12:37 -07:00
614534ac54 Add BetrayAllyQuest AI quest handler (#6658)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 22:02:24 -07:00
4696d56ec6 Add FightBeastsAloneQuest AI quest handler (#6656)
* Add FightBeastsAloneQuest AI quest handler

When an unaffiliated hero offers a FightBeastsAloneQuest, the AI now
sends the weakest expendable non-leader hero to fight beasts without a
battalion. Only heroes weaker than the quest-giving hero are considered,
since the hero will almost certainly die.

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

* Add MaxExpendableHeroPowerRatio setting for FightBeastsAloneQuest

Hero must have power at most 75% of the quest-giving hero's power to
be considered expendable, ensuring the trade is clearly worthwhile.

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

* Add MaxExpendableHeroPowerRatio to settings.tsv and regenerate loader BUILD

The new setting was added to the settings BUILD.bazel but was missing from the
TSV source file, causing the auto-generated settings_loader to lack the dep.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:47:13 -07:00
4af198fe1a Add BattalionDiversityQuest AI quest handler (#6657)
* Add BattalionDiversityQuest AI quest handler

When an unaffiliated hero offers a BattalionDiversityQuest, the AI now
hires one battalion of a type it doesn't already have. Only types where
the province meets development requirements are considered, and the
province must have sufficient gold and food surplus.

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

* Only hire battalion when it would reach 3+ distinct types

The quest requires 3+ different types at near-full capacity. Skip
hiring if the province has fewer than 2 existing types, since adding
one more wouldn't reach the required 3.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:42:40 -07:00
e74e26f191 Update AI quest completion plan with prioritized backlog (#6655)
Reorganize the "Does Not Attempt" section into a prioritized plan for
future implementation and a list of quests not planned for active
pursuit.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 12:55:25 -07:00
dadb374143 Prefer CrackDown for riots when SuppressRiotByForceQuest exists (#6654)
When an unaffiliated hero has a SuppressRiotByForceQuest and the faction
has battalions available, the AI now prefers CrackDown over Give when
handling riots. This modifies the existing riot handler priority rather
than adding a quest command chooser, since riot handling runs before
FulfillQuestsCommandSelector.

The AI estimates survival odds against a 90th-percentile riot before
committing to CrackDown, and prefers non-leader heroes to avoid risking
the faction head.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 11:22:07 -07:00
5bccde82ac Add ApprehendOutlawQuest AI quest handler (#6653) (#6653)
Matches the quest's target outlawHeroId against available outlaws in the
province and issues an ApprehendOutlaw command when found.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:46:39 -07:00
b0c7c57402 Add ReconSpecificProvincesQuest AI quest handler (#6652) (#6652)
Higher priority than the count-based ReconProvincesQuest since reconning
a specific province also counts toward generic recon counts. Prefers
targets not yet reconned by the faction.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:26:59 -07:00
f6850fc838 Add ReconProvincesQuest AI quest handler (#6651)
* Add ReconProvincesQuest AI quest handler

When an unaffiliated hero has a ReconProvincesQuest, the AI now issues
a Recon command to reconnoiter a province, helping fulfill the quest.

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

* Prioritize recon target provinces in ReconProvincesQuestCommandChooser

Sort candidates by (isNeighbor, isHostile, notRecentlyReconned) to
prefer hostile neighboring provinces that haven't been scouted recently,
matching the prioritization pattern from MidGameAIClient.

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

* Deprioritize unowned provinces in recon target selection

Unowned provinces (no ruling faction) are now sorted last, even if
they're neighbors, since reconning owned hostile provinces is more
strategically valuable.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:15:28 -07:00
2f783f7ab0 Add GrandArmyQuest AI quest handler (#6650)
* Add GrandArmyQuest AI quest handler

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

* Address review: extract helper, use flatMap/inside, verify quest fulfillable

- Extract grandArmyTarget into a private method with pattern matching
- Replace organize match with flatMap chain
- Only return command if total troops after organizing meet the quest
  target (prevents partial fulfillment)
- Replace asInstanceOf with inside() in tests
- Add test for insufficient resources to meet target

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 08:05:45 -07:00
7bdbd7ec8f Add AI quest handlers for blizzard, drought, and epidemic filtering (#6649)
* Add AI quest handlers for blizzard, drought, and epidemic own-province filtering

Create ControlWeatherQuestCommandChooser to handle StartBlizzardQuest and
StartDroughtQuest via the ControlWeather command. Update StartEpidemicQuest
CommandChooser to skip quests targeting the acting faction's own provinces.
Both handlers verify the appropriate command is available before selecting.

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

* Address review: use inside() pattern and extract questTargets method

- Replace asInstanceOf with inside() pattern in ControlWeather and
  StartEpidemic test assertions
- Extract questTargets into a private method in
  ControlWeatherQuestCommandChooser and match directly on its result

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 07:35:23 -07:00
334a431f14 Add DevelopProvinces and MobilizeProvinces AI quest handlers (#6648)
* Add DevelopProvinces and MobilizeProvinces AI quest handlers

Implement IssueOrders-based quest command choosers so the AI can
fulfill DevelopProvincesQuest and MobilizeProvincesQuest by switching
province orders. Develop prefers non-hostile-neighbor provinces;
Mobilize prefers hostile-neighbor provinces. Mobilize is skipped when
a DevelopProvincesQuest also exists to avoid conflicting orders.

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

* Extract ProvinceOrderQuestHelper to deduplicate order-switching logic

The Develop and Mobilize choosers shared nearly identical logic for
finding the IssueOrders command, counting current orders, partitioning
by hostile neighbors, and building the selection. Extract this into
ProvinceOrderQuestHelper.chooseOrders parameterized by target order
type, hostile-neighbor preference, and reason string.

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

* Remove early returns from province order quest choosers

Use pattern matching, flatMap, and Option.when instead of early
returns for idiomatic Scala style.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 21:27:30 -07:00
4ba9a475da Filter out Fear from AI search when attacker is alone (#6647)
Without a teammate to capitalize on the stun, a solo attacker casting Fear
just ends its turn no closer to killing units or capturing castles. The
state scorer currently rewards the resulting morale drop on the victim,
so minimax/MCTS can pick Fear over useful actions. Prune it in
AICommandFilter with one exception: if the victim is already on a fire
tile, the stun locks them there and the burn damage supplies the
follow-up, so Fear is still worthwhile in that case.

The rule only applies to attackers — defenders legitimately use Fear to
stall, which is their win condition.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:52:30 -07:00
acb409bf3f Update AI quest completion docs to match code (#6646)
The doc was missing 11 quest handlers that were added since it was
written: all prisoner quests, TotalDevelopment, SpendOnFeasts,
SendSupplies, RestProvince, StartEpidemic, and SwearBrotherhood.
Also fixes the priority order (Alliance is last, not first).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 19:46:47 -07:00
0389357366 Hide [CNTL] modifier hint on iOS and Android (#6645)
There is no Ctrl key on mobile platforms, so the [CNTL] helper text
under the profession attack button is meaningless. Skip it when
running on IPhonePlayer or Android.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 19:06:58 -07:00
a470bb16da Hide [CNTL] helper for untargeted profession commands like Fortify (#6644)
The profession attack button group (group 5) always showed "[CNTL]"
underneath, but that hint only makes sense for targeted commands like
Reduce where the player can ctrl-click a hex. Fortify has no target,
so showing the modifier key shortcut is misleading. Now the helper
text only appears when the group has a targeted command.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 19:02:07 -07:00
37ac215b85 Dedupe stale ActionResultViews in EagleGameModel.HandleUpdates (#6635)
* Dedupe stale ActionResultViews by unfiltered result count on main thread

Eagle's HandleUpdates previously applied every ActionResultView it was
handed, regardless of whether those results had already been applied.
The only count-based safety net was the heartbeat SyncMismatch check,
which triggers a reconnect but does not stop an in-flight MainQueue job
from applying stale deltas first. Applying a stale ActionResultView
(e.g. re-executing VASSAL_EXILED) can corrupt client state and throw
"Duplicate unaffiliated hero".

Track a main-thread-only high-water mark _lastAppliedUnfilteredCount and
gate HandleUpdates on it: if a delivery's UnfilteredResultCountAfter is
not ahead of the high-water mark but carries results, drop them with a
[DEDUP] log line. Reset the high-water mark in HandleStartingState so
state resyncs/rewinds rebuild the baseline cleanly.

This is defense-in-depth alongside the mid-stream SUBSCRIBE fix: it
protects the main thread against any source of stale/duplicate
ActionResultView delivery, not just the specific TryPendingCommands race.

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

* Handle mid-batch exceptions in HandleUpdates by forcing a state resync

The previous commit added a dedupe gate but assumed ActionResultView
batches were applied atomically. If HandleNewHistoryEntry throws partway
through a batch, _currentModel is left partially mutated:
_lastAppliedUnfilteredCount never advances (so future batches would
re-apply the 1..k results that did land), while _lastUnfilteredResultCount
on the gRPC thread has already advanced (so the heartbeat sync check
reports "in sync" and never schedules a reconnect). The client
invisibly corrupts and the dedupe gate itself makes the next delivery
worse, not better.

Wrap the apply loop in try/catch. On exception:
- mark the model _modelCorrupted and refuse to apply any further
  GameUpdates in ReceiveGameUpdate until a fresh StartingState arrives
- reset both _lastUnfilteredResultCount (under its lock) and
  _lastAppliedUnfilteredCount to 0 so the upcoming subscribe asks the
  server for a fresh snapshot
- report the exception via ErrorHandler so we still get a stack trace
- trigger PersistentConnection.ForceReconnect() to drive the resync

HandleStartingState clears the _modelCorrupted flag when the fresh
snapshot lands, at which point normal update processing resumes.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 08:57:26 -07:00
830985e01f Run S3 archive cleanup on self-hosted runner with aws CLI (#6643)
Switches from ubuntu-latest to [self-hosted, bazel] and replaces
s3cmd with aws CLI. Installs aws via brew if not already present.
Uses DO_SPACES credentials (same key pair the Eagle server uses to
write archived games) and macOS date syntax.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 08:47:30 -07:00
a50d64ee94 Disable scheduled Bazel cache cleanup (#6642)
bazel clean races with other runners sharing the output_base on
/Volumes/remote_cache, causing "Directory not empty" failures
(4 of last 5 runs). Manual trigger preserved via workflow_dispatch.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 08:13:26 -07:00
b8f6b99db2 Color unit labels via TMP_Text.color instead of rich-text tags (#6640)
* Color unit labels via TMP_Text.color instead of rich-text tags

SetOneUnitLabels was building <color=#...>Name</color> / <b> wrapped
strings on every update. Since each label is rendered in a single color,
setting TMP_Text.color directly is cheaper (no per-update string
concatenation, no rich-text tag parsing) and safer (type-checked Color
instead of hex string formatting). Secondary-label bold is now a font
style set once at cell creation.

SetUnitInfoLabels now takes an explicit Color parameter; all four
callers updated.

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

* Disable richText on hex-grid labels

None of the hex-grid TMP labels (hero names, battalion sizes, odds
percentages, action points, coord debug strings) use rich-text tags
after the previous commit. Disabling richText in the shared label
factory skips TMP's per-update tag-scanning pass and makes the labels
robust against accidental '<' characters in localized text.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:25:38 -07:00
c340f2b79d Color retargetable mages purple in battle UI (#6639)
Adds a third unit-label color in Shardok battles: purple (#8000FF) for a
player's mage that has already picked a meteor target but is still in the
Target state and could retarget on the same turn. Blue/black remain for
"has commands" / "no commands".

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 13:37:29 -07:00
c94422f1cf Clear focus province when faction loses ruler (#6638)
* Clear focus province when faction loses ruler to keep state valid

RuntimeValidator requires that every faction's focusProvinceId points
to a province it rules. ProvinceConqueredAction and
EndPlayerCommandsPhaseAction already emit ChangedFactionC with
clearFocusProvinceId for their respective pathways, but other pathways
(hero departures, battle casualties leaving a province unruled, etc.)
reach the applier's fixRulerIfNeeded hook without any faction-side
cleanup. The next validation pass then crashes with "Unowned focus
province for faction ...".

Clear the stale focus alongside fixRulerIfNeeded in the applier so any
ChangedProvinceC that removes or transfers a ruling faction also drops
the now-invalid focus pointers. The negative case (same ruler, just
losing some heroes) is preserved so active focuses aren't dropped
unnecessarily.

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

* Switch to end-of-phase focus cleanup instead of applier side-effect

Reverts the applier-level focus-clearing added in the previous commit.
Modifying factions as a side-effect of applyChangedProvinceC violates
the event-sourcing model: every game state change should be described
by an ActionResult, not implicit in the applier.

New approach, matching the pattern used by other
EndPlayerCommandsPhaseAction-style cleanups:

- RuntimeValidator.validateFactions now only enforces the "focus must
  be an owned province" invariant during PlayerCommands. Other phases
  may legitimately leave a stale focusProvinceId in transit (e.g.
  fixRulerIfNeeded silently clears rulingFactionId after hero
  departures or battle casualties).

- EndVassalCommandsPhaseAction is the sole entry point into
  PlayerCommands. Its final ActionResultC now carries
  ChangedFactionC(clearFocusProvinceId = true) for every faction
  whose focus province is no longer ruled by it, so the invariant
  holds when the validator re-engages.

- Removes the three applier-level tests that exercised the reverted
  side-effect, and adds EndVassalCommandsPhaseActionTest covering the
  new cleanup behavior (unruled focus, focus taken by another
  faction, and focus still held).

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 10:10:08 -07:00
4b4a4e7dc6 Don't shatter stranded armies arriving at unoccupied provinces (#6637)
shatterStrandedArmies only checked whether an incoming army's destination
was not its own faction's territory. Since isFriendlyMove does
`province.rulingFactionId.contains(...)`, unoccupied provinces (None)
also returned false, so any already-retreated army (fleeProvinceId = None)
arriving at an unoccupied province was spuriously shattered with a
"nowhere to withdraw to" notification.

Add an explicit `rulingFactionId.isDefined` guard so arrivals at
unoccupied provinces fall through to the normal uncontested conquest
flow.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 19:54:55 -07:00
aba582bd10 Stop mid-stream SUBSCRIBE on stale command token to prevent duplicate updates (#6634)
TryPendingCommands' stale-token branches called StreamOneGameAsync to
"refresh state" when a pending command's token was behind the current
token. This sent a SUBSCRIBE(unfilteredCount=N) over the active stream,
which caused the server to replay history.since(N) as a new
ActionResultResponse while normal updates were still in flight.

Those re-delivered ActionResultViews were then applied a second time on
the main thread, which could surface as (e.g.) "Duplicate unaffiliated
hero N in province M" when a VASSAL_EXILED action ran twice.

The refresh is unnecessary: we only reach the stale-token branch because
an ActionResultResponse already delivered the newer token and its
updates were applied. Drop the stale command without re-subscribing.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 18:40:38 -07:00
a7d3b2b421 Fix nonsensical "over X months" in supply/feast quest descriptions (#6636)
The divination and quest-ended prompts for SendSuppliesQuest and
SpendOnFeastsQuest both said "over $componentCount months", but
componentCount on these quests equals the total food/gold (since the
counter is incremented by units delivered, not months elapsed). This
produced confusing output like "send 1337 food over 1337 months".

Drop the time clause — these quests are purely cumulative totals.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:46:59 -07:00
f6ff303ba9 Catch exceptions in ReceiveGameUpdate to avoid silent state corruption (#6633)
When an exception propagates out of an async-void lambda enqueued to
MainQueue, it goes through AsyncMethodBuilderCore.ThrowAsync to Unity's
SynchronizationContext, which logs it but cannot undo the partial state
changes already applied by the failing update. Subsequent updates then
apply diffs on top of corrupt state, cascading further errors until the
client is effectively unusable.

Wrap ReceiveGameUpdate at all four call sites in HandleGameUpdate so
exceptions are caught at the source, logged via Debug.LogException
(triggering the in-game ErrorHandler panel) and the file logger, and the
enclosing lambda completes normally. TryPendingCommands and timing logs
still run, keeping the MainQueue pump healthy.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 07:14:40 -07:00
5813bc9500 Restore missing VelociraptorColor1/2 prefabs (#6632)
VelociraptorEffect.prefab's animalPrefabs array references two
prefab GUIDs (8fa2338697777704fafc6374e9750de9 and
df9fda81573442940a2a92a3628b3cbc) from the Dino Pack Low Poly V1
asset bundle that were never committed to the repo in PR #6525.
The pack's Velociraptor.fbx, materials, and textures were added,
but the standalone .prefab files that wrap the fbx with an
Animation component and a MeshRenderer were missing.

The dangling references didn't fail until something in the live
game actually triggered ProvinceBeastsController.SpawnBeastsEffect
for a "velociraptor" beast, at which point AnimalEffect.SpawnAnimals
threw MissingReferenceException on the null array element.

Re-import the two referenced prefabs from Dino Pack Low Poly V1
(PrefabPack/PrefabSingleTexture/VelociraptorP/). The re-imported
prefab GUIDs match exactly, and the root GameObject fileIDs
(7255238702826593904 and 8644578109599837090) match the fileIDs
VelociraptorEffect.prefab already references. Internal references
(VelociraptorC1.mat material, Velociraptor.fbx mesh and animations,
VelociraptorMapAnims.controller) are all already in the repo.

The atlas-texture variant prefabs from the same pack were not
committed since nothing references them.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 22:02:49 -07:00
a912347dbd Serialize TryPendingCommands to prevent BAD_TOKEN bursts (#6630)
Multiple MainQueue actions (one per ActionResultResponse and
ShardokActionResultResponse) each call TryPendingCommands as
async void lambdas. When one yields at an await, the MainQueue
Update loop immediately invokes the next action, which re-enters
TryPendingCommands. Because PostRequest synchronously re-adds
the command to _pendingCommands before yielding for WriteAsync,
each parallel invocation re-drains the same buffered command and
writes it to the gRPC stream again. This produces bursts of 10+
identical writes in a single frame, all of which the server
rejects with BAD_TOKEN.

Wrap TryPendingCommands in a non-blocking SemaphoreSlim acquire.
If another invocation is already running, skip and return — any
items added to _pendingCommands while it runs will be picked up
by the next ActionResultResponse handler that fires.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 21:34:12 -07:00
e2221c4e45 Suppress "Reconnecting..." UX during the first retry (#6627)
Occasional disconnects are normal and usually recover on the first
retry, so the old behavior of immediately flashing "Reconnecting..."
(and a 2s countdown) on every blip was too noisy.

Now the first retry after a fresh disconnect is "silent": we don't
touch _currentState, so the status UI keeps showing Connected while
the retry attempt runs in the background. Only after the first retry
has itself failed does the ScheduleReconnect path flip state to
Reconnecting, at which point the user sees the countdown (starting
from the 4s second-attempt backoff).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 20:37:37 -07:00
5af91f6e8b Multiply focus province storage caps by a settable multiplier (#6628)
Introduces the FocusProvinceStorageMultiplier setting (default 1.50) and
applies it to food and gold caps when a province is its ruling faction's
focus province, so focus provinces can stockpile extra reserves.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 20:27:12 -07:00
bc442ba230 Add delta gradient flash to battle thermometer (#6624)
* Add delta gradient flash to battle thermometer

When the thermometer ratio changes, overlay a gradient on the delta
region showing the gaining side's color fading toward white-tinted,
making the territory shift visible. Fades out 2.5x faster than the
existing boundary flash line.

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

* Fix invisible delta gradient by using FullRect sprite mesh

The gradient sprite was using the default SpriteMeshType.Tight which
trimmed out the fully-transparent pixel, producing either an empty or
half-sized mesh. Use SpriteMeshType.FullRect with a 256x1 texture for a
cleaner gradient, and SetAsLastSibling so the overlay renders on top of
the attacker/defender bars. The boundary flash line also reasserts
last-sibling so it stays visible above the gradient.

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

* Add diagnostic logging for delta gradient

Temporary logging to diagnose why the gradient overlay isn't visible
in-game. Will be removed after diagnosis.

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

* DEBUG: render delta gradient as opaque magenta to test positioning

Temporarily replace the gradient sprite with plain opaque magenta and
add canvas renderer state logging to isolate whether the problem is
with the sprite/shader or with positioning/z-order/masking.

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

* DEBUG: use attackerBar sprite, bright green, no fade

Set the delta gradient to use the attacker bar's known-working sprite,
force opaque bright green, and disable the fade coroutine so the image
stays permanently visible. Also log the full transform path to confirm
which thermometer instance is being modified.

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

* DEBUG: test gradient sprite with permanent green tint

Swap back to the procedural 256x1 gradient sprite while keeping the
permanent no-fade green tint. This isolates whether the gradient sprite
itself renders correctly (gradient visible) or has a mesh/UV issue
(solid green or invisible).

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

* Use losing side's color for delta gradient, remove debug scaffolding

The previous implementation tinted the delta region with the *gaining*
side's color, but the bar underneath the delta region is already that
same color — so the gradient was nearly invisible (faction color fading
to a slightly lighter faction color).

Flip it around: use the *losing* side's color. The old owner's
territory briefly lingers in the lost region, then fades to reveal the
new owner's bar underneath. This is a more intuitive visualization of a
territory change.

Also flip the gradient direction so the opaque (losing-color) edge sits
at the old boundary (where the losing side's territory previously
ended), fading toward the new boundary.

Remove the debug logging, green color override, and disabled fade that
were added while diagnosing the earlier rendering issue, and re-enable
the DeltaGradientRoutine fade coroutine.

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

* Flip delta gradient direction

The losing color should be on the side of the delta closer to the
losing side's remaining bar, so it visually points "home" to its own
side. The transparent edge reveals the new owner's bar underneath.

- Attacker gained → losing=defender → opaque on right (near defender).
- Defender gained → losing=attacker → opaque on left (near attacker).

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

* Double the delta gradient fade duration

Previously 0.16s (flashDuration/2.5); now 0.32s (flashDuration/1.25)
so the gradient is easier to see.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 20:06:25 -07:00
3d169136a4 Fix orphaned backstory text ID for rubber-band spawned heroes (#6626)
RandomHeroGenerator.createLowPowerHero pre-populates backstoryVersions
with hero_${id}_backstory_0, but UnaffiliatedHeroAppearedAction only
emits a HeroInitialBackstoryRequest when backstoryVersions is empty. The
rubber-band spawn path in PerformUnaffiliatedHeroesAction was relying on
that action to emit the request, so the initial backstory text ID was
orphaned from the start — never registered with ClientTextStore, always
resolving to TextGenerationDependencyUnknown.

Clear backstoryVersions before handing the hero to
UnaffiliatedHeroAppearedAction so it creates a proper
hero_${id}_initial_backstory_$roundId text ID with a matching LLM
request. This restores effectiveBackstory's ability to fall back to the
initial backstory when later updates are legitimately skipped (e.g. for
heroes only visible to AI factions).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:41:49 -07:00
fae53369bf Switch default Gemini model to 3.1 Flash-Lite preview (#6625)
3.1 Flash-Lite is faster (throughput + TTFT), meaningfully smarter, and
still among the cheapest Gemini options, making it a strict upgrade
over 2.5 Flash-Lite for narrative text generation.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 19:00:51 -07:00
ae65205d34 Add temporary debug logging for free hero backstory visibility (#6623)
Logs a warning in GameController.withHandledEngineAndResults when a
free hero's most recent backstory is not visible to the province's
ruling faction. Includes game ID, history count, round, and phase
to help trace back to the action that caused the visibility gap.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 13:15:43 -07:00
d0c87c838e Add retry logic to all Addressable asset loading (#6622)
* Retry beast effect Addressable loads on failure

Beast effect prefabs are loaded from a remote CDN via Addressables.
If the download failed (network hiccup, CDN timeout), the effects
were permanently lost for the session with no retry. Now retries up
to 3 times with backoff delays (2s, 5s, 10s). Also releases failed
handles to avoid leaking resources.

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

* Extract AddressableLoader utility with retry logic for all Addressable loads

All Addressable assets load from a remote CDN and a single transient
network failure permanently broke the affected feature for the session.
Extracts retry logic (3 retries with 2s/5s/10s backoff) into a shared
AddressableLoader.LoadWithRetry<T>() utility and updates all 6 callers:
ProvinceBeastsController, TacticalAssetLoader, SoundManager,
ImageForTerrainTracker, BattleInProgressController, ProvinceActionAnimator.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 12:09:33 -07:00
6c01a81bbf Remove unused Modern UI Pack scripts, animations, and prefabs (#6621)
Removed ButtonManagerBasic and UIGradient components from the lobby
AvailableGame prefab (they were no-ops — the button already had a
normal onClick callback). Then deleted all non-texture content from
Modern UI Pack: Scripts, Animations, Editor, Fonts, Scenes, Unused
prefabs, and Documentation. Only Textures/ is retained.

Saves ~34 MB of unused assets.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:21:16 -07:00
c25ecadd60 Add editor tool to audit third-party asset usage (#6619)
* Add editor tool to audit third-party asset pack usage

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

* Include Addressable group entries in asset usage audit

The previous version only traced dependencies from build scenes, completely
missing assets loaded via Addressables. This caused ~8.5 GB of assets to
be falsely reported as unused. Now traces dependencies from all Addressable
group entries (Beast Effects, Hex Tiles, Tactical Assets, Sound Effects)
in addition to build scenes.

Also logs all USED files per pack for easier analysis.

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

* Remove Pixel Fonts Megapack from audit pack list

Pack was deleted in PR #6620.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:02:12 -07:00
251aeb5794 Remove unused Pixel Fonts Megapack asset pack (#6620)
Asset usage audit (scenes + Addressables) confirmed 0 files from this
pack are referenced anywhere in the build. Saves 2 MB.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 08:00:43 -07:00
9223b0452e Clear fleeProvinceId after fleeing to prevent stale flee destination (#6618)
When an army flees after losing a battle, the flee province is consumed
but was not being cleared. This caused armies arriving at a now-hostile
flee province to get attack decision options instead of being shattered.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 07:23:19 -07:00
05299dbe77 Remove debug logs from battle progress UI (#6617)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 20:01:45 -07:00
f06ba27a6f Fix reversed thermometer bar widths in Shardok tactical view (#6616)
The Shardok Container prefab had defender on the left and attacker on
the right, but BattleThermometer code forces the defender bar to the
right and attacker bar to the left. This caused the wide bar to appear
next to the smaller number. Swap the field references so bars and labels
are on matching sides.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:54:19 -07:00
bf90c98add Update Unity prefabs for battle progress row and editor re-serialization (#6615)
Wire up flashDuration on BattleThermometer in the Battle Progress Row
prefab and right-align the attacker troop count label. Includes Unity
editor re-serialization of Emu and peasant prefabs.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:34:06 -07:00
95afb025e1 Replace inappropriate Excellent Glory headshot (#6614)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:31:26 -07:00
93e3804eef Only grant Allied hostility for actual alliances in battle views, not truces (#6613)
FactionUtils.hostilityStatus treats both truce and alliance as non-hostile,
which caused battle player infos to show Allied hostility for truce holders.
The client then displayed an "Observe" button that threw an exception on click
because the server correctly rejects observation for truce-only relationships.

Fix shardokBattlePlayerInfos to use hasAlliance directly instead of the
general hostilityStatus, so only actual alliances produce Allied hostility.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:27:32 -07:00
6c13ade07e Flash thermometer boundary on every day update (#6611)
Previously the flash only triggered when the ratio changed by more
than 0.001. Now it flashes on every update after the first, so each
day tick produces a visible flash even if the ratio barely moved.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:15:55 -07:00
dee2574a59 Fix losing armies being shattered instead of retreating to flee province (#6612)
ResolveBattleAction was discarding the original fleeProvinceId for
armies that lost a battle, causing them to shatter instead of retreating
to the province the player designated when dispatching the army.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 19:06:41 -07:00
8bb8a8f791 Only send BattleProgressResponse when gated round advances (#6610)
postBattleUpdate sent a BattleProgressResponse for every Shardok
action, even when the gated round hadn't changed. In AI-only battles
this floods clients with dozens of identical-day updates per round.
Now only sends battle progress when the gated round actually advances,
reducing redundant updates while still delivering Shardok action
results to participants.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:45:50 -07:00
51b95d0b62 Forward battle progress during strategic turn updates (#6609)
withHandledEngineAndResults used the 5-parameter overload of
humanClientsAfterPostingResults which did not send battle progress.
This meant non-combatant observers only received battle progress
updates when Shardok sent a GameUpdateResponse, not during Eagle
strategic turn resolution. Switch to the 7-parameter overload to
forward existing battle progress on every client update.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:45:30 -07:00
d06f26bffe Reset lastRevealedRound when all battles end (#6608)
The gated round (lastRevealedRound) used max(previous, current) to
prevent information leakage, but never reset when all battles ended.
This caused new battles to start at the previous battle's final day
(e.g. "Day 29") instead of "Day 0".

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:28:19 -07:00
7375d9f8ee Reset BattleDay to 0 when a new battle starts (#6607)
BattleDay was only set from BattleProgressResponse, so it retained
stale values from previous battles until the first progress update
arrived for the new battle.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 18:22:27 -07:00
0a650dec2c Add flash effect at thermometer boundary when balance shifts (#6606)
A thin white line briefly flashes at the attacker/defender boundary
whenever the troop ratio changes, giving visual feedback during battle.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 16:46:20 -07:00
22cdf99d4d Propagate isDefender flag to Shardok thermometer (#6604)
* Use isDefender flag in Shardok thermometer and game model

Use the new is_defender field from ShardokBattlePlayerInfo instead of
assuming player 0 is always the defender. Updates ShardokGameController
thermometer, ShardokGameModel.IsDefender, EagleGameModel.MakeGameModel,
and CustomBattleHandler player setup.

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

* Update Gameplay scene layout and soldier prefabs

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 15:41:38 -07:00
86e9489f21 Add is_defender to ShardokBattlePlayerInfo proto (#6605)
Add is_defender field (field 5) to ShardokBattlePlayerInfo so the
client can identify which players are defenders without assuming
player 0 is always the defender. Populate it from
ShardokPlayer.isDefender in BattleFilter and serialize it in
ShardokBattleViewConverter.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 15:20:47 -07:00
2339050154 Fix battle progress bar: swap sides and forward updates to client (#6601)
Two fixes for the battle progress thermometer:

1. Swap attacker/defender sides so attacker fills from left and defender
   from right, matching the expected layout.

2. Add missing BattleProgressResponse case in PersistentClientConnection's
   HandleGameUpdate switch. The server was sending progress updates but
   the client silently dropped them, causing the bar to stay at 50%.

Also extract UpdateBattleProgressDisplay helper in EagleGameController
and call it from SwapModel's early-return path so progress updates aren't
skipped when the player has active strategic commands.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 14:51:13 -07:00
f7fefe078e Fix crash in AttackDecisionCommandSelector when configuring toggles (#6602)
ConfigureToggle used toggle.isOn = false which fires the ToggleChanged
callback. That callback accesses SelectedDecision, which throws when no
toggle is on yet during SetUpUI. Use SetIsOnWithoutNotify instead.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 14:50:49 -07:00
ad3e7f19b9 Fix reversed troop counts in battle progress tracker (#6603)
The updatedBattleProgress method assumed player 0 was always the
defender, but RequestBattlesAction builds the players vector with
attackers first and defender last. Use the isDefender flag from the
battle's player list to correctly classify troop counts.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 14:45:25 -07:00
022bdab53f Fix ToggleGroup preventing disabled toggles from turning off (#6600)
When ConfigureToggle sets isOn=false on a toggle that is the last active
member of a ToggleGroup with allowSwitchOff=false, Unity forces it back
to true. The toggle is then removed from the group but retains the stale
isOn=true state. This causes the wrong option to be sent to the server
(e.g. Recruit when only Imprison/Return/Execute were available).

Fix by removing the toggle from its group before setting isOn=false.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 10:27:41 -07:00
c01e64265d Seed initial troop counts in BattleFilter from army battalion data (#6598)
When a battle is first created, participants now see troop counts and an
accurate balance bar immediately instead of a blank 50/50 bar while
waiting for the first Shardok GameUpdateResponse.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:25:17 -07:00
a7572a5a9a Use faction-specific colors in battle thermometer (#6597)
- BattleThermometer: accept IList<Color> for attacker colors, dynamically
  create extra bar segments for multi-attacker battles
- BattleProgressRowController: look up defender faction from province
  RulingFactionId and attacker factions from PlayerInfos, using
  LightPlayerColor for faction-correct colors; use DarkColoredProvinceName
  for more readable province name text
- ShardokGameController: wrap single attacker color in list for new API

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 07:03:45 -07:00
44edd8cc5e Clear hex overlays on every model update and turn end (#6596)
ModelUpdated() never unconditionally cleared overlays, so stale highlights
persisted after phase transitions (setup -> running) and after issuing a
command that ends the turn. Add ClearOverlays() at the top of ModelUpdated()
so overlays are always wiped before being selectively re-drawn, and in
EndTurn() for immediate visual feedback when clicking Commit/End Turn.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 22:03:13 -07:00
3a1f1c2bbe Add battle progress display for non-combatant observers (#6581)
* Add battle progress tracking and real-time progress updates

During BATTLE_RESOLUTION phase, track Shardok battle progress (troop counts
and defender/attacker ratios) and push synchronized updates to clients via
a new BattleProgressResponse message. All battles advance together using
gated round progression to prevent information leakage. Allied observers
see troop counts; non-allied players see only the ratio bar.

Key changes:
- Add defender_ratio, troop counts, and winning_faction_id to ShardokBattleView proto
- Add updated_battles to GameStateViewDiff proto
- Add BattleProgressResponse to GameUpdate oneof
- Track battle progress in GameController with synchronized round gating
- Send enriched battle views to clients via HumanPlayerClientConnectionState
- Update GameStateViewDiffer to detect battle updates
- Add GameStateViewDifferTest with 6 test cases

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

* Move BattleProgressResponse to views layer to fix proto layering

BattleProgressResponse only contains ShardokBattleView objects, which
belong to the views layer. Move it from eagle.proto (api layer) to
game_state_view.proto (views layer) and remove the direct
shardok_battle_view.proto import from eagle.proto.

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

* Add battle progress display for non-combatant observers

During BATTLE_RESOLUTION, non-combatant players now see real-time
progress bars instead of a single "Observe Battle" button. Each bar
shows defender/attacker balance with green/red coloring. Allied battles
show troop counts and an Observe button; non-allied battles show only
the bar. When a battle ends, the bar is replaced with Victory/Defeat.

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

* Fix TextAlignmentOptions.MidlineCenter compile error and meta GUID conflict

MidlineCenter does not exist in Unity 6 TMP; use Center instead.
Also include reassigned .meta GUID to resolve conflict with duel.asset.

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

* Add BattleProgressPanel GameObject to Gameplay scene

Wire the panel to EagleGameController.battleProgressPanel with
VerticalLayoutGroup and ContentSizeFitter components.

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

* Remove BattleInProgressController from BattleProgressPanel GameObject

BattleInProgressController belongs on the map, not the progress panel.

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

* Remove unused ShardokGameStateView import from GameController

The import triggered -Werror and broke the battle_progress_test build.

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

* Show BattleProgressPanel for all battles, not just observed ones

The panel was only activated for non-combatant observers. Now it shows
whenever there are battles, alongside the "Battle!" button for combatants.

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

* Use ShardokBattles instead of RunningShardokGameModels for panel visibility

RunningShardokGameModels requires the client to have connected to the
Shardok game and received a GameRunning status, which may not have
happened yet. ShardokBattles comes from the server game state view and
is available immediately when battles exist.

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

* Refactor BattleProgressPanel to use prefab rows and display battle day

Replace programmatic CreateRow()/PopulateRow() with a prefab-based
approach using BattleProgressRowController. The panel now instantiates
rows from a prefab into a scroll view, displays "Day X" from the
server's gated round, and uses colored province names.

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

* Use BattleThermometer in battle progress rows

Replace manual anchor/color logic with the existing BattleThermometer
component, which already handles both troop-count and ratio-only modes.

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

* Fix row container cleanup, observe button visibility, and prefab transforms

Clear stale children from rowContainer on first update. Only show
Observe button for allied battles (not the player's own). Fix bar
rotation/scale in row prefab. Add debug logging for battle progress.

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

* Update BattleProgressPanel layout in Gameplay scene

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

* Replace goToBattleButton with per-row Fight!/Waiting.../Observe buttons

Each battle row now has its own action button: "Fight!" for the
fightable battle, "Waiting..." (grayed) for other own battles,
and "Observe" for allied battles. The global goToBattleButton is removed.

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

* Remove goToBattleButton from scene and adjust battle panel layout

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

* Adjust battle progress panel layout

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 21:31:16 -07:00
0f3ffc24c2 Send gated round as current_day in BattleProgressResponse (#6595)
The server already computes lastRevealedRound (the minimum round across
all active battles) for fair reveal timing, but never sent it to the
client. Add current_day field to the proto and pass gatedRound through
sendBattleProgress so clients can display the battle day.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:56:53 -07:00
741efecc66 Fix AnimalEffect spamming "State could not be found" errors (#6594)
Guard idle and walk Play() calls with HasState(), matching the existing
pattern already used for eat/attack transitions.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:10:55 -07:00
4c7ac587e7 Add Scala battle progress tracking for non-combatant observers (#6585)
* Add battle progress tracking with proto-based troop counts

Adds real-time battle progress tracking so non-combatant observers can
see defender/attacker troop ratios during BATTLE_RESOLUTION phase.

C++ Shardok extracts current_round and per-player troop counts from the
FlatBuffer game state and sends them as new proto fields on
GameUpdateResponse, avoiding the previous approach of parsing opaque
FlatBuffer bytes as Protobuf on the Scala side.

Scala Eagle tracks progress via BattleProgressTracker, gates round
reveals across concurrent battles, and enriches ShardokBattleView with
defender_ratio and troop counts for allied observers.

Includes tests for both C++ troop count extraction and Scala progress
tracking logic.

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

* Remove default values from ShardokBattleView fields

Make all new fields (defenderRatio, defenderTroopCount,
attackerTroopCount, winningFactionId) required at construction sites
instead of relying on defaults.

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

* Preserve battleProgress across GameController operations

Convert withHumanClient, withAiClient, stopStreaming, aiClientCommands,
and withHandledEngineAndResults from explicit GameController construction
to copy(), so battleProgress and lastRevealedRound are preserved when
clients connect/disconnect or commands are processed.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:10:13 -07:00
3c18918634 Extract battle progress data from FlatBuffer game state in Shardok (#6593)
Populates the new proto fields (current_round, player_troop_counts) on
GameUpdateResponse by extracting data from the FlatBuffer game state in
ShardokGameController::GetUpdates(). Counts troops for each player
across NORMAL, RESERVE, and NEVER_ENTERED units.

Updates the OnUpdates callback signature to pass the new fields through
EagleInterfaceGrpcServer to Eagle.

Includes tests verifying troop count extraction for both normal games
and games with reserved slots.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:52:23 -07:00
b930ca626f Add proto fields for battle progress tracking (#6592)
Adds new proto fields to support real-time battle progress updates for
non-combatant observers:

- shardok_internal_interface: PlayerTroopCount message, current_round
  and player_troop_counts on GameUpdateResponse
- shardok_battle_view: defender_ratio, troop counts for allied viewers,
  winning_faction_id
- game_state_view: updated_battles on GameStateViewDiff,
  BattleProgressResponse message
- eagle.proto: battle_progress_response in GameUpdate oneof
- BUILD.bazel: game_state_view_scala_proto target

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:45:11 -07:00
d1219787a8 Add weekly S3 archive cleanup workflow (#6591)
Deletes game folders from eagle/archived/ where directory.e0i is over
1 month old. Runs weekly on Sundays at 05:00 UTC with manual trigger support.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:39:59 -07:00
8019986ea7 Populate "Your Army" table in Battle Aftermath panel (#6590)
The surviving units data was being sent by the server but never displayed.
Wire the EventBasedTable to show heroes and battalions using the same
CombatUnitRowController pattern as AttackDecisionCommandSelector.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:30:06 -07:00
62b7e3abe1 Fix "What's New" showing same entries on every sign-in (#6588)
MarkAllAsSeen() assumed entries[0] had the latest date, but entries
in whats-new.json aren't necessarily in chronological order. When a
backdated entry was added at position 0, the saved last-seen date was
older than other entries, causing them to reappear on every sign-in.

Now scans all entries to find the maximum date.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:09:21 -07:00
88b049334b Archive S3 game files on game removal (#6589)
When a game is archived (last player exits) or deleted (admin console),
S3 files under eagle/save/{gameId}/ are now moved to eagle/archived/{gameId}/
to prevent the lazy-loader from resurrecting stale data and to keep S3 clean.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 19:07:21 -07:00
0aaa726abb Preserve schemaVersion when saving loaded games (#6586)
* Preserve schemaVersion when saving loaded games to games.e0es

GamesManager.save() was building RunningGame entries for loaded games
without setting schemaVersion, causing it to default to 0. This made
every game appear as needing migration on every server restart after
being loaded into memory, even when the schema version hadn't changed.

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

* Also stamp currentSchemaVersion on newly created games

Pass GameMigrator.currentVersion into GamesManager so new games get the
correct schema version on their first save, avoiding a needless no-op
migration on the next server restart.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:57:33 -07:00
37f75d4f6b Fix CompoundPersister.delete to remove from all backends (#6587)
delete() and deleteAll() only removed from the first (local) persister,
leaving stale files in S3. This caused rewound shardok battles to be
resurrected via lazy-loading from S3.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:44:47 -07:00
adminandGitHub b35a4b834e Revert "Add battle progress tracking and real-time updates (#6580)" (#6584)
This reverts commit 4fdc97590b.
2026-04-01 17:15:38 -07:00
adminandGitHub 01a19f740d Revert "Fix battle progress parsing FlatBuffer bytes as Protobuf (#6582)" (#6583)
This reverts commit f86f53326f.
2026-04-01 17:14:45 -07:00
f86f53326f Fix battle progress parsing FlatBuffer bytes as Protobuf (#6582)
* Fix battle progress parsing FlatBuffer bytes as Protobuf

The updatedBattleProgress method was calling ShardokGameStateView.parseFrom()
on opaque FlatBuffer bytes, causing InvalidProtocolBufferException in production.

Fix: Add current_round and player_troop_counts fields to GameUpdateResponse
proto so Shardok serializes the data explicitly. Eagle reads from these new
proto fields instead of trying to parse the opaque game state bytes.

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

* Add tests for battle progress proto fields

C++ test verifies troop count extraction from FlatBuffer game state
matches GetUpdates() logic. Scala test covers updatedBattleProgress()
for tracker creation, same-round updates, round boundary snapshots,
multi-attacker summation, and missing defender defaults.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 12:05:52 -07:00
4fdc97590b Add battle progress tracking and real-time updates (#6580)
* Add battle progress tracking and real-time progress updates

During BATTLE_RESOLUTION phase, track Shardok battle progress (troop counts
and defender/attacker ratios) and push synchronized updates to clients via
a new BattleProgressResponse message. All battles advance together using
gated round progression to prevent information leakage. Allied observers
see troop counts; non-allied players see only the ratio bar.

Key changes:
- Add defender_ratio, troop counts, and winning_faction_id to ShardokBattleView proto
- Add updated_battles to GameStateViewDiff proto
- Add BattleProgressResponse to GameUpdate oneof
- Track battle progress in GameController with synchronized round gating
- Send enriched battle views to clients via HumanPlayerClientConnectionState
- Update GameStateViewDiffer to detect battle updates
- Add GameStateViewDifferTest with 6 test cases

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

* Move BattleProgressResponse to views layer to fix proto layering

BattleProgressResponse only contains ShardokBattleView objects, which
belong to the views layer. Move it from eagle.proto (api layer) to
game_state_view.proto (views layer) and remove the direct
shardok_battle_view.proto import from eagle.proto.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 21:45:35 -07:00
1d46bdd452 Fix Sparkle sign_update path to match tarball structure (#6579)
The Sparkle-2.6.4.tar.xz extracts to ./bin/sign_update, not
./Sparkle-2.6.4/bin/sign_update. This broke mac builds when the
runner had a fresh /tmp/sparkle-cache.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 07:51:29 -07:00
292097f03e Fix Please Recruit Me not highlighting the acting province (#6578)
new List<ProvinceId>(heroInfo.provinceId) calls the capacity constructor,
creating an empty list with capacity = provinceId. Changed to collection
initializer syntax { heroInfo.provinceId } so the province ID is actually
added as an element. The PopupPanelController already feeds this list into
mapController.OverrideTargetedProvinces for highlighting, but the list was
always empty.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 06:39:13 -07:00
9a8ef4f243 Log command details when postCommand fails (#6577)
Include game ID, command type, and province/player info in the error
log line so failed commands are diagnosable without reproducing.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 21:03:13 -07:00
c99d45bda7 Fix smuggler figures rendering white by using prefab refs instead of raw FBX (#6576)
SoldierEffect.prefab referenced 3 of 4 character models as raw .fbx files
(PT_Male_Soldier_01.fbx, PT_Female_Soldier_01.fbx, PT_Female_Soldier_02.fbx).
Raw FBX instantiation doesn't carry material assignments, causing models to
render with the default white material. Updated references to use the proper
.prefab files from Polytope Studio which have materials correctly configured.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 21:02:05 -07:00
18222f9cc4 Upgrade GitHub Actions to Node.js 24 versions (#6575)
Node.js 20 actions are deprecated and will stop working June 2, 2026.

- actions/checkout v4 → v6
- actions/upload-artifact v4 → v7
- actions/download-artifact v4 → v8
- actions/setup-node v4 → v6
- appleboy/ssh-action v1.0.3 → v1.2.5
- docker/setup-qemu-action v3 → v4
- docker/setup-buildx-action v3 → v4

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 07:51:55 -07:00
adminandGitHub 344f1134f6 Detect missing prefab references in Unity builds (#6574) 2026-03-22 18:20:09 -07:00
9aaca0d993 Add diagnostic for Windows-only NullRef in SettingsPanelController (#6573)
All prefab override fields are null at runtime on Windows builds only.
Log the missing fields and gracefully bail out of ApplyPersistedSettings,
Start, and HandleEscape so the game can still launch and we can
investigate whether other prefab overrides are also broken.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 13:43:39 -07:00
94db3634db Add artifact cleanup job to purge stale logs every 6 hours (#6572)
GitHub Actions retention-days: 3 is unreliable — 1,846 expired artifacts
(282 MB) accumulated over 5+ weeks. Add a cleanup-expired job that deletes
artifacts older than 3 days before the storage check runs.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 21:17:19 -07:00
adminandGitHub 19c8e63152 Replace necromancer control chain with interlocking ring links (#6571) 2026-03-21 16:22:34 -07:00
5d7f298d1c Suppress harmless stream-closed exceptions from Sentry (#6570)
* Suppress harmless stream-closed IllegalStateException in SyncResponseObserver

The most common Sentry exception was "Stream is already completed" from
CustomBattleManager. This happens when Shardok sends battle results to a
client that already disconnected — the isCancelled check passes because
"cancelled" and "completed" are different stream states, so the underlying
onNext throws IllegalStateException. Catch it silently in all three stream
methods, matching the existing pattern in GameController.

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

* Narrow catch to only suppress "already completed" stream exceptions

Only catch IllegalStateException when the message contains "already completed",
so unexpected IllegalStateExceptions still propagate to Sentry.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 14:10:25 -07:00
1cfd0ded6c Narrow longbow wind bonus cone from 120° to 60° (#6569)
The wind-assisted range-3 check was allowing tiles in adjacent hex
directions (±60° from wind), producing a 120° cone. This let longbows
shoot northwest with a northeast wind. Tighten the check so only tiles
whose direction matches the wind direction (or sits on the boundary
between two sectors, one of which is the wind direction) qualify.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:53:26 -07:00
ab69d40e64 Fix control undead animation targeting wrong location (#6568)
The control command's CommandDescriptor has target_unit but no target
coords, so the client treated it as fully untargeted and animated toward
the nearest enemy instead of the controlled undead unit. Now
PlayUntargetedCommandAnimation uses the command's target_unit when
available. Also set target_unit on the CONTROLLED action result so
server-side history replays animate correctly.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:48:00 -07:00
be94acebbf Remove noisy MeteorStart Debug.Log from ShardokGameController (#6567)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:33:29 -07:00
4f5df7d697 Remove noisy SHA256 validated Debug.Log messages from ProvinceIDLoader (#6566)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:48:47 -07:00
c5af75db7d Remove noisy Debug.Log messages from ProvinceBeastsController (#6565)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:23:10 -07:00
24421db613 Defer beast effect spawning until province centroids are loaded (#6564)
The ProvinceBeastsController could attempt to spawn beast effects before
centroids arrived from CDN, causing "No centroid found" warnings. The
effect prefabs (loaded from local Addressables) often finished loading
before the centroids (fetched over the network), creating a window where
UpdateBeastsEffects would proceed without centroid data.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:12:19 -07:00
35a804cf18 Fix AnimalEffect animator state errors (#6563)
* Guard AnimalEffect animator state plays with HasState check

Some animal prefab animator controllers don't have states named idle,
walk, eat, or attack, causing spammy "State could not be found" and
"Invalid Layer Index '-1'" errors every frame. Added SafePlay helper
that checks HasState before calling Play.

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

* Fix AnimalEffect animator state errors for kangaroo, leopard, black panther

Three animator controllers (KangarooMapAnims, LeopardMapAnims,
BlackPantherMapAnims) used "idle1" instead of "idle", causing
"State could not be found" errors every frame. Renamed to "idle".

Also skip eat/attack actions for animals whose controllers don't have
those states (e.g. scorpion has no eat) — falls back to idle instead
of playing a missing state.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:04:14 -07:00
ba9091409a Skip auto-placement of reserve units during reconnect resync (#6562)
On reconnect, HandleStartingState clears all ShardokGameModels and
re-creates them, then the server replays the full battle history. Each
ChangedReserveUnit in the replayed history was triggering auto-placement
to starting positions, causing visible UI thrashing — especially with
repeated reconnect attempts during idle periods. The user's manual
placements (local-only until submitted) were lost each time.

Added SkipAutoPlacement flag to ShardokGameModel, set during resync
model creation and cleared after the first successful HandleUpdates.
This prevents auto-placement during history replay while preserving
it for genuinely new battles.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 17:27:37 -07:00
b4452d4e9c Fix tribute sliders not activating in Attack Decision command (#6561)
The 4 decision toggles (Advance, Demand Tribute, Withdraw, Safe Passage)
were not wired to ToggleChanged in the prefab, so selecting Demand Tribute
never activated the tribute container with gold/food sliders. Also changed
ConfigureToggle to use SetIsOnWithoutNotify when disabling unavailable
toggles, preventing a "No decision selected" exception during setup.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 15:32:08 -07:00
adminandGitHub 32f5c6838c Add externalObjects material remapping to animal/creature FBX meta files (#6560) 2026-03-20 12:45:34 -07:00
8fa13da9c1 Add URP migration plan for BiRP-to-URP pipeline conversion (#6559)
Documents phased approach for migrating from Built-In Render Pipeline
to Universal Render Pipeline before Unity drops BiRP support after 6.7.
Covers shader inventory, long-lived branch strategy, and effort estimates.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:24:08 -07:00
a2958a7672 Fix backstory visibility for unaffiliated heroes after conquest (#6558)
Unaffiliated hero backstory updates were only visible to the current
province owner, so when another faction conquered and tried to recruit,
the backstory was missing. Now unaffiliated hero backstory updates use
empty recipientFactionIds (meaning all factions), matching the initial
backstory behavior.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 11:03:38 -07:00
1eb9f07c9c Unity 6.4 upgrade (#6557)
* Upgrade Unity from 6000.3.8f1 to 6000.4.0f1

Also clear the CI runner's Library/ cache when the Unity version
changes to prevent infinite import loops on version upgrades.

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

* Fix em dash characters in workflow YAML breaking GitHub Actions

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

* Fix gray textures after Unity 6.4 upgrade by switching to embedded materials

materialLocation: 0 (External) is deprecated in Unity 6.4 and causes FBX
models to render without textures. Changed all 749 .fbx.meta files to use
materialLocation: 1 (Embedded) which is the new default.

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

* Trigger CI checks

* Add trailing newline to workflow file to re-trigger CI

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

* Fix YAML syntax error on line 125 of unity_build.yml

The run: value contained | and > characters that confused the YAML parser.
Switching to block scalar syntax (run: |) avoids the ambiguity.

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

* Remap bridge materials to fix gray textures after materialLocation change

With materialLocation: 1 (Embedded), Unity ignores external .mat files.
Added externalObjects remapping so the bridge FBX models still use the
correctly-textured bridges_d material from TileableBridgePack/Materials/.

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

* Add material remapping to all remaining bridge FBX meta files

The old-format meta files (serializedVersion: 18) had no materialLocation
or externalObjects fields, causing Unity 6.4 to default to the deprecated
External material path and render bridges gray. Added externalObjects
remapping and materialLocation: 1 to all 47 remaining bridge models.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 10:14:43 -07:00
393fe7bdca Add rounded corner mask to battle thermometer bar area (#6555)
Use UISprite with Mask component on the Bar Area container to clip
the colored bars with smooth rounded corners.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:44:42 -07:00
51fc0ac99c Add battle thermometer to Shardok tactical view (#6554)
* Add battle thermometer to Shardok tactical view

Replace the always-visible armies table with a horizontal thermometer bar
showing defender vs attacker troop balance at a glance. The bar uses
Fantasy RPG boss gauge sprites with faction-matched colors. Hovering the
thermometer reveals the full armies table as a popup.

Also adds icons to Army Info Row and removes the totals row from the
armies table since troop counts are now shown on the thermometer.

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

* Fix thermometer bar sizing and null model crash

Use anchor-based horizontal sizing instead of fillAmount, preserving
vertical inspector settings. Replace BarMask with plain Bar Area
container. Guard UpdateReserves against null Model when Shardok
container is active at game launch.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 21:15:15 -07:00
f3211cf239 Replace Dumb Edged-Rod headshot with one that shows her face (#6553)
The previous headshot only showed her torso up to her lips.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 19:28:54 -07:00
a73e758551 Remove unnecessary DontDestroyOnLoad from UpdateNotificationManager (#6552)
UpdateNotificationManager lives in Gameplay.unity which is the
persistent base scene — it is never unloaded. DontDestroyOnLoad is
unnecessary and produces a warning because the object is a non-root
UI child under a Canvas. Remove the call entirely.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 06:32:20 -07:00
b47e37a083 Remove noisy startup Debug.Log messages (#6551)
Remove happy-path confirmation logs that fire on every normal startup
and provide no diagnostic value: tutorial UI built, state loaded, token
cache initialized, Sparkle version/platform info, auth configured,
session restored, and WhatsNew check results.

Error/warning logs (LogWarning, LogError) are preserved.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 22:48:48 -07:00
bccd861230 Fix Unity 6 compiler warnings across 30 C# files (#6548)
Replace deprecated APIs to eliminate non-third-party warnings:
- FindObjectOfType<T>() → FindAnyObjectByType<T>() (23 files)
- FindObjectsOfType<T>() → FindObjectsByType<T>(FindObjectsSortMode.None) (1 file)
- enableWordWrapping → textWrappingMode (3 files)
- Add _ = discard to unawaited async Task.Run calls (2 files)
- Remove unused variable in TutorialManager

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:25:25 -07:00
4151 changed files with 73426 additions and 722713 deletions
+14 -4
View File
@@ -1,4 +1,4 @@
bazel-1.0.0.bazelrc
# bazel-1.0.0.bazelrc
# for now: filter out annoying TASTY warnings
common --ui_event_filters=-INFO
@@ -29,6 +29,10 @@ common --host_cxxopt="--std=c++23"
common --javacopt="-Xlint:-options"
# std::filesystem and other modern C++ deps require macOS 10.15+.
common:macos --macos_minimum_os=10.15
common:macos --host_macos_minimum_os=10.15
# suppress warnings due to https://developer.apple.com/forums/thread/733317
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
@@ -37,11 +41,17 @@ common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
# See: https://github.com/grpc/grpc/issues/37619
common:macos --features=-module_maps
# Pin DEVELOPER_DIR so the apple_cc_autoconf repo rule doesn't re-evaluate
# when Xcode updates in-place. The sync script overrides this for mactools.
# Pin DEVELOPER_DIR so Apple repo rules use full Xcode instead of Command Line
# Tools. The sync script writes .bazelrc.xcode with an Xcode build-version
# marker so in-place Xcode updates refresh Bazel's generated Apple toolchains.
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
# Xcode config for mactools builds only. Generated by scripts/sync_bazel_xcode.sh.
# Local machine or CI overrides. This lets Bazel-only runners use Command Line
# Tools without installing full Xcode. This must stay before .bazelrc.xcode so
# generated Xcode settings win when both files exist.
try-import %workspace%/.bazelrc.local
# Xcode config for macOS builds. Generated by scripts/sync_bazel_xcode.sh.
# Bakes the Xcode build version into action cache keys and sets DEVELOPER_DIR.
try-import %workspace%/.bazelrc.xcode
+41 -4
View File
@@ -6,8 +6,44 @@ on:
- cron: '0 */6 * * *'
workflow_dispatch:
permissions:
contents: read
actions: write
jobs:
cleanup-expired:
runs-on: ubuntu-latest
steps:
- name: Delete expired artifacts and artifacts older than 3 days
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Fetching all artifacts..."
gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | "\(.id)\t\(.created_at)\t\(.expired)\t\(.name)"' > /tmp/all_artifacts.txt
total=$(wc -l < /tmp/all_artifacts.txt)
echo "Found $total total artifacts"
cutoff=$(date -u -d '3 days ago' '+%Y-%m-%dT%H:%M:%SZ')
echo "Deleting expired artifacts and artifacts created before $cutoff"
deleted=0
while IFS=$'\t' read -r id created_at expired name; do
if [[ "$expired" == "true" || "$created_at" < "$cutoff" ]]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" 2>/dev/null && deleted=$((deleted + 1))
if [ $((deleted % 50)) -eq 0 ]; then
echo "Deleted $deleted artifacts so far..."
fi
fi
done < /tmp/all_artifacts.txt
echo "Cleanup complete. Deleted $deleted artifacts out of $total total."
rm -f /tmp/all_artifacts.txt
check-storage:
needs: cleanup-expired
runs-on: ubuntu-latest
steps:
@@ -15,11 +51,12 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
# Calculate total artifact storage
# Calculate active artifact storage. The artifacts API can list expired
# artifacts until they are explicitly deleted, so do not count them.
total_bytes=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[].size_in_bytes' | awk '{sum+=$1} END {print sum}')
--paginate -q '.artifacts[] | select(.expired == false) | .size_in_bytes' | awk '{sum+=$1} END {print sum}')
total_mb=$((total_bytes / 1024 / 1024))
total_mb=$(( ${total_bytes:-0} / 1024 / 1024 ))
echo "Total artifact storage: ${total_mb} MB"
# Fail if over 500MB
@@ -29,7 +66,7 @@ jobs:
echo "Largest artifacts:"
# Save to temp file to avoid SIGPIPE/broken pipe errors with head
gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
--paginate -q '.artifacts[] | select(.expired == false) | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
sort -rn /tmp/artifacts.txt | head -20 | \
awk -F'\t' '{printf "%d MB\t%s\t%s\n", $1/1024/1024, $2, $3}'
rm -f /tmp/artifacts.txt
+16 -5
View File
@@ -11,6 +11,7 @@ on:
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'src/main/resources/net/eagle0/attributions.json'
- 'ci/BUILD.bazel'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.bazelrc'
- '.github/workflows/auth_build.yml'
workflow_dispatch:
@@ -24,17 +25,25 @@ on:
permissions:
contents: read
concurrency:
group: auth-build-deploy
cancel-in-progress: false
jobs:
build-auth:
runs-on: [self-hosted, bazel]
runs-on: [self-hosted, bazel, halfdan]
outputs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Build Auth Server Docker image
id: build-auth
run: |
@@ -127,10 +136,12 @@ jobs:
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
@@ -138,7 +149,7 @@ jobs:
script_stop: true
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,APPLE_SIGNIN_CLIENT_ID,APPLE_TEAM_ID,APPLE_SIGNIN_KEY_ID,APPLE_SIGNIN_PRIVATE_KEY,TWITCH_CLIENT_ID,TWITCH_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -x
set -e
cd /opt/eagle0
# Export env vars for docker compose (appleboy/ssh-action sets them but doesn't export)
+8 -5
View File
@@ -1,19 +1,22 @@
name: Bazel Cache Cleanup
on:
schedule:
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
# Disabled: bazel clean fails when another runner shares the output_base
# on /Volumes/remote_cache (unlinkat "Directory not empty" race).
# See https://github.com/nolen777/eagle0/issues/TBD for details.
# schedule:
# - cron: '0 0 * * 0'
workflow_dispatch: # Allow manual trigger
jobs:
cleanup:
runs-on: [self-hosted, bazel]
runs-on: [self-hosted, bazel, halfdan]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
- name: Show disk usage before cleanup
+45 -5
View File
@@ -9,6 +9,7 @@ on:
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.github/workflows/bazel_test.yml'
- '!src/main/csharp/**'
- '!src/test/csharp/**'
@@ -19,6 +20,7 @@ on:
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.github/workflows/bazel_test.yml'
- '!src/main/csharp/**'
- '!src/test/csharp/**'
@@ -40,14 +42,32 @@ jobs:
git config --local core.sparseCheckout false 2>/dev/null || true
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 'lts/*'
- name: Check JavaScript syntax
@@ -62,10 +82,30 @@ jobs:
git config --local core.sparseCheckout false 2>/dev/null || true
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Run tests
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
@@ -102,14 +142,14 @@ jobs:
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
- name: Archive test results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: test.json
path: test.json
retention-days: 3
- name: Archive failed test logs
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: failed-test-logs
path: failed_test_logs/
+6 -2
View File
@@ -11,14 +11,18 @@ permissions:
jobs:
cleanup:
runs-on: [self-hosted, bazel]
runs-on: [self-hosted, bazel, halfdan]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
clean: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Clean up unreferenced blobs
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
+10 -6
View File
@@ -27,13 +27,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Build sysroot
run: ./tools/sysroot/build_sysroot.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ubuntu-noble-sysroot-amd64
path: tools/sysroot/output/
@@ -81,21 +83,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Set up QEMU for ARM64 emulation
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build ARM64 sysroot
run: ./tools/sysroot/build_sysroot_arm64.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: ubuntu-noble-sysroot-arm64
path: tools/sysroot/output/
+63 -7
View File
@@ -18,6 +18,7 @@ on:
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'MODULE.bazel'
- '.bazelrc'
- 'docker-compose.prod.yml'
@@ -47,7 +48,7 @@ jobs:
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
build-all:
runs-on: [self-hosted, bazel]
runs-on: [self-hosted, bazel, halfdan]
outputs:
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
@@ -68,14 +69,38 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
- name: Ensure Git LFS available for checkout
if: steps.check-latest.outputs.skip != 'true'
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- name: Checkout repository
if: steps.check-latest.outputs.skip != 'true'
uses: actions/checkout@v4
uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
if: steps.check-latest.outputs.skip != 'true'
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Fetch LFS files needed for admin server
if: steps.check-latest.outputs.skip != 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./ci/github_actions/fetch_lfs.sh --include="src/main/go/net/eagle0/admin_server/static/tiles/*"
- name: Build all Docker images
@@ -117,7 +142,7 @@ jobs:
- name: Upload warmup binary
if: steps.check-latest.outputs.skip != 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: warmup-binary
path: scripts/bin/warmup
@@ -202,7 +227,7 @@ jobs:
echo "=== All images pushed successfully ==="
deploy:
runs-on: [self-hosted, bazel]
runs-on: [self-hosted, bazel, halfdan]
needs: [build-all]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
@@ -244,9 +269,26 @@ jobs:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
GITHUB_TOKEN_FOR_ADMIN: ${{ secrets.ADMIN_GITHUB_TOKEN }}
steps:
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
lfs: false
- name: Setup SSH key
@@ -257,11 +299,25 @@ jobs:
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Download warmup binary
uses: actions/download-artifact@v4
id: download-warmup
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: warmup-binary
path: scripts/bin/
- name: Build warmup binary fallback
if: steps.download-warmup.outcome != 'success'
run: |
echo "::warning::warmup-binary artifact was unavailable; rebuilding warmup binary before deploy"
./ci/github_actions/ensure_bazel_installed.sh
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:warmup_tar
mkdir -p scripts/bin
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
- name: Copy config files to droplet
run: |
# Create directory structure on remote
@@ -280,7 +336,7 @@ jobs:
- name: Deploy to production droplet
run: |
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
set -ex
set -e
cd /opt/eagle0
# =================================================================
+22 -1
View File
@@ -11,6 +11,7 @@ on:
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.github/workflows/eagle_build.yml'
concurrency:
@@ -25,9 +26,29 @@ jobs:
runs-on: [self-hosted, bazel]
steps:
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Build Eagle server
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server
+25 -2
View File
@@ -5,10 +5,12 @@ on:
branches: [ "main" ]
paths:
- ".github/workflows/installer_build.yml"
- "ci/github_actions/ensure_bazel_installed.sh"
- "src/main/go/net/eagle0/clients/win/installer/**"
pull_request:
paths:
- ".github/workflows/installer_build.yml"
- "ci/github_actions/ensure_bazel_installed.sh"
- "src/main/go/net/eagle0/clients/win/installer/**"
workflow_dispatch:
@@ -21,11 +23,32 @@ jobs:
runs-on: [self-hosted, bazel]
steps:
- uses: actions/checkout@v4
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
clean: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Build Go installer for Windows
env:
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
@@ -50,7 +73,7 @@ jobs:
- name: Archive installer binary
if: success() || failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: eagle-installer
path: ./installer-output/
+90 -6
View File
@@ -13,7 +13,7 @@ on:
permissions:
contents: read
actions: read
actions: write
env:
# Runner-specific build directory to allow parallel builds on multiple runners
@@ -108,7 +108,7 @@ jobs:
build-unity:
needs: check-changes
if: needs.check-changes.outputs.should_build == 'true'
runs-on: [self-hosted, macOS, testflight]
runs-on: [self-hosted, macOS, testflight, halfdan]
steps:
- name: Prune stale PR refs
@@ -122,8 +122,26 @@ jobs:
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
fetch-depth: 0
@@ -134,6 +152,8 @@ jobs:
rm -rf src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee/
- name: Fetch LFS files
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./ci/github_actions/fetch_lfs.sh
- name: Ensure Unity version installed
@@ -164,17 +184,81 @@ jobs:
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: editor_ios.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
retention-days: 3
- name: Package generated iOS project
if: success()
run: |
tar -czf "$RUNNER_TEMP/eagle0-ios-project.tar.gz" -C "$EAGLE0_BUILD_DIR" eagle0iOS
- name: Upload generated iOS project
if: success()
uses: actions/upload-artifact@v7
with:
name: eagle0-ios-project-${{ github.run_id }}
path: ${{ runner.temp }}/eagle0-ios-project.tar.gz
retention-days: 1
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
archive-and-upload:
needs: build-unity
runs-on: [self-hosted, macOS, testflight]
runs-on: [self-hosted, macOS, testflight, halfdan]
steps:
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
lfs: false
- name: Clean build directory
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
- name: Download generated iOS project
uses: actions/download-artifact@v8
with:
name: eagle0-ios-project-${{ github.run_id }}
path: ${{ runner.temp }}
- name: Extract generated iOS project
run: |
mkdir -p "$EAGLE0_BUILD_DIR"
tar -xzf "$RUNNER_TEMP/eagle0-ios-project.tar.gz" -C "$EAGLE0_BUILD_DIR"
- name: Delete generated iOS project artifact
env:
GH_TOKEN: ${{ github.token }}
run: |
artifact_id=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate \
--jq '.artifacts[] | select(.name == "eagle0-ios-project-${{ github.run_id }}") | .id')
if [ -n "$artifact_id" ]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id"
fi
- name: Install Signing Certificate
env:
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
@@ -264,7 +348,7 @@ jobs:
- name: Upload IPA artifact
# Only keep artifact if we skipped TestFlight upload (for debugging)
if: success() && github.event.inputs.skip_upload == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: eagle0-ios-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/archive/eagle0.ipa
+76 -12
View File
@@ -20,6 +20,7 @@ on:
- "scripts/notarize_wait.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/github_actions/ensure_bazel_installed.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
@@ -39,6 +40,7 @@ on:
- "scripts/notarize_wait.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/github_actions/ensure_bazel_installed.sh"
- "ci/mac/**"
workflow_dispatch:
inputs:
@@ -64,7 +66,7 @@ env:
jobs:
build-and-sign:
runs-on: [self-hosted, macOS, unity-mac]
runs-on: [self-hosted, macOS, unity-mac, halfdan]
outputs:
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
@@ -81,8 +83,26 @@ jobs:
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
fetch-depth: 0 # For version numbering from git history
@@ -109,11 +129,16 @@ jobs:
fi
- name: Fetch LFS files
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./ci/github_actions/fetch_lfs.sh
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh mac
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Sync Bazel Xcode config
run: ./scripts/sync_bazel_xcode.sh
@@ -122,7 +147,9 @@ jobs:
- name: Save build SHA for Bee/ cache invalidation
if: success()
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
run: |
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
@@ -227,7 +254,7 @@ jobs:
- name: Upload signed app
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
@@ -235,7 +262,7 @@ jobs:
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: editor_mac.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
@@ -247,18 +274,35 @@ jobs:
wait-notarization:
needs: build-and-sign
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, notarize]
runs-on: [self-hosted, macOS, notarize, halfdan]
steps:
- uses: actions/checkout@v4
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
sparse-checkout: scripts
- name: Clean download directory
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
@@ -286,7 +330,7 @@ jobs:
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload notarized app
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
@@ -298,20 +342,37 @@ jobs:
deploy:
needs: [build-and-sign, wait-notarization]
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, unity-mac]
runs-on: [self-hosted, macOS, notarize, halfdan]
outputs:
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
steps:
- uses: actions/checkout@v4
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
fetch-depth: 0 # For version numbering
- name: Clean download directory
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
@@ -322,6 +383,9 @@ jobs:
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Sync Bazel Xcode config
run: ./scripts/sync_bazel_xcode.sh
+20 -5
View File
@@ -39,9 +39,9 @@ jobs:
echo "Dry run: ${DRY_RUN}"
echo ""
# List of repositories to clean
# Use tail to skip header row in case --no-header doesn't work
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
# List repositories via JSON. Text output can wrap table rows and make
# headers/tags/digests look like repository names.
REPOS=$(doctl registry repository list-v2 "${REGISTRY}" --output json | jq -r '.[] | .name // .Name // empty')
for REPO in $REPOS; do
echo "=== Processing repository: ${REPO} ==="
@@ -88,5 +88,20 @@ jobs:
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
run: |
echo "Starting garbage collection..."
doctl registry garbage-collection start --force
echo "Garbage collection started. It may take a few minutes to complete."
set +e
GC_OUTPUT=$(doctl registry garbage-collection start --force 2>&1)
GC_STATUS=$?
set -e
echo "$GC_OUTPUT"
if [ "$GC_STATUS" -eq 0 ]; then
echo "Garbage collection started. It may take a few minutes to complete."
exit 0
fi
if echo "$GC_OUTPUT" | grep -q "automated garbage collection is enabled"; then
echo "Automated garbage collection is enabled for this registry; skipping manual garbage collection."
exit 0
fi
exit "$GC_STATUS"
+86
View File
@@ -0,0 +1,86 @@
name: S3 Archive Cleanup
on:
schedule:
# Run weekly on Sunday at 05:00 UTC
- cron: '0 5 * * 0'
workflow_dispatch: # Allow manual trigger
permissions:
contents: read
jobs:
cleanup:
runs-on: [self-hosted, bazel, halfdan]
steps:
- name: Ensure AWS CLI is available
run: |
if ! command -v aws &> /dev/null; then
brew install awscli
fi
- name: Delete old archived game folders
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DO_SPACES_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
run: |
set -euo pipefail
S3_ENDPOINT="https://sfo3.digitaloceanspaces.com"
BUCKET="s3://eagle0/eagle/archived/"
# macOS date syntax
CUTOFF=$(date -v-1m +%s)
DELETED=0
SKIPPED=0
NO_DIR_FILE=0
echo "Cutoff date: $(date -r ${CUTOFF} '+%Y-%m-%dT%H:%M:%S')"
echo ""
FOLDERS=$(aws s3 ls "$BUCKET" --endpoint-url "$S3_ENDPOINT" \
| awk '/PRE/{gsub(/\/$/,"",$2); print $2}')
if [ -z "$FOLDERS" ]; then
echo "No archived folders found."
exit 0
fi
TOTAL=$(echo "$FOLDERS" | wc -l | tr -d ' ')
CURRENT=0
for game_id in $FOLDERS; do
CURRENT=$((CURRENT + 1))
DIR_INFO=$(aws s3 ls "${BUCKET}${game_id}/directory.e0i" \
--endpoint-url "$S3_ENDPOINT" 2>/dev/null || true)
if [ -z "$DIR_INFO" ]; then
echo "[$CURRENT/$TOTAL] DELETE $game_id (no directory.e0i)"
aws s3 rm --recursive "${BUCKET}${game_id}/" \
--endpoint-url "$S3_ENDPOINT" > /dev/null 2>&1
DELETED=$((DELETED + 1))
NO_DIR_FILE=$((NO_DIR_FILE + 1))
continue
fi
FILE_DATE=$(echo "$DIR_INFO" | awk '{print $1 " " $2}')
FILE_EPOCH=$(date -j -f '%Y-%m-%d %H:%M:%S' "$FILE_DATE" +%s 2>/dev/null || echo "0")
if [ "$FILE_EPOCH" -lt "$CUTOFF" ]; then
echo "[$CURRENT/$TOTAL] DELETE $game_id (directory.e0i from $FILE_DATE)"
aws s3 rm --recursive "${BUCKET}${game_id}/" \
--endpoint-url "$S3_ENDPOINT" > /dev/null 2>&1
DELETED=$((DELETED + 1))
else
echo "[$CURRENT/$TOTAL] KEEP $game_id (directory.e0i from $FILE_DATE)"
SKIPPED=$((SKIPPED + 1))
fi
done
echo ""
echo "=== Summary ==="
echo "Total folders: $TOTAL"
echo "Deleted: $DELETED"
echo "Kept (recent): $SKIPPED"
+53 -25
View File
@@ -9,6 +9,7 @@ on:
- 'src/main/protobuf/net/eagle0/common/**'
- 'src/main/resources/net/eagle0/shardok/**'
- 'ci/BUILD.bazel'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'MODULE.bazel'
- '.bazelrc'
- '.github/workflows/shardok_arm64_build.yml'
@@ -29,15 +30,19 @@ permissions:
jobs:
build-shardok-arm64:
runs-on: [self-hosted, bazel]
runs-on: [self-hosted, bazel, halfdan]
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
run: |
set -ex
@@ -165,7 +170,7 @@ jobs:
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
deploy-hetzner:
runs-on: [self-hosted, bazel]
runs-on: [self-hosted, bazel, halfdan]
needs: [build-shardok-arm64]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
@@ -180,33 +185,55 @@ jobs:
# Add host key to known_hosts to avoid prompt
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Pull Shardok image tarball for Hetzner
env:
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
set -ex
# Hetzner is IPv6-only, while Docker can be redirected to IPv4-only
# DigitalOcean registry/blob endpoints. Pull on the runner and copy
# the Docker-loadable tarball over SSH instead.
CRANE_VERSION="v0.20.2"
CRANE_DIR="${HOME}/.local/bin"
CRANE="${CRANE_DIR}/crane"
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
echo "Installing crane ${CRANE_VERSION}..."
mkdir -p "$CRANE_DIR"
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
chmod +x "$CRANE"
else
echo "Using cached crane at $CRANE"
fi
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
mkdir -p ~/.docker
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
IMAGE_TAR="${RUNNER_TEMP}/shardok-arm64-image.tar"
"$CRANE" pull "${SHARDOK_IMAGE}" "$IMAGE_TAR"
echo "SHARDOK_IMAGE_TAR=$IMAGE_TAR" >> "$GITHUB_ENV"
- name: Copy Shardok image tarball to Hetzner
run: |
set -ex
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} \
"cat > /tmp/shardok-arm64-image.tar" < "$SHARDOK_IMAGE_TAR"
- name: Deploy to Hetzner
run: |
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
set -ex
cd /opt/eagle0
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Pre-warm NAT64 path to DigitalOcean registry (IPv6-only Hetzner → IPv4 registry)
curl -sf --max-time 10 https://registry.digitalocean.com/v2/ > /dev/null 2>&1 || true
# Pull the new image (retry up to 3 times for NAT64 connectivity flakiness)
for attempt in 1 2 3; do
echo "Pull attempt $attempt..."
if docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "ERROR: docker pull failed after 3 attempts"
exit 1
fi
echo "Pull failed, retrying in 10s..."
sleep 10
done
SHARDOK_IMAGE="${{ needs.build-shardok-arm64.outputs.image_tag }}"
echo "Loading Shardok ARM64 image: $SHARDOK_IMAGE"
docker load -i /tmp/shardok-arm64-image.tar
rm -f /tmp/shardok-arm64-image.tar
docker image inspect "$SHARDOK_IMAGE" > /dev/null
echo "Loaded image ID: $(docker image inspect --format '{{.Id}}' "$SHARDOK_IMAGE")"
echo "Shardok binary SHA256:"
docker run --rm --entrypoint sha256sum "$SHARDOK_IMAGE" /app/shardok-server
# Stop and remove any container using port 40042 or named shardok*
docker ps -q --filter "publish=40042" | xargs -r docker stop
@@ -224,11 +251,12 @@ jobs:
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
-e SHARDOK_RESOURCES_PATH=/app/resources \
-e SHARDOK_MAPS_PATH=/app/resources/maps \
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
"$SHARDOK_IMAGE"
# Wait and verify
sleep 5
docker ps | grep shardok-ai
echo "Running container image ID: $(docker inspect --format '{{.Image}}' shardok-ai)"
# Cleanup old images
docker image prune -f
+24 -3
View File
@@ -5,13 +5,14 @@ on:
pull_request:
paths:
- 'src/main/cpp/**'
- 'src/main/proto/net/eagle0/shardok/**'
- 'src/main/proto/net/eagle0/common/**'
- 'src/main/protobuf/net/eagle0/shardok/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'src/main/go/net/eagle0/build/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.github/workflows/shardok_build.yml'
concurrency:
@@ -26,9 +27,29 @@ jobs:
runs-on: [self-hosted, bazel]
steps:
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Build Shardok server
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
+69 -10
View File
@@ -15,6 +15,8 @@ on:
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/test_unity_editmode.sh"
- "ci/github_actions/ensure_bazel_installed.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
@@ -34,6 +36,8 @@ on:
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/test_unity_editmode.sh"
- "ci/github_actions/ensure_bazel_installed.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
@@ -53,7 +57,7 @@ env:
jobs:
windows-unity:
runs-on: [self-hosted, macOS, unity-windows]
runs-on: [self-hosted, macOS, unity-windows, halfdan]
outputs:
deployed_version: ${{ steps.get-version.outputs.deployed_version }}
@@ -69,42 +73,88 @@ jobs:
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
- name: Clean stale files
run: |
git clean -ffd
LIBRARY_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library"
VERSION_CACHE="$LIBRARY_DIR/.last_unity_version"
PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
CURRENT_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')
# Nuke Library/ when Unity version changes to avoid import loops
if [ -f "$VERSION_CACHE" ]; then
CACHED_VERSION=$(cat "$VERSION_CACHE")
if [ "$CACHED_VERSION" != "$CURRENT_VERSION" ]; then
echo "Unity version changed ($CACHED_VERSION -> $CURRENT_VERSION) -- clearing Library/"
rm -rf "$LIBRARY_DIR"
fi
fi
# Only clear Bee/ when C# files were added/deleted/renamed (structural
# changes that stale the DAG). Content-only modifications are handled by
# Bee's incremental compilation. See persist_library.sh for background.
BEE_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee"
SHA_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha"
BEE_DIR="$LIBRARY_DIR/Bee"
SHA_FILE="$LIBRARY_DIR/.last_built_sha"
if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
LAST_SHA=$(cat "$SHA_FILE")
if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
echo "C# files added/deleted/renamed since $LAST_SHA clearing Bee/"
echo "C# files added/deleted/renamed since $LAST_SHA -- clearing Bee/"
rm -rf "$BEE_DIR"
else
echo "No structural C# changes since $LAST_SHA keeping Bee/"
echo "No structural C# changes since $LAST_SHA -- keeping Bee/"
fi
else
echo "No previous build SHA or no Bee/ clearing Bee/ as safe default"
echo "No previous build SHA or no Bee/ -- clearing Bee/ as safe default"
rm -rf "$BEE_DIR"
fi
- name: Fetch LFS files
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./ci/github_actions/fetch_lfs.sh
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh windows
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Run Unity EditMode tests
run: ./ci/github_actions/test_unity_editmode.sh
- name: Build Windows unity
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
- name: Save build SHA for Bee/ cache invalidation
if: success()
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
run: |
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
- name: Save Unity version for Library/ cache invalidation
if: success()
run: |
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //' > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_unity_version
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
@@ -152,11 +202,20 @@ jobs:
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: editor_win.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
retention-days: 3
- name: Archive EditMode test artifacts
if: success() || failure()
uses: actions/upload-artifact@v7
with:
name: unity_editmode_tests
path: |
${{ env.EAGLE0_BUILD_DIR }}/editor_editmode_tests.log
${{ env.EAGLE0_BUILD_DIR }}/editmode-test-results.xml
retention-days: 3
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
@@ -172,4 +231,4 @@ jobs:
run: |
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=${{ needs.windows-unity.outputs.deployed_version }}&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
+3
View File
@@ -23,6 +23,7 @@ bazel-bin
bazel-eagle0*
bazel-out
bazel-testlogs
.bazelrc.local
.bazelrc.xcode
.ijwb
.clwb
@@ -43,3 +44,5 @@ src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
node_modules/
tools/map_generator/output/
src/main/csharp/net/eagle0/clients/unity/eagle0/docs/generated/unity_asset_usage_audit_files.csv
src/main/csharp/net/eagle0/clients/unity/eagle0/docs/generated/unity_lfs_asset_audit.csv
+2 -2
View File
@@ -34,8 +34,8 @@ repos:
hooks:
- id: scalafmt
name: scalafmt
language: system
entry: scalafmt -i -f
language: system
entry: ./scripts/pre-commit-scalafmt.sh
types_or: ["scala"]
- repo: local
hooks:
+380
View File
@@ -0,0 +1,380 @@
# AGENTS.md
## CRITICAL BASH RULES (NEVER VIOLATE)
**NEVER chain bash commands.** Do not use `&&`, `||`, or `;` to combine commands. Each command must be a separate Bash tool call. Use parallel tool calls when commands are independent.
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER use `git -C`.** Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
**ALWAYS use this workflow:**
1. Create a feature branch from origin/main
2. Commit to that branch
3. Create a PR with `gh pr create`
4. Wait for user to merge (DO NOT run `gh pr merge`)
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
## Worktree Usage
Prefer using the current worktree for small requested fixes. Create a separate worktree only when the user asks for one,
when the current branch/state would make the change unsafe, or when isolating a large/risky change is clearly beneficial.
**NEVER leave the current worktree without explicit user approval.** Do not create, switch to, edit, commit in, or run
git commands from another worktree unless the user has specifically asked for that worktree or approved the move. If the
current worktree is unsafe because of dirty state, report that and ask before using a different worktree.
Codex may edit code and run git commands in the current worktree, even when unrelated files are dirty, as long as it:
- does not revert, reset, clean, or overwrite unrelated changes
- stages only the files relevant to the requested change
- verifies the staged diff before committing
- creates feature branches and PRs as usual
- does not push to main/master or merge PRs
When the user says a Codex-created PR has been merged, delete the corresponding local branch and temporary worktree
automatically, unless there is uncommitted work that would make cleanup unsafe.
## PR Timing
When code changes appear correct locally, create the PR promptly even if long-running builds or tests are still running.
Opening the PR starts CI on the build machines, uploads results to the Bazel remote cache, and can speed up subsequent
local validation. Continue monitoring any already-started local and CI validation after opening the PR.
---
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
Eagle0 is a multi-language gaming system combining strategic turn-based gameplay (Eagle) with tactical hex-based
combat (Shardok). The system integrates LLM-based narrative generation and supports both human and AI players.
## Architecture
**Three-Tier Game System:**
- **Unity Client (C#)**: Real-time strategy game client with integrated tactical combat UI
- **Eagle (Scala)**: Strategic layer managing turn-based gameplay, diplomacy, hero progression, and province control
- **Shardok (C++)**: Tactical layer handling real-time hex-based combat simulation with performance-critical battle
resolution
**Communication Flow:**
```
Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
```
**Key Entry Points:**
- `/src/main/csharp/net/eagle0/clients/unity/eagle0/` - Unity C# game client
- `/src/main/scala/net/eagle0/eagle/Main.scala` - Eagle strategic game server
- `/src/main/cpp/net/eagle0/shardok/shardok_server_main.cpp` - Shardok tactical server
**Protocol Buffer Architecture:**
- Extensive use of protobuf for type-safe communication
- Separate packages: `api/` (client-facing), `internal/` (server state), `views/` (client projections)
- Event sourcing pattern with immutable action history
## Essential Commands
### Building
```bash
# Build Eagle server (Scala strategic layer)
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
# Build Shardok server (C++ tactical layer)
bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
# Shardok server includes both AI algorithms
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
# Build Unity/C# client
./scripts/build_protos.sh # Protocol buffer generation for Unity
./scripts/build_plugins.sh # Native plugins for all platforms
./scripts/build_windows_plugin.sh # Windows-specific plugin build
# Unity builds via CI: ci/github_actions/build_unity.sh
```
### Running Services
```bash
# Eagle server (port 40032)
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- --eagle-grpc-port 40032
# Or: ./scripts/eagle_run.sh
# Shardok server
bazel run //src/main/cpp/net/eagle0/shardok:shardok-server --compilation_mode=opt
# Or: ./scripts/shardok_run.sh
```
### Testing
```bash
# Run all tests
bazel test //src/test/... //src/main/go/...
# Component-specific tests
bazel test //src/test/scala/... # Scala Eagle tests
bazel test //src/test/cpp/... # C++ Shardok tests
```
### Code Generation
```bash
bazel run gazelle # Update Go build files
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
```
### Pre-Commit Checklist
**MANDATORY: Before running `git commit`, verify:**
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
4. **ALWAYS run the full test suite for your language changes before pushing:**
- For Scala changes: `bazel test //src/test/scala/...`
- For C++ changes: `bazel test //src/test/cpp/...`
- This catches exhaustive pattern match errors and other compile-time failures that single-target builds miss
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
### Code Formatting
```bash
# ALWAYS run clang-format after making any C++ or C# code changes
clang-format -i <modified_files>
# Format all C++ files in a directory:
find . -name "*.cpp" -o -name "*.hpp" | xargs clang-format -i
# Format all C# files in a directory:
find . -name "*.cs" | xargs clang-format -i
```
### Static Analysis
```bash
# Run clang-tidy static analysis on C++ files
# Note: This may show some header include errors but will still analyze the main file
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' <file_path> -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
# Example for AI files:
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' /Users/dancrosby/CodingProjects/github/eagle0/src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.cpp -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
```
## AI Algorithm Selection
Eagle0 supports two AI algorithms for tactical combat decision-making:
### Iterative Deepening AI (Default)
The original minimax-based AI with sophisticated randomness handling:
- **Advantages**: Proven, sophisticated randomness evaluation, comprehensive lookahead
- **Use cases**: Production builds, scenarios requiring precise evaluation
- **Performance**: Single-threaded, thorough evaluation
### Monte Carlo Tree Search AI (MCTS)
Modern MCTS-based AI with multithreading support:
- **Advantages**: Multithreaded, better performance on modern CPUs, anytime algorithm
- **Use cases**: Performance testing, scenarios requiring fast decisions
- **Performance**: Multithreaded, adaptive depth based on time budget
### Switching Between Algorithms
The algorithm is selected at **runtime** via the ShardokAIClient constructor:
```cpp
// Using Iterative Deepening AI (default)
ShardokAIClient client(playerId, isDefender, hexMap, settings);
// OR explicitly:
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::ITERATIVE_DEEPENING);
// Using MCTS AI
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::MCTS);
```
```bash
# Build the server (includes both AI algorithms)
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
# Test both algorithms
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_iterative_deepening_test
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_mcts_test # If available
# Performance tests
./scripts/ai_perf_test.sh # Uses whatever algorithm the server is configured to use
```
Both implementations are compatible with all existing interfaces and produce the same `SearchResult` structure.
**Note**: Both implementations are documented in `src/main/cpp/net/eagle0/shardok/ai/AI_SCORING_SYSTEM.md`, including
recommendations for improving MCTS randomness handling.
The AI algorithm selection is made at runtime when creating ShardokAIClient instances, allowing different AI strategies
to be used for different players or game situations within the same server process.
## Language-Specific Patterns
**Scala (Strategic Layer):**
- Use `EngineImpl.scala` for core game logic modifications
- Follow event sourcing pattern - all changes through immutable actions
- gRPC streaming for real-time client updates via `EagleServiceImpl.scala`
- LLM integration in `/common/llm_integration/` for narrative generation
**C++ (Tactical Layer):**
- Performance-critical combat in `ShardokEngine.hpp/.cpp`
- FlatBuffers for efficient serialization in `/flatbuffer/` directory
- AI systems in `/ai/` subdirectory with pluggable strategy selectors
- Extensive unit testing with Google Test framework
**Protocol Buffers:**
- Three-layer structure: `api/` (client), `internal/` (server), `views/` (projections)
- Use `shardok_internal_interface.proto` for Eagle-Shardok communication
- Maintain backward compatibility when modifying existing messages
**C# (Unity Client):**
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
- Uses Unity 6 (6000.4.9f1) with comprehensive protobuf integration (100+ .proto files)
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
- Seamless transition between strategic gameplay and hex-based tactical combat
- **NEVER add defensive null checks on Unity Inspector fields** - these hide configuration bugs. If a field isn't
linked in the editor, it should throw a NullReferenceException so the problem is immediately obvious. Silently
skipping code when a required field is null makes bugs harder to find.
**Go (Build Tools):**
- Build automation and code generation utilities
- AWS S3 integration for deployment artifacts
## Testing Strategy
- Comprehensive unit tests for both Scala and C++ components
- Integration tests for Eagle-Shardok communication
- Map validation tests ensure game content integrity
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
### Scala Testing Patterns
**Use `inside()` instead of `asInstanceOf` for type matching in tests:**
Never use `asInstanceOf` in tests. Instead, use ScalaTest's `inside()` pattern for safe type matching:
```scala
// BAD - don't do this
val changedHero = result.changedHeroes.head.asInstanceOf[ChangedHeroC]
changedHero.heroId shouldBe 19
// GOOD - use inside() pattern
import org.scalatest.Inside.inside
inside(result.changedHeroes.head) { case changedHero: ChangedHeroC =>
changedHero.heroId shouldBe 19
changedHero.vigorChange shouldBe StatDelta(17.2)
}
```
The `inside()` pattern:
- Provides better error messages when the type doesn't match
- Is idiomatic ScalaTest
- Works with pattern matching for more complex assertions
## Performance Testing
When making performance-related changes to the AI or engine:
```bash
# 1. Commit your changes to a feature branch
git checkout -b performance-improvement-feature
git add . && git commit -m "Implement performance improvement"
# 2. Run performance tests multiple times on your branch to reduce noise
for i in 1 2 3; do
echo "=== Run $i ==="
./scripts/ai_perf_test.sh 2>&1 | grep -A 20 "AI Search Performance Summary"
done
# Save or note the results
# 3. Switch to main branch and run the same tests
git checkout main
for i in 1 2 3; do
echo "=== Run $i ==="
./scripts/ai_perf_test.sh 2>&1 | grep -A 20 "AI Search Performance Summary"
done
# 4. Compare the results between your branch and main
# Key metrics to compare:
# - Commands evaluated at each depth (e.g., "Depth 3: 169/523 commands")
# - Average search depth achieved
# - Completion rates at each depth
```
**Important notes:**
- Run tests multiple times (3-5) to account for performance variance
- Focus on commands evaluated at each depth rather than total commands
- Commands at different depths aren't directly comparable (depth 3 is more valuable than depth 2)
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
behavior changes.
## Troubleshooting Scala Build Errors
### MissingType Errors
When you see errors like:
```
dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.eagle.internal.game_state.type.GameState
```
**This is NOT a Scala compiler crash.** This is a missing dependency in BUILD.bazel.
**How to fix:**
1. Identify the missing type from the error message (e.g., `game_state.GameState`)
2. Find the Bazel target that provides this type (e.g., `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`)
3. Add it to the `deps` of the failing target
4. If the type appears in a public method signature, also add it to `exports` so downstream targets can see it
**Common pattern:** When adding a method to a class that takes or returns a proto type, the proto dependency often needs to be added to both `deps` AND `exports`.
### Bazel Clean
**NEVER run `bazel clean` without asking first.** It rarely fixes actual issues and wastes significant rebuild time. The issues that seem like they need `bazel clean` are usually:
- Missing imports in Scala code
- Missing dependencies in BUILD.bazel
- Missing exports for types used in public signatures
## Game Content
**Maps:** `.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
**Configuration:** Game parameters in `/src/main/resources/net/eagle0/eagle/game_parameters.json`
**Data Files:** TSV format for battalions, heroes, and other game data
**NEVER modify hero names.** Do not change names in `heroes.tsv`, `generated_heroes`, or any other hero data files. The names are carefully chosen and are not to be altered.
## Deployment
- Bazel handles multi-language builds and dependencies
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
- Docker containerization available via `ci/eagle_run.Dockerfile`
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
+5 -3
View File
@@ -4,9 +4,11 @@
**NEVER chain bash commands.** Do not use `&&`, `||`, or `;` to combine commands. Each command must be a separate Bash tool call. Use parallel tool calls when commands are independent.
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER use `git -C`.** Instead, cd to the repo root and run git commands from there.
**NEVER use `git -C`.** Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
@@ -226,7 +228,7 @@ to be used for different players or game situations within the same server proce
**C# (Unity Client):**
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
- Uses Unity 6 (6000.0.32f1) with comprehensive protobuf integration (100+ .proto files)
- Uses Unity 6 (6000.4.9f1) with comprehensive protobuf integration (100+ .proto files)
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
@@ -350,4 +352,4 @@ dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.e
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
- Docker containerization available via `ci/eagle_run.Dockerfile`
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
+12
View File
@@ -295,6 +295,9 @@ maven.install(
# Testing
"org.scalamock:scalamock_3:7.4.1",
# Developer tools
"org.scalameta:scalafmt-cli_2.13:3.9.9",
# AWS SDK
"software.amazon.awssdk:s3-transfer-manager:%s" % AWS_SDK_VERSION,
"software.amazon.awssdk:s3:%s" % AWS_SDK_VERSION,
@@ -337,6 +340,15 @@ maven.install(
],
)
# json4s' Scala 3 reflection path can load scala.quoted.staging at runtime, but
# its Maven metadata does not bring the staging jar transitively.
maven.artifact(
artifact = "scala3-staging_3",
exclusions = ["org.scala-lang:scala3-compiler_3"],
group = "org.scala-lang",
version = "3.3.6",
)
# Force specific versions for dependencies with conflicts between grpc-java and protobuf
maven.artifact(
artifact = "gson",
+38 -7
View File
@@ -1,5 +1,6 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
load("//ci:jar_split.bzl", "jar_split")
#
# Deployment artifacts (tools needed on the host, not in containers)
@@ -50,11 +51,37 @@ pkg_tar(
# Push: bazel run //ci:eagle_server_push
#
# Package the deploy JAR
# Split the Eagle server runtime classpath into a stable third-party layer
# (Scala stdlib, gRPC, Netty, ScalaPB, AWS SDK, ...) and a small first-party
# layer that changes every commit. Most pushes then only re-upload the small
# app layer instead of the whole ~150-300MB fat JAR.
jar_split(
name = "eagle_server_jars",
binary = "//src/main/scala/net/eagle0/eagle:eagle_server",
)
filegroup(
name = "eagle_server_deps_jars",
srcs = [":eagle_server_jars"],
output_group = "deps",
)
filegroup(
name = "eagle_server_app_jars",
srcs = [":eagle_server_jars"],
output_group = "app",
)
pkg_tar(
name = "eagle_server_jar_layer",
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
package_dir = "/app",
name = "eagle_server_deps_layer",
srcs = [":eagle_server_deps_jars"],
package_dir = "/app/lib/deps",
)
pkg_tar(
name = "eagle_server_app_layer",
srcs = [":eagle_server_app_jars"],
package_dir = "/app/lib/app",
)
# Package the game resources needed at runtime
@@ -82,8 +109,11 @@ oci_image(
"-XX:+UnlockDiagnosticVMOptions",
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
"-XX:FlightRecorderOptions=stackdepth=256",
"-jar",
"/app/eagle_server_deploy.jar",
# Classpath glob is expanded by the JVM itself (exec-form, no shell).
# Deps dir first keeps third-party precedence; app last shadows nothing.
"-cp",
"/app/lib/deps/*:/app/lib/app/*",
"net.eagle0.eagle.Main",
],
env = {
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
@@ -91,7 +121,8 @@ oci_image(
exposed_ports = ["40032/tcp"],
tars = [
":busybox_layer",
":eagle_server_jar_layer",
":eagle_server_deps_layer",
":eagle_server_app_layer",
":eagle_resources_layer",
],
workdir = "/app",
+22
View File
@@ -38,3 +38,25 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
if [ ! -f "$LOG_PATH" ]; then
echo ""
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
exit 1
fi
# Fail the build if any prefab references are broken — this produces a player
# that launches but has null Inspector fields, which is hard to debug.
if grep -q "Missing Prefab" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
grep "Missing Prefab" "$LOG_PATH"
exit 1
fi
if grep -q "Build asset version error" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
grep "Build asset version error" "$LOG_PATH" | head -5
exit 1
fi
+16
View File
@@ -53,6 +53,22 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
exit $UNITY_EXIT_CODE
fi
# Fail the build if any prefab references are broken — this produces a player
# that launches but has null Inspector fields, which is hard to debug.
if grep -q "Missing Prefab" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
grep "Missing Prefab" "$LOG_PATH"
exit 1
fi
if grep -q "Build asset version error" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
grep "Build asset version error" "$LOG_PATH" | head -5
exit 1
fi
echo "iOS Unity build complete"
echo "Xcode project generated at: $BUILD_PATH"
ls -la "$BUILD_PATH"
+22
View File
@@ -38,3 +38,25 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
if [ ! -f "$LOG_PATH" ]; then
echo ""
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
exit 1
fi
# Fail the build if any prefab references are broken — this produces a player
# that launches but has null Inspector fields, which is hard to debug.
if grep -q "Missing Prefab" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
grep "Missing Prefab" "$LOG_PATH"
exit 1
fi
if grep -q "Build asset version error" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
grep "Build asset version error" "$LOG_PATH" | head -5
exit 1
fi
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -euo pipefail
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
write_developer_dir_override() {
if [ "$(uname -s)" != "Darwin" ]; then
return
fi
local developer_dir=""
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
developer_dir="/Applications/Xcode.app/Contents/Developer"
elif [ -d "/Library/Developer/CommandLineTools" ]; then
developer_dir="/Library/Developer/CommandLineTools"
else
developer_dir="$(xcode-select -p 2>/dev/null || true)"
fi
if [ -z "$developer_dir" ] || [ ! -d "$developer_dir" ]; then
echo "ERROR: no usable Apple developer directory found"
echo "Install Command Line Tools with: xcode-select --install"
echo "Full Xcode is only required for workflows that build/sign Apple apps."
exit 1
fi
export DEVELOPER_DIR="$developer_dir"
if [ -f .bazelrc.local ] && [ -z "${CI:-}" ]; then
echo "Keeping existing .bazelrc.local outside CI"
else
{
echo "# Generated by ci/github_actions/ensure_bazel_installed.sh; do not edit."
printf 'common:macos --repo_env=DEVELOPER_DIR=%s\n' "$developer_dir"
} > .bazelrc.local
fi
if [ -n "${GITHUB_ENV:-}" ]; then
echo "DEVELOPER_DIR=$developer_dir" >> "$GITHUB_ENV"
fi
echo "Using Apple developer directory at $developer_dir"
}
write_developer_dir_override
if [ -n "${GITHUB_PATH:-}" ]; then
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
fi
if command -v bazel >/dev/null 2>&1; then
echo "Using bazel at $(command -v bazel)"
bazel --version
exit 0
fi
if ! command -v bazelisk >/dev/null 2>&1; then
if ! command -v brew >/dev/null 2>&1; then
echo "ERROR: neither bazel nor bazelisk is on PATH, and Homebrew is unavailable"
exit 1
fi
echo "Installing bazelisk with Homebrew"
brew install bazelisk
fi
BAZEL_BIN_DIR="${RUNNER_TEMP:-/tmp}/bazel-bin"
mkdir -p "$BAZEL_BIN_DIR"
ln -sf "$(command -v bazelisk)" "$BAZEL_BIN_DIR/bazel"
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$BAZEL_BIN_DIR" >> "$GITHUB_PATH"
fi
export PATH="$BAZEL_BIN_DIR:$PATH"
echo "Using bazelisk as bazel at $BAZEL_BIN_DIR/bazel"
bazel --version
+31 -1
View File
@@ -11,16 +11,46 @@
set -euo pipefail
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
MAX_ATTEMPTS=5
RETRY_DELAY=15
if [ -n "${GITHUB_PATH:-}" ]; then
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
fi
if ! git lfs version >/dev/null 2>&1; then
if ! command -v brew >/dev/null 2>&1; then
echo "ERROR: git-lfs is unavailable, and Homebrew is not on PATH"
exit 1
fi
echo "Installing git-lfs with Homebrew"
brew install git-lfs
fi
echo "Using git-lfs at $(command -v git-lfs)"
git lfs version
git lfs install
echo "LFS objects before pull:"
git lfs ls-files | wc -l
GIT_LFS_PULL=(git lfs pull)
if [ -n "${GITHUB_TOKEN:-}" ]; then
BASIC_AUTH=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')
GIT_LFS_PULL=(git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $BASIC_AUTH" lfs pull)
fi
for i in $(seq 1 $MAX_ATTEMPTS); do
if git lfs pull "$@"; then
if "${GIT_LFS_PULL[@]}" "$@"; then
echo "LFS objects after pull:"
git lfs ls-files | wc -l
exit 0
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
LOG_PATH="${BUILD_BASE}/editor_editmode_tests.log"
RESULTS_PATH="${BUILD_BASE}/editmode-test-results.xml"
mkdir -p "$BUILD_BASE"
echo "Building protos"
./scripts/build_protos.sh
echo "Running Unity EditMode tests"
set +e
"${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity" \
-nographics \
-batchmode \
-quit \
-executeMethod eagle0.Tests.DynamicTextBindingTestRunner.Run \
-testResults "$RESULTS_PATH" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
set -e
if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo ""
echo "Unity EditMode tests failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity EditMode Test Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity EditMode Test Log ==="
exit $UNITY_EXIT_CODE
fi
if [ ! -f "$LOG_PATH" ]; then
echo ""
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
exit 1
fi
if [ ! -f "$RESULTS_PATH" ]; then
echo ""
echo "ERROR: Unity exited successfully but did not write test results at $RESULTS_PATH"
exit 1
fi
echo "Unity EditMode tests complete"
+57
View File
@@ -0,0 +1,57 @@
"""Split a JVM binary's runtime classpath into first-party and third-party jar sets.
This exists so the Docker image can place rarely-changing third-party jars in a
lower OCI layer and frequently-changing first-party jars in a small top layer,
so most pushes only re-upload the small layer.
"""
def _unique_name(jar):
# short_path is unique per jar and stable across commits (it depends only on
# the jar's own package/coordinate, not on unrelated targets), so the deps
# layer's tar entries stay byte-identical and its blob digest stays cached.
path = jar.short_path
if path.startswith("../"):
path = path[3:]
return path.replace("+", "_").replace("/", "_").replace("~", "_")
def _impl(ctx):
info = ctx.attr.binary[JavaInfo]
app = []
deps = []
seen = {}
for jar in sorted(info.transitive_runtime_jars.to_list(), key = lambda f: f.path):
workspace = jar.owner.workspace_name if jar.owner else ""
bucket = "app" if workspace == "" else "deps"
out_name = _unique_name(jar)
key = bucket + "/" + out_name
if key in seen:
fail("jar_split: duplicate output name %r for %s and %s" % (
key,
seen[key],
jar.path,
))
seen[key] = jar.path
link = ctx.actions.declare_file(ctx.label.name + "/" + key)
ctx.actions.symlink(output = link, target_file = jar)
(app if bucket == "app" else deps).append(link)
return [
DefaultInfo(files = depset(app + deps)),
OutputGroupInfo(app = depset(app), deps = depset(deps)),
]
jar_split = rule(
implementation = _impl,
doc = "Partitions a JVM binary's transitive runtime jars into 'app' " +
"(first-party, empty workspace) and 'deps' (third-party) output groups.",
attrs = {
"binary": attr.label(
mandatory = True,
providers = [[JavaInfo]],
doc = "A jvm binary/library target whose runtime classpath to split.",
),
},
)
+7 -6
View File
@@ -42,16 +42,17 @@ Time-to-first-token (TTFT) was measured from request initiation to the first tex
### For Narrative Text Generation (Default)
**Gemini 2.5 Flash-Lite** is recommended as the default:
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
- Quality is acceptable for short narrative snippets
**Gemini 3.1 Flash-Lite** is recommended as the default:
- Priced at $0.25/$1.50 per 1M input/output tokens
- Significantly faster than 2.5 Flash-Lite on throughput and TTFT
- Meaningfully smarter than 2.5 Flash-Lite, while remaining one of the cheapest options
- Quality is more than sufficient for short narrative snippets
### Alternative Options
| Priority | Model | When to Use |
|----------|-------|-------------|
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
| Speed + Cost | Gemini 3.1 Flash-Lite | Default for most use cases |
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
@@ -70,7 +71,7 @@ LLM settings can be changed at runtime via the admin console:
1. Navigate to Admin Console → Settings
2. Change `LlmProvider` to select vendor (gemini, openai, claude)
3. Change the corresponding model name setting:
- `GeminiModelName` (default: gemini-2.5-flash-lite)
- `GeminiModelName` (default: gemini-3.1-flash-lite-preview)
- `OpenAiModelName` (default: gpt-4.1-mini)
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
@@ -0,0 +1,77 @@
# Shardok AI Experiment Tooling Plan
## Summary
Build a reusable workflow for Shardok AI battle baselines and scoring experiments. The goal is to
turn the current one-off benchmark process into repeatable commands that extract real battles, run
baseline and candidate configurations, capture detailed per-command traces, and write comparable
reports.
This work starts with the target-aware archery scoring experiment: attacker `EXPERIMENTAL` against
defender `STANDARD`.
## Key Changes
- Add simulator trace support to `ai_battle_simulator_main`.
- New flag: `--trace-jsonl=<path>`.
- Emit one JSONL row per AI-selected command.
- Include run label, config path, state file, phase, sequence, round, player, side, command type,
actor unit, target coords, chosen index, available command count, search depth, commands
evaluated, completion reason, forced commands posted afterward, and pre/post side troop totals.
- Keep existing summary output unchanged.
- Add reusable benchmark tooling under
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/`.
- `real_battle_suite.py`: shared helpers for reading `game.db`, materializing battle/state/result
blobs, extracting configs, running simulator binaries, parsing summaries, and collecting
real-game metadata.
- `run_real_battle_experiment.py`: CLI orchestration for baseline/candidate runs.
- Outputs: `summary.csv`, `comparison.csv`, `report.md`, generated configs, and per-run trace
JSONL files.
- Add a clean EXPERIMENTAL switch for target-aware archery value.
- Use a new experiment id.
- Change only archery-availability scoring.
- Replace the flat archery-possible value with a target-sensitive value based on the best enemy
unit value.
- Do not include the previous fear-value change from experiment `27`.
## Execution Plan
- Create a feature branch from fetched `origin/main`.
- Commit this plan document first.
- Implement simulator trace output and reusable benchmark tooling.
- Add the target-aware archery-only EXPERIMENTAL switch.
- Run the new tool first on battle `6248`, then on the all-40 saved-battle suite if local runtime is
acceptable.
- Compare baseline attacker `STANDARD` / defender `STANDARD` with candidate attacker
`EXPERIMENTAL` / defender `STANDARD`.
- Record the results under the existing benchmark results area and call out battle `6248` in the
generated report.
- Push the branch and create a PR after local validation.
## Tests
- Build:
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator_main`
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:real_battle_config_extractor`
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:real_battle_smoke_metadata`
- Unit/integration:
- `bazel test //src/test/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator_test`
- Add or extend a simulator test that verifies trace output is created and contains required
command fields.
- `python3 -m py_compile` on the new benchmark scripts.
- Validation:
- Run the new tool on battle `6248`.
- Run the new tool on all 40 battles if feasible locally.
- Run `bazel test //src/test/cpp/...` before push if local time allows; otherwise open the PR
promptly and continue validation while CI starts.
## Assumptions
- "Experiment 3" means target-aware archery value from the previous recommendation list.
- The first experiment compares attacker-only candidate behavior against unchanged defender
`STANDARD`.
- JSONL is the trace format because command diagnostics will evolve over time.
- Python benchmark scripts follow the existing executable-script pattern rather than adding Python
Bazel targets.
+82
View File
@@ -0,0 +1,82 @@
# Shardok AI Scoring Benchmarks
The current STANDARD scoring experiment results are recorded in
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/experimental_results/`.
They use 40 saved Shardok battles extracted from `game_389ffb1901903118.zip`.
The corrected all-40 run uses:
- `max_rounds = 31`
- `random_seed = 1`
- iterative-deepening timeout propagation fix from PR #6923
- AI setup placement
- forced single-option commands
- defender scoring fixed to `STANDARD` for every row
- archery/start-fire capability derived like production setup
- per-unit flee capability preserved by real-battle extraction
- zero-troop combat heroes preserved as live units
- `NO_PROFESSION` treated as a valid hero profession state, not as "no hero"
Shardok siege battles do not have a draw result. If the attacker has not won after 31 rounds, the
defender wins. The simulator records those cutoffs as `max_rounds_reached` defender wins.
All 40 saved battle payloads now produce valid simulator configs. A battle side can have zero
remaining troops as long as it has a hero; this is common for `NO_PROFESSION` heroes with no
commanded battalion.
An all-40 real-vs-sim comparison records whether the saved-game attacker/defender were human or AI
players. Factions `3` and `4` are human players; all other factions in this save are AI players.
The comparison uses broad winner side from the saved Shardok result payload, because exact replay
is not available from the compact result rows. Real end-of-battle troop totals come from the
all-factions Shardok `ActionResultView` stream's final `changed_player_totals`.
An AI-vs-AI smoke check also reruns a subset where neither side used faction `3` or `4`.
## Current Results
These artifacts are the current baseline for future scoring comparisons.
| Dataset | Valid battles | Invalid rows | Attacker wins | Defender wins | Notes |
| --- | ---: | ---: | ---: | ---: | --- |
| All-40 timeout-fix STANDARD baseline | 40 | 0 | 18 | 22 | `standard_scoring_all40_post_bugfix_baseline.*`; regenerated after fixing iterative-deepening timeout score propagation |
| All-40 real saved game | 40 known | 0 unknown | 19 | 21 | `standard_scoring_all40_real_vs_sim.*`; sim matches 39/40 known winners |
| AI-vs-AI smoke | 31 | 0 | 10 | 21 | 31/31 winner-side matches |
The remaining real-vs-sim winner-side miss is `11754` (real attacker, sim defender), which had an
AI attacker but a human defender in the saved game. The real attacker won with `2119` troops
remaining; the STANDARD-vs-STANDARD simulation replaces the human defender with AI and the defender
wins with `1000` troops remaining, so this row is not a comparable AI-vs-AI smoke miss. Battle
`6248`, previously the remaining AI-vs-AI miss, now matches as an attacker win after expected-impact
archery scoring was promoted to STANDARD.
The closest six valid baseline rows are used for the scalar and behavior experiment batches. The
single-run harness still has some nondeterminism even with `random_seed = 1`, so near-margin changes
should be repeated before being promoted.
## Experiment Tooling
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/run_real_battle_experiment.py`
is the reusable real-battle experiment runner. It can read a saved-game zip or extracted `game.db`,
generate baseline/candidate simulator configs, run both sides, and write `summary.csv`,
`comparison.csv`, `report.md`, generated configs, and per-command trace JSONL files.
The repository keeps the current STANDARD baseline artifacts, but omits generated configs/traces
for abandoned experiment batches. Regenerate those locally with the runner when a new comparison
needs command-level traces.
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/run_standard_baselines.py`
regenerates the tracked STANDARD-vs-STANDARD baseline artifacts from a saved-game zip or extracted
`game.db`, including the all-40 baseline, real-vs-sim comparison, and AI-vs-AI smoke files.
The simulator supports `--trace-jsonl=<path>` and `--run-label=<label>` for detailed command traces.
Each row records the selected command, search depth, forced command count, and pre/post side troop
totals.
Expected-impact archery replaced the old flat archery-position value in STANDARD. A unit that can
shoot now gets half of the expected volley impact; a unit that is merely positioned for a future
volley gets one quarter. Promoting that behavior flips `6248` to match the real AI-vs-AI attacker
win without introducing an AI-vs-AI winner-side miss in the current saved-battle baseline.
The latest baseline refresh was generated after the iterative-deepening evaluator stopped letting
timeout fallback scores compete with completed command evaluations. Future STANDARD-vs-EXPERIMENTAL
comparisons should compare against these checked-in timeout-fix baseline artifacts.
+18 -9
View File
@@ -6,6 +6,16 @@ Be able to support a small (10-50 user) private alpha, including with strangers.
Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/17RTt3-4Wl2AAVMRLodaC3a84E4de6xuQTWBPvCRM484/edit?pli=1&tab=t.0), but most of that is not necessary for MVP.
## Current priority order
1. Finish tutorial & first-session onboarding, especially the clear first-session goal and guided first scenario.
2. Fix the basic Shardok AI issues that can make tactical battles feel broken or unfair.
3. Publish a known issues doc so alpha testers do not repeatedly report the same rough edges.
4. Verify the existing game-ended flow, then mark the win condition done if "all other factions defeated" works end-to-end.
5. Add mid-game progression events where the King recognizes the player as they gain power.
6. Add account-linking when provider switching or account recovery becomes important for alpha support.
7. Leave terms of service and Windows code signing deferred until they matter for public release or broader distribution.
## Required
### Gameplay Productionization
@@ -17,7 +27,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [x] ~~Fix long disconnects on deployments~~
- [x] Fix the Mac installer
- [x] ~~Still not reconnecting after deployments~~
- [ ] Notify about client updates, button to come directly back
- [x] Notify about client updates, button to come directly back
- [x] Generatedtext healing
- [x] Kill outstanding shardok requests when game is deleted
@@ -51,7 +61,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
### Basic Gameplay
- [ ] Tutorial
- [ ] Tutorial & first-session onboarding
- [x] In the Your Warlord panel, say what the profession is
- [x] ~~And separate panels for each profession when you encounter one~~
- [x] ~~Command tutorial for each command the first time it's clicked~~
@@ -62,17 +72,16 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [x] ~~Time to swear brotherhood~~
- [x] ~~When you get large, or~~
- [x] ~~When you get a good candidate~~
- [ ] Shardok tutorial!
- [x] ~~Shardok tutorial!~~
- [x] ~~Narrative hook in first few minutes - why should I care about my warlord?~~
- [ ] Clear first-session goal ("try to capture your first province" or similar)
- [x] ~~Early small victory to build momentum~~
- [ ] Guided first scenario vs. overwhelming sandbox?
- [ ] Basic Shardok AI stuff fixed
- [ ] Lobby fixes
- [x] ~~Lobby fixes~~
- [ ] Have goals / ending
- [ ] Win condition: all other factions defeated
- [ ] Mid-game progression: King recognizes you as you gain power (generated events)
- [ ] First-session onboarding (beyond mechanics tutorial)
- [ ] Narrative hook in first few minutes - why should I care about my warlord?
- [ ] Clear first-session goal ("try to capture your first province" or similar)
- [ ] Early small victory to build momentum
- [ ] Guided first scenario vs. overwhelming sandbox?
## Nice to have
+335
View File
@@ -0,0 +1,335 @@
# SQLite Action Result Decomposition
Follow-up to [SQLITE_HISTORY_DESIGN.md](SQLITE_HISTORY_DESIGN.md). This document plans the second wave of the SQLite migration: decomposing the `ActionResult` proto blob in `action_results.payload` into native relational tables, deleting the proto representation entirely for everything in scope.
## Status — option B adopted (updated 2026-05-17)
**This section supersedes the original plan below. Where the body conflicts with this section, this section wins.** The full-relational design is kept below for historical context and as the migration target if a structured-query need on deep aggregates ever materializes.
The original plan (decompose every field into relational tables; "decision locked") was implemented for `ChangedProvince` first — ~37 child tables, ~1500 lines (PR #6721) — and then **rejected on cost/benefit**. We adopted **option B**: apply the "stopping rule" (originally carved out only for `Quest`) *consistently*. Shipped and merged as Phase 4.5b in **PR #6725**:
- Keep the per-entity **top-level table with scalar / entity-reference columns + indexes** (the 4.5a tables). That is the part that earns its keep — "which actions changed province/hero/faction X, by how much" is one-line SQL.
- Store the **rest of the aggregate as one `<entity>_proto BLOB` column** on that row: lossless round-trip / replay, no child tables for the deep nesting.
- Scalar columns are pure query denormalization; **the proto blob is authoritative**. A later fully-relational pass (if ever needed) is a pure read-blob → write-columns migration — which is why the blob must survive any future proto deletion.
Rationale: the deep army/unit/hero/claimant nesting is never analytics-queried — the exact property that justified blobbing `Quest`. Exploding it into ~37 tables was code volume without query value. The blob also means deep-aggregate field changes need **no SQL migration** (only promoted scalar columns do), which is the right shape for the volatile parts of the model. This rescinds "Decisions locked #1" and revises principle 7, the Scope, the Sequencing table, and "Deleting the proto" (all updated in place below).
## Refinement — "refined option B" for shallow entities (updated 2026-05-19)
**This section further supersedes the body and the Status section above where they conflict; it wins.** It records the pattern actually shipped for `ChangedHero` in Phase 4.5c.1 (#6727), which is a refinement of option B, not the whole-entity-blob shape the body still describes for that entity.
Option B as originally stated keeps **one `<entity>_proto BLOB`** per row for the entire non-scalar residual. For a *shallow* entity that is more blob than it needs. Refined option B decomposes the residual into three tiers instead of one blob:
- **Tier 1 — scalar / entity-reference columns + indexes.** The queryable surface (deltas, touched entities, enum ordinals). Always present; unchanged from 4.5a.
- **Tier 2 — typed columns for sealed value-oneofs.** A sealed choice over simple values (e.g. `ChangedHeroC`'s `loyalty` / `vigor` `StatChange`, subtypes `StatDelta`/`StatAbsolute`/`StatNoChange`) becomes a **pair of nullable columns** (`loyalty_delta` / `loyalty_absolute`, …). The *column name is the discriminator*; at most one of each pair is non-NULL; both NULL ⇒ the no-change case. No `kind` column.
- **Tier 3 — per-row leaf-proto blobs in a child table.** A repeated/optional nested aggregate becomes a child table keyed by `(action_seq, entity_id, n)` storing the **leaf** proto's bytes one row per element (e.g. `action_changed_heroes_new_backstory_events` holding `EventForHeroBackstory` blobs) — *not* a whole-entity blob.
**Consequence (changes Decisions #1#3 and 4.5i):** an entity fully covered by Tiers 13 has **no whole-entity blob**. It round-trips losslessly from columns + per-leaf blobs, so its per-entity proto and converter (`changed_hero.proto` / `ChangedHeroConverter`) become genuinely dead code, deletable in 4.5i. Only the **leaf** protos used as Tier-3 blob payloads (`event_for_hero_backstory.proto`) are retained as a wire format. The whole-entity `<entity>_proto BLOB` is now reserved for **deep** entities where Tiers 23 would be more work than value (`ChangedProvince`, the "new entity" snapshots).
Per-entity decision (supersedes Decisions #2): classify each entity as **all-scalar** (Tier 1 only, no blob — e.g. likely `ChangedFaction`), **refined** (Tiers 13, no whole-entity blob, proto deletable — `ChangedHero`), or **deep** (Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained — `ChangedProvince`, new-entity snapshots). Default to **refined** for shallow entities; fall back to **deep** only when the residual is too varied to be worth Tiers 23. Wherever the body or Status says "the proto blob is authoritative," for a *refined* entity read "the columns + per-leaf-proto blobs are authoritative."
## Why
The first wave (Phases 14) stores each action as a single proto blob in `action_results.payload`. That blob is opaque to SQL — any query about action contents (changed provinces, heroes affected, etc.) requires deserializing every row. The `action_result_type`, `round_id`, and date columns we denormalized are the only handles SQL has into actions.
Two observations make full decomposition more attractive than the original design admitted:
1. **The proto exists for storage only.** 92 Scala files reference `ChangedProvince`, but exactly one imports the proto type — `ChangedProvinceConverter.scala`. Everything else (action handlers, AI, utils) operates on `ChangedProvinceC`. The proto is **not** in any client-facing RPC contract — it lives under `eagle.internal`. Same is true for `ChangedHero`, `ChangedFaction`, `ChangedBattalion`, `Notification`, `GeneratedTextRequest`, and `ActionResult` itself. Replacing proto serialization with SQL writes does **not** lose forward/backward compatibility, because there are no external consumers depending on the proto wire format.
2. **`ActionResultC` has no polymorphism at the top level.** It's a 34-field flat struct — `Option`s and `Vector`s, but no `sealed trait` discriminator at the action level. Every action has the same shape; the `actionResultType` enum tells you *which fields* are populated. This means the entire structure is relationalizable without per-action-type tables.
The work is bounded, the proto becomes dead code at the end of it, and it has to happen before alpha — once we have users, every schema change costs a real migration. Doing it pre-alpha lets us nuke save dirs as part of deployment.
## Scope
**In:** `action_results.payload` (the whole-`ActionResult` blob) is gone by the end. Per-entity scalar / entity-reference columns make the *queryable* surface (deltas, touched entities) native SQL. **Under refined option B (see Status):** `internal/action_result.proto` and *deep* entities' sub-message protos (`changed_province.proto`, …) are retained as blob payloads; *refined* entities' protos (`changed_hero.proto`) are decomposed away and deleted, with only their Tier-3 leaf protos (`event_for_hero_backstory.proto`) kept. (The original plan deleted all of them, then a draft of option B kept all of them; neither holds — it's now per-entity.)
**Out:**
- `state_snapshots.payload` — stays as proto-blob `GameState`. These are caches for replay, not authoritative data; querying inside a snapshot adds nothing over querying the action deltas that produced it. Cost/benefit doesn't justify decomposing them.
- Shardok tables — already designed in Phase 3, no changes here. The shardok protos (`ShardokActionResult`, etc.) are owned by the Shardok subsystem and are part of an actual RPC contract; they stay.
- Client text — already SQLite-backed (`SqliteClientTextStore`).
## Principles
1. **One table per non-trivial aggregate type.** Every `Vector[X]` or `Option[X]` where `X` is a Scala case class with multiple fields gets its own table.
2. **Vectors of primitives → join tables.** `Vector[Int]` becomes a two-column table: `(parent_key, value)`. No JSON arrays, no comma-separated strings — those defeat the point.
3. **Optional aggregates → presence by row existence.** `Option[X]` for aggregate `X` is a row that may or may not exist with the matching parent key. The natural primary key on `(action_seq)` enforces 0-or-1-row semantics for `Option[X]` aggregates and `(action_seq, n)` for vectors.
4. **Sealed traits get a discriminator column.** When a Scala sealed trait has a few subtypes with diverging fields, prefer one wide table with `kind TEXT NOT NULL` + nullable per-subtype columns over per-subtype tables. When subtypes diverge significantly (>5 unrelated fields each), use per-subtype tables. Decide per case.
5. **`ON DELETE CASCADE` everywhere.** Every child table has `FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE`. `truncateTo` becomes a single `DELETE FROM action_results WHERE action_seq >= ?` and the cascades do the rest.
6. **Schema migrations are now real.** Once we have a relational schema, evolving it means SQL migrations. We introduce a real migration runner (see "Schema evolution" below). The `metadata` table's `schema_version` becomes load-bearing.
7. **(Revised under option B — see Status.) The proto blob is the retained authoritative store for each entity's deep aggregates; scalar / entity-reference columns are denormalized projections of it.** The original principle assumed full decomposition and proto deletion. Instead each entity keeps one proto-blob source of truth and denormalizes only the queryable columns from the same write — so there is no dual *relational* representation to keep in sync.
## Top-level: `action_results`
The `payload BLOB` column goes away. Scalar fields become columns; aggregate fields move to related tables.
```sql
CREATE TABLE action_results (
action_seq INTEGER PRIMARY KEY,
action_result_type INTEGER NOT NULL,
-- existing scalar denormalizations
round_id INTEGER NOT NULL,
date_year INTEGER,
date_month INTEGER,
-- new scalar columns (formerly inside payload)
acting_hero_id INTEGER,
acting_faction_id INTEGER,
province_id INTEGER,
province_id_acted INTEGER,
new_round_phase INTEGER,
new_round_id INTEGER,
last_command_type_for_acting_province INTEGER,
resolved_battle TEXT,
new_victor_faction_id INTEGER,
game_ended INTEGER, -- 0/1/NULL
new_random_seed INTEGER,
new_game_type INTEGER
);
-- Vectors of bare IDs become join tables:
CREATE TABLE action_destroyed_battalion_ids (
action_seq INTEGER NOT NULL,
battalion_id INTEGER NOT NULL,
PRIMARY KEY (action_seq, battalion_id),
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);
CREATE TABLE action_removed_hero_ids (
action_seq INTEGER NOT NULL,
hero_id INTEGER NOT NULL,
PRIMARY KEY (action_seq, hero_id),
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);
CREATE TABLE action_removed_faction_ids (...);
CREATE TABLE action_affected_faction_ids (...);
-- Singleton optional aggregates: a row exists if and only if Option is Some.
CREATE TABLE action_new_battles (
action_seq INTEGER PRIMARY KEY,
-- ShardokBattle fields here, columns or sub-tables as needed
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);
CREATE TABLE action_new_chronicle_entries (action_seq INTEGER PRIMARY KEY, ...);
CREATE TABLE action_new_eagle_map_info (action_seq INTEGER PRIMARY KEY, ...);
```
Each `Vector[Aggregate]` field on `ActionResultC` gets its own table (covered below).
### Indexes
We keep the existing indexes (`round_id`, `(date_year, date_month)`) and add a few that obvious queries need:
- `idx_action_results_acting_hero_id`
- `idx_action_results_acting_faction_id`
- `idx_action_results_province_id`
Plus indexes on the per-entity join tables on the entity-id side, so cross-game queries like "all actions touching hero X" become single-index lookups:
- `idx_action_changed_provinces_province_id`
- `idx_action_changed_heroes_hero_id`
- `idx_action_changed_faction_faction_id`
- `idx_action_changed_battalions_battalion_id`
## Changed-entity tables (four parallel structures)
### `action_changed_provinces`
`ChangedProvinceC` has ~50 fields: scalar deltas, nested aggregates, ID vectors. The scalar fields become columns on `action_changed_provinces`; aggregate fields become child tables.
```sql
CREATE TABLE action_changed_provinces (
action_seq INTEGER NOT NULL,
province_id INTEGER NOT NULL,
-- resource changes
gold_delta INTEGER,
food_delta INTEGER,
-- stat changes
new_price_index REAL,
economy_delta REAL,
agriculture_delta REAL,
infrastructure_delta REAL,
economy_devastation_delta REAL,
agriculture_devastation_delta REAL,
infrastructure_devastation_delta REAL,
support_delta REAL,
-- round properties
set_has_acted INTEGER, -- 0/1/NULL
set_ruler_is_traveling INTEGER,
-- ruling faction changes
clear_ruling_faction_id INTEGER NOT NULL DEFAULT 0,
new_ruling_faction_id INTEGER,
-- misc scalar fields
new_locked_improvement_kind TEXT, -- 'none', 'new'
new_locked_improvement_value INTEGER, -- ImprovementType if kind='new'
new_province_orders INTEGER, -- ProvinceOrderType enum
clear_defending_army INTEGER NOT NULL DEFAULT 0,
cleared_pending_conquest_info INTEGER NOT NULL DEFAULT 0,
removed_deferred_change_index INTEGER,
PRIMARY KEY (action_seq, province_id),
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);
CREATE INDEX idx_action_changed_provinces_province_id
ON action_changed_provinces(province_id);
```
The vector and optional-aggregate fields of `ChangedProvinceC` become per-aggregate child tables, all keyed by `(action_seq, province_id)`:
- `action_changed_provinces_new_unaffiliated_heroes(action_seq, province_id, n, ...)` — one row per added hero, with `UnaffiliatedHero` fields as columns
- `action_changed_provinces_changed_unaffiliated_heroes(...)` — same shape
- `action_changed_provinces_removed_unaffiliated_hero_ids(action_seq, province_id, hero_id)` — join table for ID vector
- `action_changed_provinces_new_captured_heroes(...)``CapturedHero` fields
- `action_changed_provinces_recruitment_attempted_captured_hero_ids(...)` — join table
- `action_changed_provinces_removed_captured_hero_ids(...)` — join table
- `action_changed_provinces_new_ruling_faction_hero_ids(...)` — join table
- `action_changed_provinces_removed_ruling_faction_hero_ids(...)` — join table
- `action_changed_provinces_new_battalion_ids(...)` — join table
- `action_changed_provinces_removed_battalion_ids(...)` — join table
- `action_changed_provinces_new_incoming_armies(...)``MovingArmy` decomposition
- `action_changed_provinces_removed_incoming_army_ids(...)` — join table
- `action_changed_provinces_new_withdrawing_armies(...)``MovingArmy` decomposition
- `action_changed_provinces_removed_withdrawing_army_ids(...)` — join table
- `action_changed_provinces_new_hostile_armies(...)``HostileArmyGroup` decomposition
- `action_changed_provinces_removed_hostile_army_faction_ids(...)` — join table
- `action_changed_provinces_hostile_army_status_changes(action_seq, province_id, faction_id, new_status)` — small flat aggregate
- `action_changed_provinces_new_defending_army``Option[Army]` (singleton row if present)
- `action_changed_provinces_new_incoming_shipments(...)``MovingSupplies` decomposition
- `action_changed_provinces_removed_incoming_shipment_ids(...)` — join table
- `action_changed_provinces_new_incoming_end_turn_actions(...)``IncomingEndTurnAction` decomposition
- `action_changed_provinces_removed_incoming_end_turn_actions(...)` — same shape (these are values, not IDs)
- `action_changed_provinces_new_battle_revelations(...)``BattleRevelation` decomposition
- `action_changed_provinces_removed_battle_revelations(...)` — same shape
- `action_changed_provinces_new_pending_conquest_info``Option[PendingConquestInfo]` (singleton row)
- `action_changed_provinces_new_province_events``Vector[ProvinceEvent]`
- `action_changed_provinces_new_deferred_change``Option[DeferredChange]` (singleton, with discriminator since `DeferredChangeT` is sealed)
That's a lot of tables — ~25 just under `ChangedProvince`. The schema is mechanical once the principles are set; what makes it tractable is that every table follows the same shape: parent FK, `n` (vector index) where ordering matters, the aggregate's fields as columns, child aggregates as their own tables.
### `action_changed_heroes`, `action_changed_factions`, `action_changed_battalions`
Same pattern. Each gets its top-level table keyed by `(action_seq, entity_id)`, plus child tables for nested aggregates and ID vectors. Indexes on the entity id (`hero_id`, `faction_id`, `battalion_id`) so cross-game queries are fast.
`ChangedHeroC` is a *refined* entity (see "Refinement" in Status): no whole-entity blob. Its sealed `StatChange` (`StatDelta`/`StatAbsolute`/`StatNoChange`) `loyalty` and `vigor` oneofs are Tier-2 column pairs (`loyalty_delta`/`loyalty_absolute`, `vigor_delta`/`vigor_absolute`; column name discriminates, both-NULL ⇒ `StatNoChange`). Its `newBackstoryEvents: Vector[EventForHeroBackstory]` is Tier-3: per-event `EventForHeroBackstory` blobs, one row each, in `action_changed_heroes_new_backstory_events`. Columns + per-event blobs round-trip losslessly, so `changed_hero.proto`/`ChangedHeroConverter` become deletable (4.5i) and only `event_for_hero_backstory.proto` is retained as a blob format.
## "New" entity tables
`ActionResultC.newProvinces: Vector[ProvinceT]`, `newHeroes`, `newFactions`, `newBattalions`. These are *full snapshots* of new entities at creation.
These are bigger and recursive: `ProvinceT` itself has fields including its armies, heroes, battalions, events, etc. Decomposing fully duplicates the structure already covered by the existing `state_snapshots` blob (which we're explicitly keeping). The decomposition still needs to happen because we're dropping the proto entirely — and these vectors live in `ActionResultC`, which we're decomposing.
**Decision**: go fully relational here too. Each "new entity" gets its top-level table (`action_new_provinces` etc.) keyed by `(action_seq, entity_id)`, with the same per-aggregate-field decomposition pattern as the changed-entity tables. Where a "new entity" type contains its own nested aggregates, those aggregates either share child tables with the `changed_*` family (preferred where the field shape is identical, e.g., `MovingArmy`, `UnaffiliatedHero`) or get their own (where they diverge).
This is the most expensive single chunk of the decomposition — explicitly called out so we know what we're committing to in PR 4.5f.
## Notifications and generated text requests
```sql
CREATE TABLE action_new_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
CREATE TABLE action_removed_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
CREATE TABLE action_new_generated_text_requests (...);
CREATE TABLE action_client_text_visibility_extensions (...);
```
`NotificationT` and `GeneratedTextRequestT` are both sealed traits. Use the discriminator-column pattern: `kind TEXT NOT NULL` plus nullable per-subtype columns, unless a subtype's fields are too divergent to share a row.
## Schema evolution
We can't punt on this any more. With this many tables, the next field addition can't be "edit the proto." It needs a real migration mechanism.
Conventions:
- `metadata['schema_version']` is a small integer, currently `1`.
- A `Migrations` object holds an ordered `Vector[(Int, Connection => Unit)]` of migration steps.
- On `SqliteHistory.loaded`, the schema version is read; any pending migrations are applied in a single transaction; the version is bumped.
- New games start at the latest version.
- Migration steps are append-only — never edit an existing step.
This is the standard Rails / Flyway / etc. pattern, scaled down to one file.
## Read path: reconstructing `ActionResultC`
The current pattern:
```scala
val proto = ActionResult.parseFrom(payloadBlob)
val scala = ActionResultProtoConverter.fromProto(proto)
```
The new pattern:
```scala
val scala = ActionResultDbReader.read(connection, actionSeq) // assembles from action_results + child tables
```
`ActionResultDbReader.read` is one query per child table (or a single multi-result query with carefully ordered joins, though that has aggregation pitfalls). Per-table queries are simpler to write and SQLite query planning handles them well.
For bulk reads (`since`, `all`, `sinceDate`, etc.), the existing `replayFrom` helper iterates action seqs in order, and currently parses one proto per action. The new equivalent runs one batched query per child table for the relevant action_seq range, materializing rows into a map keyed by action_seq, then walks the range emitting `ActionResultC` instances assembled from the maps. This is more code than the proto version but doesn't change the algorithmic shape.
## Write path
`withNewResults` becomes:
1. INSERT into `action_results` (scalar fields).
2. For each non-empty aggregate: INSERT N rows into the corresponding child table.
All inside a single transaction. Per-action insert count is bounded by what the action does — most actions touch 03 provinces, 05 heroes, 010 changed entities total, plus 02 notifications. So the typical insert is ~520 rows. With WAL and a single per-batch transaction, this is cheap.
## Replay performance
Today's `replayFrom` parses one proto per action. Decomposed, each action requires per-child-table queries. For a 25-action replay between snapshots, that's still small absolute work — maybe 100500 rows fetched from a handful of indexed tables. Should be faster than proto parsing once the JIT warms up, but won't matter either way at typical sizes.
## Deleting the proto
> **Superseded by refined option B (see Status).** Whole-`ActionResult` `payload` is removed in 4.5h. After that it depends on per-entity classification: *refined* entities' per-entity sub-message protos + converters (e.g. `changed_hero.proto`) **do** become dead code and are deleted in 4.5i; *deep* entities' whole-entity blob protos, the leaf protos used as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`), and `Quest` are *retained*. The original full-deletion plan below is kept for historical context.
Once `SqliteHistory` writes and reads exclusively from the new tables:
1. Delete `ActionResultProtoConverter.scala`, the `Changed{Province,Hero,Faction,Battalion}Converter.scala` files, the `Notification`/`GeneratedTextRequest`/etc. converters.
2. Delete the proto definitions: `action_result.proto`, `changed_province.proto`, `changed_hero.proto`, `changed_faction.proto`, `notification.proto`, `generated_text_request.proto`, anything else in this scope.
3. Delete the corresponding `_scala_proto` Bazel targets.
4. Gazelle should drop the unused deps from downstream targets.
This is satisfying but should land *last*, after everything's stable. The proto files are dead code at that point — keeping them around for a PR or two while we verify migration is fine.
## Sequencing
**Phase 4** (cutover) lands as planned: `GamesManager` swaps to `SqliteHistory`, save dirs get nuked, action data is the single proto-blob `payload`. Days of work, validates the lifecycle integration without conflating with schema design.
**Phase 4.5** (this design) starts after Phase 4 is verified stable. Subdivided into small PRs. **Re-planned for option B (see Status):** each entity gets the 4.5a scalar/entity-ref columns + a `<entity>_proto BLOB`; no child-table explosion. Each decomposition PR splits into `.1` (schema + backfill over existing `payload`) and `.2` (live `withNewResults` write path + read path), per the precedent set by 4.5b.
| PR | Work | State |
|---|---|---|
| 4.5a | Schema-migration scaffolding + four changed-entity top-level tables (scalar columns) | ✅ merged |
| 4.5b | `ChangedProvince`: scalar columns + `changed_province_proto BLOB` + v3 backfill (option B, #6725) | ✅ merged |
| 4.5c.1 | `ChangedHero` (**refined option B**, see Status): 4.5a scalars + loyalty/vigor Tier-2 column pairs + `newBackstoryEvents` as per-event `EventForHeroBackstory` Tier-3 blobs in `action_changed_heroes_new_backstory_events`; **no whole-entity blob**`changed_hero.proto`/`ChangedHeroConverter` become deletable, `event_for_hero_backstory.proto` retained. Schema v4 + backfill over existing `payload` (#6727) | ✅ merged |
| 4.5c.2 | `ChangedHero`: live `withNewResults` write path + read path | planned |
| 4.5d | `ChangedFaction`: same pattern (mostly scalar already; small/empty blob — see Decisions #2) | planned |
| 4.5e | `ChangedBattalion`: `changed_battalion_proto BLOB` (sole field is the contained `BattalionT`) + battalion_id index from 4.5a | planned |
| 4.5f | "New entity" vectors (`newProvinces`/`newHeroes`/`newFactions`/`newBattalions`): one table per type keyed by `(action_seq, entity_id)` + a couple of queryable handles + `<entity>_proto BLOB`. Biggest win for B — these are the deepest snapshots | planned |
| 4.5g | Notifications + generated text requests: `(action_seq, n, kind, <entity>_proto BLOB)``kind` discriminator queryable, payload blobbed (quest-hybrid shape) | planned |
| 4.5h | Top-level `ActionResultC` scalar/entity-ref columns on `action_results` (acting_hero_id, acting_faction_id, province_id, …) + **drop `payload BLOB`**. Read path = scalar columns + per-entity proto blobs (close to today's proto parse; simpler than the original per-table-join plan) | planned |
| 4.5i | Delete proto files/targets that became dead code. *Refined* entities' per-entity protos + converters (e.g. `changed_hero.proto`/`ChangedHeroConverter`) **are deleted** here. *Deep* entities' protos + leaf Tier-3 protos (e.g. `event_for_hero_backstory.proto`) + `Quest` are **retained** as blob payloads. No longer near-nothing — scope scales with how many entities went *refined* | planned |
Each PR nukes save dirs as part of its rollout (still pre-alpha). Each PR is independently reviewable and revertable. Under option B the per-PR cost is far lower than the original estimate (~110-line writer per entity, no child-table schema), so the remaining sequence is roughly **1 week**, not 34.
After 4.5: Phase 5 (idle-game eviction) and Phase 6 (final cleanup) proceed as the parent design doc lays out.
## What we gain at the end
- **Single source of truth.** Action data lives in tables; no proto representation for the things in scope.
- **Native SQL analytics.** Cross-game queries like "actions touching province X" or "all riots in year Y" are one-line SELECTs. No backfill code needed.
- **Inspectable saves.** `sqlite3 game.db` shows readable data. Debugging is normal SQL work, not "parse this binary blob."
- **Cleaner replay path.** No proto parsing during replay. The `ActionResultProtoConverter` (currently called per replayed action in `replayFrom`) goes away entirely.
- **Less code.** ~7 proto files deleted; their generated Scala targets deleted; their converter classes deleted; the dual proto/Scala representation collapses to one.
- **Schema migrations as a first-class concept.** Future field additions are SQL migrations, which is the right shape for a system that owns its storage.
## What we lose
- **The "edit one proto" workflow** for adding fields. Adding `newFooDelta: Double` to `ChangedProvinceC` is now a Scala edit + SQL migration step + converter update. More steps. Pre-alpha this is cheap; post-alpha (when migrations apply to real data) it's a careful PR but still bounded.
- **A safety net for unrecognized fields.** Proto silently keeps unknown fields on parse; SQL doesn't. We accept this because we control both ends.
## Decisions locked
1. **Refined option B is the pattern (see "Refinement" in Status; rescinds both "fully relational, no blob fallback" and the original whole-entity-blob-per-entity rule).** Per entity, three tiers: scalar/entity-ref columns (Tier 1) + typed columns for sealed value-oneofs (Tier 2) + per-row leaf-proto blobs in child tables (Tier 3). A whole-entity `<entity>_proto BLOB` is used **only for deep entities** where Tiers 23 cost more than they're worth. No child-table explosion for deep aggregates.
2. **Classify each entity: all-scalar / refined / deep (supersedes the old "is a blob needed").** *All-scalar* → Tier 1 only, no blob (likely `ChangedFaction`). *Refined* → Tiers 13, no whole-entity blob, per-entity proto deletable (`ChangedHero`). *Deep* → Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained (`ChangedProvince`, new-entity snapshots). Default to *refined* for shallow entities; fall back to *deep* only when the residual is too varied for Tiers 23.
3. **What's retained vs. deleted depends on the classification.** `action_results.payload` (the whole-`ActionResult` blob) still goes away in 4.5h. *Refined* entities' per-entity sub-message protos + converters become dead code, deleted in 4.5i; only the **leaf** protos they use as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`) are retained. *Deep* entities' protos + `Quest` stay as whole-entity blob payloads. So 4.5i is no longer near-nothing.
4. **Sub-phase ordering** as in the Sequencing table; each decomposition PR splits into `.1` (schema + backfill) and `.2` (live write + read).
5. **Phase 4 landed separately, before 4.5a** (historical, unchanged). Cutover validated the lifecycle integration with the simpler schema before decomposition began.
+291
View File
@@ -0,0 +1,291 @@
# SQLite Game History Design
## Why
The current persistence layer is file-based: action results are chunked into `.e0a` files, individual results spill to `.e0i` files for crash recovery, state snapshots live in `.e0s` files, and a `directory.e0i` index tracks chunks. This shape has produced three workarounds in recent history:
1. A `games.e0es` cache (PR #6706) to avoid re-reading the running-games list.
2. Dual-storage in `PersistedActionResult` (proto bytes + parsed proto) to amortize serialization across save flushes.
3. Lazy `gameState` (PR #6692) to avoid eager proto conversion on every replayed step, after PR #6691 fixed the `stateAfter` OOM that materialized ~4500 `PersistedActionResult` instances during a stale-LLM-replay path.
Each is a workaround for the same root cause: file-based random access is expensive, so we cache aggressively, and the cache layers are fragile (one misplaced `.map(_.gameState)` reintroduces the OOM).
The alternative — SQLite as a per-game container, with proto bytes stored as BLOB columns indexed by action sequence number — gives us random access by construction, removes the need for all three workarounds, and makes idle-game eviction cheap (rehydrate from the DB instead of reparsing files). The pattern is already in production here: `SqliteClientTextStore` does exactly this for the client text cache. We extend the same pattern to game history.
## Goal & non-goals
**In scope:**
- Per-game SQLite container (`game.db`) holding action history, state snapshots, and shardok results.
- A new `SqliteHistory` class implementing the existing `FullGameHistory` trait, drop-in replacement for `PersistedHistory` at the call site.
**Out of scope (deferred or rejected):**
- **Migration of existing saves.** Pre-alpha there are only two users and a handful of test games. Cheaper to nuke the existing save directories at cutover than to write and verify a migration importer. New games created post-cutover land in `game.db` directly; existing games are gone.
- The top-level `games.e0es` running-games registry. Stays as-is; its cache (PR #6706) already works.
- Idle-game eviction. Enabled by this work but lands in a follow-up phase after `SqliteHistory` is the authoritative path.
- A shared cross-game analytics DB. Per-game design supports ad-hoc cross-game queries via iterate-and-aggregate; build the centralized analytics DB only if/when those queries become hot.
## File layout
`game.db` lives in the existing per-game save directory, alongside `text_store.db`:
```
${EAGLE_SAVE_DIR}/${gameIdHex}/
├── game.db # NEW — action history, snapshots, shardok results
└── text_store.db # existing — client text (SqliteClientTextStore)
```
Existing `.e0a` / `.e0s` / `.e0i` / `directory.e0i` files do not coexist with `game.db`: at cutover we nuke save directories one time. There is no migration path and no legacy-file fallback.
### Why `game.db` and `text_store.db` are separate files (not tables in one DB)
It's tempting to combine them — one file per game, one connection per game, one cloud-upload story. The reason not to: **writer-lock contention.** SQLite serializes writers per-database file. `SqliteClientTextStore` writes frequently during LLM streaming (one `UPDATE` per token append on `texts.text`). `SqliteHistory` writes per-action during turn commits. Combining them means an in-flight LLM token append can block a turn commit, or vice versa. Separate DBs have separate writer locks and never contend. Subsystem ownership (each package owning its own schema, independent migration paths if the schemas evolve) is a bonus.
`Persister` integration follows the `SqliteClientTextStore` pattern: `game.db` is uploaded to / downloaded from cloud storage as a single opaque blob, keyed by the filename. On first load, if `game.db` is missing locally, try `persister.retrieveAsStream("game.db")`. If both are missing, this is a new game and we create an empty DB.
## Schema
```sql
-- Action results, one row per action_result_index.
CREATE TABLE action_results (
action_seq INTEGER PRIMARY KEY, -- 0-based, dense, never sparse
action_result_type INTEGER NOT NULL, -- denormalized for filtering
round_id INTEGER NOT NULL, -- denormalized for recentResultsForRound
date_year INTEGER, -- denormalized for sinceDate; NULL pre-game-start
date_month INTEGER, -- denormalized for sinceDate; NULL pre-game-start
payload BLOB NOT NULL -- ActionResult proto bytes
) WITHOUT ROWID;
CREATE INDEX idx_action_results_round ON action_results(round_id);
CREATE INDEX idx_action_results_date ON action_results(date_year, date_month);
-- action_seq is the PK so no index needed there.
-- State snapshots at chunk boundaries (and the starting state at seq 0).
-- boundary_action_seq is the action_seq AFTER which the snapshot reflects state.
-- The starting state lives at boundary_action_seq = 0 (state before any actions).
CREATE TABLE state_snapshots (
boundary_action_seq INTEGER PRIMARY KEY,
payload BLOB NOT NULL -- GameState proto bytes
) WITHOUT ROWID;
-- Shardok per-battle results.
CREATE TABLE shardok_results (
shardok_game_id TEXT NOT NULL,
action_seq INTEGER NOT NULL, -- 0-based within this shardok game
payload BLOB NOT NULL, -- ShardokActionResult proto bytes
PRIMARY KEY (shardok_game_id, action_seq)
) WITHOUT ROWID;
-- Shardok per-game state (one row per shardok_game_id).
CREATE TABLE shardok_state (
shardok_game_id TEXT PRIMARY KEY,
game_state BLOB NOT NULL, -- ShardokGameState proto bytes
last_eagle_round_id INTEGER NOT NULL
) WITHOUT ROWID;
-- Shardok per-player results (filtered ActionResultView per faction).
CREATE TABLE shardok_player_results (
shardok_game_id TEXT NOT NULL,
faction_id INTEGER NOT NULL,
seq INTEGER NOT NULL,
payload BLOB NOT NULL, -- ShardokActionResultView proto bytes
PRIMARY KEY (shardok_game_id, faction_id, seq)
) WITHOUT ROWID;
-- Shardok per-player available commands (latest only, keyed by game + faction).
CREATE TABLE shardok_player_commands (
shardok_game_id TEXT NOT NULL,
faction_id INTEGER NOT NULL,
payload BLOB, -- ShardokAvailableCommands proto bytes; NULL = no commands
PRIMARY KEY (shardok_game_id, faction_id)
) WITHOUT ROWID;
-- Schema version and starting state.
CREATE TABLE metadata (
key TEXT PRIMARY KEY,
value BLOB NOT NULL
);
-- Seeded with: schema_version=1
```
### Column rationale
- **`action_seq`** as `INTEGER PRIMARY KEY` (the SQLite rowid alias) — densest possible storage and no separate index; range scans for `since(start)` are O(log n + result count).
- **`WITHOUT ROWID`** on tables with synthetic keys to skip the implicit rowid column.
- **Denormalized `action_result_type`, `round_id`, `date_year`, `date_month`** — these are the predicates the existing read paths use (`recentResultsForRound`, `sinceDate`). Keeping them as columns avoids parsing the proto blob to filter.
- **No `created_at` timestamp** — wall-clock time isn't queried by the History trait, and game date (year/month) is what matters semantically.
- **`payload BLOB`** — the proto bytes are the source of truth for the structured action result. Polymorphic action types make a relational schema painful for limited gain; the denormalized columns above cover the queries we need.
- **Snapshots keyed by `boundary_action_seq`** — `stateAfter(N)` finds `MAX(boundary_action_seq) WHERE boundary_action_seq <= N`, returns that snapshot, replays forward `N - boundary_action_seq` actions. Snapshot at `0` is the game's starting state.
### Snapshot strategy
Match the existing `resultsPerSaveFile = 25` boundary: write a `state_snapshots` row every 25 actions. That gives the same replay-window cost as the current chunk-file design (`stateAfter(N)` replays at most 25 actions to reach an arbitrary point), and matches the cadence developers are already calibrated to.
Snapshots are GameState proto bytes, identical in shape to today's chunk-file `startingState`. The migration importer derives them directly from the chunk files. New games write a snapshot after every 25th `withNewResults` action.
## Connection lifecycle
Mirror `SqliteClientTextStore`:
- One `Connection` per loaded game, opened when `GamesManager` loads the game, closed when the game is evicted (future eviction work) or the server shuts down.
- `Class.forName("org.sqlite.JDBC")` + `DriverManager.getConnection("jdbc:sqlite:${path}")` on open.
- `PRAGMA journal_mode = WAL` on every connection open. WAL gives us crash-safe writes and lets readers proceed concurrently with the single writer — important because gRPC stream readers (humanPlayerClientConnectionState) query history mid-turn.
- `PRAGMA synchronous = NORMAL` (the WAL-recommended setting; durability is preserved through WAL checkpoint).
- `PRAGMA foreign_keys = ON` (defensive; we have no FKs today but cheap to enable).
- Auto-commit on by default. Transactions explicitly opened for `withNewResults` (batch of action inserts + optional snapshot) and `truncateTo` (deletes across all tables).
### Threading
The current `PersistedHistory` is an immutable case class; `withNewResults` returns a new instance. `SqliteHistory` cannot be pure-immutable (the DB is mutable state) but should present the same interface: methods that "change" the history return `this` after a successful write. The underlying `Connection` is shared.
JDBC `Connection` is not thread-safe in general; SQLite's JDBC driver serializes operations per-connection. Existing call sites already serialize writes through the `EngineApplier` flow, so single-threaded write access is preserved. Reads from gRPC stream readers can use the same connection — SQLite serializes them transparently, and WAL prevents read-write blocking.
## Read paths
How each `FullGameHistory` method maps to SQL:
| Method | Query |
|---|---|
| `count` | `SELECT COALESCE(MAX(action_seq), -1) + 1 FROM action_results` (cached as a counter after the first read) |
| `last` | `SELECT payload FROM action_results ORDER BY action_seq DESC LIMIT 1` + state from `stateAfter(count)` |
| `all` | `SELECT payload FROM action_results ORDER BY action_seq` — used by `GameAdminServiceImpl.getActionDetail` to fetch one action by index. See follow-up note below. |
| `since(start)` | `SELECT payload FROM action_results WHERE action_seq >= ? ORDER BY action_seq` |
| `sinceDate(date)` | `SELECT payload FROM action_results WHERE (date_year, date_month) >= (?, ?) ORDER BY action_seq` |
| `recentResultsForRound(round, pred)` | `SELECT payload FROM action_results WHERE round_id = ? AND action_seq > ? ORDER BY action_seq` where `?` is the cutoff matching current `recentHistory` semantics (last N actions, or all actions for the current round) |
| `stateAfter(N)` | Find latest snapshot ≤ N, replay forward via `replayApplier` (same logic as `replayScalaOnlyToState`) |
For methods that return `Vector[ActionResultWithResultingState]` (with resulting state per row): we **do not** materialize per-row gameStates. Instead, fold the actions through `replayApplier` starting from the latest snapshot ≤ start, producing the states on the fly. This matches what `formAwrs` does today, with one critical difference: no `PersistedActionResult` wrapper is allocated, and no proto-conversion is performed per step. This is the same shape as `replayScalaOnlyToState`, generalized to produce intermediate states.
### `all()` follow-up
`GameAdminServiceImpl.getActionDetail` is the one production caller. It fetches a single action by index from the full vector. Either of these is cheaper than materializing the whole history:
- Replace the call site with `history.since(index).headOption`.
- Add a new `actionAt(index): Option[ActionResultWithResultingState]` method and drop `all()` from the trait entirely.
`SqliteHistory.all` will work — it's just `SELECT * ORDER BY action_seq` — but it's expensive (materializes the full history into memory), so we should switch the admin caller in a small follow-up PR. Not blocking the SQLite work.
### `recentResultsForRound` semantics
Today this filters `recentHistory` (the in-memory tail) by `roundId`. In SQLite there's no in-memory/persisted split — all results live in the DB. The semantics shift slightly: return all results matching `round_id` after a configurable cutoff. The cutoff should match today's behavior (results since the start of the current round, or some bounded recent window). Default to "all results with the given `round_id`," which is correct as long as `round_id` uniquely identifies a round across the game's history (it does, per the current `RoundId` model).
## Write paths
### `withNewResults(newResults)`
In a transaction:
1. `INSERT INTO action_results (action_seq, action_result_type, round_id, date_year, date_month, payload) VALUES ...` — one row per new result. Use `addBatch()` for multiple.
2. For each new result whose `action_seq % 25 == 0` (snapshot boundary), `INSERT INTO state_snapshots(boundary_action_seq, payload) VALUES (?, ?)` with the GameState proto bytes.
3. Commit.
The denormalized columns (`action_result_type`, `round_id`, `date_year`, `date_month`) are extracted from the action's resulting state at insert time. They are immutable once written; if the schema interpretation changes, a migration is required.
The "individual result for crash recovery" pattern (`.e0i` files) is replaced by: the action result is durably written when the transaction commits. WAL gives us atomicity per transaction. No separate crash-recovery file is needed.
### `saveNow`
Becomes a no-op in normal operation — writes are already durable per `withNewResults` commit. We keep the method on the trait for API compatibility but the implementation just returns `this`. (We could call `PRAGMA wal_checkpoint(TRUNCATE)` here to roll the WAL into the main DB file, useful before cloud upload; defer this until we measure WAL growth.)
### `truncateTo(targetActionCount)`
In a transaction:
1. `DELETE FROM action_results WHERE action_seq >= ?`
2. `DELETE FROM state_snapshots WHERE boundary_action_seq > ?`
3. Shardok cleanup: `DELETE FROM shardok_results / shardok_state / shardok_player_results / shardok_player_commands WHERE shardok_game_id NOT IN (...)` (the set of battles still outstanding at the truncate point; mirrors `deleteOrphanedShardokFiles`).
4. Commit.
`truncateTo(0)` resets the game; everything after the starting-state snapshot is deleted.
## Shardok results
The shardok subsystem currently lives in `.e0s` files (one per battle, full per-battle state). It's loaded selectively at game-load time, only for outstanding battles (see `PersistedHistory.apply` line 248-256).
Moving to SQLite, the four shardok tables above capture:
- `shardok_results` — the per-battle result stream
- `shardok_state` — current per-battle state (one row per battle)
- `shardok_player_results` — per-faction filtered views
- `shardok_player_commands` — current available commands per faction
`withNewShardokResults` writes to all four in a transaction. `shardokCount`, `shardokGameState`, etc., become single-row indexed lookups.
The selective-load optimization disappears with SQLite: we don't proactively read anything; queries hit the DB on demand. The "load only outstanding battles" logic is replaced by "query by `shardok_game_id` when needed."
## Crash recovery
WAL replaces the `.e0i` individual-result-file mechanism. On startup:
- WAL is automatically replayed by SQLite if the previous shutdown was unclean. No application code needed.
- A successful commit means durable; an interrupted commit means rolled back. No half-written state visible.
The current "orphaned individual results on load" path (`loadIndividualResults` in `PersistedHistory.apply`) is gone.
## Cutover
No migration path. At cutover:
1. Stop the server.
2. Nuke the contents of `${EAGLE_SAVE_DIR}` (and `${EAGLE_ARCHIVE_DIR}` if there's anything there).
3. Deploy.
4. New games created post-deploy land in `game.db` directly.
Pre-alpha there are only two users and a handful of test games; a one-time nuke is cheaper than a verified migration importer. Trade-off accepted by the user explicitly.
### Testing strategy (no migration)
Without migration, we don't need equivalence-vs-`PersistedHistory` testing. The correctness gate becomes:
1. **Adapt `PersistedHistoryTest`** to run against `SqliteHistory` instead. The 890-line existing test suite covers the trait surface exhaustively. Same assertions, new implementation under test.
2. **Property-style end-to-end test**: create a synthetic game, run N batches of `withNewResults`, verify that `since(start)`, `stateAfter(N)`, `sinceDate(date)`, `recentResultsForRound`, `truncateTo`, etc., all produce expected values. Run with a deterministic random seed.
3. **Manual smoke test in dev**: start a fresh game, play several turns, restart the server, verify saves persist and replay correctly.
## Cloud-storage integration
`Persister.save(key, bytes)` and `persister.retrieveAsStream(key)` already handle local-vs-S3 transparently. `SqliteClientTextStore` integrates by treating `text_store.db` as a single binary blob: upload after writes, download before reads.
`SqliteHistory` will do the same with `game.db`:
- **On load:** if local `game.db` missing, try to download via `persister.retrieveAsStream("game.db")`. Fall back to migration from `.e0a` if both are missing.
- **On save:** after a meaningful change (turn boundary? configurable cadence?), upload the current `game.db` file to S3. Need to decide the upload trigger — too frequent and we burn S3 PUTs; too rare and crash recovery is lossy.
**Open question:** the right upload cadence. Today, chunk saves trigger cloud upload at chunk boundaries (every 25 actions). The simplest match: keep that cadence, upload `game.db` once per chunk boundary. We could be smarter (only upload if WAL has been checkpointed; only upload deltas) but those are optimizations.
## What the trait-level cutover looks like
`GamesManager` currently has roughly:
```scala
val history: FullGameHistory = PersistedHistory(gameId, persister).getOrElse(...)
```
Becomes:
```scala
val history: FullGameHistory = SqliteHistory.loaded(gameId, persister)
```
Every other consumer of `FullGameHistory` is unchanged.
## Decisions locked
1. **Per-game `game.db`** (not shared across games).
2. **`game.db` and `text_store.db` stay separate files** (not combined into one DB). Writer-lock contention is the deciding factor.
3. **No migration** of existing saves. Nuke save directories at cutover. Pre-alpha trade-off accepted by the user.
4. **No feature flag.** Cutover is unconditional. Failures are loud.
5. **Cloud upload cadence**: match the existing 25-action chunk cadence. Upload `game.db` once per snapshot boundary.
6. **`recentResultsForRound`**: return all results for the given round (no recent-window cap). The `round_id` predicate bounds the scan naturally.
7. **WAL checkpointing**: rely on SQLite's auto-checkpoint. Add explicit `PRAGMA wal_checkpoint` only if WAL file growth becomes a problem.
8. **Schema versioning**: seed `metadata` with `schema_version = 1`. Schema migration mechanism deferred until we need a v2.
9. **`history.all`**: keep on the trait for now; admin call site moved to `since(i).headOption` (or a new `actionAt(i)` method) in a small follow-up PR. Not blocking.
## Phase plan (revised)
| Phase | Work | Estimate |
|---|---|---|
| 0 | This design doc | 2-3 days (in flight) |
| 1 | `SqliteHistory` schema + write paths + tests adapted from `PersistedHistoryTest` | ~1 week |
| 2 | Read paths + full test parity | ~1 week |
| 3 | Shardok results integration | 3-5 days |
| 4 | Cutover in `GamesManager` (nuke `${EAGLE_SAVE_DIR}` as part of deploy) | 2-3 days |
| 5 | Idle-game eviction | 3-5 days |
| 6 | Post-alpha cleanup (delete `PersistedHistory`, `PersistedActionResult`, `PartialGameUtils`, the chunk-file save code, `games.e0es` cache if no longer needed) | 1-2 days |
**Total: ~3-4 weeks** (down from 5-6 — migration and equivalence-testing dropped).
The follow-up to migrate `GameAdminServiceImpl.getActionDetail` off `history.all` is a small, independent PR that can land any time.
+1 -1
View File
@@ -32,7 +32,7 @@ When a new player starts their first game in tutorial mode, they experience:
- 1x Heavy Cavalry (600 troops, 80 training/armament)
- 1x Heavy Infantry (500 troops, 80 training/armament)
- 1x Longbowmen (300 troops, 80 training/armament)
- **Origin Province**: 32
- **Origin Province**: 31
### Flee Trigger
Attacker flees when ANY of these conditions are met:
+322
View File
@@ -0,0 +1,322 @@
# Tutorial System Architecture
This document describes how the Unity client's tutorial system is wired together: the classes involved, the trigger catalog, the step lifecycle, and the expected first-session flow. It complements two existing docs:
- **`TUTORIAL_CONTENT.md`** — human-facing copy for tutorial steps and dialogues
- **`TUTORIAL_BATTLE_SYSTEM.md`** — the scripted first-battle scenario (Tarn vs. John Ranil)
It also overlaps slightly with the in-tree `Assets/Tutorial/TUTORIAL_PLAN.md`, which is an older implementation plan.
All paths below are relative to `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/`.
---
## Current State (important)
There are **two parallel subsystems** that share the same trigger plumbing:
| Subsystem | Status | Lives in |
|-----------|--------|----------|
| Step-based tutorial UI (modals, overlays, hints) | **Dormant** | `Tutorial/TutorialManager.cs`, `Tutorial/UI/*`, `Tutorial/Content/*` |
| Narrative dialogue system | **Live** | `Tutorial/Dialogue/*`, `Resources/Dialogues/*.json` |
`TutorialContentDefinitions.RegisterAll()` returns early at line 17 with the comment *"Old tutorial content suppressed — replaced by narrative dialogue system."* As a result:
- `OnboardingSequence` is never assigned, so `StartOnboarding()` no-ops at the "No onboarding sequence assigned" branch.
- No contextual tutorial sequences are registered with the trigger registry.
- The trigger registry still fires events normally, but nothing on the step-UI side is listening.
- `DialogueManager` consumes those same events and matches them against scripts in `Resources/Dialogues/` (`tutorial_strategic.json`, `tutorial_battle.json`).
**In practice today, the entire active tutorial experience runs through the dialogue system.** The step-UI machinery is preserved for future use — content definitions still exist below the early return in `TutorialContentDefinitions.cs` for reference.
---
## Class Map
### Orchestration
- **`Tutorial/TutorialManager.cs`** — Singleton. Owns `OnboardingSequence`, the active step queue, the trigger registry, and the dialogue manager handle. Initialized by `EagleGameController.SetUpGame()` and `ShardokGameController.SetUpGame()`.
- **`Tutorial/TutorialState.cs`** — PlayerPrefs persistence. Tracks `OnboardingCompleted`, `OnboardingStepReached`, completed sequence IDs (HashSet for O(1) lookup), dismissed hints, and a global "tutorials disabled" preference.
- **`Tutorial/TutorialTargetRegistry.cs`** — Maps string IDs to `RectTransform`s for highlighting. Static targets are Inspector-assigned (`ProvinceInfoPanel`, `SupportField`, `CommitButton`, etc.); dynamic targets register at runtime (e.g., hero rows from `HeroesAndBattalionsPanelController`).
### Triggers
- **`Tutorial/Triggers/TutorialTriggerRegistry.cs`** (~1100 lines) — Routes game events to both the step UI (when sequences are registered) and `DialogueManager`. Maintains one-shot session flags so the same first-encounter trigger doesn't fire twice.
### Content (step UI — dormant)
- **`Tutorial/Content/TutorialStep.cs`** — Per-step record: `DisplayMode`, `CompletionType`, target path, copy, panel anchor, highlight options.
- **`Tutorial/Content/TutorialSequence.cs`** — `ScriptableObject` holding ordered `TutorialStep`s plus `IsOnboarding` flag and lifecycle callbacks.
- **`Tutorial/Content/TutorialContentDefinitions.cs`** — Static class that *would* register all sequences. Currently short-circuits before any registration.
### UI (step UI — dormant)
- **`Tutorial/UI/TutorialUIManager.cs`** — Coordinates which presenter renders each step.
- **`Tutorial/UI/TutorialCanvasBuilder.cs`** — Builds the Canvas at runtime (no prefab dependency).
- **`Tutorial/UI/TutorialModalPanel.cs`** — Full-screen blocking modal.
- **`Tutorial/UI/TutorialOverlayController.cs`** + **`TutorialOverlayBuilder.cs`** — Dimmer with highlighted cutout, gold border, pulsing animation, and adjacent tooltip text.
- **`Tutorial/UI/TutorialHintIndicator.cs`** — Stub for pulsing-dot mode.
### Dialogue (live)
- **`Tutorial/Dialogue/DialogueManager.cs`** — Loads all `Resources/Dialogues/*.json` scripts at startup, indexes by trigger ID, drives the panel.
- **`Tutorial/Dialogue/DialogueScript.cs`** / **`DialogueStep.cs`** — JSON-shaped records for scripts and steps.
- **`Tutorial/Dialogue/DialoguePanelController.cs`** — Renders the speaker headshot, body text, instruction line, optional highlight target, and Continue button.
- **Scripts:** `Assets/Resources/Dialogues/tutorial_strategic.json`, `tutorial_battle.json`.
---
## Step Lifecycle (Step-UI System)
1. **Triggered**`TutorialTriggerRegistry` raises an event (e.g., `game_started`, `battle_entered`).
2. **Queued or shown** — If no active sequence, show immediately. If an active step is hidden (`DisplayMode.None`), interrupt it. If both new and active are command tutorials, interrupt for responsiveness. Otherwise queue.
3. **Rendered**`TutorialUIManager.ShowCurrentStep()` dispatches to the modal/overlay/hint presenter based on `DisplayMode`.
4. **Awaiting completion** — One of:
- `ButtonClick` — user dismisses
- `GameEvent` — wait for a named event (e.g., `province_selected`)
- `Timer` — auto-advance after delay
- `UIInteraction` — wait for the highlighted target to be interacted with
- `Condition` — custom predicate (rare)
5. **Advance**`AdvanceStep()` moves to the next step or completes the sequence; completion writes to `TutorialState`.
Hidden steps (`DisplayMode.None`) deliberately don't block — they exist so contextual tutorials can fire while the onboarding sequence is waiting on an async event.
---
## Display Modes
| Mode | Renderer | Notes |
|------|----------|-------|
| **Modal** | `TutorialModalPanel` | Full blocking dialog with dim background |
| **Overlay** | `TutorialOverlayController` | Dimmer with cutout + tooltip near a target |
| **Tooltip** | `TutorialOverlayController` | Currently same renderer as overlay |
| **Hint** | `TutorialHintIndicator` | Pulsing dot only (stub) |
| **None** | (invisible) | Waits for a completion event |
Panels can be anchored via `TutorialPanelAnchor` (`Center` / `Left` / `Right` / `Top` / `Bottom`).
---
## Trigger Catalog
All triggers are raised via `TutorialTriggerRegistry`. Today these flow to `DialogueManager.TriggerDialogue()` and are matched against the dialogue JSON; if step-UI sequences are re-registered, they will also route there. File:line citations are for the registry unless noted; line numbers may drift.
### Bootstrap / first-session
| Trigger | Fires from | When |
|---------|-----------|------|
| `game_started` | `EagleGameController.SetUpGame()` | First time entering a tutorial game |
| `first_battle_available` | `OnModelUpdated()` ~L204 | A `RunningShardokGameModel` first appears |
| `battle_entered` | `OnBattleEntered()` ~L793 | User clicks **Battle!** |
| `tutorial_battle_ended` | `CheckTutorialBattleEnded()` ~L594 | Tutorial battle removed from running models |
| `tutorial_rebuild_support` | `CheckTutorialRebuildSupport()` ~L616 | Captured-heroes phase done |
| `tutorial_taxes_collected` | `CheckTaxesCollected()` ~L680 | New Year action with positive tax delta |
| `tutorial_in_town` | `CheckInTown()` ~L500 | Return command becomes available |
### Strategic-map contextual
| Trigger | Site | When |
|---------|------|------|
| `province_selected` | `EagleGameController.ProvinceWasSelected()` | Province click |
| `command_issued` | `EagleGameController.PostCommittedCommand()` | User commits |
| `diplomacy_available` | `CheckStrategicCommandsAvailable()` ~L243 | Diplomacy command appears |
| `weather_control_available` | `CheckStrategicCommandsAvailable()` ~L249 | Weather command appears |
| `hero_recruitment_available` | `CheckHeroRecruitmentAvailable()` ~L266 | Free heroes detected |
| `tutorial_ready_to_join` | `CheckReadyToJoinHero()` ~L479 | Free hero with `WouldJoin` |
| `profession_<X>_encountered` | `CheckProfessionTutorial()` ~L726 | First time seeing each profession |
### Strategic guidance (state-derived, conditions checked each model update)
| Trigger | Site | Condition |
|---------|------|-----------|
| `guidance_loyalty_danger` | `CheckLoyaltyDanger()` ~L339 | November + hero loyalty < 70 |
| `guidance_neighbor_danger` | `CheckNeighborDanger()` ~L383 | Hostile faction adjacent |
| `guidance_recruit_heroes` | `CheckRecruitHeroesGuidance()` ~L420 | Province with support ≥40 has free heroes |
| `guidance_expand` | `CheckExpandGuidance()` ~L449 | One stable province, hasn't expanded |
| `guidance_sworn_kinship` | `CheckSwornKinshipGuidance()` ~L549 | Good candidate or 4+ provinces |
| `tutorial_loyalty_warning` | `CheckTutorialLoyaltyWarning()` ~L634 | November + hero loyalty < 70 |
| `tutorial_support_deadline` | `CheckTutorialSupportDeadline()` ~L654 | December + province support < 40 |
### Strategic action-result triggers (fired from `OnStrategicActionResult()`)
| Trigger | Site | When |
|---------|------|------|
| `hero_stat_gained` | ~L281 | `HeroStatGained` action result |
| `hero_profession_gained` | ~L284 | `ProfessionGained` action result |
| `tutorial_faction_appears` | ~L287 | `TutorialFactionAppears` action — the Fracture Covenant landing |
| `tutorial_hero_faction_appears` | ~L290 | `TutorialHeroFactionAppears` action (no dialogue script today) |
| `tutorial_hero_departed` | ~L299 | First `HeroesDeparted` action against another player (King's hero abandons service) |
| `tutorial_hero_departed_again` | ~L302 | Second `HeroesDeparted` in a *different* month than the first |
### Tactical (battle) — abilities and spells
Combat triggers are *paced*: `_combatTutorialPending` ensures only one fires per action, with priority `archery > duel > melee > charge > fire`.
| Trigger | Site | When |
|---------|------|------|
| `shardok_placement_started` | `OnBattleEntered()` ~L796 | Battle in setup phase |
| `shardok_battle_started` | `OnBattleEntered()` ~L793 | Battle begins |
| `shardok_battle_running` | `OnTacticalCommandsAvailable()` ~L948 | Player's first turn |
| `archery_available` | `OnTacticalCommandsAvailable()` ~L990 | Archery available |
| `melee_available` | `OnTacticalCommandsAvailable()` ~L991 | Melee available |
| `duel_available` | `OnTacticalCommandsAvailable()` ~L1005 | Duel available (non-Tarn target) |
| `ability_charge_available` | `OnTacticalCommandsAvailable()` ~L1001 | Charge for Old Marek |
| `start_fire_available` | `OnTacticalCommandsAvailable()` ~L1021 | Start Fire on enemy |
| `hide_available` | `OnTacticalCommandsAvailable()` ~L985 | Hedrick can hide |
| `thunderstorm` | `OnTacticalCommandsAvailable()` ~L1056 | Weather is thunderstorm |
| `spell_lightning_available` | `OnTacticalCommandsAvailable()` ~L960 | Lightning available |
| `spell_meteor_available` | `OnTacticalCommandsAvailable()` ~L963 | Meteor available |
| `spell_holywave_available` | `OnTacticalCommandsAvailable()` ~L965 | Holy Wave available |
| `spell_raisedead_available` | `OnTacticalCommandsAvailable()` ~L969 | Raise Dead available |
| `spell_lightning_cast` / `spell_meteor_cast` / `spell_holywave_cast` / `spell_raisedead_cast` | `CheckTacticalActionType()` ~L830-838 | Spell action observed |
| `ability_charge_used` | `CheckTacticalActionType()` ~L842 | Charge attack executed |
| `terrain_fire_encountered` | `CheckTacticalActionType()` ~L864 | Fire damage / spread |
| `terrain_water_encountered` | `CheckTacticalActionType()` ~L867 | Water crossing |
| `engineer_near_enemy` | `CheckEngineerNearEnemy()` ~L1105 | John Ranil within 3 hexes of enemy |
| `tutorial_reinforcement_engineer` / `tutorial_reinforcement_paladin` | `CheckTacticalActionType()` ~L857 | Reinforcement arrival |
| `friendly_hero_captured` | `CheckHeroRemovals()` ~L919 | Friendly hero unit removed |
| `enemy_hero_captured` | `CheckHeroRemovals()` ~L928 | Enemy hero unit removed |
| `battle_action` | `ShardokGameController.OnBattleAction()` | Any action result |
| `turn_ended` | `ShardokGameController.OnTurnEnded()` ~L1137 | Battle turn ends |
| `shardok_battle_reset` | `ShardokGameController.cs:627` (fired *directly* to `DialogueManager`, bypassing the registry) | Battle retry; the registry's battle flags and the battle dialogue scripts' completed-set are reset alongside, so per-battle tutorials re-fire |
### One-shot semantics
Most contextual triggers are guarded by booleans on the registry (e.g., a `_diplomacyShown` flag). These are session-local — they don't persist via `TutorialState`, but the registry has `ResetBattleTriggerFlags()` for replays. The strategic completion list *does* persist across sessions.
---
## Highlight Targets
Steps reference UI by string ID through `TutorialTargetRegistry`:
1. Static targets are wired in the Inspector on the registry.
2. Dynamic targets register at runtime (`RegisterTarget(id, rectTransform)`).
3. Lookup falls back to `GameObject.Find()` if not registered.
Step options:
- `TargetGameObjectPath` — primary highlight
- `AdditionalHighlightTargets[]` — multiple at once
- `HighlightBoundsFromChildren` — use children's bounding box
- `HighlightPulsing` — strobe animation
Dialogue scripts use the same registry via `highlightTarget` (and `persistHighlight: true` to keep the highlight after the dialogue closes).
---
## Sequences vs. Contextual Dispatch
- **Onboarding sequence** (`IsOnboarding = true`) — Single linear sequence, started by `StartOnboarding()`. Resumes from `OnboardingStepReached`. Counts only visible steps for progress.
- **Contextual** (`IsOnboarding = false`) — Single- or multi-step, dispatched on demand by `TriggerContextualTutorial()`.
- Dispatch rules in `TriggerContextualTutorial()`:
1. No active sequence → show immediately.
2. Active step is hidden (`None`) → interrupt.
3. Both are command tutorials → interrupt.
4. Otherwise → queue.
---
## Dialogue System (Currently Live)
`DialogueManager` is a parallel system, not a step-UI subclass. It loads every `TextAsset` in `Resources/Dialogues/` at startup, parses them as `DialogueScript` objects, and indexes by `trigger`.
Script shape (see `tutorial_strategic.json`):
```json
{
"scripts": [{
"id": "tutorial_opening",
"trigger": "game_started",
"panelPosition": null,
"steps": [{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "...",
"instructionText": "Click Battle! to enter tactical combat.",
"highlightTarget": "GoToBattleButton",
"persistHighlight": true,
"highlightProvince": "Onmaa",
"completionEvent": null
}]
}]
}
```
When a registry trigger fires, `OnGameEvent()` calls `DialogueManager.TriggerDialogue(triggerId)`. If a script matches and hasn't completed, it queues (or shows immediately) and the panel renders speaker headshot + body + instruction. Steps can pause for a `completionEvent` (e.g., wait for the player to click the highlighted button), allowing the dialogue to chain across game state changes.
Dialogue panel position defaults sensibly per scene (`top` during combat) and can be overridden per script.
---
## Persistence
`TutorialState` (PlayerPrefs JSON):
- `OnboardingCompleted` (bool)
- `OnboardingStepReached` (int)
- `CompletedTutorials` (List → HashSet at load)
- `DismissedHints` (List → HashSet at load)
- `AllTutorialsDisabled` (bool)
Reset paths:
- `TutorialState.Reset()` clears everything.
- Entering a tutorial game (`GameType.Tutorial`) resets so it can be replayed.
- Settings → "Reset Tutorials" calls `TutorialManager.ResetAllProgress()`, which also calls `DialogueManager.ResetCompletedScripts()`.
`DialogueManager` tracks completed scripts in memory only — they reset whenever `TutorialState` does.
---
## Bootstrap
1. `TutorialManager` is in the scene as a singleton; `Awake` loads state and creates the trigger registry. `RegisterAll()` is called here but currently no-ops.
2. `ConnectionHandler` sets `IsTutorialGame` and `IsMultiplayerGame` when the game is created/joined.
3. `EagleGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(this, null)`. If `IsTutorialGame && !OnboardingCompleted`, it would call `StartOnboarding()` — currently a no-op because `OnboardingSequence` is null.
4. The first model update raises `game_started`. `DialogueManager` matches `tutorial_opening` from `tutorial_strategic.json` and the dialogue runs.
5. `ShardokGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(null, this)` when battle starts; raises `battle_entered` and battle-only triggers from there.
---
## Expected Tutorial Flow (today)
Driven by `tutorial_strategic.json` + `tutorial_battle.json`. Old Marek the Learned narrates almost everything.
### Narrative arc
You play **Sadar Rakon**, formerly Ikhaan Tarn's most trusted lieutenant. Tarn's behavior grew erratic, the King wouldn't listen, so Sadar broke off and raised the **Reclamation**. He's been pushed back to **Onmaa** for a last stand against Tarn's superior royal army. After surviving, the real twist arrives: a new invasion — **The Fracture Covenant**, led by a mysterious figure called *The Eagle* — lands ships on the western coast and starts taking provinces. Heroes from the King's own service mysteriously begin to desert. The Reclamation's framing pivots from "rebellion" to "the only force that can stop the actual invasion."
### Strategic-map flow (`tutorial_strategic.json`)
| # | Trigger | What happens |
|---|---------|--------------|
| 1 | `game_started` | **Opening monologue** (3 steps): Sadar's backstory, the Reclamation, last stand at Onmaa. Ends highlighting the **Battle!** button (`persistHighlight`) |
| 2 | `tutorial_battle_ended` | **Aftermath**: Tarn has *vanished*; you've captured his lieutenants. Recruit them or hold them |
| 3 | `tutorial_rebuild_support` | **Rebuild Onmaa** (3 steps): Marek frames it; **John Ranil** introduces *Improve* (engineer bonus); **Elena Fyar** introduces *Give Alms* (paladin bonus). Goal: 40 support by January |
| 4 | `tutorial_loyalty_warning` | November-ish, low-loyalty hero may leave at year end → use *Give Gold* / *Feast* |
| 5 | `tutorial_support_deadline` | December nag if support < 40: have Elena give alms now |
| 6 | `guidance_expand` (script `tutorial_expansion`) | Once support is stable: keep developing, taxes coming in January |
| 7 | `tutorial_taxes_collected` | January: gold/food collected. Now expand — March your warlord + most heroes to a neighbor, leave 12 behind |
| 8 | `tutorial_ready_to_join` | A free hero in `{provinceName}` is ready to recruit → Travel + Recruit |
| 9 | `tutorial_in_town` | When you Travel: explains Trade / Arm Troops / Divine / Recruit / Manage Prisoners; *Return* ends the turn |
| 10 | `tutorial_faction_appears` | **The twist**: Fracture Covenant lands at Ingia and Soria. *The Eagle* commands them. Tarn may be with them |
| 11 | `tutorial_hero_departed` | First King's hero abandons service — "something deeper is at work" |
| 12 | `tutorial_hero_departed_again` | Second one in a different month — "something is wrong, I can feel it" (foreshadowing) |
### Tactical battle flow (`tutorial_battle.json`)
| # | Trigger | What happens |
|---|---------|--------------|
| 1 | `shardok_placement_started` | **Placement** (3 steps): identify Tarn's units (knights, heavy infantry, dragoons), color legend (red/blue/black), placement instructions, highlight Commit |
| 2 | `shardok_battle_running` | **Turn 1 strategy** (2 steps): victory conditions for both sides, advice to hold castles and End Turn |
| 3 | `archery_available` | First archery opportunity → purple outlines, longbows vs. armor |
| 4 | `melee_available` | First melee opportunity → right-click adjacent enemy |
| 5 | `ability_charge_available` | Charge mechanics (Marek hams it up: "old scholar without a horse") |
| 6 | `start_fire_available` | Start Fire on enemy hex, fire spread mechanics |
| 7 | `thunderstorm` | Weather: archery disabled, fires extinguished |
| 8 | `tutorial_reinforcement_paladin` | **Elena Fyar arrives** mid-battle (2-step exchange) → Paladin profession intro (Holy Wave) |
| 9 | `tutorial_reinforcement_engineer` | **John Ranil arrives** (2-step exchange) → Engineer profession intro |
| 10 | `engineer_near_enemy` | Once Ranil is close to enemy: Fortify + Reduce (siege bombardment) |
| 11 | `duel_available` | Champion duel mechanics (Marek warns *not* to duel Tarn himself) |
| 12 | `hide_available` | Hedrick the Hedge-merchant: forest/swamp Hide + ambush |
| 13 | `enemy_hero_captured` / `friendly_hero_captured` | Capture mechanics, with `{heroName}` substitution |
| 14 | `shardok_battle_reset` | If you lose and replay: Marek "had the strangest sensation… as though we'd already fought this battle, and lost" — fourth-wall-adjacent retry framing |
Most remaining "Tutorial / first-session onboarding" items in `SMALL_EAGLE_TODO.md` (Shardok tutorial, narrative hook, first-session goal, early small victory, guided first scenario vs. sandbox) map to *adding new dialogue scripts* against existing trigger IDs, or — if the step-UI is revived — to populating `TutorialContentDefinitions.RegisterAll()` and removing the early return.
---
## Notable Behaviors and Gotchas
- **`IsTutorialGame` gates everything.** Outside tutorial games, both subsystems short-circuit early.
- **Combat tutorials are deliberately paced.** Don't expect every available-ability trigger to fire on its first eligible turn — the registry intentionally spaces them.
- **One-shot flags are session-local.** A trigger that "already fired" will not re-fire even if `TutorialState` is reset, until the registry is recreated.
- **`TutorialTargetRegistry` lookups silently fall back to `GameObject.Find`.** Typos in target IDs will render with no highlight rather than throwing.
- **`TutorialContentDefinitions.RegisterAll()` returning early is load-bearing.** If the step UI is revived without auditing trigger overlap, dialogue and step-UI tutorials may double-fire on the same trigger.
- **`Tutorial/TUTORIAL_PLAN.md`** in the Unity project is an older implementation plan and partially overlaps this doc.
+197
View File
@@ -0,0 +1,197 @@
# Unity Scene Separation Plan
## Current State
The Unity client currently has both a large legacy scene and an unfinished split-scene setup.
- `Assets/Gameplay.unity` is still the production-sized monolithic scene. It contains connection/lobby UI, Eagle strategic UI, Shardok tactical UI, shared UI, camera/event-system objects, and miscellaneous persistent controllers.
- `Assets/Scenes/Main.unity`, `Connection.unity`, `Eagle.unity`, `Shardok.unity`, and `Shared.unity` already exist, but they are small shell scenes.
- `ProjectSettings/EditorBuildSettings.asset` includes both `Assets/Gameplay.unity` and the split scenes.
- `Assets/UI/Scripts/BootstrapManager.cs` is explicitly transitional and currently defaults to loading `Gameplay` additively through `useOriginalGameplayScene = true`.
- Additional migration notes exist under `Assets/SCENE_SEPARATION_PLAN.md`, `Assets/Scenes/README.md`, and `Assets/NEXT_STEPS.md`, but there is no repo-level plan in `docs/`.
This means the project is partway through a scene-separation migration, but runtime still depends on the monolithic scene.
## Recommendation
Finish the split-scene architecture. Keep one small bootstrap scene and load mode-specific UI scenes additively.
The target architecture should be:
- `Main`: startup/bootstrap only. Owns scene loading and truly persistent app services.
- `Shared`: shared UI and app-wide interaction infrastructure, such as the event system, error panels, confirmation modals, connection status overlays, settings, and update notifications.
- `Connection`: login, account selection, lobby, custom battle creation, and game-selection UI.
- `Eagle`: strategic map, strategic command panels, province/hero/battalion UI, notifications, and Eagle-specific effects.
- `Shardok`: tactical battle UI, hex grid, battle command UI, unit rows, tactical effects, and battle-only camera/input helpers.
Do not treat large prefabs as inherently bad. A large prefab is acceptable when it represents a coherent, reusable feature root. The problematic pattern is a large scene that owns unrelated application states and long-lived object references.
## Goals
- Reduce merge conflicts and accidental Unity serialization churn.
- Make login/lobby, Eagle, and Shardok changes independently editable.
- Make scene lifecycle explicit: load only the mode currently needed.
- Avoid cross-scene inspector references that are hard to reason about.
- Preserve a safe fallback until each migrated section is validated.
## Non-Goals
- Do not refactor gameplay logic and scene structure in the same change.
- Do not delete `Gameplay.unity` until all migrated scenes are verified in Editor, local builds, and CI.
- Do not move large vendor asset packs as part of this work.
- Do not split every small widget into its own scene. Prefer prefabs for reusable UI components and scenes for application modes.
## Target Runtime Flow
```text
Main
Load Shared
Load Connection
Connection -> Eagle
Unload Connection
Ensure Shared remains loaded
Load Eagle
Eagle -> Shardok
Keep Eagle loaded unless the battle UI must exclusively own the viewport
Load Shardok additively
Shardok -> Eagle
Unload Shardok
Keep Eagle and Shared loaded
Eagle -> Connection
Unload Shardok if loaded
Unload Eagle
Load Connection
```
## Ownership Rules
Use these rules during migration to avoid ambiguous object lifetimes.
- `Main` owns `GameSceneManager`, app bootstrap, and any service object that must survive across login, lobby, Eagle, and Shardok.
- `Shared` owns exactly one active `EventSystem` and shared modal/overlay UI.
- `Connection` owns `ConnectionHandler` and connection/lobby panel instances.
- `Eagle` owns `EagleGameController` and strategic-mode scene roots.
- `Shardok` owns `ShardokGameController` and tactical-mode scene roots.
- Cross-mode communication should happen through model/controller APIs, not direct serialized references to objects in another loadable scene.
- If a scene needs an object from another scene, prefer resolving it during scene initialization and failing loudly when required dependencies are missing.
## Migration Plan
### Phase 1: Make Bootstrap Explicit
- Make `Main.unity` the intended startup scene.
- Update `BootstrapManager` so the split-scene path is the default in development builds.
- Keep a temporary fallback flag for loading `Gameplay.unity`.
- Ensure build settings order is intentional: `Main` first, then loadable scenes. Leave `Gameplay` present only while fallback is still required.
- Add logging that states which startup path was chosen.
Validation:
- Enter Play Mode from `Main.unity`.
- Confirm `Shared` and `Connection` load without duplicate `EventSystem` objects.
- Confirm fallback still loads `Gameplay.unity` if explicitly enabled.
### Phase 2: Migrate Shared Infrastructure
- Move shared error UI, update UI, settings UI, connection status overlays, and global modal roots into `Shared.unity`.
- Keep only one event system active when additive scenes are loaded.
- Remove duplicated shared UI from `Gameplay.unity` only after migrated UI is validated.
Validation:
- Trigger an error modal from Connection, Eagle, and Shardok flows.
- Open/close settings from each mode that supports it.
- Verify no duplicate event-system warnings.
### Phase 3: Migrate Connection and Lobby
- Move `ConnectionCanvas`, login controls, account selector, lobby, custom battle UI, and game-selection rows into `Connection.unity`.
- Make `ConnectionHandler` initialize after the scene is loaded rather than assuming objects exist in `Gameplay.unity`.
- Replace show/hide transitions that activate large inactive subtrees with scene transitions where appropriate.
Validation:
- Start unauthenticated, authenticate, list available games, create/drop a custom battle, and enter a game.
- Return from an active game to the lobby.
- Test stored account flows.
### Phase 4: Migrate Eagle Strategic UI
- Move strategic map, command panel roots, hero/battalion/province UI, Eagle notifications, and strategic effects into `Eagle.unity`.
- Keep reusable row/panel widgets as prefabs under `Assets/Eagle/`.
- Remove direct dependencies on connection-scene objects.
Validation:
- Load a game from the lobby.
- Select provinces, heroes, battalions, command buttons, and notifications.
- Issue at least one representative command from each major command-panel family.
- Confirm generated/dynamic text listeners clean up when leaving the scene.
### Phase 5: Migrate Shardok Tactical UI
- Move Shardok canvas, hex grid, battle panels, tactical rows, battle effects, and battle-only input/camera roots into `Shardok.unity`.
- Decide whether Eagle remains visible/loaded under Shardok. Prefer keeping Eagle loaded if returning from battle must preserve strategic UI state.
- Ensure Shardok scene unload disposes generated text listeners, subscriptions, object pools, and pending animation state.
Validation:
- Enter battle from Eagle.
- Select units, issue movement/attack/fire/extinguish/end-turn commands.
- Resolve a battle and return to Eagle.
- Start a second battle in the same session to catch stale singleton/listener state.
### Phase 6: Remove Legacy Scene Dependency
- Flip the default so `Gameplay.unity` is not loaded during normal startup.
- Remove `Gameplay.unity` from build settings once no supported flow needs it.
- Keep `Gameplay.unity` in the repo temporarily as a reference snapshot if useful, then delete it in a separate cleanup PR.
- Remove transitional helpers whose only purpose was loading or testing the legacy scene.
Validation:
- Local Editor smoke test through login, Eagle, Shardok, and return flows.
- Unity edit-mode test suite if available.
- CI Unity build.
- TestFlight build before deleting the legacy scene.
## Review Checklist For Each Migration PR
- The PR moves one coherent subsystem, not multiple unrelated UI areas.
- Scene files remain small enough to review.
- Prefab changes are limited to the migrated subsystem.
- No required Inspector field is hidden behind a defensive null check.
- No duplicate `EventSystem`, camera, global canvas, or singleton is active.
- Leaving and re-entering the migrated scene works in one app session.
- The old `Gameplay.unity` object is removed only after the new scene object is verified.
## Risks
- Serialized cross-scene references will break when objects move. Prefer runtime lookup/initialization through explicit scene controllers.
- Singletons may survive scene unload and hold stale references. Audit `OnDestroy`, `Dispose`, and listener cleanup for every moved controller.
- Additive scenes can accidentally create duplicate input/event/camera objects. Keep these centralized in `Shared` or `Main`.
- Loading Shardok additively over Eagle can expose hidden assumptions about active canvases, cameras, and selected input targets.
- Unity scene YAML changes are easy to corrupt manually. Prefer moving objects in the Unity Editor and reviewing diffs carefully.
## Open Decisions
- Whether Shardok should overlay Eagle additively or unload most Eagle UI while battle is active.
- Whether the primary persistent game connection object belongs in `Main` or should be recreated per selected game.
- Whether `Shared` should always load at startup or only after successful connection.
- How long to keep `Gameplay.unity` after the split scene flow is fully validated.
## Suggested First PR
The first implementation PR should not move Eagle or Shardok. It should:
1. Make `Main.unity` the startup scene in build settings.
2. Make the split-scene bootstrap path explicit and logged.
3. Load `Shared` plus `Connection` by default.
4. Keep a fallback flag for loading `Gameplay.unity`.
5. Validate login/lobby startup in Editor and CI.
That gives a controlled base for subsequent subsystem migrations.
+173
View File
@@ -0,0 +1,173 @@
# URP Migration Plan
## Why
Unity is removing Built-In Render Pipeline (BiRP) support after Unity 6.7. We're currently on Unity 6.4. Each Unity minor version ships roughly quarterly, giving us a few release cycles before BiRP is dropped. This document lays out the migration plan.
## Strategy: Long-Lived Feature Branch
All URP conversion work happens on a **long-lived `urp-migration` branch**. Main stays fully functional on BiRP throughout the migration.
- **Main branch**: Continues using BiRP. All gameplay, AI, and server development proceeds normally.
- **URP branch**: Accumulates rendering pipeline changes across all phases.
- **Regular rebasing**: Periodically rebase/merge `main` into the URP branch to stay current. Shader and material changes rarely conflict with gameplay code, so merge conflicts should be manageable.
- **Merge to main**: Only when everything renders correctly and visual QA passes.
This avoids the "everything is pink" problem of switching the pipeline on main before shaders are converted.
## Current State
| Category | Count | Notes |
|---|---|---|
| Shaders | 47 | 7 custom Eagle, 1 hex mesh, 20 Polytope, 13 TextMesh Pro, 6 other third-party |
| Materials | 325 | Mix of Standard shader, custom, and third-party |
| ShaderGraph files | 4 | All TextMesh Pro (URP + HDRP variants already exist) |
| Scenes with baked lighting | 6 | Gameplay, Connection, Eagle, Shardok, Shared, Map Editor |
| Post-processing | 1 profile | PPP_Orc.asset (Post Processing Stack v2, FXAA/TAA, AO) |
| C# rendering scripts | 16 | Material/shader property manipulation only |
| Rendering path | Forward | Matches URP default |
### Positive Findings
- **No OnRenderImage, Graphics.Blit, CommandBuffer, or GL.\* usage** in C# code
- Forward rendering already in use (matches URP default)
- TextMesh Pro already has URP ShaderGraph variants in the project
- No custom render passes or ScriptableRenderFeatures
- Shader property manipulation (SetTexture, SetFloat, SetColor) is URP-compatible
### Known Risk Areas
- **GrabPass shaders** (HeatShimmerShader, PT_Water_Shader) have no direct URP equivalent
- **ProvinceMapShader** is complex (province ID lookup, border rendering, ocean animation, faction highlighting, UI clipping)
- **Surface shaders** (`#pragma surface surf Standard`) must be rewritten as HLSL or ShaderGraph
- **Polytope Studio shaders** (20 shaders) have no vendor-provided URP variants
- **Post Processing Stack v2** must be replaced with URP's integrated Volume system
- **Tessellation** (PT_Water_Shader) is not natively supported in URP
## Shader Inventory
### Custom Eagle Shaders (7) -- HIGH PRIORITY
| Shader | Complexity | Key Issues |
|---|---|---|
| ProvinceMapShader | High | Province ID texture lookup, border rendering, ocean animation, faction highlighting, `UnityUI.cginc` dependency, stencil/clipping |
| ProvinceWeatherMapShader | Medium | Weather overlay rendering |
| ProvinceWeatherShader | Medium | Province weather effects |
| ProvinceParticleShader | Low | Custom particle rendering |
| HeatShimmerShader | High | **GrabPass** for screen distortion, province masking |
| ClipRectParticleUnlit | Low | UI-clipped particle shader |
| maskShader | Low | UI masking |
### Hex Mesh Shader (1) -- MODERATE PRIORITY
Uses `#pragma surface surf Standard` with GPU instancing. Straightforward conversion to URP Lit or ShaderGraph.
### Third-Party Shaders (39)
| Source | Count | Complexity | Notes |
|---|---|---|---|
| Polytope Studio | 20 | Moderate-High | PBR, Toon, vegetation (custom lighting), water (**GrabPass + tessellation**). No vendor URP pack available. |
| TextMesh Pro | 13 | Low | URP ShaderGraph variants already exist in project |
| RRFreelance | 3 | Low | Standard surface shaders, direct conversion |
| Clown.fat | 2 | Moderate | Custom ToonRamp lighting model |
| GUI Pro Kit | 1 | Low | Hidden particle shader |
### GrabPass Shaders (Require Special Handling)
GrabPass does not exist in URP. These must be reimplemented using `ScriptableRenderPass` + `Renderer Features` or Blit-based alternatives:
1. **HeatShimmerShader** -- Screen distortion effect (currently has a shimmer-disabled TODO, may be deprioritized)
2. **PT_Water_Shader** -- Water refraction/transparency (PT_Water_Shader_WebGl exists without GrabPass as a reference)
## Phased Approach
### Phase 1: Pipeline Setup + Auto-Conversion (Days 1-3)
- Install URP package
- Create URP Pipeline Asset and Renderer Asset
- Configure basic pipeline settings (forward rendering, shadow settings)
- Run Unity's **Render Pipeline Converter** (Edit > Rendering > Render Pipeline Converter)
- Auto-converts Standard shader materials and some built-in shaders
- Handles a significant portion of the 325 materials
- Will NOT touch custom shaders
- Create a test scene to validate basic rendering
- Migrate viewport clipping system (`RendererViewportClipper`, `ViewportClipper`, `PopupClipper`) to use URP-compatible global shader properties
### Phase 2: Custom Eagle Shaders (Weeks 1-3)
This is the critical path. Without these, the game is unplayable.
**Week 1-2: ProvinceMapShader**
- Convert from BiRP Cg/HLSL to URP HLSL
- Replace `UnityCG.cginc` includes with `Core.hlsl` / `Common.hlsl`
- Replace `UnityUI.cginc` with custom URP-compatible UI clipping
- Preserve province ID texture lookup, border rendering, ocean animation, faction highlighting
- Validate stencil operations and render queue ordering
**Week 2-3: Remaining Eagle shaders**
- ProvinceWeatherMapShader + ProvinceWeatherShader
- ProvinceParticleShader + ClipRectParticleUnlit
- maskShader
- Hex Mesh Shader (convert surface shader to URP Lit)
- HeatShimmerShader (reimplement without GrabPass, or defer if shimmer remains disabled)
### Phase 3: Third-Party Shaders (Weeks 4-6)
**Polytope Studio (20 shaders)**
- Convert PBR shaders (Armors, NPC, Weapons, Props, Rock) from surface shaders to URP Lit / ShaderGraph
- Convert Toon shaders to custom URP shader or ShaderGraph with custom lighting
- Convert vegetation shaders (custom `StandardCustom` lighting) to ShaderGraph
- PT_Water_Shader: Reimplement without GrabPass and tessellation (use PT_Water_Shader_WebGl as reference for non-GrabPass approach)
**Other third-party**
- Clown.fat ToonRamp shaders: Convert custom lighting model to ShaderGraph
- RRFreelance shaders: Direct Standard-to-URP-Lit conversion
- GUI Pro Kit particle shader: Convert to URP particle shader
### Phase 4: Materials, Lighting & Post-Processing (Weeks 7-8)
**Materials**
- Batch-update any remaining materials not handled by the auto-converter
- Verify all 325 materials render correctly
- Fix any visual differences from lighting model changes
**Lighting**
- Rebake lightmaps for all 6 scenes (Gameplay, Connection, Eagle, Shardok, Shared, Map Editor)
- Configure URP shadow cascade settings to match current quality levels
- Verify HDR rendering
**Post-Processing**
- Remove Post Processing Stack v2 dependency (`com.unity.postprocessing: 3.5.1`)
- Replace with URP integrated Volume system
- Recreate PPP_Orc effects (FXAA/TAA, Ambient Occlusion) using URP Volume overrides
### Phase 5: Testing & Validation (Weeks 9-11)
- Visual QA across all 6 scenes
- Verify beast/character rendering (vertex colors, animations)
- Verify weather effects (blizzard, drought, flood)
- Verify particle systems (fire & explosion effects, UI particles)
- Verify UI rendering (GUI Pro Kit, Modern UI Pack, TextMesh Pro)
- Performance profiling (URP has different performance characteristics)
- Test on target platforms
- Bug fixes and visual polish
## Effort Estimates
| Phase | Best Case | Realistic | Worst Case |
|---|---|---|---|
| 1. Pipeline Setup | 1-2 days | 2-3 days | 3-5 days |
| 2. Custom Eagle Shaders | 1.5 weeks | 2-3 weeks | 3-4 weeks |
| 3. Third-Party Shaders | 1.5 weeks | 2-3 weeks | 3-4 weeks |
| 4. Materials & Lighting | 3-5 days | 1-1.5 weeks | 2 weeks |
| 5. Testing & Validation | 1 week | 1.5-2 weeks | 2-3 weeks |
| **Total** | **6-8 weeks** | **8-12 weeks** | **12-16 weeks** |
The biggest variable is Polytope Studio shader conversion. If vendor URP packs become available, Phase 3 shrinks significantly.
## Key Reminders
- **Don't leave GrabPass rewrites and ProvinceMapShader for the end** -- they're the riskiest pieces and should be tackled early.
- The migration window (~1 year) is comfortable. Spreading the work across this window is fine, but front-load the hard shader work.
- C# code changes should be minimal -- the codebase avoids direct rendering API usage.
- `UnityUI.cginc` has no direct URP equivalent. Custom implementation will be needed for UI clipping in shaders.
+119 -51
View File
@@ -4,17 +4,36 @@ This document describes which quests the AI attempts to complete proactively to
## Overview
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
The AI attempts to complete most quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`.
Important scope limits:
- The selector only considers quests from unaffiliated heroes in provinces owned by the acting faction.
- It further filters to provinces ruled by that faction's leader.
- It only emits a command when the corresponding command is currently available.
- Handler order is fixed. The first handler that produces a valid command wins.
## Quests the AI Actively Completes
This section lists quests with direct `QuestCommandChooser` handlers in `FulfillQuestsCommandSelector`.
### Diplomacy Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `BetrayAllyQuest` | `BetrayAllyQuestCommandChooser` | Break alliance with target faction. Only if factions don't share a border. Sends weakest non-leader hero. |
### Province Development Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
| `TotalDevelopmentQuest` | `TotalDevelopmentQuestCommandChooser` | Improve the lowest stat in the target province |
### Resource Giving Quests
@@ -24,72 +43,99 @@ The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
| `SpendOnFeastsInProvinceQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on feasts in the quest holder's province |
| `SpendOnFeastsAcrossRealmQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on the most expensive currently available feast |
| `SendSuppliesQuest` | `SendSuppliesQuestCommandChooser` | Send food to the target province |
### Province Development Quests
### Prisoner Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
| `ReleasePrisonerQuest` | `ReleasePrisonerQuestCommandChooser` | Release a specific prisoner |
| `ExilePrisonerQuest` | `ExilePrisonerQuestCommandChooser` | Exile a specific prisoner |
| `ExecutePrisonerQuest` | `ExecutePrisonerQuestCommandChooser` | Execute a specific prisoner |
| `ReturnPrisonerQuest` | `ReturnPrisonerQuestCommandChooser` | Return a prisoner to their faction |
| `ReleaseAllPrisonersQuest` | `ReleaseAllPrisonersQuestCommandChooser` | Release all prisoners |
### Province Order Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `DevelopProvincesQuest` | `DevelopProvincesQuestCommandChooser` | Switches provinces to Develop order. Prefers provinces without hostile neighbors. |
| `MobilizeProvincesQuest` | `MobilizeProvincesQuestCommandChooser` | Switches provinces to Mobilize order. Prefers provinces with hostile neighbors. Skipped if a DevelopProvincesQuest also exists (conflict avoidance). |
### Weather/Epidemic Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `StartEpidemicQuest` | `StartEpidemicQuestCommandChooser` | Start an epidemic in a province. Skipped if the target province belongs to the acting faction. |
| `StartBlizzardQuest` | `ControlWeatherQuestCommandChooser` | Start a blizzard via ControlWeather command. Skipped if the target province belongs to the acting faction. |
| `StartDroughtQuest` | `ControlWeatherQuestCommandChooser` | Start a drought via ControlWeather command. Skipped if the target province belongs to the acting faction. |
### Military Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `GrandArmyQuest` | `GrandArmyQuestCommandChooser` | Uses OrganizeTroops to top off existing battalions and hire Light Infantry. Only executes if resources are sufficient to fully meet the troop target. |
| `BattalionDiversityQuest` | `BattalionDiversityQuestCommandChooser` | Hires one battalion of a missing type to reach 3+ distinct types. Only attempts if the province already has 2+ existing types, has sufficient development (`meetsRequirements`), and gold/food surplus. |
| `UpgradeBattalionQuest` | `UpgradeBattalionQuestCommandChooser` | Walks a priority tree per turn: arm-completes (with optional travel and/or food sale setup) → train-completes → march in a pre-qualified neighbor battalion → organize/top-off of the target type → march in an unqualified neighbor battalion → develop Economy/Agriculture → train progress → develop Infrastructure → arm progress. Only toggles Travel when it directly enables an arm-completes finisher. |
### Reconnaissance Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ReconSpecificProvincesQuest` | `ReconSpecificProvincesQuestCommandChooser` | Recon specific target provinces. Prefers not-yet-reconned targets. |
| `ReconProvincesQuest` | `ReconProvincesQuestCommandChooser` | Issues a Recon command to reconnoiter a province |
### Beast Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `FightBeastsAloneQuest` | `FightBeastsAloneQuestCommandChooser` | Send an expendable non-leader hero to fight beasts without a battalion. Hero must have power at most `MaxExpendableHeroPowerRatio` (default 0.75) times the quest-giving hero's power. Picks the weakest eligible hero. |
### Special Event Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ApprehendOutlawQuest` | `ApprehendOutlawQuestCommandChooser` | Apprehend a specific outlaw hero when present in the province |
### Other Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `RestProvinceQuest` | `RestProvinceQuestCommandChooser` | Use the Rest command in a specific province |
| `SwearBrotherhoodWithHeroQuest` | `SwearBrotherhoodQuestCommandChooser` | Swear brotherhood with a specific hero |
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
## Quests the AI Does Not Attempt to Complete
## Quests the AI Handles Indirectly
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
These are not in `FulfillQuestsCommandSelector`, but other AI command-selection code can still bias toward completing them.
### Combat/Military Quests
- `DefeatFactionQuest` - Defeat a specific faction
- `GrandArmyQuest` - Accumulate a large number of troops
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
- `WinBattlesQuest` - Win a number of battles
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
| Quest | Handler | Notes |
|-------|---------|-------|
| `SuppressRiotByForceQuest` | `CommandChoiceHelpers.handleRiotSelectedCommand` | When this quest exists and the faction has battalions, the AI prefers CrackDown over Give when handling riots. |
### Expansion Quests
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
- `SpecificExpansionQuest` - Conquer a specific province
- `BorderSecurityQuest` - Have troops in a border province
## Quests the AI Does Not Yet Attempt to Complete
### Prisoner Quests
- `ExecutePrisonerQuest` - Execute a specific prisoner
- `ExilePrisonerQuest` - Exile a specific prisoner
- `ReleasePrisonerQuest` - Release a specific prisoner
- `ReturnPrisonerQuest` - Return a prisoner to their faction
- `ReleaseAllPrisonersQuest` - Release all prisoners
The following quests have no handler and must be completed naturally through gameplay. They are listed in rough priority order for future implementation.
### Province Order Quests
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
- `RestProvinceQuest` - Use the Rest command in a specific province
### Planned for Implementation
### Reconnaissance Quests
- `ReconProvincesQuest` - Reconnoiter a number of provinces
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
None currently planned.
### Economic Quests
- `TotalDevelopmentQuest` - Achieve total development level in a province
- `WealthQuest` - Accumulate gold and food
- `SpendOnFeastsQuest` - Spend gold on feasts
- `SendSuppliesQuest` - Send food to a specific province
- `RepairDevastationQuest` - Repair devastation
### Not Planned
### Special Event Quests
- `SuppressRiotByForceQuest` - Suppress a riot by force
- `FightBeastsAloneQuest` - Fight beasts alone
- `StartBlizzardQuest` - Start a blizzard in a province
- `StartEpidemicQuest` - Start an epidemic in a province
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
These quests are too situational, passive, or risky to actively pursue. They may be completed naturally through gameplay.
### Miscellaneous
- `BattalionDiversityQuest` - Have diverse battalion types
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
- `BetrayAllyQuest` - Betray an allied faction
- `BorderSecurityQuest` - Have troops in a border province. Involves taking provinces; better left to organic expansion.
- `DefeatFactionQuest` - Defeat a specific faction. Too risky to pursue proactively.
- `SpecificExpansionQuest` - Conquer a specific province. Too risky.
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces. Too risky.
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered. Can't reliably engineer.
- `WinBattlesQuest` - Win a number of battles. Passive.
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader. Complex multi-step.
- `WealthQuest` - Accumulate gold and food. Happens passively.
- `RepairDevastationQuest` - Repair devastation. Happens passively.
## Implementation Details
@@ -102,12 +148,34 @@ Each chooser extends either:
- `DeterministicQuestCommandChooser` - For quests with deterministic command selection
The AI prioritizes quests in the order they appear in `FulfillQuestsCommandSelector.choosers`:
1. Alliance
2. TruceWithFaction
3. Improve (Agriculture/Economy/Infrastructure)
1. TruceWithFaction
2. Improve (Agriculture/Economy/Infrastructure)
3. TotalDevelopment
4. AlmsToProvince
5. GiveToHeroesInProvince
6. AlmsAcrossRealm
7. GiveToHeroesAcrossRealm
8. TruceCount
9. DismissSpecificVassal
10. ReleasePrisoner
11. ExilePrisoner
12. ExecutePrisoner
13. ReturnPrisoner
14. ReleaseAllPrisoners
15. ApprehendOutlaw
16. SpendOnFeastsInProvince / SpendOnFeastsAcrossRealm
17. RestProvince
18. DevelopProvinces
19. MobilizeProvinces
20. SendSupplies
21. StartEpidemic
22. ControlWeather (StartBlizzard/StartDrought)
23. GrandArmy
24. FightBeastsAlone
25. BattalionDiversity
26. UpgradeBattalion
27. ReconSpecificProvinces
28. ReconProvinces
29. SwearBrotherhood
30. Alliance
31. BetrayAlly
+829
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -4,8 +4,10 @@
set -e
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
# Run gazelle
bazel run //:gazelle 2>/dev/null
bazel run //:gazelle
# Check if any BUILD files were modified
if ! git diff --quiet -- '*.bazel' '**/BUILD' 'WORKSPACE*'; then
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# Pre-commit hook wrapper for scalafmt.
# Runs the repository-pinned scalafmt CLI through Bazel.
set -e
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
if [ "$#" -eq 0 ]; then
exit 0
fi
repo_root="$(git rev-parse --show-toplevel)"
files=()
for file in "$@"; do
case "$file" in
/*) files+=("$file") ;;
*) files+=("$repo_root/$file") ;;
esac
done
bazel run //tools:scalafmt -- --config "$repo_root/.scalafmt.conf" -i "${files[@]}"
+27 -14
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env bash
# Keeps Bazel mactools builds in sync with the installed Xcode version.
# Keeps Bazel macOS builds in sync with the installed Xcode version.
#
# Only needed on runners that do mactools builds (Mac/Sparkle). Non-Mac builds
# don't run this script; their apple_cc_autoconf stays cached because none of
# its environ vars change, so they're unaffected by Xcode version changes.
# Needed on macOS runners and developer machines because Bazel's generated
# local_config_xcode/local_config_apple_cc repositories bake in the discovered
# Xcode version. If Xcode is upgraded in place, the DEVELOPER_DIR path stays the
# same, so Bazel can otherwise keep using stale Xcode metadata.
#
# 1. Writes .bazelrc.xcode with mactools-scoped flags:
# 1. Writes .bazelrc.xcode with macOS-scoped flags:
# - action_env for remote cache key invalidation
# - DEVELOPER_DIR pointing to the active Xcode
#
@@ -17,17 +18,24 @@
set -euo pipefail
XCODE_BUILD_VERSION=$(xcodebuild -version 2>/dev/null | awk '/Build version/ {print $3}')
if [ -z "$XCODE_BUILD_VERSION" ]; then
echo "Warning: Could not detect Xcode build version, skipping"
exit 0
DEVELOPER_DIR=""
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
else
DEVELOPER_DIR=$(xcode-select -p 2>/dev/null || true)
fi
DEVELOPER_DIR=$(xcode-select -p 2>/dev/null)
XCODE_BUILD_VERSION=$(DEVELOPER_DIR="$DEVELOPER_DIR" xcodebuild -version 2>/dev/null | awk '/Build version/ {print $3}' || true)
if [ -z "$XCODE_BUILD_VERSION" ]; then
echo "ERROR: Could not detect full Xcode build version."
echo "mactools/Sparkle builds require full Xcode, not just Command Line Tools."
echo "Install Xcode at /Applications/Xcode.app or select it with xcode-select."
exit 1
fi
BAZELRC_XCODE=".bazelrc.xcode"
EXPECTED_LINE="common:mactools --action_env=XCODE_BUILD_VERSION=${XCODE_BUILD_VERSION}"
EXPECTED_LINE="common:macos --action_env=XCODE_BUILD_VERSION=${XCODE_BUILD_VERSION}"
# Check if the file already has the right version (first line is the version marker)
if [ -f "$BAZELRC_XCODE" ]; then
@@ -49,6 +57,11 @@ bazel clean --expunge 2>/dev/null || true
cat > "$BAZELRC_XCODE" << EOF
${EXPECTED_LINE}
common:mactools --repo_env=DEVELOPER_DIR=${DEVELOPER_DIR}
common:macos --repo_env=DEVELOPER_DIR=${DEVELOPER_DIR}
EOF
echo "Updated ${BAZELRC_XCODE} — next mactools build will rebuild with Xcode ${XCODE_BUILD_VERSION}"
if [ -n "${GITHUB_ENV:-}" ]; then
echo "DEVELOPER_DIR=${DEVELOPER_DIR}" >> "$GITHUB_ENV"
fi
echo "Updated ${BAZELRC_XCODE} — next macOS Bazel build will rebuild with Xcode ${XCODE_BUILD_VERSION}"
@@ -66,6 +66,14 @@ cc_library(
],
)
cc_library(
name = "shardok_latency_trace",
srcs = ["ShardokLatencyTrace.cpp"],
hdrs = ["ShardokLatencyTrace.hpp"],
copts = COPTS,
visibility = ["//visibility:public"],
)
cc_library(
name = "repeated_proto_utils",
srcs = [],
@@ -88,6 +88,11 @@ StdLibraryGenerator::StdLibraryGenerator() : RandomGenerator() {
engine.seed(static_cast<std::mt19937_64::result_type>(std::time(nullptr)));
}
StdLibraryGenerator::StdLibraryGenerator(const std::mt19937_64::result_type seed)
: RandomGenerator() {
engine.seed(seed);
}
auto StdLibraryGenerator::IntBetween(const int min, const int max) -> int {
std::uniform_int_distribution<int> unifInt(min, max - 1);
return unifInt(engine);
@@ -51,6 +51,7 @@ private:
public:
StdLibraryGenerator();
explicit StdLibraryGenerator(std::mt19937_64::result_type seed);
auto IntBetween(int min, int max) -> int override; // inclusive, exclusive (min<=return<max)
};
@@ -0,0 +1,101 @@
#include "ShardokLatencyTrace.hpp"
#include <cstdlib>
#include <iostream>
#include <mutex>
#include <sstream>
namespace net::eagle0::common {
namespace {
auto ReadEnvEnabled() -> bool {
const char* value = std::getenv("EAGLE0_SHARDOK_LATENCY_TRACE");
if (value == nullptr) { return false; }
const std::string text(value);
return text == "1" || text == "true" || text == "TRUE" || text == "yes" || text == "YES";
}
auto TraceEnabled() -> bool {
static const bool enabled = ReadEnvEnabled();
return enabled;
}
auto ReadGameFilter() -> std::string {
const char* value = std::getenv("EAGLE0_SHARDOK_LATENCY_GAME_ID");
return value == nullptr ? "" : std::string(value);
}
auto GameFilter() -> const std::string& {
static const std::string filter = ReadGameFilter();
return filter;
}
auto JsonEscape(std::string_view value) -> std::string {
std::ostringstream out;
for (const char c : value) {
switch (c) {
case '"': out << "\\\""; break;
case '\\': out << "\\\\"; break;
case '\n': out << "\\n"; break;
case '\r': out << "\\r"; break;
case '\t': out << "\\t"; break;
default: out << c; break;
}
}
return out.str();
}
auto LogMutex() -> std::mutex& {
static auto mutex = new std::mutex();
return *mutex;
}
} // namespace
auto ShardokLatencyTraceEnabledForGame(std::string_view gameId) -> bool {
if (!TraceEnabled()) { return false; }
const std::string& filter = GameFilter();
return filter.empty() || filter == gameId;
}
void EmitShardokLatencyTrace(
std::string_view event,
std::string_view gameId,
const ShardokLatencyFields& fields,
std::chrono::steady_clock::duration duration) {
if (!ShardokLatencyTraceEnabledForGame(gameId)) { return; }
const auto durationMs =
std::chrono::duration_cast<std::chrono::microseconds>(duration).count() / 1000.0;
std::ostringstream out;
out << "{\"event\":\"shardok_latency\","
<< "\"component\":\"shardok\","
<< "\"span\":\"" << JsonEscape(event) << "\","
<< "\"game_id\":\"" << JsonEscape(gameId) << "\","
<< "\"duration_ms\":" << durationMs;
for (const auto& [key, value] : fields) {
out << ",\"" << JsonEscape(key) << "\":\"" << JsonEscape(value) << "\"";
}
out << "}";
std::scoped_lock lock(LogMutex());
std::cerr << out.str() << std::endl;
}
ScopedShardokLatencyTrace::ScopedShardokLatencyTrace(
std::string event,
std::string gameId,
ShardokLatencyFields fields)
: event_(std::move(event)),
gameId_(std::move(gameId)),
fields_(std::move(fields)) {
if (ShardokLatencyTraceEnabledForGame(gameId_)) { start_ = std::chrono::steady_clock::now(); }
}
ScopedShardokLatencyTrace::~ScopedShardokLatencyTrace() {
if (!start_.has_value()) { return; }
EmitShardokLatencyTrace(event_, gameId_, fields_, std::chrono::steady_clock::now() - *start_);
}
} // namespace net::eagle0::common
@@ -0,0 +1,42 @@
#pragma once
#include <chrono>
#include <optional>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace net::eagle0::common {
using ShardokLatencyFields = std::vector<std::pair<std::string, std::string>>;
auto ShardokLatencyTraceEnabledForGame(std::string_view gameId) -> bool;
void EmitShardokLatencyTrace(
std::string_view event,
std::string_view gameId,
const ShardokLatencyFields& fields,
std::chrono::steady_clock::duration duration);
class ScopedShardokLatencyTrace {
public:
ScopedShardokLatencyTrace(
std::string event,
std::string gameId,
ShardokLatencyFields fields = {});
~ScopedShardokLatencyTrace();
ScopedShardokLatencyTrace(const ScopedShardokLatencyTrace&) = delete;
auto operator=(const ScopedShardokLatencyTrace&) -> ScopedShardokLatencyTrace& = delete;
ScopedShardokLatencyTrace(ScopedShardokLatencyTrace&&) = delete;
auto operator=(ScopedShardokLatencyTrace&&) -> ScopedShardokLatencyTrace& = delete;
private:
std::string event_;
std::string gameId_;
ShardokLatencyFields fields_;
std::optional<std::chrono::steady_clock::time_point> start_;
};
} // namespace net::eagle0::common
@@ -21,7 +21,14 @@ namespace shardok::mcts {
AbstractMCTSAI::AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config)
: playerId_(playerId),
config_(config) {}
config_(config),
randomEngine_(config.randomSeed == 0 ? std::random_device{}() : config.randomSeed) {}
void AbstractMCTSAI::SetConfig(const MCTSConfig& newConfig) {
config_ = newConfig;
std::lock_guard lock(randomEngineMutex_);
randomEngine_.seed(newConfig.randomSeed == 0 ? std::random_device{}() : newConfig.randomSeed);
}
auto AbstractMCTSAI::Search(
const MCTSGameEngine& engine,
@@ -350,22 +357,19 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
// fair UCB comparison with non-chance actions like END_TURN
{
double expectedImmediate = 0.0;
double totalProbability = 0.0;
for (size_t i = 0; i < node->children.size(); i++) {
for (size_t i = 0; i < node->outcomeProbabilities.size(); i++) {
const double prob = node->outcomeProbabilities[i];
const double childImmediate = node->children[i]->immediateScore;
const double childImmediate = i < node->children.size()
? node->children[i]->immediateScore
: node->unvisitedOutcomeScore;
expectedImmediate += prob * childImmediate;
totalProbability += prob;
}
// Normalize by total probability of expanded outcomes
if (totalProbability > 0.0) {
node->immediateScore = expectedImmediate / totalProbability;
// CRITICAL: Always update lookaheadScore to the expected value.
// Without this, chance nodes keep their initial lookaheadScore from the parent
// state (before the action), while regular actions use the child state (after).
// This gives chance nodes an unfair initial UCB advantage.
node->lookaheadScore = node->immediateScore;
}
node->immediateScore = expectedImmediate;
// CRITICAL: Always update lookaheadScore to the expected value.
// Without this, chance nodes keep their initial lookaheadScore from the parent
// state (before the action), while regular actions use the child state (after).
// This gives chance nodes an unfair initial UCB advantage.
node->lookaheadScore = node->immediateScore;
}
return node->children.back().get();
@@ -396,8 +400,14 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
const auto& action = nodeActions[actionIndex];
const double actionWeight =
actionIndex < actionWeights.size() ? actionWeights[actionIndex] : 1.0;
double totalActionWeight = 0.0;
for (const double weight : actionWeights) { totalActionWeight += std::max(0.0, weight); }
const double rawActionWeight =
actionIndex < actionWeights.size() ? std::max(0.0, actionWeights[actionIndex]) : 1.0;
const double actionWeight = totalActionWeight > 0.0
? rawActionWeight / totalActionWeight
: 1.0 / static_cast<double>(nodeActions.size());
// Check if this action requires a chance node
if (action->requiresChanceNode()) {
@@ -417,15 +427,16 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
// Get outcome information from engine
const auto outcomeInfo = engine.getBinaryOutcomeInfo(*node->gameState, *action);
// Set up outcome metadata (2 outcomes for binary actions)
// Set up outcome metadata.
chanceNode->outcomeProbabilities = outcomeInfo.getProbabilities();
chanceNode->outcomeRolls = outcomeInfo.getRepresentativeRolls();
chanceNode->totalActions = 2; // Binary: success and failure
chanceNode->totalActions = chanceNode->outcomeProbabilities.size();
// Chance node immediate score will be computed as expected value during backpropagation
// For now, initialize to parent's score as a reasonable default
chanceNode->immediateScore = engine.evaluateState(*node->gameState, playerId_);
chanceNode->lookaheadScore = chanceNode->immediateScore;
chanceNode->unvisitedOutcomeScore = chanceNode->immediateScore;
// Set parent and add to children
chanceNode->parent = node;
@@ -602,29 +613,20 @@ auto AbstractMCTSAI::MCTSBackpropagation(
// Chance nodes: compute expected value (weighted average of outcomes)
// lookaheadScore = sum(probability[i] * childValue[i])
double expectedValue = 0.0;
double totalProbability = 0.0;
int visitedChildCount = 0;
for (size_t i = 0; i < node->children.size(); i++) {
const auto& child = node->children[i];
if (child->visitCount == 0) continue; // Unvisited outcomes don't contribute
bool hasVisitedChild = false;
for (size_t i = 0; i < node->outcomeProbabilities.size(); i++) {
const double probability = node->outcomeProbabilities[i];
const double childValue = child->lookaheadScore;
double childValue = node->unvisitedOutcomeScore;
if (i < node->children.size() && node->children[i]->visitCount > 0) {
childValue = node->children[i]->lookaheadScore;
hasVisitedChild = true;
}
expectedValue += probability * childValue;
totalProbability += probability;
visitedChildCount++;
}
// Use expected value if we have visited outcomes, else use average
if (visitedChildCount > 0) {
// CRITICAL: Normalize by total probability to get correct expected value
// when not all outcomes have been visited yet
if (totalProbability > 0.0 && totalProbability < 1.0) {
// Normalize to account for unvisited outcomes
// This gives the correct expected value among visited outcomes
expectedValue /= totalProbability;
}
if (hasVisitedChild) {
node->lookaheadScore = expectedValue;
} else {
// No outcomes visited yet, fall back to average
@@ -705,23 +707,21 @@ auto AbstractMCTSAI::SelectSimulationAction(
"MCTS tree building or game state");
}
thread_local std::mt19937 gen(std::random_device{}());
switch (config_.simulationPolicy) {
case MCTSSimulationPolicy::RANDOM: {
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
return dis(gen);
return SampleDistribution(dis);
}
case MCTSSimulationPolicy::FILTERED_RANDOM: {
if (const auto filteredIndices = engine.filterActions(actions, state);
!filteredIndices.empty()) {
std::uniform_int_distribution<size_t> dis(0, filteredIndices.size() - 1);
return filteredIndices[dis(gen)];
return filteredIndices[SampleDistribution(dis)];
}
// Fall back to random
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
return dis(gen);
return SampleDistribution(dis);
}
case MCTSSimulationPolicy::BEST_IMMEDIATE: {
@@ -790,7 +790,7 @@ auto AbstractMCTSAI::SelectSimulationAction(
// Select based on weights
std::discrete_distribution<> dis(weights.begin(), weights.end());
return scores[dis(gen)].first;
return scores[SampleDistribution(dis)].first;
}
case MCTSSimulationPolicy::WEIGHTED_HEURISTIC: {
@@ -820,13 +820,13 @@ auto AbstractMCTSAI::SelectSimulationAction(
// Select based on heuristic weights using discrete_distribution
std::discrete_distribution<> dis(validWeights.begin(), validWeights.end());
return validIndices[dis(gen)];
return validIndices[SampleDistribution(dis)];
}
}
// Default to random
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
return dis(gen);
return SampleDistribution(dis);
}
auto AbstractMCTSAI::FindNodeAtDepthWithHash(
@@ -7,6 +7,8 @@
#include <chrono>
#include <memory>
#include <mutex>
#include <random>
#include <unordered_map>
#include <vector>
@@ -41,7 +43,7 @@ public:
// Configuration
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return config_; }
void SetConfig(const MCTSConfig& newConfig) { config_ = newConfig; }
void SetConfig(const MCTSConfig& newConfig);
[[nodiscard]] auto FindNodeAtDepthWithHash(
const MCTSNode* root,
@@ -51,6 +53,8 @@ public:
private:
MCTSPlayerId playerId_;
MCTSConfig config_;
mutable std::mt19937 randomEngine_;
mutable std::mutex randomEngineMutex_;
// Transposition table: maps state hash -> minimum depth at which state was reached
// Used to detect and penalize longer paths to the same game state
@@ -85,6 +89,13 @@ private:
const std::vector<std::unique_ptr<MCTSAction>>& actions,
bool isMaximizing) const -> size_t;
template<typename Distribution>
[[nodiscard]] auto SampleDistribution(Distribution& distribution) const ->
typename Distribution::result_type {
std::lock_guard lock(randomEngineMutex_);
return distribution(randomEngine_);
}
// Logging
static auto LogSearchResults(
const MCTSNode* rootNode,
@@ -103,4 +114,4 @@ private:
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_ABSTRACT_MCTSAI_HPP
#endif // EAGLE0_ABSTRACT_MCTSAI_HPP
@@ -6,6 +6,7 @@ cc_library(
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:__pkg__",
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
],
@@ -54,6 +54,7 @@ struct MCTSNode {
// Chance node specific fields (only used when nodeType == CHANCE)
std::vector<double> outcomeProbabilities; // Probability of each outcome
std::vector<double> outcomeRolls; // Representative roll for each outcome
double unvisitedOutcomeScore = 0.0; // Default value for outcomes not expanded yet
// Game context
MCTSPlayerId playerId;
@@ -274,4 +275,4 @@ struct MCTSNode {
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_ABSTRACT_MCTSNODE_HPP
#endif // EAGLE0_ABSTRACT_MCTSNODE_HPP
@@ -51,10 +51,11 @@ struct MCTSConfig {
// When evaluating a leaf at playerFlips < maxSimulationFlips,
// simulate forward to this phase for fair comparison
// (default 0 = evaluate leaves as-is, backward compatible)
uint32_t randomSeed = 0; // 0 = seed nondeterministically
std::string debugDumpPath = ""; // If non-empty, dump MCTS tree to this file path
};
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_MCTS_TYPES_HPP
#endif // EAGLE0_MCTS_TYPES_HPP
@@ -54,6 +54,12 @@ static auto IsDeterministic(const CommandType type) -> bool {
}
}
static auto RandomnessSampleForRepeat(const int repeatIteration, const int maxRepeatCount)
-> double {
if (maxRepeatCount <= 1) { return 0.5; }
return static_cast<double>(repeatIteration) / static_cast<double>(maxRepeatCount - 1);
}
// Helper function to sort commands by score
static auto CommandSorter(
const AICommandEvaluator::IndexAndScore& l,
@@ -85,15 +91,15 @@ auto AICommandEvaluator::PerformLookahead(
const ScoreValue currentUtility,
const AIStrategy& attackerStrategy,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue> {
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult> {
// Check transposition table before expensive computation
auto cachedScore =
g_transpositionTable.probe(innerEngine->GetCurrentGameState(), remainingLookahead, pid);
if (cachedScore.has_value()) {
// Return cached result immediately
std::promise<ScoreValue> p;
p.set_value(*cachedScore);
std::promise<EvaluationResult> p;
p.set_value(EvaluationResult{.score = *cachedScore, .completed = true});
return p.get_future();
}
const auto nextUtility = currentUtility;
@@ -105,8 +111,8 @@ auto AICommandEvaluator::PerformLookahead(
// table
g_transpositionTable.store(innerEngine->GetCurrentGameState(), 1, pid, nextUtility);
std::promise<ScoreValue> p;
p.set_value(nextUtility);
std::promise<EvaluationResult> p;
p.set_value(EvaluationResult{.score = nextUtility, .completed = true});
return p.get_future();
}
@@ -131,16 +137,18 @@ auto AICommandEvaluator::PerformLookahead(
innerEngine,
pid,
nextUtility,
remainingLookahead]() mutable -> ScoreValue {
const auto [index, type, lookaheadScore, immediateScore] =
bestCommandFuture.get();
remainingLookahead]() mutable -> EvaluationResult {
const auto bestCommand = bestCommandFuture.get();
if (!bestCommand.completed) {
return EvaluationResult{.score = nextUtility, .completed = false};
}
ScoreValue resultScore;
if (auto& nextCommand =
innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(index);
if (auto& nextCommand = innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(
bestCommand.index);
nextCommand->GetCommandType() !=
net::eagle0::shardok::common::END_TURN_COMMAND) {
resultScore = immediateScore;
resultScore = bestCommand.immediateScore;
} else {
resultScore = nextUtility;
}
@@ -152,7 +160,7 @@ auto AICommandEvaluator::PerformLookahead(
pid,
resultScore);
return resultScore;
return EvaluationResult{.score = resultScore, .completed = true};
});
}
@@ -160,8 +168,8 @@ auto AICommandEvaluator::PerformLookahead(
g_transpositionTable
.store(innerEngine->GetCurrentGameState(), remainingLookahead, pid, nextUtility);
std::promise<ScoreValue> p;
p.set_value(nextUtility);
std::promise<EvaluationResult> p;
p.set_value(EvaluationResult{.score = nextUtility, .completed = true});
return p.get_future();
}
@@ -180,10 +188,10 @@ auto AICommandEvaluator::EvaluateWithRandomness(
// Check if we've exceeded the deadline
if (std::chrono::steady_clock::now() > deadline) {
// Return with a default score and an empty future that resolves immediately
std::promise<ScoreValue> p;
p.set_value(0.0); // Default timeout score
std::promise<EvaluationResult> p;
p.set_value(EvaluationResult{.score = 0.0, .completed = false});
returnValue.immediateScore = 0.0;
returnValue.completed = false;
returnValue.lookaheadScore = p.get_future();
return returnValue;
}
@@ -198,11 +206,12 @@ auto AICommandEvaluator::EvaluateWithRandomness(
allCastleCoords);
returnValue.immediateScore = innerUtility;
returnValue.completed = true;
if (remainingLookahead <= 0) {
std::promise<ScoreValue> p;
std::promise<EvaluationResult> p;
returnValue.lookaheadScore = p.get_future();
p.set_value(innerUtility);
p.set_value(EvaluationResult{.score = innerUtility, .completed = true});
} else {
auto lookaheadLambda = [this,
pid,
@@ -213,7 +222,7 @@ auto AICommandEvaluator::EvaluateWithRandomness(
attackerStrategy,
innerUtility,
&allCastleCoords,
deadline]() -> ScoreValue {
deadline]() -> EvaluationResult {
auto lookaheadFuture = PerformLookahead(
pid,
isDefender,
@@ -231,7 +240,7 @@ auto AICommandEvaluator::EvaluateWithRandomness(
auto launchPolicy = remainingLookahead == 1 ? std::launch::async : std::launch::deferred;
returnValue.lookaheadScore = std::async(launchPolicy, lookaheadLambda);
#else
std::promise<ScoreValue> p;
std::promise<EvaluationResult> p;
returnValue.lookaheadScore = p.get_future();
auto lambdaResult = lookaheadLambda();
p.set_value(lambdaResult);
@@ -316,7 +325,8 @@ auto AICommandEvaluator::FindBestCommand(
size_t index;
CommandType type;
ScoreValue immediateScore;
std::vector<std::future<ScoreValue>> lookaheadFutures;
bool immediateCompleted = true;
std::vector<std::future<EvaluationResult>> lookaheadFutures;
};
std::vector<CommandEvaluation> commandEvaluations(commandCount);
@@ -330,12 +340,12 @@ auto AICommandEvaluator::FindBestCommand(
commandEvaluations[index].type = guessedCommandType;
if (guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
std::promise<ScoreValue> p;
std::promise<EvaluationResult> p;
commandEvaluations[index].lookaheadFutures.push_back(p.get_future());
p.set_value(currentUtility);
p.set_value(EvaluationResult{.score = currentUtility, .completed = true});
commandEvaluations[index].immediateScore = currentUtility;
} else if (IsDeterministic(guessedCommandType)) {
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
auto evaluation = EvaluateWithRandomness(
pid,
isDefender,
originalIndex,
@@ -347,14 +357,16 @@ auto AICommandEvaluator::FindBestCommand(
allCastleCoords,
deadline);
commandEvaluations[index].immediateScore = immediateScore;
commandEvaluations[index].lookaheadFutures.push_back(std::move(lookaheadScore));
commandEvaluations[index].immediateScore = evaluation.immediateScore;
commandEvaluations[index].immediateCompleted = evaluation.completed;
commandEvaluations[index].lookaheadFutures.push_back(
std::move(evaluation.lookaheadScore));
} else if (guessedDescriptor->HasOdds()) {
const auto successChancePercentile = guessedDescriptor->GetOddsPercentile();
const double successChance = static_cast<double>(successChancePercentile) / 100.0;
// Success attempt uses 1.0 - (successChance / 2) as the roll
auto [successImmediateScore, successLookaheadScore] = EvaluateWithRandomness(
auto successEvaluation = EvaluateWithRandomness(
pid,
isDefender,
originalIndex,
@@ -368,7 +380,7 @@ auto AICommandEvaluator::FindBestCommand(
deadline);
// Failure attempt uses the average of (1 - successChance) and 0 as the roll
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
auto failureEvaluation = EvaluateWithRandomness(
pid,
isDefender,
originalIndex,
@@ -381,24 +393,35 @@ auto AICommandEvaluator::FindBestCommand(
allCastleCoords,
deadline);
commandEvaluations[index].immediateScore =
std::lerp(failureImmediateScore, successImmediateScore, successChance);
commandEvaluations[index].immediateCompleted =
failureEvaluation.completed && successEvaluation.completed;
commandEvaluations[index].immediateScore = std::lerp(
failureEvaluation.immediateScore,
successEvaluation.immediateScore,
successChance);
auto successSF = successLookaheadScore.share();
auto failureSF = failureLookaheadScore.share();
auto successSF = successEvaluation.lookaheadScore.share();
auto failureSF = failureEvaluation.lookaheadScore.share();
commandEvaluations[index].lookaheadFutures.push_back(std::async(
std::launch::deferred,
[successSF, failureSF, successChance]() -> double {
return std::lerp(failureSF.get(), successSF.get(), successChance);
[successSF, failureSF, successChance]() -> EvaluationResult {
const auto failure = failureSF.get();
const auto success = successSF.get();
if (!failure.completed || !success.completed) {
return EvaluationResult{.score = 0.0, .completed = false};
}
return EvaluationResult{
.score = std::lerp(failure.score, success.score, successChance),
.completed = true};
}));
} else {
ScoreValue sum = 0.0;
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
const int sampleCount = std::max(1, maxRepeatCount);
for (int repeatIteration = 0; repeatIteration < sampleCount; repeatIteration++) {
// In each iteration, use a double from [0, 1] as the random roll
auto sequence = std::vector{
static_cast<double>(repeatIteration) /
static_cast<double>(maxRepeatCount - 1)};
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
auto sequence =
std::vector{RandomnessSampleForRepeat(repeatIteration, sampleCount)};
auto evaluation = EvaluateWithRandomness(
pid,
isDefender,
originalIndex,
@@ -410,10 +433,12 @@ auto AICommandEvaluator::FindBestCommand(
allCastleCoords,
deadline);
sum += immediateScore;
commandEvaluations[index].lookaheadFutures.push_back(std::move(lookaheadScore));
if (!evaluation.completed) { commandEvaluations[index].immediateCompleted = false; }
sum += evaluation.immediateScore;
commandEvaluations[index].lookaheadFutures.push_back(
std::move(evaluation.lookaheadScore));
}
commandEvaluations[index].immediateScore = sum / maxRepeatCount;
commandEvaluations[index].immediateScore = sum / sampleCount;
}
}
@@ -427,19 +452,33 @@ auto AICommandEvaluator::FindBestCommand(
// Wait for all futures and compute final scores
for (auto& eval : evals) {
ScoreValue totalLookaheadScore = 0.0;
bool completed = eval.immediateCompleted;
for (auto& future : eval.lookaheadFutures) {
totalLookaheadScore += future.get();
const auto lookahead = future.get();
if (!lookahead.completed) { completed = false; }
totalLookaheadScore += lookahead.score;
}
ScoreValue avgLookaheadScore =
eval.lookaheadFutures.empty()
? eval.immediateScore
: totalLookaheadScore / eval.lookaheadFutures.size();
if (!completed) { continue; }
allResults.push_back(IndexAndScore{
.index = eval.index,
.type = eval.type,
.lookaheadScore = avgLookaheadScore,
.immediateScore = eval.immediateScore});
.immediateScore = eval.immediateScore,
.completed = true});
}
if (allResults.empty()) {
return IndexAndScore{
.index = 0,
.type = net::eagle0::shardok::common::UNKNOWN_COMMAND,
.lookaheadScore = 0.0,
.immediateScore = 0.0,
.completed = false};
}
// Find the best command using the existing sorter
auto bestIt = std::ranges::max_element(allResults, CommandSorter);
@@ -457,12 +496,12 @@ auto AICommandEvaluator::EvaluateCommand(
const ScoreValue currentUtility,
const CoordsSet& allCastleCoords,
const size_t commandIndex,
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue> {
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult> {
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
if (commandIndex >= guessedDescriptors->size()) {
std::promise<ScoreValue> p;
p.set_value(currentUtility);
std::promise<EvaluationResult> p;
p.set_value(EvaluationResult{.score = currentUtility, .completed = true});
return p.get_future();
}
@@ -470,11 +509,11 @@ auto AICommandEvaluator::EvaluateCommand(
if (const auto guessedCommandType = guessedDescriptor->GetCommandType();
guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
std::promise<ScoreValue> p;
p.set_value(currentUtility);
std::promise<EvaluationResult> p;
p.set_value(EvaluationResult{.score = currentUtility, .completed = true});
return p.get_future();
} else if (IsDeterministic(guessedCommandType)) {
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
auto evaluation = EvaluateWithRandomness(
pid,
isDefender,
commandIndex,
@@ -485,13 +524,13 @@ auto AICommandEvaluator::EvaluateCommand(
attackerStrategy,
allCastleCoords,
deadline);
return std::move(lookaheadScore);
return std::move(evaluation.lookaheadScore);
} else if (guessedDescriptor->HasOdds()) {
const auto successChancePercentile = guessedDescriptor->GetOddsPercentile();
const double successChance = static_cast<double>(successChancePercentile) / 100.0;
// Success attempt
auto [successImmediateScore, successLookaheadScore] = EvaluateWithRandomness(
auto successEvaluation = EvaluateWithRandomness(
pid,
isDefender,
commandIndex,
@@ -504,7 +543,7 @@ auto AICommandEvaluator::EvaluateCommand(
deadline);
// Failure attempt
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
auto failureEvaluation = EvaluateWithRandomness(
pid,
isDefender,
commandIndex,
@@ -517,20 +556,29 @@ auto AICommandEvaluator::EvaluateCommand(
deadline);
// Return weighted average of success and failure
auto successSF = successLookaheadScore.share();
auto failureSF = failureLookaheadScore.share();
return std::async(std::launch::deferred, [successSF, failureSF, successChance]() -> double {
return std::lerp(failureSF.get(), successSF.get(), successChance);
});
auto successSF = successEvaluation.lookaheadScore.share();
auto failureSF = failureEvaluation.lookaheadScore.share();
return std::async(
std::launch::deferred,
[successSF, failureSF, successChance]() -> EvaluationResult {
const auto failure = failureSF.get();
const auto success = successSF.get();
if (!failure.completed || !success.completed) {
return EvaluationResult{.score = 0.0, .completed = false};
}
return EvaluationResult{
.score = std::lerp(failure.score, success.score, successChance),
.completed = true};
});
} else {
// For non-deterministic commands without odds, use multiple attempts
std::vector<std::future<ScoreValue>> lookaheadFutures;
lookaheadFutures.reserve(maxRepeatCount);
std::vector<std::future<EvaluationResult>> lookaheadFutures;
const int sampleCount = std::max(1, maxRepeatCount);
lookaheadFutures.reserve(sampleCount);
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
auto sequence = std::vector{
static_cast<double>(repeatIteration) / static_cast<double>(maxRepeatCount - 1)};
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
for (int repeatIteration = 0; repeatIteration < sampleCount; repeatIteration++) {
auto sequence = std::vector{RandomnessSampleForRepeat(repeatIteration, sampleCount)};
auto evaluation = EvaluateWithRandomness(
pid,
isDefender,
commandIndex,
@@ -542,17 +590,25 @@ auto AICommandEvaluator::EvaluateCommand(
allCastleCoords,
deadline);
lookaheadFutures.push_back(std::move(lookaheadScore));
lookaheadFutures.push_back(std::move(evaluation.lookaheadScore));
}
// Return a future that computes the average when needed
return std::async(
std::launch::deferred,
[lookaheadFutures = std::move(lookaheadFutures),
maxRepeatCount]() mutable -> double {
sampleCount]() mutable -> EvaluationResult {
ScoreValue total = 0.0;
for (auto& future : lookaheadFutures) { total += future.get(); }
return total / maxRepeatCount;
bool completed = true;
for (auto& future : lookaheadFutures) {
const auto result = future.get();
if (!result.completed) { completed = false; }
total += result.score;
}
if (!completed) { return EvaluationResult{.score = 0.0, .completed = false}; }
return EvaluationResult{
.score = total / static_cast<ScoreValue>(sampleCount),
.completed = true};
});
}
}
@@ -38,6 +38,11 @@ public:
BattalionTypeGetter battalionTypeGetter); // Pass by value
/// Evaluates the score for a particular command index with lookahead.
struct EvaluationResult {
ScoreValue score;
bool completed;
};
[[nodiscard]] auto EvaluateCommand(
PlayerId pid,
bool isDefender,
@@ -48,7 +53,7 @@ public:
ScoreValue currentUtility,
const CoordsSet& allCastleCoords,
size_t commandIndex,
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue>;
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult>;
/// Find the best command among all available commands at the given depth.
struct IndexAndScore {
@@ -56,6 +61,7 @@ public:
CommandType type;
ScoreValue lookaheadScore;
ScoreValue immediateScore;
bool completed;
};
[[nodiscard]] auto FindBestCommand(
@@ -76,7 +82,8 @@ private:
struct ImmediateAndLookaheadScore {
ScoreValue immediateScore;
std::future<ScoreValue> lookaheadScore;
bool completed;
std::future<EvaluationResult> lookaheadScore;
};
/// Recursive lookahead calculator
@@ -89,7 +96,7 @@ private:
ScoreValue currentUtility,
const AIStrategy& attackerStrategy,
const CoordsSet& allCastleCoords,
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue>;
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult>;
/// Evaluate single command execution with randomness handling
[[nodiscard]] auto EvaluateWithRandomness(
@@ -75,15 +75,47 @@ std::vector<size_t> AICommandFilter::FilterCommands(
// Calculate minimum distance to enemies once for all filters
const double minDistToEnemies = MinDistanceToEnemyUnits(gameState, pid, enemyLocations);
bool hasAvailablePlacementCommand = false;
for (const auto& cmd : *commands) {
if (cmd->GetCommandType() == CommandType::PLACE_UNIT_COMMAND ||
cmd->GetCommandType() == CommandType::PLACE_HIDDEN_UNIT_COMMAND) {
hasAvailablePlacementCommand = true;
break;
}
}
bool playerHasReserveUnits = false;
for (const Unit* unit : *gameState->units()) {
if (unit->player_id() == pid &&
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT) {
playerHasReserveUnits = true;
break;
}
}
for (size_t i = 0; i < commands->size(); ++i) {
const auto& cmd = (*commands)[i];
if (cmd->GetCommandType() == CommandType::END_PLAYER_SETUP_COMMAND &&
hasAvailablePlacementCommand && playerHasReserveUnits) {
continue;
}
// Always allow END_TURN commands
if (cmd->GetCommandType() == CommandType::END_TURN_COMMAND) {
filteredIndices.push_back(i);
continue;
}
// Setup placement commands should always remain available. The movement/spell heuristics
// below are combat-oriented and can otherwise filter every placement for an in-reserve
// unit, making MCTS think ending setup is the only good option.
if (cmd->GetCommandType() == CommandType::PLACE_UNIT_COMMAND ||
cmd->GetCommandType() == CommandType::PLACE_HIDDEN_UNIT_COMMAND) {
filteredIndices.push_back(i);
continue;
}
// Filter obviously bad moves
bool shouldFilter = false;
@@ -423,6 +455,35 @@ bool AICommandFilter::IsWastefulAction(
break;
}
case CommandType::FEAR_COMMAND: {
// Solo attackers gain nothing from Fear: the target is stunned for a round but
// there is no teammate to capitalize, and the caster ends its turn no closer to
// killing units or capturing castles. Defenders, by contrast, benefit from stalling,
// so this restriction only applies to the attacking side.
//
// Exception: if the victim is standing on a fire tile, stunning locks them in place
// while the fire modifier keeps chewing through their unit value — the burn does the
// work that a follow-up attacker would normally provide.
if (isDefender) { break; }
if (CountPlayerUnits(gameState, pid) > 1) { break; }
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"FEAR_COMMAND missing required target information");
}
const Coords victimLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto* victimTerrain = GetTerrain(gameState->hex_map(), victimLocation);
if (!victimTerrain->modifier().fire().present()) {
return true; // Solo attacker fear with no fire-follow-up is a wasted action
}
break;
}
case CommandType::EXTINGUISH_FIRE_COMMAND: {
// Extinguish fire filtering - don't extinguish fires on enemy-occupied tiles
const int targetRow = cmd.GetTargetRow();
@@ -639,4 +700,4 @@ bool AICommandFilter::WouldAbandonCriticalCastle(
return false;
}
} // namespace shardok
} // namespace shardok
@@ -15,11 +15,11 @@ enum class AIAlgorithmType {
// Enum for scoring calculator selection
enum class ScoringCalculatorType {
STANDARD, // Default: Unbounded raw scores
NORMALIZED, // Normalized scores in [0, 1] range for ML training
MCTS_OPTIMIZED // Bounded linear scores tuned for MCTS
STANDARD, // Default: Unbounded raw scores
MCTS_OPTIMIZED, // Bounded linear scores tuned for MCTS
EXPERIMENTAL // Standard-derived scorer selected by experiment id
};
} // namespace shardok
#endif // EAGLE0_AI_CONFIG_HPP
#endif // EAGLE0_AI_CONFIG_HPP
@@ -84,6 +84,7 @@ auto AIDefenderStrategySelector::BestDefenderStrategy(
// * the defender has way, way fewer troops
// other considerations we should have but do not presently:
// * the attacker is close to the defender / castles
if (canFlee && roundsRemaining > 2 &&
defenderTroops < MAXIMUM_RATIO_FOR_DEFENDER_TO_FLEE * attackerTroops &&
attackerNonUndeadUnitNotRequiringWaterCrossingCount >= criticalTileCoords.size()) {
@@ -0,0 +1,23 @@
//
// Temporary AI experiment switches used by benchmark-only EXPERIMENTAL runs.
//
#ifndef EAGLE0_AI_EXPERIMENT_CONFIG_HPP
#define EAGLE0_AI_EXPERIMENT_CONFIG_HPP
#include <cstdlib>
#include <string>
namespace shardok {
inline auto CurrentAIExperimentId() -> int {
const char* rawValue = std::getenv("SHARDOK_SCORING_EXPERIMENT_ID");
if (rawValue == nullptr || std::string(rawValue).empty()) { return 0; }
return std::atoi(rawValue);
}
inline auto IsAIExperiment(const int id) -> bool { return CurrentAIExperimentId() == id; }
} // namespace shardok
#endif // EAGLE0_AI_EXPERIMENT_CONFIG_HPP
@@ -5,7 +5,6 @@
#include "AIUnitScoreCalculator.hpp"
#include <algorithm>
#include <cstdlib>
#include "AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
@@ -33,7 +32,9 @@ constexpr double kMeteorCastRoundMultiplier = 1.5;
constexpr double kMeteorCastInsufficientVigorMultiplier = 0.5;
constexpr double kMeteorCastleMultiplier = 2.0;
constexpr double kLightningPossibleValue = 0.05;
constexpr double kArcheryPossibleValue = 38;
constexpr double kExpectedArcheryVolleyTargetValueFraction = 0.16;
constexpr double kImmediateArcheryThreatMultiplier = 0.5;
constexpr double kFutureArcheryThreatMultiplier = 0.25;
constexpr double kReduceFortifiedPossibleValue = 50;
constexpr double kReduceUnfortifiedPossibleValue = 12;
constexpr double kFearPossibleValue = 38;
@@ -101,9 +102,24 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
return battalionValue + heroValue;
}
auto archeryValue(const Unit * /*unit*/) -> double {
// TODO: make this depend on the value of the targets
return kArcheryPossibleValue;
auto bestEnemyUnitValue(const vector<const Unit *> &enemyUnits) -> double {
double bestTargetValue = 0.0;
for (const Unit *enemyUnit : enemyUnits) {
bestTargetValue = std::max(bestTargetValue, ContextFreeUnitValue(enemyUnit));
}
return bestTargetValue;
}
auto expectedImpactArcheryValue(const vector<const Unit *> &enemyUnits, const bool canShootNow)
-> double {
const double expectedVolleyImpact =
kExpectedArcheryVolleyTargetValueFraction * bestEnemyUnitValue(enemyUnits);
return expectedVolleyImpact *
(canShootNow ? kImmediateArcheryThreatMultiplier : kFutureArcheryThreatMultiplier);
}
auto archeryValue(const vector<const Unit *> &enemyUnits, const bool canShootNow) -> double {
return expectedImpactArcheryValue(enemyUnits, canShootNow);
}
auto reduceValue(const Unit *unit, const Terrain *unitTerrain) -> double {
@@ -116,10 +132,7 @@ auto reduceValue(const Unit *unit, const Terrain *unitTerrain) -> double {
return 0.0;
}
auto fearValue(const Unit * /*unit*/) -> double {
// TODO: make this depend on the value of the targets
return kFearPossibleValue;
}
auto fearValue(const vector<const Unit *> &) -> double { return kFearPossibleValue; }
auto lightningValue(const Unit *unit) -> double {
// TODO: make this depend on the value of the targets
@@ -265,11 +278,13 @@ auto GetRangedAttackBonus(
const vector<const Unit *> &attackerUnits,
const vector<const Unit *> &defenderUnits,
const int meteorRange,
const double minVigorToCast) -> double {
const double minVigorToCast,
const ActionPoints archeryActionPointCost) -> double {
vector<double> rangedAttackValues{};
const auto &enemyUnits = isAttacker ? defenderUnits : attackerUnits;
const auto &friendlyUnits = isAttacker ? attackerUnits : defenderUnits;
const bool canShootNow = unit->remaining_action_points() >= archeryActionPointCost;
const auto &unitLocation = unit->location();
@@ -306,19 +321,19 @@ auto GetRangedAttackBonus(
unit->attached_hero().profession_info().profession() ==
net::eagle0::shardok::storage::fb::Profession_NECROMANCER &&
unit->attached_hero().control_info().controlled_unit_id() != -1) {
rangedAttackValues.push_back(fearValue(unit));
rangedAttackValues.push_back(fearValue(enemyUnits));
}
if (attackLocations.ArcheryLocations().Contains(unitLocation) &&
unit->volleys_remaining() > 0) {
rangedAttackValues.push_back(archeryValue(unit));
rangedAttackValues.push_back(archeryValue(enemyUnits, canShootNow));
}
// Longbowmen in castle can archer adjacent
if (attackLocations.AdjacentLocations().Contains(unitLocation) &&
unit->volleys_remaining() > 0 && terrain->modifier().castle().present() &&
unit->battalion().type() == net::eagle0::shardok::storage::fb::BattalionTypeId_LONGBOWMEN) {
rangedAttackValues.push_back(archeryValue(unit));
rangedAttackValues.push_back(archeryValue(enemyUnits, canShootNow));
}
if (rangedAttackValues.empty()) return 0.0;
@@ -338,7 +353,8 @@ auto UnitValue(
const CoordsSet &locationsInDangerFromEnemy,
const ActionPointDistances *distances,
int meteorRange,
double meteorCastVigorCost) -> ScoreValue {
double meteorCastVigorCost,
ActionPoints archeryActionPointCost) -> ScoreValue {
const auto &location = unit->location();
if (location.row() < 0) return 0; // unplaced unit
@@ -382,7 +398,8 @@ auto UnitValue(
attackerUnits,
defenderUnits,
meteorRange,
meteorCastVigorCost);
meteorCastVigorCost,
archeryActionPointCost);
// scouting values
// attack range
@@ -32,7 +32,8 @@ auto GetRangedAttackBonus(
const vector<const Unit *> &attackerUnits,
const vector<const Unit *> &defenderUnits,
int meteorRange,
double minVigorToCast) -> double;
double minVigorToCast,
ActionPoints archeryActionPointCost = 0) -> double;
auto UnitValue(
const Unit *unit,
@@ -47,7 +48,8 @@ auto UnitValue(
const CoordsSet &locationsInDangerFromEnemy,
const ActionPointDistances *distances,
int meteorRange,
double meteorCastVigorCost) -> ScoreValue;
double meteorCastVigorCost,
ActionPoints archeryActionPointCost = 0) -> ScoreValue;
} // namespace shardok
@@ -156,7 +156,9 @@ The system uses recursive lookahead with:
- `kProfessionValue`: 200
### Ranged Attack Values
- `kArcheryPossibleValue`: 38
- Archery position value: expected-impact based. A unit that can shoot now gets half of
`0.16 * best target unit value`; a unit in archery range that cannot shoot yet gets one
quarter of that expected volley value.
- `kMeteorDirectTargetingEnemy`: 2 per soldier
- `kMeteorSplashTargetingEnemy`: 1 per soldier
- `kLightningPossibleValue`: 0.05 per soldier
@@ -762,4 +764,4 @@ MCTSAI ai(playerId, isDefender, strategy, castleCoords, apdCache, alCache, confi
- Higher `immediateScoreTieBreakThreshold` to emphasize direct paths
- `BEST_IMMEDIATE` simulation for most predictable behavior
The configuration system allows fine-tuning MCTS behavior for different scenarios while maintaining compatibility with the existing AI infrastructure.
The configuration system allows fine-tuning MCTS behavior for different scenarios while maintaining compatibility with the existing AI infrastructure.
+11 -1
View File
@@ -11,6 +11,13 @@ cc_library(
],
)
cc_library(
name = "ai_experiment_config",
hdrs = ["AIExperimentConfig.hpp"],
copts = COPTS,
visibility = ["//src/main/cpp/net/eagle0/shardok/ai:__subpackages__"],
)
cc_library(
name = "ai_attacker_strategy_selector",
srcs = ["AIAttackerStrategySelector.cpp"],
@@ -84,6 +91,7 @@ cc_library(
],
deps = [
":ai_attack_locations",
":ai_experiment_config",
":ai_score_utilities",
":ai_strategy",
":ai_water_crossing_calculator",
@@ -196,6 +204,7 @@ cc_library(
],
deps = [
":ai_command_filter",
":ai_experiment_config",
":ai_strategy",
":transposition_table",
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
@@ -272,6 +281,7 @@ cc_library(
],
deps = [
":ai_attack_locations",
":ai_experiment_config",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
],
@@ -348,6 +358,7 @@ cc_library(
":ai_attacker_strategy_selector",
":ai_command_evaluator",
":ai_defender_strategy_selector",
":ai_experiment_config",
":ai_time_budget",
":ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/common:time_utils",
@@ -385,7 +396,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/ai/mcts:shardok_mcts_ai", # MCTS with abstraction layer
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/ai/score:mcts_optimized_ai_score_calculator", # Bounded linear scorer for MCTS
"//src/main/cpp/net/eagle0/shardok/ai/score:normalized_ai_score_calculator", # Normalized [0,1] scorer for ML training
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator", # Standard unbounded scorer (default)
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
@@ -13,6 +13,7 @@
#include "AICommandEvaluator.hpp"
#include "AICommandFilter.hpp"
#include "TranspositionTable.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIExperimentConfig.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
@@ -130,6 +131,10 @@ auto IterativeDeepeningAI::IterativeSearch(
// Now wait for all futures and collect results
for (auto& [cmdIndex, future] : futures) {
auto cmdResult = future.get();
if (!cmdResult.searchCompleted) {
allEvaluated = false;
continue;
}
// Ensure scoresByDepth[cmdIndex] has enough space
if (scoresByDepth[cmdIndex].size() <= currentDepth) {
@@ -221,7 +226,7 @@ auto IterativeDeepeningAI::IterativeSearch(
}
// If all evaluated commands had unchanged scores, we've hit END_TURN in lookahead
if (scoresUnchanged && unchangedCount == evaluatedCount) {
if (!IsAIExperiment(26) && scoresUnchanged && unchangedCount == evaluatedCount) {
completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
break;
}
@@ -251,7 +256,9 @@ auto IterativeDeepeningAI::IterativeSearch(
completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
}
// Select best result from highest depth achieved for each command
// Select best result from the highest completed depth achieved for each command. Commands that
// timed out at a depth are not recorded for that depth, so partial deeper work can help without
// letting timeout fallback scores compete.
result = SelectBestResult(scoresByDepth, highestDepthCompleted);
result.minimumDepthCompleted = result.depthAchieved >= timeBudget.minDepthRequired;
result.searchCompleted = result.minimumDepthCompleted;
@@ -342,7 +349,9 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
// Deduct adjusted time from remaining budget
timeBudget.remainingBudget -= adjustedElapsedMs;
result.bestScore = commandScore;
result.bestScore = commandScore.score;
result.searchCompleted = commandScore.completed;
result.minimumDepthCompleted = commandScore.completed;
std::promise<SearchResult> p;
p.set_value(result);
@@ -355,7 +364,7 @@ auto IterativeDeepeningAI::GetCommandsSortedByPreviousDepth(
const std::vector<size_t>& highestDepthCompleted,
const std::vector<size_t>& filteredIndices) -> std::vector<size_t> {
if (currentDepth == 1) {
// For depth 1, return filtered indices in natural order
// For depth 1, preserve the upstream command-factory priority order.
return filteredIndices;
}
@@ -392,10 +401,20 @@ auto IterativeDeepeningAI::SelectBestResult(
result.bestScore = -std::numeric_limits<ScoreValue>::infinity();
result.searchCompleted = false;
size_t selectionDepth = 0;
if (IsAIExperiment(24)) {
selectionDepth = std::numeric_limits<size_t>::max();
for (const size_t depth : highestDepthCompleted) {
if (depth > 0) { selectionDepth = std::min(selectionDepth, depth); }
}
if (selectionDepth == std::numeric_limits<size_t>::max()) { selectionDepth = 0; }
}
// Find the command with best score at its highest evaluated depth
for (size_t i = 0; i < scoresByDepth.size(); ++i) {
if (highestDepthCompleted[i] > 0) {
const size_t depth = highestDepthCompleted[i];
const size_t depth = selectionDepth > 0 ? selectionDepth : highestDepthCompleted[i];
if (highestDepthCompleted[i] < depth || scoresByDepth[i].size() <= depth) { continue; }
if (ScoreValue score = scoresByDepth[i][depth]; score > result.bestScore) {
result.bestScore = score;
result.bestCommandIndex = i;
@@ -407,4 +426,4 @@ auto IterativeDeepeningAI::SelectBestResult(
return result;
}
} // namespace shardok
} // namespace shardok
@@ -111,4 +111,4 @@ private:
} // namespace shardok
#endif // EAGLE0_ITERATIVEDEEPENINGAI_HPP
#endif // EAGLE0_ITERATIVEDEEPENINGAI_HPP
@@ -32,7 +32,6 @@
#include "IterativeDeepeningAI.hpp"
#include "mcts/ShardokMCTSAI.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/MCTSOptimizedAIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/NormalizedAIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/StandardAIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateGuesser.hpp"
@@ -299,12 +298,12 @@ auto ShardokAIClient::StandardChooseCommandIndex(
// Create scorer for actual scoring during search - type selected at construction
std::unique_ptr<AIScoreCalculator> scorer;
switch (scoringCalculatorType) {
case ScoringCalculatorType::NORMALIZED:
scorer = MakeNormalizedAIScoreCalculator(settingsGetter, apdCache, alCache);
break;
case ScoringCalculatorType::MCTS_OPTIMIZED:
scorer = MakeMCTSOptimizedAIScoreCalculator(settingsGetter, apdCache, alCache);
break;
case ScoringCalculatorType::EXPERIMENTAL:
scorer = MakeExperimentalAIScoreCalculator(settingsGetter, apdCache, alCache);
break;
case ScoringCalculatorType::STANDARD:
default: scorer = MakeStandardAIScoreCalculator(settingsGetter, apdCache, alCache); break;
}
@@ -21,6 +21,22 @@
namespace shardok::mcts {
namespace {
auto IsDefenderPlayer(const GameStateW& gameState, const PlayerId playerId) -> bool {
for (const auto* pi : *gameState->player_infos()) {
if (pi->player_id() == playerId) { return pi->is_defender(); }
}
return false;
}
auto LegalActionsCacheKey(const uint64_t stateHash, const bool rootIsDefender) -> uint64_t {
constexpr uint64_t kDefenderRoleHash = 0x9e3779b97f4a7c15ULL;
return rootIsDefender ? stateHash ^ kDefenderRoleHash : stateHash;
}
} // namespace
// Shared cache for legal actions (uses lock-free parallel hash map for thread safety)
// Using 8 submaps to reduce contention with 16 MCTS threads
gtl::parallel_flat_hash_map<
@@ -223,13 +239,14 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
// Time hash computation
const auto hashStart = std::chrono::high_resolution_clock::now();
const uint64_t stateHash = shardokState->hash();
const uint64_t cacheKey = LegalActionsCacheKey(stateHash, isDefender_);
const auto hashEnd = std::chrono::high_resolution_clock::now();
timeInHashComputation_.fetch_add(
std::chrono::duration_cast<std::chrono::microseconds>(hashEnd - hashStart).count(),
std::memory_order_relaxed);
// Check transposition table for cached legal actions
if (auto it = legalActionsCache_.find(stateHash); it != legalActionsCache_.end()) {
if (auto it = legalActionsCache_.find(cacheKey); it != legalActionsCache_.end()) {
cacheHits_.fetch_add(1, std::memory_order_relaxed);
// Use cached engine
@@ -304,7 +321,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
const std::vector<size_t> filteredIndices = AICommandFilter::FilterCommands(
commands,
currentPlayer,
isDefender_,
IsDefenderPlayer(shardokState->getShardokState(), currentPlayer),
shardokState->getShardokState(),
*apdCache_,
[this](BattalionTypeId typeId) {
@@ -362,7 +379,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
// This avoids duplicating heavy protocol buffer objects
// Use lazy_emplace_l to ensure thread-safe insertion (locks the bucket during construction)
legalActionsCache_.lazy_emplace_l(
stateHash,
cacheKey,
[&](typename decltype(legalActionsCache_)::value_type& v) {
// Update existing entry
v.second.filteredIndices = filteredIndices;
@@ -370,7 +387,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
},
[&](const typename decltype(legalActionsCache_)::constructor& ctor) {
// Create new entry
ctor(stateHash, LegalActionsCache{filteredIndices, engine});
ctor(cacheKey, LegalActionsCache{filteredIndices, engine});
});
return actions;
@@ -416,14 +433,8 @@ std::vector<double> ShardokGameEngine::getActionWeights(
// Determine if current player is defender (not root player!)
// During simulation we need to use the correct perspective for action weighting
bool currentPlayerIsDefender = false;
const auto& gameState = shardokState->getShardokState();
for (const auto* pi : *gameState->player_infos()) {
if (pi->player_id() == currentPlayer) {
currentPlayerIsDefender = pi->is_defender();
break;
}
}
const bool currentPlayerIsDefender = IsDefenderPlayer(gameState, currentPlayer);
// Use AIHeuristicWeighting for fast O(1) context-aware command weighting
std::vector<double> weights;
@@ -110,8 +110,19 @@ bool ShardokGameState::equals(const MCTSGameState& other) const {
}
MCTSPlayerId ShardokGameState::getWinner() const {
// Note: FlatBuffer doesn't have a winner field
// In practice, this would need to determine winner from victory conditions
if (!state_->status() ||
state_->status()->state() !=
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
return -1;
}
const auto* winningIds = state_->status()->winning_shardok_ids();
if (!winningIds || winningIds->empty()) { return -1; }
for (const PlayerId winningPid : *winningIds) {
if (winningPid >= 0) { return winningPid; }
}
return -1; // No winner
}
@@ -41,31 +41,6 @@ cc_library(
],
)
cc_library(
name = "normalized_ai_score_calculator",
srcs = ["NormalizedAIScoreCalculator.cpp"],
hdrs = ["NormalizedAIScoreCalculator.hpp"],
copts = COPTS,
visibility = [
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
],
deps = [
":ai_score_calculator_interface",
":ai_victory_condition_score_calculator",
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
"//src/main/cpp/net/eagle0/shardok/ai/score/private:abstract_ai_score_calculator",
"//src/main/cpp/net/eagle0/shardok/ai/score/private:ai_score_calculator_shared_utilities",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
],
)
cc_library(
name = "standard_ai_score_calculator",
srcs = ["StandardAIScoreCalculator.cpp"],
@@ -79,6 +54,7 @@ cc_library(
deps = [
":ai_score_calculator_interface",
":ai_victory_condition_score_calculator",
"//src/main/cpp/net/eagle0/shardok/ai:ai_experiment_config",
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
@@ -59,6 +59,7 @@ public:
MCTSOptimizedAIScoreCalculator(
int maxRounds,
ActionPoints braveWaterCost,
ActionPoints archeryActionPointCost,
int meteorRange,
double meteorCastVigorCost,
int minimumFleeOddsThreshold,
@@ -69,6 +70,7 @@ public:
: AbstractAIScoreCalculator(
maxRounds,
braveWaterCost,
archeryActionPointCost,
meteorRange,
meteorCastVigorCost,
minimumFleeOddsThreshold,
@@ -236,6 +238,7 @@ auto MakeMCTSOptimizedAIScoreCalculator(
return std::make_unique<MCTSOptimizedAIScoreCalculator>(
settingsGetter.Backing().max_rounds(),
settingsGetter.Backing().brave_water_action_point_cost(),
settingsGetter.Backing().archery_action_point_cost(),
settingsGetter.Backing().meteor_range(),
settingsGetter.Backing().meteor_cast_vigor_cost(),
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
@@ -5,7 +5,7 @@
We need a scoring algorithm that makes MCTS perform well by providing score differences in the right range:
- **Standard Scorer**: Returns unbounded relative scores. Small differences get amplified in MCTS UCB formula, causing over-exploitation (commits to 1-2 high-scoring nodes too early).
- **Normalized Scorer**: Returns scores in [0,1] range with power transformation (exponent=0.1). Differences are too compressed (~0.02-0.05), causing over-exploration (all nodes explored equally, AI makes bad choices).
- **Retired bounded-ratio scorer**: Returned scores in [0,1] range with power transformation (exponent=0.1). Differences were too compressed (~0.02-0.05), causing over-exploration (all nodes explored equally, AI made bad choices).
### MCTS Requirements
@@ -32,9 +32,9 @@ This means individual scores should differ by **0.5 to 2.0** between meaningfull
- Range: 0 (all captured) to -3000 (all held, far away)
### Terminal States
- Victory: INT_MAX (or 1.0 for normalized)
- Defeat: INT_MIN (or 0.0 for normalized)
- Flee/Draw: 0 (or 0.5 for normalized)
- Victory: INT_MAX
- Defeat: INT_MIN
- Flee/Draw: 0
- Captured unit: -10,000
- Captured VIP: -25,000
@@ -257,7 +257,7 @@ This progression is ideal:
This avoids both pathologies:
- Not over-exploiting (like Standard scorer which overcommitted to tiny early differences)
- Not over-exploring (like Normalized scorer which explored equally even with large advantages)
- Not over-exploring like the retired bounded-ratio scorer, which explored equally even with large advantages
## Why MCTS Needs Stronger Signal Than Minimax
@@ -281,11 +281,11 @@ This gives MCTS the right balance: explore when moves are similar, exploit when
## Implementation Notes
1. **Use same calculation structure**: Inherit from AbstractAIScoreCalculator like Standard and Normalized
1. **Use same calculation structure**: Inherit from AbstractAIScoreCalculator like Standard
2. **Reuse unit scoring**: Use existing CalculateUnitsScoreComponents and victory condition calculators
3. **Only change combination**: Override CombineAttackerScores, CombineDefenderScores, etc.
4. **Bounded terminals**: Use ±1000 instead of INT_MAX/MIN for numerical stability
5. **No transformation**: Unlike Normalized, don't apply power transformation - linear scaling is sufficient
5. **No transformation**: Do not apply power transformation - linear scaling is sufficient
6. **Scale constants tuned for MCTS**: 10x larger than naive normalization to provide appropriate signal strength
## Testing with Integration Tests
@@ -383,6 +383,5 @@ Once the scorer passes integration tests with IterativeDeepeningAI, then integra
- `BoundedLinearAIScoreCalculator`
- `MCTSOptimizedAIScoreCalculator`
- `LinearNormalizedAIScoreCalculator`
Recommend: **`MCTSOptimizedAIScoreCalculator`** to clearly indicate purpose.
@@ -1,252 +0,0 @@
//
// Normalized [0,1] implementation of AIScoreCalculator
//
#include "NormalizedAIScoreCalculator.hpp"
#include <cmath>
#include <unordered_map>
#include "private/AIScoreCalculatorSharedUtilities.hpp"
#include "private/AbstractAIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok {
using net::eagle0::shardok::storage::fb::BattalionTypeId;
// Bring shared utilities into scope
using score_calculator_internal::UnitsScoreComponents;
/// Normalized implementation of AIScoreCalculator that produces scores in [0, 1] range.
/// Inherits from AbstractAIScoreCalculator to share common functionality.
class NormalizedAIScoreCalculator : public AbstractAIScoreCalculator {
public:
NormalizedAIScoreCalculator(
int maxRounds,
ActionPoints braveWaterCost,
int meteorRange,
double meteorCastVigorCost,
int minimumFleeOddsThreshold,
int desperateFleeThreshold,
std::vector<BattalionTypeSPtr> battalionTypes,
const APDCache &apdCache,
const ALCache &alCache)
: AbstractAIScoreCalculator(
maxRounds,
braveWaterCost,
meteorRange,
meteorCastVigorCost,
minimumFleeOddsThreshold,
desperateFleeThreshold,
std::move(battalionTypes),
apdCache,
alCache) {}
[[nodiscard]] auto GuessedStateScore(
bool isDefender,
const GameStateW &state,
const AIStrategy &aiStrategy,
const CoordsSet &allCastleCoords) const -> ScoreValue override;
// Implement pure virtual methods from AbstractAIScoreCalculator
[[nodiscard]] auto InterpretDefenderOutcome(GameOutcome outcome) const -> ScoreValue override;
[[nodiscard]] auto InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue override;
[[nodiscard]] auto AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
-> ScoreValue override;
[[nodiscard]] auto CombineAttackerScores(
const UnitsScoreComponents &components,
double victoryConditionScore,
int roundsRemaining) const -> ScoreValue override;
[[nodiscard]] auto CombineDefenderScatterScores(const UnitsScoreComponents &components) const
-> ScoreValue override;
[[nodiscard]] auto CombineDefenderHoldCastlesScores(
const UnitsScoreComponents &components,
double victoryConditionScore,
int roundsRemaining) const -> ScoreValue override;
private:
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
-> ScoreValue override;
/// Applies power transformation to spread out compressed scores for MCTS.
/// Maps [0,1] → [0,1] but pushes values away from 0.5 toward the extremes.
/// Terminal states (0.0, 1.0) are unchanged.
[[nodiscard]] auto TransformForMCTS(ScoreValue score) const -> ScoreValue;
};
// Implementation of NormalizedAIScoreCalculator methods
auto NormalizedAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) const
-> ScoreValue {
switch (outcome) {
case GameOutcome::DEFENDER_VICTORY: return 1.0;
case GameOutcome::ATTACKER_VICTORY: return 0.0;
case GameOutcome::DRAW: return 0.5;
case GameOutcome::FLEE_OUTCOME: return 0.5;
}
throw ShardokInternalErrorException("Unknown GameOutcome");
}
auto NormalizedAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) const
-> ScoreValue {
switch (outcome) {
case GameOutcome::ATTACKER_VICTORY: return 1.0;
case GameOutcome::DEFENDER_VICTORY: return 0.0;
case GameOutcome::DRAW: return 0.5;
case GameOutcome::FLEE_OUTCOME: return 0.5;
}
throw ShardokInternalErrorException("Unknown GameOutcome");
}
auto NormalizedAIScoreCalculator::CombineDefenderScatterScores(
const UnitsScoreComponents &components) const -> ScoreValue {
// For defender, we flip the perspective: defenderValue is (1), attackerValue is (2)
const double defenderValue = components.defenderUnitsValue;
const double attackerValue = components.attackerUnitsValue;
// No victory condition for scatter strategy
const double denominator = defenderValue + attackerValue;
if (denominator == 0.0) { return 0.5; }
return defenderValue / denominator;
}
auto NormalizedAIScoreCalculator::CombineDefenderHoldCastlesScores(
const UnitsScoreComponents &components,
const double victoryConditionScore,
const int /*roundsRemaining*/) const -> ScoreValue {
// For defender, flip perspective
const double defenderValue = components.defenderUnitsValue;
const double attackerValue = components.attackerUnitsValue;
// Apply normalization
double numerator;
double denominator;
if (victoryConditionScore >= 0) {
numerator = defenderValue + victoryConditionScore;
denominator = defenderValue + attackerValue + victoryConditionScore;
} else {
numerator = defenderValue;
denominator = defenderValue + attackerValue - victoryConditionScore;
}
if (denominator == 0.0) { return 0.5; }
return numerator / denominator;
}
auto NormalizedAIScoreCalculator::DefenderFleeStrategyScoreForState(
const GameStateW & /*gameState*/) const -> ScoreValue {
// FLEE strategy doesn't fit the [0,1] model well - return 0.5
return 0.5;
}
auto NormalizedAIScoreCalculator::AttackerFleeStrategyScoreForState(
const GameStateW & /*gameState*/) const -> ScoreValue {
// FLEE strategy doesn't fit the [0,1] model well - return 0.5
return 0.5;
}
auto NormalizedAIScoreCalculator::CombineAttackerScores(
const UnitsScoreComponents &components,
const double victoryConditionScore,
const int /*roundsRemaining*/) const -> ScoreValue {
const double attackerUnitsValue = components.attackerUnitsValue;
const double defenderUnitsValue = components.defenderUnitsValue;
// Apply normalization formula
double numerator;
double denominator;
if (victoryConditionScore >= 0) {
// Positive victory condition: add to numerator
numerator = attackerUnitsValue + victoryConditionScore;
denominator = attackerUnitsValue + defenderUnitsValue + victoryConditionScore;
} else {
// Negative victory condition: subtract from denominator (making it larger)
numerator = attackerUnitsValue;
denominator = attackerUnitsValue + defenderUnitsValue - victoryConditionScore;
}
// Handle edge case of all zeros
if (denominator == 0.0) { return 0.5; }
return numerator / denominator;
}
auto NormalizedAIScoreCalculator::TransformForMCTS(ScoreValue score) const -> ScoreValue {
// Power transformation exponent - lower values spread scores more toward extremes
// Tuned for MCTS: balances exploration vs exploitation
// - Too low (e.g., 0.3): over-exploitation like standard scorer
// - Too high (e.g., 0.9): over-exploration like untransformed normalized
// - 0.6-0.7: sweet spot for MCTS
constexpr double EXPONENT = 0.1;
if (score > 0.5) {
// Map [0.5, 1.0] → [0.5, 1.0] with power curve
// (score - 0.5) * 2.0 maps to [0, 1], apply power, then scale back
return 0.5 + 0.5 * std::pow((score - 0.5) * 2.0, EXPONENT);
} else {
// Map [0.0, 0.5] → [0.0, 0.5] with power curve (symmetric)
return 0.5 - 0.5 * std::pow((0.5 - score) * 2.0, EXPONENT);
}
}
auto NormalizedAIScoreCalculator::GuessedStateScore(
const bool isDefender,
const GameStateW &state,
const AIStrategy &aiStrategy,
const CoordsSet &allCastleCoords) const -> ScoreValue {
const int roundsRemaining = GetMaxRounds() - state->current_round();
ScoreValue rawScore;
if (isDefender) {
rawScore = DefenderScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
} else {
rawScore = AttackerScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
}
// Apply power transformation to spread out scores for MCTS
return TransformForMCTS(rawScore);
}
// Factory function implementation
auto MakeNormalizedAIScoreCalculator(
const SettingsGetter &settingsGetter,
const APDCache &apdCache,
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
// Extract all battalion types into a vector indexed by BattalionTypeId
std::vector<BattalionTypeSPtr> battalionTypes(BattalionTypeId::BattalionTypeId_MAX + 1);
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
typeId <= BattalionTypeId::BattalionTypeId_MAX;
typeId++) {
auto battalionTypeId = static_cast<BattalionTypeId>(typeId);
battalionTypes[battalionTypeId] = settingsGetter.GetBattalionType(battalionTypeId);
}
return std::make_unique<NormalizedAIScoreCalculator>(
settingsGetter.Backing().max_rounds(),
settingsGetter.Backing().brave_water_action_point_cost(),
settingsGetter.Backing().meteor_range(),
settingsGetter.Backing().meteor_cast_vigor_cost(),
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
settingsGetter.Backing().ai_desperate_flee_threshold(),
std::move(battalionTypes),
apdCache,
alCache);
}
} // namespace shardok
@@ -1,44 +0,0 @@
//
// Normalized [0,1] implementation of AIScoreCalculator
//
#ifndef EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
#define EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
#include <memory>
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok {
// Forward declarations
class AIScoreCalculator;
using APDCache = std::shared_ptr<ActionPointDistancesCache>;
using ALCache = std::unique_ptr<AttackLocationsCache>;
/// Factory function to create a NormalizedAIScoreCalculator.
/// Returns a unique_ptr to AIScoreCalculator to hide the implementation.
///
/// The normalized scorer produces scores in the range [0, 1] where:
/// - 0.0 = complete defender victory
/// - 1.0 = complete attacker victory
/// - 0.5 = neutral/draw state
///
/// Terminal states (victory/defeat) always return 1.0 or 0.0.
/// Non-terminal states use asymmetric normalization:
/// - If victory condition >= 0:
/// score = (attackerUnits + victoryCondition) / (attackerUnits + defenderUnits +
/// victoryCondition)
/// - If victory condition < 0:
/// score = attackerUnits / (attackerUnits + defenderUnits - victoryCondition)
[[nodiscard]] auto MakeNormalizedAIScoreCalculator(
const SettingsGetter& settingsGetter,
const APDCache& apdCache,
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
} // namespace shardok
#endif // EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
@@ -4,14 +4,17 @@
#include "StandardAIScoreCalculator.hpp"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <unordered_map>
#include "private/AIScoreCalculatorSharedUtilities.hpp"
#include "private/AbstractAIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIExperimentConfig.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
@@ -67,6 +70,68 @@ struct AttackerScorePerformanceLogger {
}
};
struct StandardScoringExperiment {
double attackerUnitsScale = 1.0;
double defenderUnitsScale = 1.0;
double victoryConditionScale = 1.0;
double unitsBaseMultiplierScale = 1.0;
double attackerRoundMultiplier = 1.0;
double defenderRoundAdvantageScale = 1.0;
};
auto ExperimentalScoringId() -> int { return CurrentAIExperimentId(); }
auto ScoringExperimentForId(const int id) -> StandardScoringExperiment {
StandardScoringExperiment experiment;
switch (id) {
case 1: experiment.attackerUnitsScale = 1.05; break;
case 2: experiment.attackerUnitsScale = 1.10; break;
case 3: experiment.attackerUnitsScale = 0.95; break;
case 4: experiment.attackerUnitsScale = 0.90; break;
case 5: experiment.defenderUnitsScale = 0.95; break;
case 6: experiment.defenderUnitsScale = 0.90; break;
case 7: experiment.defenderUnitsScale = 1.05; break;
case 8: experiment.defenderUnitsScale = 1.10; break;
case 9: experiment.victoryConditionScale = 1.10; break;
case 10: experiment.victoryConditionScale = 1.25; break;
case 11: experiment.victoryConditionScale = 0.90; break;
case 12: experiment.victoryConditionScale = 0.75; break;
case 13: experiment.unitsBaseMultiplierScale = 0.90; break;
case 14: experiment.unitsBaseMultiplierScale = 1.10; break;
case 15: experiment.attackerRoundMultiplier = 0.90; break;
case 16: experiment.attackerRoundMultiplier = 0.75; break;
case 17: experiment.defenderRoundAdvantageScale = 0.50; break;
case 18: experiment.defenderRoundAdvantageScale = 0.0; break;
case 19:
experiment.attackerUnitsScale = 1.10;
experiment.victoryConditionScale = 1.10;
break;
case 20:
experiment.defenderUnitsScale = 0.90;
experiment.victoryConditionScale = 1.10;
break;
default: break;
}
return experiment;
}
auto AdjustDefenderUnitsForExperiment(
double defenderUnitsValue,
const int roundsRemaining,
const int maxRounds,
const StandardScoringExperiment &experiment) -> double {
const double currentRound = static_cast<double>(maxRounds - roundsRemaining);
const double standardAdvantage = 1.0 + currentRound / 31.0;
if (standardAdvantage <= 0.0) { return defenderUnitsValue * experiment.defenderUnitsScale; }
const double unadjustedDefenderValue = defenderUnitsValue / standardAdvantage;
const double adjustedAdvantage =
1.0 + experiment.defenderRoundAdvantageScale * currentRound / 31.0;
return unadjustedDefenderValue * adjustedAdvantage * experiment.defenderUnitsScale;
}
std::atomic<int> AttackerScorePerformanceLogger::callCount{0};
std::atomic<double> AttackerScorePerformanceLogger::intervalTime{0.0};
std::atomic<double> AttackerScorePerformanceLogger::totalTime{0.0};
@@ -95,23 +160,27 @@ public:
StandardAIScoreCalculator(
int maxRounds,
ActionPoints braveWaterCost,
ActionPoints archeryActionPointCost,
int meteorRange,
double meteorCastVigorCost,
int minimumFleeOddsThreshold,
int desperateFleeThreshold,
std::vector<BattalionTypeSPtr> battalionTypes,
const APDCache &apdCache,
const ALCache &alCache)
const ALCache &alCache,
StandardScoringExperiment experiment = {})
: AbstractAIScoreCalculator(
maxRounds,
braveWaterCost,
archeryActionPointCost,
meteorRange,
meteorCastVigorCost,
minimumFleeOddsThreshold,
desperateFleeThreshold,
std::move(battalionTypes),
apdCache,
alCache) {}
alCache),
experiment_(experiment) {}
[[nodiscard]] auto GuessedStateScore(
bool isDefender,
@@ -151,6 +220,8 @@ private:
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
-> ScoreValue override;
StandardScoringExperiment experiment_;
};
// Implementation of StandardAIScoreCalculator methods
@@ -159,7 +230,7 @@ auto StandardAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) co
switch (outcome) {
case GameOutcome::DEFENDER_VICTORY: return INT_MAX;
case GameOutcome::ATTACKER_VICTORY: return INT_MIN;
case GameOutcome::DRAW: return 0;
case GameOutcome::DRAW: return INT_MAX;
case GameOutcome::FLEE_OUTCOME: return 0;
}
throw ShardokInternalErrorException("Unknown GameOutcome");
@@ -169,7 +240,7 @@ auto StandardAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) co
switch (outcome) {
case GameOutcome::ATTACKER_VICTORY: return INT_MAX;
case GameOutcome::DEFENDER_VICTORY: return INT_MIN;
case GameOutcome::DRAW: return 0;
case GameOutcome::DRAW: return INT_MIN;
case GameOutcome::FLEE_OUTCOME: return 0;
}
throw ShardokInternalErrorException("Unknown GameOutcome");
@@ -241,10 +312,20 @@ auto StandardAIScoreCalculator::CombineAttackerScores(
const UnitsScoreComponents &components,
const double victoryConditionScore,
const int roundsRemaining) const -> ScoreValue {
const double unitsDifference = components.attackerUnitsValue - components.defenderUnitsValue;
const double unitsMultiplier =
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
const double attackerUnitsValue =
components.attackerUnitsValue * experiment_.attackerUnitsScale;
const double defenderUnitsValue = AdjustDefenderUnitsForExperiment(
components.defenderUnitsValue,
roundsRemaining,
GetMaxRounds(),
experiment_);
const double unitsDifference = attackerUnitsValue - defenderUnitsValue;
// Keep tactical unit value relevant late in the battle. The previous time decay could make
// attackers undervalue decisive combat near the round limit.
const double unitsMultiplier = experiment_.attackerRoundMultiplier;
return UNITS_BASE_MULTIPLIER * experiment_.unitsBaseMultiplierScale * unitsMultiplier *
unitsDifference +
victoryConditionScore * experiment_.victoryConditionScale;
}
auto StandardAIScoreCalculator::GuessedStateScore(
@@ -261,10 +342,11 @@ auto StandardAIScoreCalculator::GuessedStateScore(
}
// Factory function implementation
auto MakeStandardAIScoreCalculator(
auto MakeStandardAIScoreCalculatorWithExperiment(
const SettingsGetter &settingsGetter,
const APDCache &apdCache,
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
const ALCache &alCache,
const StandardScoringExperiment &experiment) -> std::unique_ptr<AIScoreCalculator> {
// Extract all battalion types into a vector indexed by BattalionTypeId
std::vector<BattalionTypeSPtr> battalionTypes(BattalionTypeId::BattalionTypeId_MAX + 1);
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
@@ -277,13 +359,37 @@ auto MakeStandardAIScoreCalculator(
return std::make_unique<StandardAIScoreCalculator>(
settingsGetter.Backing().max_rounds(),
settingsGetter.Backing().brave_water_action_point_cost(),
settingsGetter.Backing().archery_action_point_cost(),
settingsGetter.Backing().meteor_range(),
settingsGetter.Backing().meteor_cast_vigor_cost(),
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
settingsGetter.Backing().ai_desperate_flee_threshold(),
std::move(battalionTypes),
apdCache,
alCache);
alCache,
experiment);
}
auto MakeStandardAIScoreCalculator(
const SettingsGetter &settingsGetter,
const APDCache &apdCache,
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
return MakeStandardAIScoreCalculatorWithExperiment(
settingsGetter,
apdCache,
alCache,
StandardScoringExperiment{});
}
auto MakeExperimentalAIScoreCalculator(
const SettingsGetter &settingsGetter,
const APDCache &apdCache,
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
return MakeStandardAIScoreCalculatorWithExperiment(
settingsGetter,
apdCache,
alCache,
ScoringExperimentForId(ExperimentalScoringId()));
}
} // namespace shardok
@@ -26,6 +26,12 @@ using ALCache = std::unique_ptr<AttackLocationsCache>;
const APDCache& apdCache,
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
/// Factory function to create the environment-selected experimental scorer.
[[nodiscard]] auto MakeExperimentalAIScoreCalculator(
const SettingsGetter& settingsGetter,
const APDCache& apdCache,
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
} // namespace shardok
#endif // EAGLE0_STANDARDAISCORECALCULATOR_HPP
@@ -40,7 +40,7 @@ constexpr double kMaxProximityBuf = 1.5;
constexpr double kDistanceDebufRatio = 8.0;
/// Structure to hold separated attacker/defender unit scores
/// Used by NormalizedAIScoreCalculator to apply asymmetric normalization
/// Used by scorers that combine attacker and defender unit values separately.
struct UnitsScoreComponents {
double attackerUnitsValue;
double defenderUnitsValue;
@@ -157,7 +157,8 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
locationsCausingDanger,
notBravingApd,
GetMeteorRange(),
GetMeteorCastVigorCost());
GetMeteorCastVigorCost(),
GetArcheryActionPointCost());
attackerUnitsValue += distanceMultiplier * uv;
}
@@ -188,7 +189,8 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
locationsCausingDangerForAttacker,
defenderNotBravingApd,
GetMeteorRange(),
GetMeteorCastVigorCost());
GetMeteorCastVigorCost(),
GetArcheryActionPointCost());
double distanceMultiplier = 1.0;
@@ -251,7 +253,7 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
braveWaterCost)
: nullptr,
cachedHexMap);
if (thisDistance < closestDistanceToEnemy) {
if (thisDistance < closestDistanceToFriendly) {
closestDistanceToFriendly = thisDistance;
}
}
@@ -506,9 +508,7 @@ auto AbstractAIScoreCalculator::AttackerScoreForState(
const ScoreValue victoryConditionTotal =
CalculateAttackerVictoryConditionScore(gameState, attackerStrategy, castleCoords);
// Combine scores using subclass-specific logic
// Standard: uses difference and multiplier
// Normalized: uses normalization formula
// Combine scores using subclass-specific logic.
return CombineAttackerScores(components, victoryConditionTotal, roundsRemaining);
}
@@ -44,6 +44,7 @@ protected:
AbstractAIScoreCalculator(
int maxRounds,
ActionPoints braveWaterCost,
ActionPoints archeryActionPointCost,
int meteorRange,
double meteorCastVigorCost,
int minimumFleeOddsThreshold,
@@ -53,6 +54,7 @@ protected:
const ALCache &alCache)
: maxRounds_(maxRounds),
braveWaterCost_(braveWaterCost),
archeryActionPointCost_(archeryActionPointCost),
meteorRange_(meteorRange),
meteorCastVigorCost_(meteorCastVigorCost),
minimumFleeOddsThreshold_(minimumFleeOddsThreshold),
@@ -70,6 +72,9 @@ protected:
[[nodiscard]] auto GetApdCache() const -> const APDCache & { return apdCache_; }
[[nodiscard]] auto GetAlCache() const -> const ALCache & { return alCache_; }
[[nodiscard]] auto GetBraveWaterCost() const -> ActionPoints { return braveWaterCost_; }
[[nodiscard]] auto GetArcheryActionPointCost() const -> ActionPoints {
return archeryActionPointCost_;
}
[[nodiscard]] auto GetMaxRounds() const -> int { return maxRounds_; }
[[nodiscard]] auto GetMeteorRange() const -> int { return meteorRange_; }
[[nodiscard]] auto GetMeteorCastVigorCost() const -> double { return meteorCastVigorCost_; }
@@ -146,9 +151,8 @@ protected:
-> ScoreValue = 0;
// Pure virtual method for combining units score and victory condition score
// This is where Standard and Normalized diverge in their scoring approach
// Standard: uses difference and applies multiplier
// Normalized: uses normalization formula with separate attacker/defender values
// This is where concrete scorers combine unit value and victory condition pressure.
// Standard uses the raw unit-value difference; MCTS-optimized uses bounded scaling.
[[nodiscard]] virtual auto CombineAttackerScores(
const UnitsScoreComponents &components,
double victoryConditionScore,
@@ -158,6 +162,7 @@ private:
// Scalar settings extracted from SettingsGetter
int maxRounds_;
ActionPoints braveWaterCost_;
ActionPoints archeryActionPointCost_;
int meteorRange_;
double meteorCastVigorCost_;
int minimumFleeOddsThreshold_;
@@ -54,30 +54,38 @@ std::unique_ptr<BattleConfigProto> AiBattleConfigLoader::CreateDefaultPerfConfig
// Basic setup matching Unity's "Perf" configuration
config->set_map_name("Alah");
config->set_month(month);
config->set_max_rounds(40);
config->set_random_seed(0); // Use system random
config->set_max_rounds(31);
config->set_random_seed(1);
config->set_use_configured_placements(false);
config->set_apply_forced_commands(true);
// Attacker configuration - 6 Longbowmen units with professions 1-6
PlayerConfig* attacker = config->mutable_attacker();
attacker->set_ai_algorithm(AIAlgorithmType::ITERATIVE_DEEPENING);
attacker->set_scoring_calculator(
net::eagle0::shardok::ai_battle_simulator::ScoringCalculatorType::STANDARD);
for (int profession = 1; profession <= 6; ++profession) {
UnitConfig* unit = attacker->add_units();
unit->set_profession(profession);
unit->set_battalion_type_id(4); // Longbowmen (battalion type 4)
unit->set_starting_position_index(0); // Attackers use position 0
unit->set_battalion_type_id(4); // Longbowmen (battalion type 4)
unit->set_starting_position_index(0); // Attackers use position 0
unit->set_can_flee(true);
unit->mutable_battalion()->set_size(1000); // Match client battalion size
}
// Defender configuration - 6 Light Infantry units with NO_PROFESSION (profession 14)
PlayerConfig* defender = config->mutable_defender();
defender->set_ai_algorithm(AIAlgorithmType::ITERATIVE_DEEPENING);
defender->set_scoring_calculator(
net::eagle0::shardok::ai_battle_simulator::ScoringCalculatorType::STANDARD);
for (int i = 0; i < 6; ++i) {
UnitConfig* unit = defender->add_units();
unit->set_profession(14); // NO_PROFESSION to match client battles
unit->set_battalion_type_id(0); // Light Infantry (battalion type 0)
unit->set_starting_position_index(-1); // Defenders use position -1
unit->set_profession(14); // NO_PROFESSION to match client battles
unit->set_battalion_type_id(0); // Light Infantry (battalion type 0)
unit->set_starting_position_index(-1); // Defenders use position -1
unit->set_can_flee(true);
unit->mutable_battalion()->set_size(1000); // Match client battalion size
}
@@ -4,14 +4,19 @@
#include "AiBattleSimulator.hpp"
#include <algorithm>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GameSettingsFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/UnitPlacementInfo.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
@@ -45,6 +50,67 @@ constexpr int DEFAULT_HERO_INTEGRITY = 0;
constexpr int DEFAULT_HERO_AMBITION = 0;
constexpr int DEFAULT_HERO_VIGOR = 102;
constexpr int PROFESSION_MAGE = 1;
constexpr double MINIMUM_AGILITY_FOR_ARCHERY = 75.0;
constexpr double MINIMUM_AGILITY_FOR_START_FIRE = 70.0;
constexpr double MINIMUM_WISDOM_FOR_START_FIRE = 70.0;
struct SideTotals {
int attackerUnits = 0;
int defenderUnits = 0;
double attackerTroops = 0.0;
double defenderTroops = 0.0;
};
std::string JsonEscape(const std::string& value) {
std::ostringstream out;
for (const char c : value) {
switch (c) {
case '"': out << "\\\""; break;
case '\\': out << "\\\\"; break;
case '\n': out << "\\n"; break;
case '\r': out << "\\r"; break;
case '\t': out << "\\t"; break;
default: out << c; break;
}
}
return out.str();
}
std::string CompletionReasonLabel(EvaluationCompletionReason reason) {
switch (reason) {
case EvaluationCompletionReason::RAN_OUT_OF_COMMANDS: return "ran_out_of_commands";
case EvaluationCompletionReason::RAN_OUT_OF_TIME: return "ran_out_of_time";
case EvaluationCompletionReason::NOT_ENOUGH_TIME_TO_CONTINUE:
return "not_enough_time_to_continue";
}
}
SideTotals CalculateSideTotals(const GameStateW& state) {
SideTotals totals;
const auto* units = state->units();
if (units == nullptr) { return totals; }
for (const auto* unit : *units) {
if (unit == nullptr) { continue; }
const bool isAlive =
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT ||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT ||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT;
if (!isAlive) { continue; }
if (unit->player_id() == ATTACKER_ID) {
totals.attackerUnits++;
totals.attackerTroops += unit->battalion().size();
} else if (unit->player_id() == DEFENDER_ID) {
totals.defenderUnits++;
totals.defenderTroops += unit->battalion().size();
}
}
return totals;
}
// Convert proto AI algorithm type to ShardokAI enum
AIAlgorithmType ConvertAIAlgorithmType(
net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType protoType) {
@@ -55,21 +121,160 @@ AIAlgorithmType ConvertAIAlgorithmType(
}
}
ScoringCalculatorType ConvertScoringCalculatorType(
net::eagle0::shardok::ai_battle_simulator::ScoringCalculatorType protoType) {
switch (protoType) {
case net::eagle0::shardok::ai_battle_simulator::EXPERIMENTAL:
return ScoringCalculatorType::EXPERIMENTAL;
case net::eagle0::shardok::ai_battle_simulator::MCTS_OPTIMIZED:
return ScoringCalculatorType::MCTS_OPTIMIZED;
case net::eagle0::shardok::ai_battle_simulator::STANDARD:
case net::eagle0::shardok::ai_battle_simulator::SCORING_CALCULATOR_TYPE_UNSPECIFIED:
default: return ScoringCalculatorType::STANDARD;
}
}
mcts::MCTSSimulationPolicy ConvertSimulationPolicy(
net::eagle0::shardok::ai_battle_simulator::MctsSimulationPolicy protoType) {
switch (protoType) {
case net::eagle0::shardok::ai_battle_simulator::RANDOM:
return mcts::MCTSSimulationPolicy::RANDOM;
case net::eagle0::shardok::ai_battle_simulator::FILTERED_RANDOM:
return mcts::MCTSSimulationPolicy::FILTERED_RANDOM;
case net::eagle0::shardok::ai_battle_simulator::BEST_IMMEDIATE:
return mcts::MCTSSimulationPolicy::BEST_IMMEDIATE;
case net::eagle0::shardok::ai_battle_simulator::WEIGHTED_BEST_IMMEDIATE:
return mcts::MCTSSimulationPolicy::WEIGHTED_BEST_IMMEDIATE;
case net::eagle0::shardok::ai_battle_simulator::WEIGHTED_HEURISTIC:
case net::eagle0::shardok::ai_battle_simulator::MCTS_SIMULATION_POLICY_UNSPECIFIED:
default: return mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
}
}
mcts::MCTSBackpropagationPolicy ConvertBackpropagationPolicy(
net::eagle0::shardok::ai_battle_simulator::MctsBackpropagationPolicy protoType) {
switch (protoType) {
case net::eagle0::shardok::ai_battle_simulator::AVERAGING:
return mcts::MCTSBackpropagationPolicy::AVERAGING;
case net::eagle0::shardok::ai_battle_simulator::MINIMAX:
case net::eagle0::shardok::ai_battle_simulator::MCTS_BACKPROPAGATION_POLICY_UNSPECIFIED:
default: return mcts::MCTSBackpropagationPolicy::MINIMAX;
}
}
mcts::MCTSConfig BuildMCTSConfig(
const net::eagle0::shardok::ai_battle_simulator::PlayerConfig& playerConfig,
int battleSeed,
PlayerId playerId) {
mcts::MCTSConfig config{};
config.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::MINIMAX;
config.maxPlayerFlips = 1;
config.maxSimulationFlips = 1;
config.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
if (battleSeed != 0) { config.useMultithreading = false; }
if (battleSeed != 0) {
config.randomSeed = static_cast<uint32_t>(battleSeed + (1009 * playerId));
}
if (!playerConfig.has_mcts_config()) { return config; }
const auto& protoConfig = playerConfig.mcts_config();
if (protoConfig.exploration_constant() > 0.0) {
config.explorationConstant = protoConfig.exploration_constant();
}
if (protoConfig.max_simulation_depth() > 0) {
config.maxSimulationDepth = protoConfig.max_simulation_depth();
}
if (protoConfig.max_tree_depth() > 0) { config.maxTreeDepth = protoConfig.max_tree_depth(); }
config.useMultithreading = protoConfig.use_multithreading();
if (protoConfig.num_threads() > 0) { config.numThreads = protoConfig.num_threads(); }
config.simulationPolicy = ConvertSimulationPolicy(protoConfig.simulation_policy());
config.backpropagationPolicy =
ConvertBackpropagationPolicy(protoConfig.backpropagation_policy());
if (protoConfig.max_player_flips() >= 0) {
config.maxPlayerFlips = protoConfig.max_player_flips();
}
if (protoConfig.max_simulation_flips() >= 0) {
config.maxSimulationFlips = protoConfig.max_simulation_flips();
}
if (protoConfig.random_seed() != 0) {
config.randomSeed = static_cast<uint32_t>(protoConfig.random_seed());
}
return config;
}
bool HasAttachedHero(int profession) { return profession > 0; }
// Get value with default if not set (proto3 uses 0 as default, we check for that)
template<typename T>
T GetOrDefault(T value, T defaultValue) {
return (value == 0) ? defaultValue : value;
}
bool HeroCanArchery(const net::eagle0::shardok::ai_battle_simulator::UnitConfig& unitConfig) {
if (!HasAttachedHero(unitConfig.profession())) { return false; }
return GetOrDefault(unitConfig.hero().agility(), DEFAULT_HERO_AGILITY) >=
MINIMUM_AGILITY_FOR_ARCHERY;
}
bool HeroCanStartFire(const net::eagle0::shardok::ai_battle_simulator::UnitConfig& unitConfig) {
if (!HasAttachedHero(unitConfig.profession())) { return false; }
return unitConfig.profession() == PROFESSION_MAGE ||
(GetOrDefault(unitConfig.hero().agility(), DEFAULT_HERO_AGILITY) >=
MINIMUM_AGILITY_FOR_START_FIRE &&
GetOrDefault(unitConfig.hero().wisdom(), DEFAULT_HERO_WISDOM) >=
MINIMUM_WISDOM_FOR_START_FIRE);
}
double GetBattalionSizeOrDefault(
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion,
double defaultValue) {
return battalion.has_size() ? battalion.size() : defaultValue;
}
bool GetCanFleeOrDefault(const net::eagle0::shardok::ai_battle_simulator::UnitConfig& unitConfig) {
return unitConfig.has_can_flee() ? unitConfig.can_flee() : true;
}
double GetBattalionArmamentOrDefault(
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion) {
return battalion.has_armament() ? battalion.armament() : DEFAULT_BATTALION_ARMAMENT;
}
double GetBattalionTrainingOrDefault(
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion) {
return battalion.has_training() ? battalion.training() : DEFAULT_BATTALION_TRAINING;
}
double GetBattalionMoraleOrDefault(
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion) {
return battalion.has_morale() ? battalion.morale() : DEFAULT_BATTALION_MORALE;
}
} // namespace
AiBattleSimulator::AiBattleSimulator(const BattleConfigProto& config, GameSettingsSPtr gameSettings)
AiBattleSimulator::AiBattleSimulator(
const BattleConfigProto& config,
GameSettingsSPtr gameSettings,
std::optional<GameStateW> initialGameState,
BattleTraceOptions traceOptions)
: config_(config),
gameSettings_(std::move(gameSettings)) {
gameSettings_(std::move(gameSettings)),
initialGameState_(std::move(initialGameState)),
traceOptions_(std::move(traceOptions)) {
if (!gameSettings_) {
// Initialize default game settings
gameSettings_ = ai_testing_common::GameSettingsFactory::CreateDefault();
}
if (config_.random_seed() != 0) {
auto setter = gameSettings_->GetSetter();
setter.SetRandomGenerator(std::make_shared<StdLibraryGenerator>(
static_cast<std::mt19937_64::result_type>(config_.random_seed())));
}
if (config_.max_rounds() > 0) {
auto setter = gameSettings_->GetSetter();
setter.SetInt("maxRounds", config_.max_rounds());
}
}
BattleResult AiBattleSimulator::RunBattle() {
@@ -77,6 +282,7 @@ BattleResult AiBattleSimulator::RunBattle() {
std::cout << " Map: " << config_.map_name() << "\n";
std::cout << " Month: " << config_.month() << "\n";
std::cout << " Max rounds: " << config_.max_rounds() << "\n";
std::cout << " Random seed: " << config_.random_seed() << "\n";
std::cout << " Attacker AI: "
<< net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType_Name(
config_.attacker().ai_algorithm())
@@ -87,7 +293,7 @@ BattleResult AiBattleSimulator::RunBattle() {
<< "\n";
// Create initial game state
auto gameState = CreateInitialGameState();
auto gameState = initialGameState_.has_value() ? *initialGameState_ : CreateInitialGameState();
// Get hex map from game state
const auto* hexMap = gameState->hex_map();
@@ -99,17 +305,25 @@ BattleResult AiBattleSimulator::RunBattle() {
// Create the engine once for the entire simulation
ShardokEngine engine(gameSettings_, gameState);
// Run setup phase
std::cout << "\nStarting setup phase...\n";
auto setupResult = RunSetupPhase(engine, *attackerAI, *defenderAI);
if (setupResult.endReason != BattleResult::EndReason::DRAW) {
// Game ended during setup (shouldn't happen normally)
return setupResult;
int setupCommands = 0;
if (!initialGameState_.has_value()) {
// Run setup phase
std::cout << "\nStarting setup phase...\n";
auto setupResult = config_.use_configured_placements()
? RunConfiguredSetupPhase(engine)
: RunSetupPhase(engine, *attackerAI, *defenderAI);
if (setupResult.endReason != BattleResult::EndReason::DRAW) {
// Game ended during setup (shouldn't happen normally)
return setupResult;
}
setupCommands = setupResult.totalCommands;
} else {
std::cout << "\nStarting from saved Shardok state; skipping setup phase.\n";
}
// Run battle phase
std::cout << "\nStarting battle phase...\n";
return RunBattlePhase(engine, *attackerAI, *defenderAI);
return RunBattlePhase(engine, *attackerAI, *defenderAI, setupCommands);
}
GameStateW AiBattleSimulator::CreateInitialGameState() const {
@@ -157,7 +371,7 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
unit.mutate_remaining_action_points(12);
unit.mutate_hidden(false);
unit.mutate_fortified(false);
unit.mutate_can_flee(true);
unit.mutate_can_flee(GetCanFleeOrDefault(unitConfig));
unit.mutate_can_start_fire(false);
unit.mutate_can_archery(false);
unit.mutate_stun_rounds_remaining(0);
@@ -178,18 +392,17 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
// Get battalion type to determine default capacity
const auto& battalionType = gameSettings_->GetGetter().GetBattalionType(battalionTypeId);
const double defaultSize = battalionType->capacity;
unit.mutate_can_archery(battalionType->alwaysArcheryCapable || HeroCanArchery(unitConfig));
unit.mutate_can_start_fire(HeroCanStartFire(unitConfig));
battalion.mutate_size(GetOrDefault(unitConfig.battalion().size(), defaultSize));
battalion.mutate_armament(
GetOrDefault(unitConfig.battalion().armament(), DEFAULT_BATTALION_ARMAMENT));
battalion.mutate_training(
GetOrDefault(unitConfig.battalion().training(), DEFAULT_BATTALION_TRAINING));
battalion.mutate_morale(
GetOrDefault(unitConfig.battalion().morale(), DEFAULT_BATTALION_MORALE));
battalion.mutate_size(GetBattalionSizeOrDefault(unitConfig.battalion(), defaultSize));
battalion.mutate_armament(GetBattalionArmamentOrDefault(unitConfig.battalion()));
battalion.mutate_training(GetBattalionTrainingOrDefault(unitConfig.battalion()));
battalion.mutate_morale(GetBattalionMoraleOrDefault(unitConfig.battalion()));
unit.mutable_battalion() = battalion;
// Hero
if (unitConfig.profession() > 0) {
if (HasAttachedHero(unitConfig.profession())) {
unit.mutate_has_attached_hero(true);
net::eagle0::shardok::storage::fb::Hero hero;
@@ -228,6 +441,9 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
} else {
unit.mutate_has_attached_hero(false);
}
if (battalion.size() <= 0 && !unit.has_attached_hero()) {
unit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT);
}
// Initialize opponent knowledge
unit.mutable_opponent_knowledge()->Mutate(0, 0);
@@ -250,7 +466,7 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
unit.mutate_remaining_action_points(0); // Player 1 starts with 0 AP (not their turn yet)
unit.mutate_hidden(false);
unit.mutate_fortified(false);
unit.mutate_can_flee(true);
unit.mutate_can_flee(GetCanFleeOrDefault(unitConfig));
unit.mutate_can_start_fire(false);
unit.mutate_can_archery(false);
unit.mutate_stun_rounds_remaining(0);
@@ -271,18 +487,17 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
// Get battalion type to determine default capacity
const auto& battalionType = gameSettings_->GetGetter().GetBattalionType(battalionTypeId);
const double defaultSize = battalionType->capacity;
unit.mutate_can_archery(battalionType->alwaysArcheryCapable || HeroCanArchery(unitConfig));
unit.mutate_can_start_fire(HeroCanStartFire(unitConfig));
battalion.mutate_size(GetOrDefault(unitConfig.battalion().size(), defaultSize));
battalion.mutate_armament(
GetOrDefault(unitConfig.battalion().armament(), DEFAULT_BATTALION_ARMAMENT));
battalion.mutate_training(
GetOrDefault(unitConfig.battalion().training(), DEFAULT_BATTALION_TRAINING));
battalion.mutate_morale(
GetOrDefault(unitConfig.battalion().morale(), DEFAULT_BATTALION_MORALE));
battalion.mutate_size(GetBattalionSizeOrDefault(unitConfig.battalion(), defaultSize));
battalion.mutate_armament(GetBattalionArmamentOrDefault(unitConfig.battalion()));
battalion.mutate_training(GetBattalionTrainingOrDefault(unitConfig.battalion()));
battalion.mutate_morale(GetBattalionMoraleOrDefault(unitConfig.battalion()));
unit.mutable_battalion() = battalion;
// Hero
if (unitConfig.profession() > 0) {
if (HasAttachedHero(unitConfig.profession())) {
unit.mutate_has_attached_hero(true);
net::eagle0::shardok::storage::fb::Hero hero;
@@ -321,6 +536,9 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
} else {
unit.mutate_has_attached_hero(false);
}
if (battalion.size() <= 0 && !unit.has_attached_hero()) {
unit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT);
}
// Initialize opponent knowledge
unit.mutable_opponent_knowledge()->Mutate(0, 0);
@@ -348,13 +566,19 @@ std::unique_ptr<ShardokAIClient> AiBattleSimulator::CreateAIClient(
const bool isDefender = (playerId == DEFENDER_ID);
AIAlgorithmType algorithmType = ConvertAIAlgorithmType(playerConfig.ai_algorithm());
const ScoringCalculatorType scoringType =
ConvertScoringCalculatorType(playerConfig.scoring_calculator());
const auto mctsConfig = BuildMCTSConfig(playerConfig, config_.random_seed(), playerId);
return ai_testing_common::AIClientFactory::Create(
playerId,
isDefender,
hexMap,
gameSettings_->GetGetter(),
algorithmType);
algorithmType,
scoringType,
true,
mctsConfig);
}
BattleResult AiBattleSimulator::RunSetupPhase(
@@ -365,7 +589,67 @@ BattleResult AiBattleSimulator::RunSetupPhase(
return (playerId == ATTACKER_ID) ? attackerAI : defenderAI;
};
auto phaseResult = ai_testing_common::GamePhaseRunner::RunSetupPhase(engine, getAI);
ai_testing_common::PhaseResult phaseResult;
while (engine.GetCurrentGameState()->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
auto currentState = engine.GetCurrentGameState();
PlayerId currentPlayer = currentState->current_player();
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
if (availableCommands.empty()) { break; }
ShardokAIClient& activeAI = getAI(currentPlayer);
const SideTotals preTotals = CalculateSideTotals(currentState);
auto choiceResults = activeAI.ChooseCommandIndex(engine);
const auto command = availableCommands.at(choiceResults.chosenIndex);
const auto randomGenerator = gameSettings_->GetGetter().GetRandomGenerator();
engine.PostCommand(currentPlayer, choiceResults.chosenIndex, randomGenerator);
phaseResult.commandsExecuted++;
const auto postState = engine.GetCurrentGameState();
const SideTotals postTotals = CalculateSideTotals(postState);
if (traceOptions_.output != nullptr) {
*traceOptions_.output
<< "{"
<< "\"run_label\":\"" << JsonEscape(traceOptions_.runLabel) << "\","
<< "\"config\":\"" << JsonEscape(traceOptions_.configPath) << "\","
<< "\"state_file\":\"" << JsonEscape(traceOptions_.statePath) << "\","
<< "\"phase\":\"setup\","
<< "\"sequence\":" << phaseResult.commandsExecuted << ","
<< "\"round\":" << static_cast<int>(currentState->current_round()) << ","
<< "\"player\":" << static_cast<int>(currentPlayer) << ","
<< "\"side\":\"" << (currentPlayer == ATTACKER_ID ? "attacker" : "defender")
<< "\","
<< "\"command_type\":\""
<< net::eagle0::shardok::common::CommandType_Name(command.type()) << "\","
<< "\"actor_unit\":" << (command.has_actor() ? command.actor().value() : -1)
<< ","
<< "\"target_row\":" << command.target().row() << ","
<< "\"target_column\":" << command.target().column() << ","
<< "\"chosen_index\":" << choiceResults.chosenIndex << ","
<< "\"available_command_count\":" << choiceResults.availableCommandCount << ","
<< "\"depth_achieved\":" << choiceResults.depthAchieved << ","
<< "\"commands_evaluated\":" << choiceResults.commandCountEvaluated << ","
<< "\"completion_reason\":\""
<< CompletionReasonLabel(choiceResults.completionReason) << "\","
<< "\"forced_commands\":0,"
<< "\"pre_attacker_units\":" << preTotals.attackerUnits << ","
<< "\"pre_defender_units\":" << preTotals.defenderUnits << ","
<< "\"pre_attacker_troops\":" << preTotals.attackerTroops << ","
<< "\"pre_defender_troops\":" << preTotals.defenderTroops << ","
<< "\"post_attacker_units\":" << postTotals.attackerUnits << ","
<< "\"post_defender_units\":" << postTotals.defenderUnits << ","
<< "\"post_attacker_troops\":" << postTotals.attackerTroops << ","
<< "\"post_defender_troops\":" << postTotals.defenderTroops << "}\n";
}
if (engine.GameIsOver()) {
phaseResult.gameEnded = true;
break;
}
}
std::cout << "Setup phase complete. Commands executed: " << phaseResult.commandsExecuted
<< "\n";
@@ -388,14 +672,72 @@ BattleResult AiBattleSimulator::RunSetupPhase(
return result;
}
BattleResult AiBattleSimulator::RunConfiguredSetupPhase(ShardokEngine& engine) {
auto placePlayer = [&](PlayerId playerId,
const net::eagle0::shardok::ai_battle_simulator::PlayerConfig&
playerConfig,
int unitIdOffset) {
std::vector<UnitPlacementInfo> placements;
placements.reserve(playerConfig.units_size());
for (int i = 0; i < playerConfig.units_size(); ++i) {
const auto& unitConfig = playerConfig.units(i);
if (!unitConfig.has_fixed_position()) {
throw std::invalid_argument(
"use_configured_placements requires has_fixed_position for every unit");
}
placements.emplace_back(
static_cast<UnitId>(unitIdOffset + i),
Coords(unitConfig.row(), unitConfig.column()));
}
const auto randomGenerator = gameSettings_->GetGetter().GetRandomGenerator();
engine.PostPlacementCommands(playerId, placements, randomGenerator);
engine.PostFinishedPlacementCommand(playerId, randomGenerator);
if (config_.apply_forced_commands()) {
engine.PostWhileCurrentPlayerHasOnlyOneOption(randomGenerator);
}
};
placePlayer(ATTACKER_ID, config_.attacker(), 0);
if (!engine.GameIsOver()) {
placePlayer(DEFENDER_ID, config_.defender(), config_.attacker().units_size());
}
std::cout << "Configured setup complete.\n";
if (engine.GameIsOver()) {
return CreateResultFromGameState(
engine.GetCurrentGameState(),
0,
static_cast<int>(engine.GetUnfilteredHistoryCount()));
}
BattleResult result;
result.winner = -1;
result.totalRounds = 0;
result.totalCommands = static_cast<int>(engine.GetUnfilteredHistoryCount());
result.endReason = BattleResult::EndReason::DRAW;
result.description = "Configured setup completed";
return result;
}
BattleResult AiBattleSimulator::RunBattlePhase(
ShardokEngine& engine,
ShardokAIClient& attackerAI,
ShardokAIClient& defenderAI) {
int totalCommands = 0;
int currentRound = 1;
ShardokAIClient& defenderAI,
int setupCommands) {
int totalCommands = setupCommands;
int attackerCommands = 0;
int defenderCommands = 0;
int attackerDepthTotal = 0;
int defenderDepthTotal = 0;
int currentRound = std::max<int>(1, engine.GetCurrentGameState()->current_round());
while (!engine.GameIsOver() && currentRound <= config_.max_rounds()) {
const int harnessMaxRounds = config_.max_rounds() > 0
? config_.max_rounds()
: gameSettings_->GetGetter().Backing().max_rounds();
while (!engine.GameIsOver() && currentRound <= harnessMaxRounds) {
auto currentState = engine.GetCurrentGameState();
PlayerId currentPlayer = currentState->current_player();
@@ -410,14 +752,66 @@ BattleResult AiBattleSimulator::RunBattlePhase(
ShardokAIClient& activeAI = (currentPlayer == ATTACKER_ID) ? attackerAI : defenderAI;
// Get AI decision
const SideTotals preTotals = CalculateSideTotals(currentState);
auto choiceResults = activeAI.ChooseCommandIndex(engine);
const auto command = availableCommands.at(choiceResults.chosenIndex);
// Apply command
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
const auto randomGenerator = gameSettings_->GetGetter().GetRandomGenerator();
engine.PostCommand(currentPlayer, choiceResults.chosenIndex, randomGenerator);
totalCommands++;
int forcedCommands = 0;
if (currentPlayer == ATTACKER_ID) {
attackerCommands++;
attackerDepthTotal += choiceResults.depthAchieved;
} else if (currentPlayer == DEFENDER_ID) {
defenderCommands++;
defenderDepthTotal += choiceResults.depthAchieved;
}
if (config_.apply_forced_commands()) {
forcedCommands = engine.PostWhileCurrentPlayerHasOnlyOneOption(randomGenerator);
totalCommands += forcedCommands;
}
// Check round progression
auto newState = engine.GetCurrentGameState();
const SideTotals postTotals = CalculateSideTotals(newState);
if (traceOptions_.output != nullptr) {
const int sequence = totalCommands - forcedCommands;
*traceOptions_.output
<< "{"
<< "\"run_label\":\"" << JsonEscape(traceOptions_.runLabel) << "\","
<< "\"config\":\"" << JsonEscape(traceOptions_.configPath) << "\","
<< "\"state_file\":\"" << JsonEscape(traceOptions_.statePath) << "\","
<< "\"phase\":\"battle\","
<< "\"sequence\":" << sequence << ","
<< "\"round\":" << static_cast<int>(currentState->current_round()) << ","
<< "\"player\":" << static_cast<int>(currentPlayer) << ","
<< "\"side\":\"" << (currentPlayer == ATTACKER_ID ? "attacker" : "defender")
<< "\","
<< "\"command_type\":\""
<< net::eagle0::shardok::common::CommandType_Name(command.type()) << "\","
<< "\"actor_unit\":" << (command.has_actor() ? command.actor().value() : -1)
<< ","
<< "\"target_row\":" << command.target().row() << ","
<< "\"target_column\":" << command.target().column() << ","
<< "\"chosen_index\":" << choiceResults.chosenIndex << ","
<< "\"available_command_count\":" << choiceResults.availableCommandCount << ","
<< "\"depth_achieved\":" << choiceResults.depthAchieved << ","
<< "\"commands_evaluated\":" << choiceResults.commandCountEvaluated << ","
<< "\"completion_reason\":\""
<< CompletionReasonLabel(choiceResults.completionReason) << "\","
<< "\"forced_commands\":" << forcedCommands << ","
<< "\"pre_attacker_units\":" << preTotals.attackerUnits << ","
<< "\"pre_defender_units\":" << preTotals.defenderUnits << ","
<< "\"pre_attacker_troops\":" << preTotals.attackerTroops << ","
<< "\"pre_defender_troops\":" << preTotals.defenderTroops << ","
<< "\"post_attacker_units\":" << postTotals.attackerUnits << ","
<< "\"post_defender_units\":" << postTotals.defenderUnits << ","
<< "\"post_attacker_troops\":" << postTotals.attackerTroops << ","
<< "\"post_defender_troops\":" << postTotals.defenderTroops << "}\n";
}
if (newState->current_round() > currentRound) {
std::cout << "Round " << currentRound
<< " completed. Commands this round: " << totalCommands << "\n";
@@ -428,7 +822,23 @@ BattleResult AiBattleSimulator::RunBattlePhase(
std::cout << "Battle phase complete. Total rounds: " << currentRound
<< ", Total commands: " << totalCommands << "\n";
return CreateResultFromGameState(engine.GetCurrentGameState(), currentRound, totalCommands);
auto result =
CreateResultFromGameState(engine.GetCurrentGameState(), currentRound, totalCommands);
if (!engine.GameIsOver() && currentRound > harnessMaxRounds) {
result.winner = DEFENDER_ID;
result.totalRounds = harnessMaxRounds;
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
result.description = "Defender won by surviving maximum rounds";
}
result.attackerCommands = attackerCommands;
result.defenderCommands = defenderCommands;
result.attackerAverageDepth =
attackerCommands == 0 ? 0.0
: static_cast<double>(attackerDepthTotal) / attackerCommands;
result.defenderAverageDepth =
defenderCommands == 0 ? 0.0
: static_cast<double>(defenderDepthTotal) / defenderCommands;
return result;
}
bool AiBattleSimulator::IsGameOver(const GameStateW& state) const {
@@ -455,10 +865,10 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
if (winningIds && winningIds->size() > 0) {
result.winner = winningIds->Get(0);
// Determine how the game ended
if (result.winner == ATTACKER_ID) {
// Attacker won
if (totalRounds >= config_.max_rounds()) {
if (status->end_game_condition()->victory_details() ==
net::eagle0::shardok::storage::fb::
VictoryCondition_VICTORY_CONDITION_HOLDS_CRITICAL_TILES) {
result.endReason = BattleResult::EndReason::CRITICAL_TILES_CONTROLLED;
result.description = "Attacker won by controlling critical tiles";
} else {
@@ -466,8 +876,9 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
result.description = "Attacker won by eliminating all defenders";
}
} else if (result.winner == DEFENDER_ID) {
// Defender won
if (totalRounds >= config_.max_rounds()) {
if (status->end_game_condition()->victory_details() ==
net::eagle0::shardok::storage::fb::
VictoryCondition_VICTORY_CONDITION_WIN_AFTER_MAX_ROUNDS) {
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
result.description = "Defender won by surviving maximum rounds";
} else {
@@ -489,10 +900,11 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
result.endReason = BattleResult::EndReason::DRAW;
result.description = "Game ended in a draw";
} else {
// Game not over yet (shouldn't happen)
result.winner = -1;
result.endReason = BattleResult::EndReason::DRAW;
result.description = "Game incomplete";
// If the harness stops before the engine posts its max-round status transition, Shardok's
// battle rule still awards the battle to the defender.
result.winner = DEFENDER_ID;
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
result.description = "Defender won by surviving maximum rounds";
}
// Collect surviving units (for winner, or all units if draw)
@@ -511,6 +923,14 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
if (!isAlive) { continue; }
if (unit->player_id() == ATTACKER_ID) {
result.attackerSurvivingUnits++;
result.attackerSurvivingTroops += unit->battalion().size();
} else if (unit->player_id() == DEFENDER_ID) {
result.defenderSurvivingUnits++;
result.defenderSurvivingTroops += unit->battalion().size();
}
// For victories, only include winner's units; for draws, include all
if (result.winner != -1 && unit->player_id() != result.winner) { continue; }
@@ -6,6 +6,8 @@
#define EAGLE0_AI_BATTLE_SIMULATOR_HPP
#include <memory>
#include <optional>
#include <ostream>
#include <string>
#include <vector>
@@ -51,6 +53,20 @@ struct BattleResult {
// Total number of commands executed
int totalCommands;
// AI-selected commands by player.
int attackerCommands = 0;
int defenderCommands = 0;
// Approximate average search depth reported by each AI.
double attackerAverageDepth = 0.0;
double defenderAverageDepth = 0.0;
// Surviving units and troop totals by side, useful for scoring A/B tests.
int attackerSurvivingUnits = 0;
int defenderSurvivingUnits = 0;
double attackerSurvivingTroops = 0.0;
double defenderSurvivingTroops = 0.0;
// How the game ended
enum class EndReason {
LAST_PLAYER_STANDING, // One player eliminated all opponent units
@@ -67,6 +83,13 @@ struct BattleResult {
std::vector<SurvivingUnit> survivingUnits;
};
struct BattleTraceOptions {
std::ostream* output = nullptr;
std::string runLabel;
std::string configPath;
std::string statePath;
};
class AiBattleSimulator {
public:
/**
@@ -77,7 +100,9 @@ public:
*/
explicit AiBattleSimulator(
const BattleConfigProto& config,
GameSettingsSPtr gameSettings = nullptr);
GameSettingsSPtr gameSettings = nullptr,
std::optional<GameStateW> initialGameState = std::nullopt,
BattleTraceOptions traceOptions = {});
/**
* Run the battle from start to completion.
@@ -90,9 +115,13 @@ public:
[[nodiscard]] BattleResult RunBattle();
private:
friend class AiBattleSimulatorTest_GeneratedUnitsMirrorProductionCapabilityFlags_Test;
// Configuration
const BattleConfigProto config_;
GameSettingsSPtr gameSettings_;
std::optional<GameStateW> initialGameState_;
BattleTraceOptions traceOptions_;
// Game state builder and helpers
[[nodiscard]] GameStateW CreateInitialGameState() const;
@@ -104,8 +133,13 @@ private:
[[nodiscard]] BattleResult
RunSetupPhase(ShardokEngine& engine, ShardokAIClient& attackerAI, ShardokAIClient& defenderAI);
[[nodiscard]] BattleResult
RunBattlePhase(ShardokEngine& engine, ShardokAIClient& attackerAI, ShardokAIClient& defenderAI);
[[nodiscard]] BattleResult RunConfiguredSetupPhase(ShardokEngine& engine);
[[nodiscard]] BattleResult RunBattlePhase(
ShardokEngine& engine,
ShardokAIClient& attackerAI,
ShardokAIClient& defenderAI,
int setupCommands);
[[nodiscard]] bool IsGameOver(const GameStateW& state) const;
[[nodiscard]] BattleResult
@@ -21,6 +21,7 @@ cc_library(
deps = [
":ai_battle_config",
"//src/main/cpp/net/eagle0/common:filesystem_utils",
"//src/main/cpp/net/eagle0/common:random_generator",
"//src/main/cpp/net/eagle0/common:tsv_parser",
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:ai_client_factory",
@@ -28,6 +29,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_settings_factory",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:unit_placement_info",
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/util:battalion_type_registrar",
@@ -42,7 +44,7 @@ cc_binary(
name = "ai_battle_simulator_main",
srcs = ["ai_battle_simulator_main.cpp"],
copts = COPTS,
data = [
data = glob(["benchmarks/*.json"]) + [
"//src/main/resources/net/eagle0/shardok:battalion_types",
"//src/main/resources/net/eagle0/shardok:settings",
"//src/main/resources/net/eagle0/shardok/maps",
@@ -54,3 +56,26 @@ cc_binary(
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:fixed_action_point_distances",
],
)
cc_binary(
name = "real_battle_config_extractor",
srcs = ["real_battle_config_extractor_main.cpp"],
copts = COPTS + ["-Wno-deprecated-declarations"],
deps = [
":ai_battle_config",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_cc_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_cc_proto",
],
)
cc_binary(
name = "real_battle_smoke_metadata",
srcs = ["real_battle_smoke_metadata_main.cpp"],
copts = COPTS + ["-Wno-deprecated-declarations"],
deps = [
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
"//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/api:action_result_view_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_cc_proto",
],
)
@@ -2,37 +2,78 @@
// AI Battle Simulator - CLI Entry Point
//
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <optional>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
#include "AiBattleConfig.hpp"
#include "AiBattleSimulator.hpp"
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
using shardok::GameStateW;
using shardok::ai_battle_simulator::AiBattleConfigLoader;
using shardok::ai_battle_simulator::AiBattleSimulator;
using shardok::ai_battle_simulator::BattleResult;
using shardok::ai_battle_simulator::BattleTraceOptions;
namespace {
enum class SummaryFormat {
HUMAN,
CSV,
JSON,
};
class NullBuffer : public std::streambuf {
protected:
int overflow(int c) override { return c; }
};
void PrintUsage(const char* programName) {
std::cout << "AI Battle Simulator - AI vs AI Battle Testing Tool\n"
<< "\n"
<< "Usage:\n"
<< " " << programName << " --config=<path> Run battle from JSON config file\n"
<< " " << programName << " --config=<path> --config=<path> --summary=csv --quiet\n"
<< " " << programName
<< " --config=<path> --state-file=<path> --summary=csv --quiet\n"
<< " " << programName
<< " --config=<path> --trace-jsonl=<path> --run-label=baseline\n"
<< " " << programName << " --generate-config Generate sample config to stdout\n"
<< " " << programName
<< " --generate-config --output=<path> Generate sample config to file\n"
<< " " << programName << " --help Show this help message\n"
<< "\n"
<< "Options:\n"
<< " --summary=human|csv|json Output format for completed battle summaries\n"
<< " --quiet Suppress simulator progress logs\n"
<< " --trace-jsonl=<path> Write one JSON object per AI-selected command\n"
<< " --run-label=<label> Label included in trace rows\n"
<< "\n"
<< "Examples:\n"
<< " # Generate sample config\n"
<< " " << programName << " --generate-config > my_battle.json\n"
<< "\n"
<< " # Run battle from config\n"
<< " " << programName << " --config=my_battle.json\n"
<< "\n"
<< " # Run a compact benchmark suite\n"
<< " " << programName
<< " --config=baseline.json --config=candidate.json --summary=csv --quiet\n"
<< "\n"
<< " # Run an AI config from a saved Shardok GameState flatbuffer\n"
<< " " << programName
<< " --config=ai_config.json --state-file=state.fb --summary=csv --quiet\n"
<< "\n";
}
@@ -55,23 +96,50 @@ void GenerateConfigFile(const std::string& outputPath) {
}
}
void RunBattleFromConfig(const std::string& configPath) {
// Load config
std::cout << "Loading config from: " << configPath << "\n";
auto config = AiBattleConfigLoader::LoadFromJsonFile(configPath);
if (!config) {
std::cerr << "Error: Failed to load config file\n";
std::exit(1);
std::string CsvEscape(const std::string& value) {
bool mustQuote = false;
std::string escaped;
escaped.reserve(value.size());
for (const char c : value) {
if (c == '"' || c == ',' || c == '\n' || c == '\r') { mustQuote = true; }
if (c == '"') { escaped.push_back('"'); }
escaped.push_back(c);
}
if (!mustQuote) { return escaped; }
return "\"" + escaped + "\"";
}
// Create simulator
AiBattleSimulator simulator(*config);
std::string JsonEscape(const std::string& value) {
std::ostringstream out;
for (const char c : value) {
switch (c) {
case '"': out << "\\\""; break;
case '\\': out << "\\\\"; break;
case '\n': out << "\\n"; break;
case '\r': out << "\\r"; break;
case '\t': out << "\\t"; break;
default: out << c; break;
}
}
return out.str();
}
// Run battle
auto result = simulator.RunBattle();
std::string WinnerLabel(int winner) {
if (winner == 0) { return "attacker"; }
if (winner == 1) { return "defender"; }
return "draw";
}
// Print results
std::string EndReasonLabel(BattleResult::EndReason endReason) {
switch (endReason) {
case BattleResult::EndReason::LAST_PLAYER_STANDING: return "last_player_standing";
case BattleResult::EndReason::CRITICAL_TILES_CONTROLLED: return "critical_tiles_controlled";
case BattleResult::EndReason::MAX_ROUNDS_REACHED: return "max_rounds_reached";
case BattleResult::EndReason::DRAW: return "draw";
}
}
void PrintHumanSummary(const BattleResult& result) {
std::cout << "\n";
std::cout << "=================================\n";
std::cout << "Battle Complete\n";
@@ -87,10 +155,17 @@ void RunBattleFromConfig(const std::string& configPath) {
std::cout << "Result: " << result.description << "\n";
std::cout << "Total Rounds: " << result.totalRounds << "\n";
std::cout << "Total Commands: " << result.totalCommands << "\n";
std::cout << "Attacker AI Commands: " << result.attackerCommands << "\n";
std::cout << "Defender AI Commands: " << result.defenderCommands << "\n";
std::cout << "Attacker Avg Depth: " << result.attackerAverageDepth << "\n";
std::cout << "Defender Avg Depth: " << result.defenderAverageDepth << "\n";
std::cout << "Attacker Survivors: " << result.attackerSurvivingUnits << " units, "
<< result.attackerSurvivingTroops << " troops\n";
std::cout << "Defender Survivors: " << result.defenderSurvivingUnits << " units, "
<< result.defenderSurvivingTroops << " troops\n";
// Print surviving units
if (!result.survivingUnits.empty()) {
std::cout << "\nSurviving Units (" << result.survivingUnits.size() << "):\n";
std::cout << "\nWinning/Draw Surviving Units (" << result.survivingUnits.size() << "):\n";
for (const auto& unit : result.survivingUnits) {
std::cout << " Unit " << unit.unitId << ": " << unit.battalionTypeName << " ("
<< static_cast<int>(unit.battalionSize) << " troops)";
@@ -102,6 +177,143 @@ void RunBattleFromConfig(const std::string& configPath) {
std::cout << "=================================\n";
}
void PrintCsvHeader() {
std::cout << "config,state_file,winner,end_reason,result,total_rounds,total_commands,"
<< "attacker_ai_commands,defender_ai_commands,attacker_avg_depth,"
<< "defender_avg_depth,attacker_surviving_units,defender_surviving_units,"
<< "attacker_surviving_troops,defender_surviving_troops\n";
}
void PrintCsvRow(
const std::string& configPath,
const std::string& statePath,
const BattleResult& result) {
std::cout << CsvEscape(configPath) << "," << CsvEscape(statePath) << ","
<< WinnerLabel(result.winner) << "," << EndReasonLabel(result.endReason) << ","
<< CsvEscape(result.description) << "," << result.totalRounds << ","
<< result.totalCommands << "," << result.attackerCommands << ","
<< result.defenderCommands << "," << result.attackerAverageDepth << ","
<< result.defenderAverageDepth << "," << result.attackerSurvivingUnits << ","
<< result.defenderSurvivingUnits << "," << result.attackerSurvivingTroops << ","
<< result.defenderSurvivingTroops << "\n";
}
void PrintJsonSummary(
const std::string& configPath,
const std::string& statePath,
const BattleResult& result) {
std::cout << "{"
<< "\"config\":\"" << JsonEscape(configPath) << "\","
<< "\"state_file\":\"" << JsonEscape(statePath) << "\","
<< "\"winner\":\"" << WinnerLabel(result.winner) << "\","
<< "\"end_reason\":\"" << EndReasonLabel(result.endReason) << "\","
<< "\"result\":\"" << JsonEscape(result.description) << "\","
<< "\"total_rounds\":" << result.totalRounds << ","
<< "\"total_commands\":" << result.totalCommands << ","
<< "\"attacker_ai_commands\":" << result.attackerCommands << ","
<< "\"defender_ai_commands\":" << result.defenderCommands << ","
<< "\"attacker_avg_depth\":" << result.attackerAverageDepth << ","
<< "\"defender_avg_depth\":" << result.defenderAverageDepth << ","
<< "\"attacker_surviving_units\":" << result.attackerSurvivingUnits << ","
<< "\"defender_surviving_units\":" << result.defenderSurvivingUnits << ","
<< "\"attacker_surviving_troops\":" << result.attackerSurvivingTroops << ","
<< "\"defender_surviving_troops\":" << result.defenderSurvivingTroops << "}\n";
}
std::unique_ptr<shardok::ai_battle_simulator::BattleConfigProto> LoadConfig(
const std::string& configPath) {
if (configPath.empty()) { return AiBattleConfigLoader::CreateDefaultPerfConfig(); }
return AiBattleConfigLoader::LoadFromJsonFile(configPath);
}
GameStateW LoadGameStateFromFile(const std::string& statePath) {
if (statePath.empty()) { return GameStateW{}; }
auto bytes = FilesystemUtils::LoadFromPath(statePath);
if (bytes.empty()) { throw std::runtime_error("Failed to load state file: " + statePath); }
return GameStateW::FromByteVector(bytes);
}
BattleResult RunBattle(
const std::string& configPath,
const std::string& statePath,
bool quiet,
const std::string& tracePath,
const std::string& runLabel) {
std::ofstream traceFile;
BattleTraceOptions traceOptions;
if (!tracePath.empty()) {
traceFile.open(tracePath);
if (!traceFile.is_open()) {
throw std::runtime_error("Failed to open trace file: " + tracePath);
}
traceOptions.output = &traceFile;
traceOptions.runLabel = runLabel;
traceOptions.configPath = configPath;
traceOptions.statePath = statePath;
}
NullBuffer nullBuffer;
std::ostream nullStream(&nullBuffer);
auto* originalCout = std::cout.rdbuf();
auto* originalCerr = std::cerr.rdbuf();
int stdoutCopy = -1;
int stderrCopy = -1;
if (quiet) {
std::cout.rdbuf(nullStream.rdbuf());
std::cerr.rdbuf(nullStream.rdbuf());
std::fflush(stdout);
std::fflush(stderr);
stdoutCopy = dup(STDOUT_FILENO);
stderrCopy = dup(STDERR_FILENO);
const int nullFd = open("/dev/null", O_WRONLY);
if (nullFd >= 0) {
dup2(nullFd, STDOUT_FILENO);
dup2(nullFd, STDERR_FILENO);
close(nullFd);
}
}
auto restoreOutput = [&]() {
if (!quiet) { return; }
std::fflush(stdout);
std::fflush(stderr);
if (stdoutCopy >= 0) {
dup2(stdoutCopy, STDOUT_FILENO);
close(stdoutCopy);
}
if (stderrCopy >= 0) {
dup2(stderrCopy, STDERR_FILENO);
close(stderrCopy);
}
std::cout.rdbuf(originalCout);
std::cerr.rdbuf(originalCerr);
};
// Load config
if (!configPath.empty()) { std::cout << "Loading config from: " << configPath << "\n"; }
auto config = LoadConfig(configPath);
if (!config) {
restoreOutput();
std::cerr << "Error: Failed to load config file\n";
std::exit(1);
}
// Create simulator
std::optional<GameStateW> initialGameState;
if (!statePath.empty()) {
std::cout << "Loading saved state from: " << statePath << "\n";
initialGameState = LoadGameStateFromFile(statePath);
}
AiBattleSimulator simulator(*config, nullptr, std::move(initialGameState), traceOptions);
// Run battle
auto result = simulator.RunBattle();
restoreOutput();
return result;
}
} // namespace
int main(int argc, char* argv[]) {
@@ -114,9 +326,14 @@ int main(int argc, char* argv[]) {
return 1;
}
std::string configPath;
std::vector<std::string> configPaths;
std::vector<std::string> statePaths;
std::string outputPath;
std::string tracePath;
std::string runLabel;
bool generateConfig = false;
bool quiet = false;
SummaryFormat summaryFormat = SummaryFormat::HUMAN;
// Parse command line arguments
for (int i = 1; i < argc; ++i) {
@@ -128,9 +345,30 @@ int main(int argc, char* argv[]) {
} else if (arg == "--generate-config") {
generateConfig = true;
} else if (arg.starts_with("--config=")) {
configPath = arg.substr(9);
configPaths.push_back(arg.substr(9));
} else if (arg.starts_with("--state-file=")) {
statePaths.push_back(arg.substr(13));
} else if (arg.starts_with("--output=")) {
outputPath = arg.substr(9);
} else if (arg.starts_with("--trace-jsonl=")) {
tracePath = arg.substr(14);
} else if (arg.starts_with("--run-label=")) {
runLabel = arg.substr(12);
} else if (arg == "--quiet") {
quiet = true;
} else if (arg.starts_with("--summary=")) {
const auto format = arg.substr(10);
if (format == "human") {
summaryFormat = SummaryFormat::HUMAN;
} else if (format == "csv") {
summaryFormat = SummaryFormat::CSV;
} else if (format == "json") {
summaryFormat = SummaryFormat::JSON;
} else {
std::cerr << "Error: Unknown summary format: " << format << "\n";
PrintUsage(argv[0]);
return 1;
}
} else {
std::cerr << "Error: Unknown argument: " << arg << "\n";
PrintUsage(argv[0]);
@@ -141,10 +379,25 @@ int main(int argc, char* argv[]) {
// Execute appropriate action
if (generateConfig) {
GenerateConfigFile(outputPath);
} else if (!configPath.empty()) {
RunBattleFromConfig(configPath);
} else if (!configPaths.empty() || !statePaths.empty()) {
if (summaryFormat == SummaryFormat::CSV) { PrintCsvHeader(); }
if (configPaths.empty()) { configPaths.emplace_back(""); }
if (statePaths.empty()) { statePaths.emplace_back(""); }
for (const auto& configPath : configPaths) {
for (const auto& statePath : statePaths) {
const auto result =
RunBattle(configPath, statePath, quiet, tracePath, runLabel);
switch (summaryFormat) {
case SummaryFormat::HUMAN: PrintHumanSummary(result); break;
case SummaryFormat::CSV: PrintCsvRow(configPath, statePath, result); break;
case SummaryFormat::JSON:
PrintJsonSummary(configPath, statePath, result);
break;
}
}
}
} else {
std::cerr << "Error: Either --generate-config or --config must be specified\n";
std::cerr << "Error: --generate-config, --config, or --state-file must be specified\n";
PrintUsage(argv[0]);
return 1;
}
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""Compare paired Shardok AI simulator traces for one or more battles."""
from __future__ import annotations
import argparse
import json
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
STALL_COMMANDS = {
"END_TURN_COMMAND",
"FLEE_COMMAND",
"MOVE_COMMAND",
"UNIT_REST_COMMAND",
}
@dataclass(frozen=True)
class TraceEvent:
run_label: str
phase: str
sequence: int
round: int
side: str
player: int
command_type: str
actor_unit: int
available_command_count: int
depth_achieved: int
completion_reason: str
forced_commands: int
pre_attacker_troops: int
pre_defender_troops: int
post_attacker_troops: int
post_defender_troops: int
@classmethod
def from_json(cls, row: dict[str, object]) -> TraceEvent:
return cls(
run_label=str(row["run_label"]),
phase=str(row["phase"]),
sequence=int(row["sequence"]),
round=int(row["round"]),
side=str(row["side"]),
player=int(row["player"]),
command_type=str(row["command_type"]),
actor_unit=int(row["actor_unit"]),
available_command_count=int(row["available_command_count"]),
depth_achieved=int(row["depth_achieved"]),
completion_reason=str(row["completion_reason"]),
forced_commands=int(row["forced_commands"]),
pre_attacker_troops=int(row["pre_attacker_troops"]),
pre_defender_troops=int(row["pre_defender_troops"]),
post_attacker_troops=int(row["post_attacker_troops"]),
post_defender_troops=int(row["post_defender_troops"]),
)
@property
def attacker_delta(self) -> int:
return self.post_attacker_troops - self.pre_attacker_troops
@property
def defender_delta(self) -> int:
return self.post_defender_troops - self.pre_defender_troops
@property
def did_damage(self) -> bool:
return self.attacker_delta < 0 or self.defender_delta < 0
@dataclass(frozen=True)
class RoundSummary:
battle_round: int
attacker_commands: int
defender_commands: int
attacker_damage: int
defender_damage: int
attacker_command_counts: Counter[str]
defender_command_counts: Counter[str]
@dataclass(frozen=True)
class RunSummary:
label: str
final_attacker_troops: int
final_defender_troops: int
command_counts: Counter[str]
side_command_counts: dict[str, Counter[str]]
side_damage_done: dict[str, int]
no_damage_streaks: list[list[TraceEvent]]
rounds: dict[int, RoundSummary]
def load_trace(path: Path) -> list[TraceEvent]:
events = []
with path.open() as f:
for line in f:
line = line.strip()
if line:
events.append(TraceEvent.from_json(json.loads(line)))
return events
def battle_events(events: Iterable[TraceEvent]) -> list[TraceEvent]:
return [event for event in events if event.phase == "battle"]
def side_damage(event: TraceEvent) -> int:
if event.side == "attacker":
return max(0, -event.defender_delta)
return max(0, -event.attacker_delta)
def longest_no_damage_streaks(events: list[TraceEvent], limit: int) -> list[list[TraceEvent]]:
streaks = []
current = []
for event in events:
if event.command_type in STALL_COMMANDS and not event.did_damage:
current.append(event)
continue
if current:
streaks.append(current)
current = []
if current:
streaks.append(current)
streaks.sort(key=len, reverse=True)
return streaks[:limit]
def summarize_run(label: str, events: list[TraceEvent], streak_limit: int) -> RunSummary:
battle = battle_events(events)
if not battle:
raise RuntimeError(f"No battle events in {label}")
command_counts = Counter(event.command_type for event in battle)
side_command_counts: dict[str, Counter[str]] = {
"attacker": Counter(),
"defender": Counter(),
}
side_damage_done = {
"attacker": 0,
"defender": 0,
}
per_round: dict[int, list[TraceEvent]] = defaultdict(list)
for event in battle:
side_command_counts[event.side][event.command_type] += 1
side_damage_done[event.side] += side_damage(event)
per_round[event.round].append(event)
rounds = {}
for battle_round, round_events in sorted(per_round.items()):
attacker_events = [event for event in round_events if event.side == "attacker"]
defender_events = [event for event in round_events if event.side == "defender"]
rounds[battle_round] = RoundSummary(
battle_round=battle_round,
attacker_commands=len(attacker_events),
defender_commands=len(defender_events),
attacker_damage=sum(side_damage(event) for event in attacker_events),
defender_damage=sum(side_damage(event) for event in defender_events),
attacker_command_counts=Counter(event.command_type for event in attacker_events),
defender_command_counts=Counter(event.command_type for event in defender_events),
)
final = battle[-1]
return RunSummary(
label=label,
final_attacker_troops=final.post_attacker_troops,
final_defender_troops=final.post_defender_troops,
command_counts=command_counts,
side_command_counts=side_command_counts,
side_damage_done=side_damage_done,
no_damage_streaks=longest_no_damage_streaks(battle, streak_limit),
rounds=rounds,
)
def command_counts_text(counts: Counter[str]) -> str:
if not counts:
return ""
return ", ".join(f"{name.removesuffix('_COMMAND')} {count}" for name, count in counts.most_common())
def format_streak(streak: list[TraceEvent]) -> str:
first = streak[0]
last = streak[-1]
command_counts = Counter(event.command_type for event in streak)
return (
f"rounds {first.round}-{last.round}, seq {first.sequence}-{last.sequence}, "
f"{len(streak)} commands: {command_counts_text(command_counts)}"
)
def write_battle_report(
lines: list[str],
battle: int,
baseline: RunSummary,
candidate: RunSummary,
) -> None:
lines.extend(
[
f"## Battle {battle}",
"",
"| Run | Final troops | Attacker damage | Defender damage | Attacker commands | Defender commands |",
"| --- | ---: | ---: | ---: | --- | --- |",
]
)
for summary in [baseline, candidate]:
lines.append(
f"| {summary.label} | {summary.final_attacker_troops} vs {summary.final_defender_troops} | "
f"{summary.side_damage_done['attacker']} | {summary.side_damage_done['defender']} | "
f"{command_counts_text(summary.side_command_counts['attacker'])} | "
f"{command_counts_text(summary.side_command_counts['defender'])} |"
)
lines.extend(
[
"",
"### Per-Round Differences",
"",
"| Round | Baseline attacker | Candidate attacker | Baseline defender | Candidate defender |",
"| ---: | --- | --- | --- | --- |",
]
)
all_rounds = sorted(set(baseline.rounds) | set(candidate.rounds))
for battle_round in all_rounds:
baseline_round = baseline.rounds.get(battle_round)
candidate_round = candidate.rounds.get(battle_round)
baseline_attacker = round_side_text(baseline_round, "attacker")
candidate_attacker = round_side_text(candidate_round, "attacker")
baseline_defender = round_side_text(baseline_round, "defender")
candidate_defender = round_side_text(candidate_round, "defender")
if baseline_attacker == candidate_attacker and baseline_defender == candidate_defender:
continue
lines.append(
f"| {battle_round} | {baseline_attacker} | {candidate_attacker} | "
f"{baseline_defender} | {candidate_defender} |"
)
lines.extend(
[
"",
"### Longest No-Damage Streaks",
"",
"| Run | Streak |",
"| --- | --- |",
]
)
for summary in [baseline, candidate]:
if not summary.no_damage_streaks:
lines.append(f"| {summary.label} | _none_ |")
continue
for streak in summary.no_damage_streaks:
lines.append(f"| {summary.label} | {format_streak(streak)} |")
lines.append("")
def round_side_text(summary: RoundSummary | None, side: str) -> str:
if summary is None:
return "_none_"
if side == "attacker":
damage = summary.attacker_damage
counts = summary.attacker_command_counts
commands = summary.attacker_commands
else:
damage = summary.defender_damage
counts = summary.defender_command_counts
commands = summary.defender_commands
return f"{commands} cmds, {damage} dmg ({command_counts_text(counts)})"
def parse_battles(value: str) -> list[int]:
return [int(item.strip()) for item in value.split(",") if item.strip()]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--experiment-dir", type=Path, required=True)
parser.add_argument("--battles", required=True, help="Comma-separated action_seq values")
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--streak-limit", type=int, default=4)
args = parser.parse_args()
lines = [
"# Trace Comparison",
"",
f"- Experiment directory: `{args.experiment_dir}`",
f"- Battles: `{args.battles}`",
"",
]
for battle in parse_battles(args.battles):
trace_dir = args.experiment_dir / "traces"
baseline_events = load_trace(trace_dir / f"{battle}_baseline.jsonl")
candidate_events = load_trace(trace_dir / f"{battle}_candidate.jsonl")
baseline = summarize_run("baseline", baseline_events, args.streak_limit)
candidate = summarize_run("candidate", candidate_events, args.streak_limit)
write_battle_report(lines, battle, baseline, candidate)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text("\n".join(lines))
if __name__ == "__main__":
main()
@@ -0,0 +1,32 @@
action_seq,shardok_game_id,attacker_factions,defender_factions,real_winner,sim_winner,winner_match,real_round,sim_round,real_attacker_troops,real_defender_troops,sim_attacker_troops,sim_defender_troops
306,389ffb1901903118_1_b3e7d8ab,1,2,attacker,attacker,true,0,19,1454,0,1388,0
307,389ffb1901903118_2_bf499d3c,2,1,defender,defender,true,0,16,0,699,0,648
399,389ffb1901903118_3_cd9141a,2,1,defender,defender,true,0,6,0,1310,0,1315
631,389ffb1901903118_4_6e7b9b,1,2,defender,defender,true,0,5,0,3766,0,3654
1326,389ffb1901903118_5_8a57255a,9,6,attacker,attacker,true,0,11,1715,0,1701,0
2437,389ffb1901903118_7_5cf74e0,2,1,attacker,attacker,true,0,16,3146,0,3247,0
2584,389ffb1901903118_8_41ce0ebd,1,2,defender,defender,true,0,31,0,2028,0,168
2654,389ffb1901903118_9_eb43d421,2,1,defender,defender,true,0,28,0,335,0,467
3536,389ffb1901903118_a_950de1c1,1,2,attacker,attacker,true,0,12,2172,0,2528,0
3701,389ffb1901903118_b_5d1d76e0,8,2,defender,defender,true,0,30,0,2819,0,1348
4298,389ffb1901903118_c_7bdb54dd,2,1,defender,defender,true,0,3,0,3514,0,1977
4576,389ffb1901903118_d_689ee2fa,2,1,defender,defender,true,0,4,0,2028,0,1986
6014,389ffb1901903118_e_89c90dc6,2,8,defender,defender,true,0,6,0,1942,0,2252
6248,389ffb1901903118_f_3a9d9136,7,9,attacker,attacker,true,0,25,520,0,676,0
6705,389ffb1901903118_11_e0611233,1,2,defender,defender,true,0,31,0,3316,0,535
7212,389ffb1901903118_12_84a36154,2,8,defender,defender,true,0,8,0,1253,0,1273
8217,389ffb1901903118_13_2e98b1cf,8,2,attacker,attacker,true,0,22,920,0,2167,0
8394,389ffb1901903118_14_b5570863,2,8,attacker,attacker,true,0,9,2449,0,3186,0
9975,389ffb1901903118_16_3d6ec341,2,1,defender,defender,true,0,4,0,7420,0,7840
10229,389ffb1901903118_17_3ba689e3,5,7,attacker,attacker,true,0,23,2135,0,1816,0
10631,389ffb1901903118_18_9e463db3,1,2,attacker,attacker,true,0,1,4000,0,4000,0
11011,389ffb1901903118_19_239725d3,2,1,defender,defender,true,0,5,0,3532,0,3658
11012,389ffb1901903118_1a_16e1ebf,9,6,defender,defender,true,0,30,0,846,0,1347
11297,389ffb1901903118_1c_529ba6e5,1,2,attacker,attacker,true,0,1,2400,0,2400,0
11469,389ffb1901903118_1d_49993012,5,9,defender,defender,true,0,8,0,787,0,1489
11470,389ffb1901903118_1e_fd8c60a8,7,5,attacker,attacker,true,0,17,3220,0,3425,0
11565,389ffb1901903118_1f_53309484,5,7,defender,defender,true,0,31,190,3420,78,3600
12136,389ffb1901903118_24_f0d61a5a,9,7,attacker,attacker,true,0,21,1829,0,1581,0
12137,389ffb1901903118_25_a19c4d87,2,1,defender,defender,true,0,8,0,1894,0,1892
12765,389ffb1901903118_27_c008989b,6,9,defender,defender,true,0,11,0,1773,0,1763
13752,389ffb1901903118_28_b33e2868,2,1,defender,defender,true,0,7,0,943,0,932
1 action_seq shardok_game_id attacker_factions defender_factions real_winner sim_winner winner_match real_round sim_round real_attacker_troops real_defender_troops sim_attacker_troops sim_defender_troops
2 306 389ffb1901903118_1_b3e7d8ab 1 2 attacker attacker true 0 19 1454 0 1388 0
3 307 389ffb1901903118_2_bf499d3c 2 1 defender defender true 0 16 0 699 0 648
4 399 389ffb1901903118_3_cd9141a 2 1 defender defender true 0 6 0 1310 0 1315
5 631 389ffb1901903118_4_6e7b9b 1 2 defender defender true 0 5 0 3766 0 3654
6 1326 389ffb1901903118_5_8a57255a 9 6 attacker attacker true 0 11 1715 0 1701 0
7 2437 389ffb1901903118_7_5cf74e0 2 1 attacker attacker true 0 16 3146 0 3247 0
8 2584 389ffb1901903118_8_41ce0ebd 1 2 defender defender true 0 31 0 2028 0 168
9 2654 389ffb1901903118_9_eb43d421 2 1 defender defender true 0 28 0 335 0 467
10 3536 389ffb1901903118_a_950de1c1 1 2 attacker attacker true 0 12 2172 0 2528 0
11 3701 389ffb1901903118_b_5d1d76e0 8 2 defender defender true 0 30 0 2819 0 1348
12 4298 389ffb1901903118_c_7bdb54dd 2 1 defender defender true 0 3 0 3514 0 1977
13 4576 389ffb1901903118_d_689ee2fa 2 1 defender defender true 0 4 0 2028 0 1986
14 6014 389ffb1901903118_e_89c90dc6 2 8 defender defender true 0 6 0 1942 0 2252
15 6248 389ffb1901903118_f_3a9d9136 7 9 attacker attacker true 0 25 520 0 676 0
16 6705 389ffb1901903118_11_e0611233 1 2 defender defender true 0 31 0 3316 0 535
17 7212 389ffb1901903118_12_84a36154 2 8 defender defender true 0 8 0 1253 0 1273
18 8217 389ffb1901903118_13_2e98b1cf 8 2 attacker attacker true 0 22 920 0 2167 0
19 8394 389ffb1901903118_14_b5570863 2 8 attacker attacker true 0 9 2449 0 3186 0
20 9975 389ffb1901903118_16_3d6ec341 2 1 defender defender true 0 4 0 7420 0 7840
21 10229 389ffb1901903118_17_3ba689e3 5 7 attacker attacker true 0 23 2135 0 1816 0
22 10631 389ffb1901903118_18_9e463db3 1 2 attacker attacker true 0 1 4000 0 4000 0
23 11011 389ffb1901903118_19_239725d3 2 1 defender defender true 0 5 0 3532 0 3658
24 11012 389ffb1901903118_1a_16e1ebf 9 6 defender defender true 0 30 0 846 0 1347
25 11297 389ffb1901903118_1c_529ba6e5 1 2 attacker attacker true 0 1 2400 0 2400 0
26 11469 389ffb1901903118_1d_49993012 5 9 defender defender true 0 8 0 787 0 1489
27 11470 389ffb1901903118_1e_fd8c60a8 7 5 attacker attacker true 0 17 3220 0 3425 0
28 11565 389ffb1901903118_1f_53309484 5 7 defender defender true 0 31 190 3420 78 3600
29 12136 389ffb1901903118_24_f0d61a5a 9 7 attacker attacker true 0 21 1829 0 1581 0
30 12137 389ffb1901903118_25_a19c4d87 2 1 defender defender true 0 8 0 1894 0 1892
31 12765 389ffb1901903118_27_c008989b 6 9 defender defender true 0 11 0 1773 0 1763
32 13752 389ffb1901903118_28_b33e2868 2 1 defender defender true 0 7 0 943 0 932
@@ -0,0 +1,45 @@
# AI-vs-AI Real Battle Smoke Test
This smoke test reruns saved battles where neither side used faction `3` or `4`,
then compares STANDARD-vs-STANDARD winner side with the winner side recorded by
the original Shardok battle. It is intentionally a broad similarity check rather
than an exact replay assertion.
- Checked battles: 31
- Winner-side matches: 31
- Match rate: 100%
- Required match rate: 75%
| Action seq | Factions | Real winner | Sim winner | Real troops | Sim troops |
| ---: | --- | --- | --- | ---: | ---: |
| 306 | 1 vs 2 | attacker | attacker | 1454 vs 0 | 1388 vs 0 |
| 307 | 2 vs 1 | defender | defender | 0 vs 699 | 0 vs 648 |
| 399 | 2 vs 1 | defender | defender | 0 vs 1310 | 0 vs 1315 |
| 631 | 1 vs 2 | defender | defender | 0 vs 3766 | 0 vs 3654 |
| 1326 | 9 vs 6 | attacker | attacker | 1715 vs 0 | 1701 vs 0 |
| 2437 | 2 vs 1 | attacker | attacker | 3146 vs 0 | 3247 vs 0 |
| 2584 | 1 vs 2 | defender | defender | 0 vs 2028 | 0 vs 168 |
| 2654 | 2 vs 1 | defender | defender | 0 vs 335 | 0 vs 467 |
| 3536 | 1 vs 2 | attacker | attacker | 2172 vs 0 | 2528 vs 0 |
| 3701 | 8 vs 2 | defender | defender | 0 vs 2819 | 0 vs 1348 |
| 4298 | 2 vs 1 | defender | defender | 0 vs 3514 | 0 vs 1977 |
| 4576 | 2 vs 1 | defender | defender | 0 vs 2028 | 0 vs 1986 |
| 6014 | 2 vs 8 | defender | defender | 0 vs 1942 | 0 vs 2252 |
| 6248 | 7 vs 9 | attacker | attacker | 520 vs 0 | 676 vs 0 |
| 6705 | 1 vs 2 | defender | defender | 0 vs 3316 | 0 vs 535 |
| 7212 | 2 vs 8 | defender | defender | 0 vs 1253 | 0 vs 1273 |
| 8217 | 8 vs 2 | attacker | attacker | 920 vs 0 | 2167 vs 0 |
| 8394 | 2 vs 8 | attacker | attacker | 2449 vs 0 | 3186 vs 0 |
| 9975 | 2 vs 1 | defender | defender | 0 vs 7420 | 0 vs 7840 |
| 10229 | 5 vs 7 | attacker | attacker | 2135 vs 0 | 1816 vs 0 |
| 10631 | 1 vs 2 | attacker | attacker | 4000 vs 0 | 4000 vs 0 |
| 11011 | 2 vs 1 | defender | defender | 0 vs 3532 | 0 vs 3658 |
| 11012 | 9 vs 6 | defender | defender | 0 vs 846 | 0 vs 1347 |
| 11297 | 1 vs 2 | attacker | attacker | 2400 vs 0 | 2400 vs 0 |
| 11469 | 5 vs 9 | defender | defender | 0 vs 787 | 0 vs 1489 |
| 11470 | 7 vs 5 | attacker | attacker | 3220 vs 0 | 3425 vs 0 |
| 11565 | 5 vs 7 | defender | defender | 190 vs 3420 | 78 vs 3600 |
| 12136 | 9 vs 7 | attacker | attacker | 1829 vs 0 | 1581 vs 0 |
| 12137 | 2 vs 1 | defender | defender | 0 vs 1894 | 0 vs 1892 |
| 12765 | 6 vs 9 | defender | defender | 0 vs 1773 | 0 vs 1763 |
| 13752 | 2 vs 1 | defender | defender | 0 vs 943 | 0 vs 932 |
@@ -0,0 +1,41 @@
variant,action_seq,config,winner,end_reason,result,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
expected_impact_archery_standard_baseline,306,/private/tmp/eagle0-standard-baselines/configs/306_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,19,99,41,32,4.04878,3.65625,2,0,1388,0,
expected_impact_archery_standard_baseline,307,/private/tmp/eagle0-standard-baselines/configs/307_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,16,75,37,15,3.54054,2.86667,0,1,0,648,
expected_impact_archery_standard_baseline,399,/private/tmp/eagle0-standard-baselines/configs/399_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,6,30,7,14,3,3.85714,0,3,0,1315,
expected_impact_archery_standard_baseline,631,/private/tmp/eagle0-standard-baselines/configs/631_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,5,38,12,14,3.58333,4.14286,0,5,0,3654,
expected_impact_archery_standard_baseline,1326,/private/tmp/eagle0-standard-baselines/configs/1326_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,11,72,34,19,3.26471,3.63158,2,0,1701,0,
expected_impact_archery_standard_baseline,1472,/private/tmp/eagle0-standard-baselines/configs/1472_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,31,143,59,67,3.25424,3.46269,0,2,0,944,
expected_impact_archery_standard_baseline,2437,/private/tmp/eagle0-standard-baselines/configs/2437_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,16,148,89,43,3.37079,3.32558,5,0,3247,0,
expected_impact_archery_standard_baseline,2584,/private/tmp/eagle0-standard-baselines/configs/2584_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,31,190,48,85,3.45833,3.16471,0,1,0,168,
expected_impact_archery_standard_baseline,2654,/private/tmp/eagle0-standard-baselines/configs/2654_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,28,198,78,72,3.19231,4.04167,0,2,0,467,
expected_impact_archery_standard_baseline,3536,/private/tmp/eagle0-standard-baselines/configs/3536_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,12,120,57,36,3.40351,2.88889,4,0,2528,0,
expected_impact_archery_standard_baseline,3701,/private/tmp/eagle0-standard-baselines/configs/3701_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,30,155,62,70,3.5,3.24286,0,4,0,1348,
expected_impact_archery_standard_baseline,4298,/private/tmp/eagle0-standard-baselines/configs/4298_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,3,38,13,11,3.07692,2.90909,0,6,0,1977,
expected_impact_archery_standard_baseline,4576,/private/tmp/eagle0-standard-baselines/configs/4576_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,4,35,17,7,3,3.28571,0,3,0,1986,
expected_impact_archery_standard_baseline,6014,/private/tmp/eagle0-standard-baselines/configs/6014_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,6,39,20,7,3.15,3.42857,0,3,0,2252,
expected_impact_archery_standard_baseline,6248,/private/tmp/eagle0-standard-baselines/configs/6248_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,25,153,75,46,3.65333,2.8913,2,0,676,0,
expected_impact_archery_standard_baseline,6408,/private/tmp/eagle0-standard-baselines/configs/6408_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,11,107,68,18,3.5,3.33333,5,0,4020,0,
expected_impact_archery_standard_baseline,6705,/private/tmp/eagle0-standard-baselines/configs/6705_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,31,205,87,86,3.01149,3.74419,0,2,0,535,
expected_impact_archery_standard_baseline,7212,/private/tmp/eagle0-standard-baselines/configs/7212_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,8,39,18,11,3.38889,3.63636,0,2,0,1273,
expected_impact_archery_standard_baseline,8217,/private/tmp/eagle0-standard-baselines/configs/8217_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,22,164,79,49,3.35443,3.08163,3,0,2167,0,
expected_impact_archery_standard_baseline,8394,/private/tmp/eagle0-standard-baselines/configs/8394_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,9,81,48,17,3.25,2.64706,4,0,3186,0,
expected_impact_archery_standard_baseline,8628,/private/tmp/eagle0-standard-baselines/configs/8628_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,5,41,27,3,2.96296,2,4,0,2760,0,
expected_impact_archery_standard_baseline,9975,/private/tmp/eagle0-standard-baselines/configs/9975_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,4,70,23,27,2.65217,2.40741,0,10,0,7840,
expected_impact_archery_standard_baseline,10229,/private/tmp/eagle0-standard-baselines/configs/10229_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,23,104,57,4,3.77193,3,3,0,1816,0,
expected_impact_archery_standard_baseline,10631,/private/tmp/eagle0-standard-baselines/configs/10631_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,1,16,7,1,3,3,5,1,4000,0,
expected_impact_archery_standard_baseline,11011,/private/tmp/eagle0-standard-baselines/configs/11011_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,5,51,22,18,3.5,3,0,5,0,3658,
expected_impact_archery_standard_baseline,11012,/private/tmp/eagle0-standard-baselines/configs/11012_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,30,146,47,67,3.40426,3.62687,0,3,0,1347,
expected_impact_archery_standard_baseline,11112,/private/tmp/eagle0-standard-baselines/configs/11112_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,3,31,7,8,3.42857,2,0,11,0,7595,
expected_impact_archery_standard_baseline,11297,/private/tmp/eagle0-standard-baselines/configs/11297_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,1,13,5,1,3,3,3,1,2400,0,
expected_impact_archery_standard_baseline,11469,/private/tmp/eagle0-standard-baselines/configs/11469_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,8,58,30,12,3.16667,3.16667,0,2,0,1489,
expected_impact_archery_standard_baseline,11470,/private/tmp/eagle0-standard-baselines/configs/11470_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,17,99,65,22,3.50769,2.68182,4,0,3425,0,
expected_impact_archery_standard_baseline,11565,/private/tmp/eagle0-standard-baselines/configs/11565_standard.json,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,103,30,34,4.26667,3.91176,1,4,78,3600,
expected_impact_archery_standard_baseline,11662,/private/tmp/eagle0-standard-baselines/configs/11662_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,8,120,85,17,2.85882,3.23529,9,0,6624,0,
expected_impact_archery_standard_baseline,11754,/private/tmp/eagle0-standard-baselines/configs/11754_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,30,163,72,76,3.31944,3.28947,0,3,0,1000,
expected_impact_archery_standard_baseline,11941,/private/tmp/eagle0-standard-baselines/configs/11941_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,6,64,44,7,3.25,3,6,0,4777,0,
expected_impact_archery_standard_baseline,11942,/private/tmp/eagle0-standard-baselines/configs/11942_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,5,91,59,16,2.64407,3.375,12,0,8572,0,
expected_impact_archery_standard_baseline,12136,/private/tmp/eagle0-standard-baselines/configs/12136_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,21,141,79,34,3.70886,4.52941,3,0,1581,0,
expected_impact_archery_standard_baseline,12137,/private/tmp/eagle0-standard-baselines/configs/12137_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,8,50,19,17,3.21053,3.29412,0,3,0,1892,
expected_impact_archery_standard_baseline,12138,/private/tmp/eagle0-standard-baselines/configs/12138_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,14,102,69,16,3.43478,2.9375,3,0,2512,0,
expected_impact_archery_standard_baseline,12765,/private/tmp/eagle0-standard-baselines/configs/12765_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,11,46,26,11,3.61538,3.09091,0,2,0,1763,
expected_impact_archery_standard_baseline,13752,/private/tmp/eagle0-standard-baselines/configs/13752_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,7,38,16,10,3.5,3.3,0,2,0,932,
1 variant action_seq config winner end_reason result total_rounds total_commands attacker_ai_commands defender_ai_commands attacker_avg_depth defender_avg_depth attacker_surviving_units defender_surviving_units attacker_surviving_troops defender_surviving_troops elapsed_seconds
2 expected_impact_archery_standard_baseline 306 /private/tmp/eagle0-standard-baselines/configs/306_standard.json attacker last_player_standing Attacker won by eliminating all defenders 19 99 41 32 4.04878 3.65625 2 0 1388 0
3 expected_impact_archery_standard_baseline 307 /private/tmp/eagle0-standard-baselines/configs/307_standard.json defender last_player_standing Defender won by eliminating all attackers 16 75 37 15 3.54054 2.86667 0 1 0 648
4 expected_impact_archery_standard_baseline 399 /private/tmp/eagle0-standard-baselines/configs/399_standard.json defender last_player_standing Defender won by eliminating all attackers 6 30 7 14 3 3.85714 0 3 0 1315
5 expected_impact_archery_standard_baseline 631 /private/tmp/eagle0-standard-baselines/configs/631_standard.json defender last_player_standing Defender won by eliminating all attackers 5 38 12 14 3.58333 4.14286 0 5 0 3654
6 expected_impact_archery_standard_baseline 1326 /private/tmp/eagle0-standard-baselines/configs/1326_standard.json attacker last_player_standing Attacker won by eliminating all defenders 11 72 34 19 3.26471 3.63158 2 0 1701 0
7 expected_impact_archery_standard_baseline 1472 /private/tmp/eagle0-standard-baselines/configs/1472_standard.json defender last_player_standing Defender won by eliminating all attackers 31 143 59 67 3.25424 3.46269 0 2 0 944
8 expected_impact_archery_standard_baseline 2437 /private/tmp/eagle0-standard-baselines/configs/2437_standard.json attacker last_player_standing Attacker won by eliminating all defenders 16 148 89 43 3.37079 3.32558 5 0 3247 0
9 expected_impact_archery_standard_baseline 2584 /private/tmp/eagle0-standard-baselines/configs/2584_standard.json defender last_player_standing Defender won by eliminating all attackers 31 190 48 85 3.45833 3.16471 0 1 0 168
10 expected_impact_archery_standard_baseline 2654 /private/tmp/eagle0-standard-baselines/configs/2654_standard.json defender last_player_standing Defender won by eliminating all attackers 28 198 78 72 3.19231 4.04167 0 2 0 467
11 expected_impact_archery_standard_baseline 3536 /private/tmp/eagle0-standard-baselines/configs/3536_standard.json attacker last_player_standing Attacker won by eliminating all defenders 12 120 57 36 3.40351 2.88889 4 0 2528 0
12 expected_impact_archery_standard_baseline 3701 /private/tmp/eagle0-standard-baselines/configs/3701_standard.json defender last_player_standing Defender won by eliminating all attackers 30 155 62 70 3.5 3.24286 0 4 0 1348
13 expected_impact_archery_standard_baseline 4298 /private/tmp/eagle0-standard-baselines/configs/4298_standard.json defender last_player_standing Defender won by eliminating all attackers 3 38 13 11 3.07692 2.90909 0 6 0 1977
14 expected_impact_archery_standard_baseline 4576 /private/tmp/eagle0-standard-baselines/configs/4576_standard.json defender last_player_standing Defender won by eliminating all attackers 4 35 17 7 3 3.28571 0 3 0 1986
15 expected_impact_archery_standard_baseline 6014 /private/tmp/eagle0-standard-baselines/configs/6014_standard.json defender last_player_standing Defender won by eliminating all attackers 6 39 20 7 3.15 3.42857 0 3 0 2252
16 expected_impact_archery_standard_baseline 6248 /private/tmp/eagle0-standard-baselines/configs/6248_standard.json attacker last_player_standing Attacker won by eliminating all defenders 25 153 75 46 3.65333 2.8913 2 0 676 0
17 expected_impact_archery_standard_baseline 6408 /private/tmp/eagle0-standard-baselines/configs/6408_standard.json attacker last_player_standing Attacker won by eliminating all defenders 11 107 68 18 3.5 3.33333 5 0 4020 0
18 expected_impact_archery_standard_baseline 6705 /private/tmp/eagle0-standard-baselines/configs/6705_standard.json defender last_player_standing Defender won by eliminating all attackers 31 205 87 86 3.01149 3.74419 0 2 0 535
19 expected_impact_archery_standard_baseline 7212 /private/tmp/eagle0-standard-baselines/configs/7212_standard.json defender last_player_standing Defender won by eliminating all attackers 8 39 18 11 3.38889 3.63636 0 2 0 1273
20 expected_impact_archery_standard_baseline 8217 /private/tmp/eagle0-standard-baselines/configs/8217_standard.json attacker last_player_standing Attacker won by eliminating all defenders 22 164 79 49 3.35443 3.08163 3 0 2167 0
21 expected_impact_archery_standard_baseline 8394 /private/tmp/eagle0-standard-baselines/configs/8394_standard.json attacker last_player_standing Attacker won by eliminating all defenders 9 81 48 17 3.25 2.64706 4 0 3186 0
22 expected_impact_archery_standard_baseline 8628 /private/tmp/eagle0-standard-baselines/configs/8628_standard.json attacker last_player_standing Attacker won by eliminating all defenders 5 41 27 3 2.96296 2 4 0 2760 0
23 expected_impact_archery_standard_baseline 9975 /private/tmp/eagle0-standard-baselines/configs/9975_standard.json defender last_player_standing Defender won by eliminating all attackers 4 70 23 27 2.65217 2.40741 0 10 0 7840
24 expected_impact_archery_standard_baseline 10229 /private/tmp/eagle0-standard-baselines/configs/10229_standard.json attacker last_player_standing Attacker won by eliminating all defenders 23 104 57 4 3.77193 3 3 0 1816 0
25 expected_impact_archery_standard_baseline 10631 /private/tmp/eagle0-standard-baselines/configs/10631_standard.json attacker last_player_standing Attacker won by eliminating all defenders 1 16 7 1 3 3 5 1 4000 0
26 expected_impact_archery_standard_baseline 11011 /private/tmp/eagle0-standard-baselines/configs/11011_standard.json defender last_player_standing Defender won by eliminating all attackers 5 51 22 18 3.5 3 0 5 0 3658
27 expected_impact_archery_standard_baseline 11012 /private/tmp/eagle0-standard-baselines/configs/11012_standard.json defender last_player_standing Defender won by eliminating all attackers 30 146 47 67 3.40426 3.62687 0 3 0 1347
28 expected_impact_archery_standard_baseline 11112 /private/tmp/eagle0-standard-baselines/configs/11112_standard.json defender last_player_standing Defender won by eliminating all attackers 3 31 7 8 3.42857 2 0 11 0 7595
29 expected_impact_archery_standard_baseline 11297 /private/tmp/eagle0-standard-baselines/configs/11297_standard.json attacker last_player_standing Attacker won by eliminating all defenders 1 13 5 1 3 3 3 1 2400 0
30 expected_impact_archery_standard_baseline 11469 /private/tmp/eagle0-standard-baselines/configs/11469_standard.json defender last_player_standing Defender won by eliminating all attackers 8 58 30 12 3.16667 3.16667 0 2 0 1489
31 expected_impact_archery_standard_baseline 11470 /private/tmp/eagle0-standard-baselines/configs/11470_standard.json attacker last_player_standing Attacker won by eliminating all defenders 17 99 65 22 3.50769 2.68182 4 0 3425 0
32 expected_impact_archery_standard_baseline 11565 /private/tmp/eagle0-standard-baselines/configs/11565_standard.json defender max_rounds_reached Defender won by surviving maximum rounds 31 103 30 34 4.26667 3.91176 1 4 78 3600
33 expected_impact_archery_standard_baseline 11662 /private/tmp/eagle0-standard-baselines/configs/11662_standard.json attacker last_player_standing Attacker won by eliminating all defenders 8 120 85 17 2.85882 3.23529 9 0 6624 0
34 expected_impact_archery_standard_baseline 11754 /private/tmp/eagle0-standard-baselines/configs/11754_standard.json defender last_player_standing Defender won by eliminating all attackers 30 163 72 76 3.31944 3.28947 0 3 0 1000
35 expected_impact_archery_standard_baseline 11941 /private/tmp/eagle0-standard-baselines/configs/11941_standard.json attacker last_player_standing Attacker won by eliminating all defenders 6 64 44 7 3.25 3 6 0 4777 0
36 expected_impact_archery_standard_baseline 11942 /private/tmp/eagle0-standard-baselines/configs/11942_standard.json attacker last_player_standing Attacker won by eliminating all defenders 5 91 59 16 2.64407 3.375 12 0 8572 0
37 expected_impact_archery_standard_baseline 12136 /private/tmp/eagle0-standard-baselines/configs/12136_standard.json attacker last_player_standing Attacker won by eliminating all defenders 21 141 79 34 3.70886 4.52941 3 0 1581 0
38 expected_impact_archery_standard_baseline 12137 /private/tmp/eagle0-standard-baselines/configs/12137_standard.json defender last_player_standing Defender won by eliminating all attackers 8 50 19 17 3.21053 3.29412 0 3 0 1892
39 expected_impact_archery_standard_baseline 12138 /private/tmp/eagle0-standard-baselines/configs/12138_standard.json attacker last_player_standing Attacker won by eliminating all defenders 14 102 69 16 3.43478 2.9375 3 0 2512 0
40 expected_impact_archery_standard_baseline 12765 /private/tmp/eagle0-standard-baselines/configs/12765_standard.json defender last_player_standing Defender won by eliminating all attackers 11 46 26 11 3.61538 3.09091 0 2 0 1763
41 expected_impact_archery_standard_baseline 13752 /private/tmp/eagle0-standard-baselines/configs/13752_standard.json defender last_player_standing Defender won by eliminating all attackers 7 38 16 10 3.5 3.3 0 2 0 932
@@ -0,0 +1,40 @@
# STANDARD Scoring All-40 Baseline
This baseline is the current comparison baseline for STANDARD scoring experiments. It was
regenerated after fixing iterative-deepening timeout score propagation.
Generated battle configs preserve archery/start-fire capability, per-unit flee capability,
explicit zero-size battalions, no-profession heroes, forced commands, seed `1`, and the
31-round defender timeout.
Raw rows are recorded in `standard_scoring_all40_post_bugfix_baseline.csv`.
## Summary
| Metric | Count |
| --- | ---: |
| Real battles | 40 |
| Valid simulated battles | 40 |
| Invalid / no recoverable troops | 0 |
| Attacker wins | 18 |
| Defender wins | 22 |
| Last-player-standing endings | 39 |
| Max-round defender endings | 1 |
## Closest Battles By Survivor Margin
Survivor margin is `attacker_surviving_troops - defender_surviving_troops`.
| Action seq | Winner | End reason | Attacker troops | Defender troops | Margin |
| ---: | --- | --- | ---: | ---: | ---: |
| 2584 | defender | last_player_standing | 0 | 168 | -168 |
| 2654 | defender | last_player_standing | 0 | 467 | -467 |
| 6705 | defender | last_player_standing | 0 | 535 | -535 |
| 307 | defender | last_player_standing | 0 | 648 | -648 |
| 6248 | attacker | last_player_standing | 676 | 0 | +676 |
| 13752 | defender | last_player_standing | 0 | 932 | -932 |
| 1472 | defender | last_player_standing | 0 | 944 | -944 |
| 11754 | defender | last_player_standing | 0 | 1000 | -1000 |
| 7212 | defender | last_player_standing | 0 | 1273 | -1273 |
| 399 | defender | last_player_standing | 0 | 1315 | -1315 |
| 11012 | defender | last_player_standing | 0 | 1347 | -1347 |
| 3701 | defender | last_player_standing | 0 | 1348 | -1348 |
@@ -0,0 +1,41 @@
action_seq,attacker_factions,attacker_player,defender_factions,defender_player,ai_vs_ai,real_winner,sim_winner,winner_match,real_round,sim_round,real_attacker_troops,real_defender_troops,sim_attacker_troops,sim_defender_troops
306,1,AI,2,AI,true,attacker,attacker,true,0,19,1454,0,1388,0
307,2,AI,1,AI,true,defender,defender,true,0,16,0,699,0,648
399,2,AI,1,AI,true,defender,defender,true,0,6,0,1310,0,1315
631,1,AI,2,AI,true,defender,defender,true,0,5,0,3766,0,3654
1326,9,AI,6,AI,true,attacker,attacker,true,0,11,1715,0,1701,0
1472,8,AI,3,human,false,defender,defender,true,0,31,0,1593,0,944
2437,2,AI,1,AI,true,attacker,attacker,true,0,16,3146,0,3247,0
2584,1,AI,2,AI,true,defender,defender,true,0,31,0,2028,0,168
2654,2,AI,1,AI,true,defender,defender,true,0,28,0,335,0,467
3536,1,AI,2,AI,true,attacker,attacker,true,0,12,2172,0,2528,0
3701,8,AI,2,AI,true,defender,defender,true,0,30,0,2819,0,1348
4298,2,AI,1,AI,true,defender,defender,true,0,3,0,3514,0,1977
4576,2,AI,1,AI,true,defender,defender,true,0,4,0,2028,0,1986
6014,2,AI,8,AI,true,defender,defender,true,0,6,0,1942,0,2252
6248,7,AI,9,AI,true,attacker,attacker,true,0,25,520,0,676,0
6408,4,human,7,AI,false,attacker,attacker,true,0,11,4156,0,4020,0
6705,1,AI,2,AI,true,defender,defender,true,0,31,0,3316,0,535
7212,2,AI,8,AI,true,defender,defender,true,0,8,0,1253,0,1273
8217,8,AI,2,AI,true,attacker,attacker,true,0,22,920,0,2167,0
8394,2,AI,8,AI,true,attacker,attacker,true,0,9,2449,0,3186,0
8628,3,human,8,AI,false,attacker,attacker,true,0,5,2874,0,2760,0
9975,2,AI,1,AI,true,defender,defender,true,0,4,0,7420,0,7840
10229,5,AI,7,AI,true,attacker,attacker,true,0,23,2135,0,1816,0
10631,1,AI,2,AI,true,attacker,attacker,true,0,1,4000,0,4000,0
11011,2,AI,1,AI,true,defender,defender,true,0,5,0,3532,0,3658
11012,9,AI,6,AI,true,defender,defender,true,0,30,0,846,0,1347
11112,5,AI,4,human,false,defender,defender,true,0,3,0,7595,0,7595
11297,1,AI,2,AI,true,attacker,attacker,true,0,1,2400,0,2400,0
11469,5,AI,9,AI,true,defender,defender,true,0,8,0,787,0,1489
11470,7,AI,5,AI,true,attacker,attacker,true,0,17,3220,0,3425,0
11565,5,AI,7,AI,true,defender,defender,true,0,31,190,3420,78,3600
11662,4,human,9,AI,false,attacker,attacker,true,0,8,7326,0,6624,0
11754,7,AI,4,human,false,attacker,defender,false,0,30,2119,0,0,1000
11941,3,human,5,AI,false,attacker,attacker,true,0,6,4901,0,4777,0
11942,4,human,7,AI,false,attacker,attacker,true,0,5,8840,0,8572,0
12136,9,AI,7,AI,true,attacker,attacker,true,0,21,1829,0,1581,0
12137,2,AI,1,AI,true,defender,defender,true,0,8,0,1894,0,1892
12138,3,human,5,AI,false,attacker,attacker,true,0,14,2960,0,2512,0
12765,6,AI,9,AI,true,defender,defender,true,0,11,0,1773,0,1763
13752,2,AI,1,AI,true,defender,defender,true,0,7,0,943,0,932
1 action_seq attacker_factions attacker_player defender_factions defender_player ai_vs_ai real_winner sim_winner winner_match real_round sim_round real_attacker_troops real_defender_troops sim_attacker_troops sim_defender_troops
2 306 1 AI 2 AI true attacker attacker true 0 19 1454 0 1388 0
3 307 2 AI 1 AI true defender defender true 0 16 0 699 0 648
4 399 2 AI 1 AI true defender defender true 0 6 0 1310 0 1315
5 631 1 AI 2 AI true defender defender true 0 5 0 3766 0 3654
6 1326 9 AI 6 AI true attacker attacker true 0 11 1715 0 1701 0
7 1472 8 AI 3 human false defender defender true 0 31 0 1593 0 944
8 2437 2 AI 1 AI true attacker attacker true 0 16 3146 0 3247 0
9 2584 1 AI 2 AI true defender defender true 0 31 0 2028 0 168
10 2654 2 AI 1 AI true defender defender true 0 28 0 335 0 467
11 3536 1 AI 2 AI true attacker attacker true 0 12 2172 0 2528 0
12 3701 8 AI 2 AI true defender defender true 0 30 0 2819 0 1348
13 4298 2 AI 1 AI true defender defender true 0 3 0 3514 0 1977
14 4576 2 AI 1 AI true defender defender true 0 4 0 2028 0 1986
15 6014 2 AI 8 AI true defender defender true 0 6 0 1942 0 2252
16 6248 7 AI 9 AI true attacker attacker true 0 25 520 0 676 0
17 6408 4 human 7 AI false attacker attacker true 0 11 4156 0 4020 0
18 6705 1 AI 2 AI true defender defender true 0 31 0 3316 0 535
19 7212 2 AI 8 AI true defender defender true 0 8 0 1253 0 1273
20 8217 8 AI 2 AI true attacker attacker true 0 22 920 0 2167 0
21 8394 2 AI 8 AI true attacker attacker true 0 9 2449 0 3186 0
22 8628 3 human 8 AI false attacker attacker true 0 5 2874 0 2760 0
23 9975 2 AI 1 AI true defender defender true 0 4 0 7420 0 7840
24 10229 5 AI 7 AI true attacker attacker true 0 23 2135 0 1816 0
25 10631 1 AI 2 AI true attacker attacker true 0 1 4000 0 4000 0
26 11011 2 AI 1 AI true defender defender true 0 5 0 3532 0 3658
27 11012 9 AI 6 AI true defender defender true 0 30 0 846 0 1347
28 11112 5 AI 4 human false defender defender true 0 3 0 7595 0 7595
29 11297 1 AI 2 AI true attacker attacker true 0 1 2400 0 2400 0
30 11469 5 AI 9 AI true defender defender true 0 8 0 787 0 1489
31 11470 7 AI 5 AI true attacker attacker true 0 17 3220 0 3425 0
32 11565 5 AI 7 AI true defender defender true 0 31 190 3420 78 3600
33 11662 4 human 9 AI false attacker attacker true 0 8 7326 0 6624 0
34 11754 7 AI 4 human false attacker defender false 0 30 2119 0 0 1000
35 11941 3 human 5 AI false attacker attacker true 0 6 4901 0 4777 0
36 11942 4 human 7 AI false attacker attacker true 0 5 8840 0 8572 0
37 12136 9 AI 7 AI true attacker attacker true 0 21 1829 0 1581 0
38 12137 2 AI 1 AI true defender defender true 0 8 0 1894 0 1892
39 12138 3 human 5 AI false attacker attacker true 0 14 2960 0 2512 0
40 12765 6 AI 9 AI true defender defender true 0 11 0 1773 0 1763
41 13752 2 AI 1 AI true defender defender true 0 7 0 943 0 932
@@ -0,0 +1,56 @@
# All-40 Real vs STANDARD Simulation
This table compares the saved-game Shardok winner side with a fresh
STANDARD-vs-STANDARD simulator run from the extracted battle setup.
Faction `3` and faction `4` are marked as human players; all other
factions are marked as AI players.
- Battles checked: 40
- Known real outcomes: 40
- Winner-side matches on known outcomes: 39/40
- Real saved-game winners: 19 attacker, 21 defender, 0 unknown
- Sim baseline winners: 18 attacker, 22 defender
- Battles involving a human side: 9
| Action seq | Attacker | Defender | Real winner | Sim winner | Match | Real troops | Sim troops |
| ---: | --- | --- | --- | --- | --- | ---: | ---: |
| 306 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 1454 vs 0 | 1388 vs 0 |
| 307 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 699 | 0 vs 648 |
| 399 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 1310 | 0 vs 1315 |
| 631 | 1 (AI) | 2 (AI) | defender | defender | yes | 0 vs 3766 | 0 vs 3654 |
| 1326 | 9 (AI) | 6 (AI) | attacker | attacker | yes | 1715 vs 0 | 1701 vs 0 |
| 1472 | 8 (AI) | 3 (human) | defender | defender | yes | 0 vs 1593 | 0 vs 944 |
| 2437 | 2 (AI) | 1 (AI) | attacker | attacker | yes | 3146 vs 0 | 3247 vs 0 |
| 2584 | 1 (AI) | 2 (AI) | defender | defender | yes | 0 vs 2028 | 0 vs 168 |
| 2654 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 335 | 0 vs 467 |
| 3536 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 2172 vs 0 | 2528 vs 0 |
| 3701 | 8 (AI) | 2 (AI) | defender | defender | yes | 0 vs 2819 | 0 vs 1348 |
| 4298 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 3514 | 0 vs 1977 |
| 4576 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 2028 | 0 vs 1986 |
| 6014 | 2 (AI) | 8 (AI) | defender | defender | yes | 0 vs 1942 | 0 vs 2252 |
| 6248 | 7 (AI) | 9 (AI) | attacker | attacker | yes | 520 vs 0 | 676 vs 0 |
| 6408 | 4 (human) | 7 (AI) | attacker | attacker | yes | 4156 vs 0 | 4020 vs 0 |
| 6705 | 1 (AI) | 2 (AI) | defender | defender | yes | 0 vs 3316 | 0 vs 535 |
| 7212 | 2 (AI) | 8 (AI) | defender | defender | yes | 0 vs 1253 | 0 vs 1273 |
| 8217 | 8 (AI) | 2 (AI) | attacker | attacker | yes | 920 vs 0 | 2167 vs 0 |
| 8394 | 2 (AI) | 8 (AI) | attacker | attacker | yes | 2449 vs 0 | 3186 vs 0 |
| 8628 | 3 (human) | 8 (AI) | attacker | attacker | yes | 2874 vs 0 | 2760 vs 0 |
| 9975 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 7420 | 0 vs 7840 |
| 10229 | 5 (AI) | 7 (AI) | attacker | attacker | yes | 2135 vs 0 | 1816 vs 0 |
| 10631 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 4000 vs 0 | 4000 vs 0 |
| 11011 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 3532 | 0 vs 3658 |
| 11012 | 9 (AI) | 6 (AI) | defender | defender | yes | 0 vs 846 | 0 vs 1347 |
| 11112 | 5 (AI) | 4 (human) | defender | defender | yes | 0 vs 7595 | 0 vs 7595 |
| 11297 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 2400 vs 0 | 2400 vs 0 |
| 11469 | 5 (AI) | 9 (AI) | defender | defender | yes | 0 vs 787 | 0 vs 1489 |
| 11470 | 7 (AI) | 5 (AI) | attacker | attacker | yes | 3220 vs 0 | 3425 vs 0 |
| 11565 | 5 (AI) | 7 (AI) | defender | defender | yes | 190 vs 3420 | 78 vs 3600 |
| 11662 | 4 (human) | 9 (AI) | attacker | attacker | yes | 7326 vs 0 | 6624 vs 0 |
| 11754 | 7 (AI) | 4 (human) | attacker | defender | no | 2119 vs 0 | 0 vs 1000 |
| 11941 | 3 (human) | 5 (AI) | attacker | attacker | yes | 4901 vs 0 | 4777 vs 0 |
| 11942 | 4 (human) | 7 (AI) | attacker | attacker | yes | 8840 vs 0 | 8572 vs 0 |
| 12136 | 9 (AI) | 7 (AI) | attacker | attacker | yes | 1829 vs 0 | 1581 vs 0 |
| 12137 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 1894 | 0 vs 1892 |
| 12138 | 3 (human) | 5 (AI) | attacker | attacker | yes | 2960 vs 0 | 2512 vs 0 |
| 12765 | 6 (AI) | 9 (AI) | defender | defender | yes | 0 vs 1773 | 0 vs 1763 |
| 13752 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 943 | 0 vs 932 |
@@ -0,0 +1,70 @@
# ID Scoring Experiments, Seeds 1-4
These results use `id_standard_seed1.json` as the base generated battle shape:
- Map: `Alah`
- Max rounds: 10
- Setup: AI placement, forced single-option commands enabled
- Attacker: iterative deepening, six longbowmen with professions 1-6
- Defender: iterative deepening, six light infantry units with no attached heroes
The saved game zip at the repository root contains real Shardok battles, but the available
`shardok_state` rows are latest-state snapshots and the sampled states are terminal. They are
useful for saved-state ingestion checks, not for live AI decision win-rate measurement.
## Baseline
`STANDARD` attacker vs `STANDARD` defender:
- Attacker wins: 0 / 4
- Defender wins: 4 / 4
- Attacker win rate: 0%
- Average defender surviving units: 6.00
- Average defender surviving troops: 3510.25
Raw rows are recorded in `id_standard_baseline_seed1_4.csv`.
## Candidate Results
| Attacker scoring | Defender scoring | Attacker wins | Defender wins | Avg defender units | Avg defender troops | Notes |
| --- | --- | ---: | ---: | ---: | ---: | --- |
| `NORMALIZED` | `STANDARD` | 0 | 4 | 5.50 | 3224.00 | Better casualties than baseline, no win-rate movement. |
| `MCTS_OPTIMIZED` | `STANDARD` | 0 | 4 | 5.50 | 3503.50 | Similar troop outcome to baseline. |
| `STANDARD` with attacker unit decay removed | `STANDARD` | 0 | 4 | 6.00 | 3204.25 | Best casualty signal in this small sample, no win-rate movement. |
The attacker unit-decay experiment changes `StandardAIScoreCalculator::CombineAttackerScores` to
use a constant unit multiplier, matching the defender hold-castle scoring behavior. The rationale is
the same as the existing defender-side comment: decaying unit value late in the battle can make
tactical actions look worse than waiting when a side is behind on unit value.
## Candidate Raw Rows
### `NORMALIZED` Attacker vs `STANDARD` Defender
```csv
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
1,NORMALIZED,STANDARD,defender,last_player_standing,9,121,60,43,2.53333,3.44186,0,5,0,2665
2,NORMALIZED,STANDARD,defender,last_player_standing,10,129,64,47,2.50000,3.46809,0,6,0,3102
3,NORMALIZED,STANDARD,defender,last_player_standing,9,116,59,41,2.59322,3.31707,0,6,0,3752
4,NORMALIZED,STANDARD,defender,last_player_standing,9,114,63,34,2.47619,3.47059,0,5,0,3377
```
### `MCTS_OPTIMIZED` Attacker vs `STANDARD` Defender
```csv
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
1,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,129,64,46,2.48438,3.54348,0,6,0,3203
2,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,119,62,40,2.51613,3.22500,0,6,0,3559
3,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,115,62,35,2.67742,3.37143,0,5,0,3969
4,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,114,63,34,2.41270,3.41176,0,5,0,3283
```
### `STANDARD` Attacker With Unit Decay Removed vs `STANDARD` Defender
```csv
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
1,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,10,135,65,51,2.53846,3.45098,0,6,0,2707
2,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,9,130,64,48,2.48438,3.58333,0,6,0,3149
3,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,9,129,63,45,2.57143,3.44444,0,6,0,3401
4,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,9,129,62,46,2.66129,3.41304,0,6,0,3560
```
@@ -0,0 +1,5 @@
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
1,STANDARD,STANDARD,defender,last_player_standing,9,130,65,47,2.52308,3.61702,0,6,0,3376
2,STANDARD,STANDARD,defender,last_player_standing,9,130,63,47,2.58730,3.29787,0,6,0,3704
3,STANDARD,STANDARD,defender,last_player_standing,9,129,63,45,2.58730,3.44444,0,6,0,3401
4,STANDARD,STANDARD,defender,last_player_standing,9,129,62,46,2.70968,3.39130,0,6,0,3560
1 seed attacker_scoring defender_scoring winner end_reason total_rounds total_commands attacker_ai_commands defender_ai_commands attacker_avg_depth defender_avg_depth attacker_surviving_units defender_surviving_units attacker_surviving_troops defender_surviving_troops
2 1 STANDARD STANDARD defender last_player_standing 9 130 65 47 2.52308 3.61702 0 6 0 3376
3 2 STANDARD STANDARD defender last_player_standing 9 130 63 47 2.58730 3.29787 0 6 0 3704
4 3 STANDARD STANDARD defender last_player_standing 9 129 63 45 2.58730 3.44444 0 6 0 3401
5 4 STANDARD STANDARD defender last_player_standing 9 129 62 46 2.70968 3.39130 0 6 0 3560
@@ -0,0 +1,32 @@
{
"map_name": "Alah",
"month": 4,
"max_rounds": 10,
"random_seed": 1,
"use_configured_placements": false,
"apply_forced_commands": true,
"attacker": {
"ai_algorithm": "ITERATIVE_DEEPENING",
"scoring_calculator": "STANDARD",
"units": [
{ "profession": 1, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
{ "profession": 2, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
{ "profession": 3, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
{ "profession": 4, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
{ "profession": 5, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
{ "profession": 6, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } }
]
},
"defender": {
"ai_algorithm": "ITERATIVE_DEEPENING",
"scoring_calculator": "STANDARD",
"units": [
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } }
]
}
}
@@ -0,0 +1,41 @@
variant,attacker_scoring,defender_scoring,shardok_game_id,action_seq,attacker_units,defender_units,attacker_initial_troops,defender_initial_troops,attacker_troop_ratio,winner,end_reason,result,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1_b3e7d8ab,306,2,3,1600.0,2600.0,0.6153846153846154,defender,last_player_standing,Defender won by eliminating all attackers,31,192,67,85,3.61194,3.61176,0,1,0,685,18.768
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_2_bf499d3c,307,2,1,1600.0,1000.0,1.6,defender,last_player_standing,Defender won by eliminating all attackers,20,82,32,20,3.09375,2.3,0,1,0,324,5.722
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_3_cd9141a,399,1,3,450.0,1454.0,0.30949105914718017,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,188,45,106,2,3.89623,1,3,450,2454,30.201
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_4_6e7b9b,631,2,5,1310.0,4200.0,0.3119047619047619,defender,last_player_standing,Defender won by eliminating all attackers,8,70,20,33,3.55,3.90909,0,4,0,2991,9.564
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_5_8a57255a,1326,2,4,1800.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,9,61,18,27,2.77778,3.18519,0,5,0,4037,10.772
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_6_ab8632ba,1472,2,2,1800.0,1600.0,1.125,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,198,69,84,3.13043,3.5,1,2,57,378,23.999
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_7_5cf74e0,2437,5,6,3766.0,3787.0,0.9944547134935305,defender,last_player_standing,Defender won by eliminating all attackers,18,267,82,158,3.30488,3.42405,0,5,0,2948,73.020
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_8_41ce0ebd,2584,2,7,1600.0,3668.0,0.4362050163576881,defender,last_player_standing,Defender won by eliminating all attackers,6,78,10,52,3.2,3.53846,0,7,0,3832,21.143
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_9_eb43d421,2654,3,2,2599.0,726.0,3.5798898071625342,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,317,186,93,2,4.12903,3,2,2599,726,33.775
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_a_950de1c1,3536,5,10,3858.0,2028.0,1.9023668639053255,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,428,138,247,2.52174,3.39271,1,6,375,4511,115.585
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_b_5d1d76e0,3701,2,6,1800.0,4240.0,0.42452830188679247,defender,last_player_standing,Defender won by eliminating all attackers,31,255,40,179,2.825,3.45251,0,4,0,2358,56.109
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_c_7bdb54dd,4298,4,6,3123.0,2172.0,1.4378453038674033,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,599,247,309,2,3.69579,4,6,2914,4085,128.531
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_d_689ee2fa,4576,4,3,3300.0,2135.0,1.5456674473067915,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,415,210,148,2,3.33108,4,3,629,1771,41.509
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_e_89c90dc6,6014,2,3,1512.0,2600.0,0.5815384615384616,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,214,97,79,2,3.29114,2,3,1512,2600,27.933
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_f_3a9d9136,6248,2,2,1800.0,1600.0,1.125,attacker,last_player_standing,Attacker won by eliminating all defenders,12,86,32,27,3.25,3.37037,2,0,1455,0,7.670
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_10_c972d3e1,6408,5,2,4169.0,1223.0,3.4088307440719543,attacker,last_player_standing,Attacker won by eliminating all defenders,15,145,96,24,3.38542,2.54167,5,0,3832,0,30.677
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_11_e0611233,6705,5,5,3510.0,4400.0,0.7977272727272727,attacker,last_player_standing,Attacker won by eliminating all defenders,28,219,101,81,2.9802,3.48148,3,0,2150,0,40.168
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_12_84a36154,7212,2,2,1156.0,1800.0,0.6422222222222222,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,254,62,124,2,3.45161,2,2,1156,1800,15.591
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_13_2e98b1cf,8217,3,2,2270.0,1136.0,1.9982394366197183,attacker,last_player_standing,Attacker won by eliminating all defenders,24,185,90,53,3.46667,3.37736,3,0,1944,0,21.446
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_14_b5570863,8394,4,2,3600.0,1212.0,2.9702970297029703,attacker,last_player_standing,Attacker won by eliminating all defenders,20,130,74,26,3.41892,2.84615,4,0,2552,0,25.721
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_15_28a14d7d,8628,4,1,3011.0,800.0,3.76375,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,255,186,31,2,3,4,1,3011,800,5.159
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_16_3d6ec341,9975,5,11,4049.0,9110.0,0.44445664105378707,defender,last_player_standing,Defender won by eliminating all attackers,5,115,26,65,3.03846,2.98462,0,11,0,7033,33.494
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_17_3ba689e3,10229,2,1,1800.0,1000.0,1.8,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,160,81,13,3.85185,2.53846,1,1,56,727,15.586
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_18_9e463db3,10631,5,2,4000.0,29.0,137.93103448275863,attacker,last_player_standing,Attacker won by eliminating all defenders,10,104,55,27,2.98182,2.62963,5,0,3955,0,17.981
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_19_239725d3,11011,3,5,2092.0,4000.0,0.523,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,274,80,151,2.3625,3.75497,1,4,120,2749,65.354
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1a_16e1ebf,11012,2,2,1600.0,1800.0,0.8888888888888888,defender,last_player_standing,Defender won by eliminating all attackers,21,112,30,48,2.9,3.3125,0,2,0,1328,12.932
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1b_814a8b05,11112,2,11,1800.0,7595.0,0.2369980250164582,defender,last_player_standing,Defender won by eliminating all attackers,5,89,10,60,3.4,3.05,0,11,0,9248,26.113
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1c_529ba6e5,11297,3,2,2400.0,0.0,,attacker,last_player_standing,Attacker won by eliminating all defenders,14,110,46,39,2.95652,3.4359,3,0,1569,0,20.096
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1d_49993012,11469,3,2,2383.0,1800.0,1.323888888888889,attacker,last_player_standing,Attacker won by eliminating all defenders,27,159,73,34,3.52055,3.05882,3,0,2160,0,19.694
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1e_fd8c60a8,11470,4,2,3600.0,1800.0,2.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,356,177,112,2.31073,3.47321,3,2,2267,952,18.046
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1f_53309484,11565,1,4,334.0,3600.0,0.09277777777777778,defender,last_player_standing,Defender won by eliminating all attackers,1,8,1,0,3,0,0,4,0,3600,0.555
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_20_6d9f21cd,11662,9,3,7595.0,2600.0,2.921153846153846,attacker,last_player_standing,Attacker won by eliminating all defenders,11,174,111,41,3.1982,3.36585,9,0,7070,0,47.504
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_21_c536f279,11754,3,3,2470.0,2400.0,1.0291666666666666,defender,last_player_standing,Defender won by eliminating all attackers,7,55,20,22,3.65,3.54545,0,3,0,1624,10.181
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_22_a0f39b09,11941,6,2,4996.0,1800.0,2.7755555555555556,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,290,186,69,3.46237,3.52174,1,2,165,299,60.206
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_23_865a7a0e,11942,12,3,8916.0,2119.0,4.207645115620576,attacker,last_player_standing,Attacker won by eliminating all defenders,12,232,164,47,2.94512,3.59574,12,0,7940,0,77.700
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_24_f0d61a5a,12136,3,2,2089.0,1800.0,1.1605555555555556,attacker,last_player_standing,Attacker won by eliminating all defenders,15,98,50,25,3.18,3.12,2,0,1384,0,14.063
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_25_a19c4d87,12137,2,3,1524.0,2400.0,0.635,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,148,67,43,2.34328,2.55814,1,3,214,2282,19.123
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_26_d897bec7,12138,4,2,3301.0,1400.0,2.3578571428571427,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,422,290,93,2,3.33333,4,2,3301,1400,6.678
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_27_c008989b,12765,2,2,1800.0,1800.0,1.0,defender,last_player_standing,Defender won by eliminating all attackers,30,108,40,30,3.5,2.86667,0,2,0,1678,9.069
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_28_b33e2868,13752,2,2,1798.0,1600.0,1.12375,defender,last_player_standing,Defender won by eliminating all attackers,30,189,39,123,3.15385,3.98374,0,2,0,1114,26.389
1 variant attacker_scoring defender_scoring shardok_game_id action_seq attacker_units defender_units attacker_initial_troops defender_initial_troops attacker_troop_ratio winner end_reason result total_rounds total_commands attacker_ai_commands defender_ai_commands attacker_avg_depth defender_avg_depth attacker_surviving_units defender_surviving_units attacker_surviving_troops defender_surviving_troops elapsed_seconds
2 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_1_b3e7d8ab 306 2 3 1600.0 2600.0 0.6153846153846154 defender last_player_standing Defender won by eliminating all attackers 31 192 67 85 3.61194 3.61176 0 1 0 685 18.768
3 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_2_bf499d3c 307 2 1 1600.0 1000.0 1.6 defender last_player_standing Defender won by eliminating all attackers 20 82 32 20 3.09375 2.3 0 1 0 324 5.722
4 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_3_cd9141a 399 1 3 450.0 1454.0 0.30949105914718017 defender max_rounds_reached Defender won by surviving maximum rounds 31 188 45 106 2 3.89623 1 3 450 2454 30.201
5 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_4_6e7b9b 631 2 5 1310.0 4200.0 0.3119047619047619 defender last_player_standing Defender won by eliminating all attackers 8 70 20 33 3.55 3.90909 0 4 0 2991 9.564
6 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_5_8a57255a 1326 2 4 1800.0 0.0 defender last_player_standing Defender won by eliminating all attackers 9 61 18 27 2.77778 3.18519 0 5 0 4037 10.772
7 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_6_ab8632ba 1472 2 2 1800.0 1600.0 1.125 defender max_rounds_reached Defender won by surviving maximum rounds 31 198 69 84 3.13043 3.5 1 2 57 378 23.999
8 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_7_5cf74e0 2437 5 6 3766.0 3787.0 0.9944547134935305 defender last_player_standing Defender won by eliminating all attackers 18 267 82 158 3.30488 3.42405 0 5 0 2948 73.020
9 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_8_41ce0ebd 2584 2 7 1600.0 3668.0 0.4362050163576881 defender last_player_standing Defender won by eliminating all attackers 6 78 10 52 3.2 3.53846 0 7 0 3832 21.143
10 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_9_eb43d421 2654 3 2 2599.0 726.0 3.5798898071625342 defender max_rounds_reached Defender won by surviving maximum rounds 31 317 186 93 2 4.12903 3 2 2599 726 33.775
11 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_a_950de1c1 3536 5 10 3858.0 2028.0 1.9023668639053255 defender max_rounds_reached Defender won by surviving maximum rounds 31 428 138 247 2.52174 3.39271 1 6 375 4511 115.585
12 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_b_5d1d76e0 3701 2 6 1800.0 4240.0 0.42452830188679247 defender last_player_standing Defender won by eliminating all attackers 31 255 40 179 2.825 3.45251 0 4 0 2358 56.109
13 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_c_7bdb54dd 4298 4 6 3123.0 2172.0 1.4378453038674033 defender max_rounds_reached Defender won by surviving maximum rounds 31 599 247 309 2 3.69579 4 6 2914 4085 128.531
14 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_d_689ee2fa 4576 4 3 3300.0 2135.0 1.5456674473067915 defender max_rounds_reached Defender won by surviving maximum rounds 31 415 210 148 2 3.33108 4 3 629 1771 41.509
15 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_e_89c90dc6 6014 2 3 1512.0 2600.0 0.5815384615384616 defender max_rounds_reached Defender won by surviving maximum rounds 31 214 97 79 2 3.29114 2 3 1512 2600 27.933
16 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_f_3a9d9136 6248 2 2 1800.0 1600.0 1.125 attacker last_player_standing Attacker won by eliminating all defenders 12 86 32 27 3.25 3.37037 2 0 1455 0 7.670
17 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_10_c972d3e1 6408 5 2 4169.0 1223.0 3.4088307440719543 attacker last_player_standing Attacker won by eliminating all defenders 15 145 96 24 3.38542 2.54167 5 0 3832 0 30.677
18 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_11_e0611233 6705 5 5 3510.0 4400.0 0.7977272727272727 attacker last_player_standing Attacker won by eliminating all defenders 28 219 101 81 2.9802 3.48148 3 0 2150 0 40.168
19 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_12_84a36154 7212 2 2 1156.0 1800.0 0.6422222222222222 defender max_rounds_reached Defender won by surviving maximum rounds 31 254 62 124 2 3.45161 2 2 1156 1800 15.591
20 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_13_2e98b1cf 8217 3 2 2270.0 1136.0 1.9982394366197183 attacker last_player_standing Attacker won by eliminating all defenders 24 185 90 53 3.46667 3.37736 3 0 1944 0 21.446
21 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_14_b5570863 8394 4 2 3600.0 1212.0 2.9702970297029703 attacker last_player_standing Attacker won by eliminating all defenders 20 130 74 26 3.41892 2.84615 4 0 2552 0 25.721
22 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_15_28a14d7d 8628 4 1 3011.0 800.0 3.76375 defender max_rounds_reached Defender won by surviving maximum rounds 31 255 186 31 2 3 4 1 3011 800 5.159
23 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_16_3d6ec341 9975 5 11 4049.0 9110.0 0.44445664105378707 defender last_player_standing Defender won by eliminating all attackers 5 115 26 65 3.03846 2.98462 0 11 0 7033 33.494
24 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_17_3ba689e3 10229 2 1 1800.0 1000.0 1.8 defender max_rounds_reached Defender won by surviving maximum rounds 31 160 81 13 3.85185 2.53846 1 1 56 727 15.586
25 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_18_9e463db3 10631 5 2 4000.0 29.0 137.93103448275863 attacker last_player_standing Attacker won by eliminating all defenders 10 104 55 27 2.98182 2.62963 5 0 3955 0 17.981
26 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_19_239725d3 11011 3 5 2092.0 4000.0 0.523 defender max_rounds_reached Defender won by surviving maximum rounds 31 274 80 151 2.3625 3.75497 1 4 120 2749 65.354
27 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_1a_16e1ebf 11012 2 2 1600.0 1800.0 0.8888888888888888 defender last_player_standing Defender won by eliminating all attackers 21 112 30 48 2.9 3.3125 0 2 0 1328 12.932
28 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_1b_814a8b05 11112 2 11 1800.0 7595.0 0.2369980250164582 defender last_player_standing Defender won by eliminating all attackers 5 89 10 60 3.4 3.05 0 11 0 9248 26.113
29 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_1c_529ba6e5 11297 3 2 2400.0 0.0 attacker last_player_standing Attacker won by eliminating all defenders 14 110 46 39 2.95652 3.4359 3 0 1569 0 20.096
30 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_1d_49993012 11469 3 2 2383.0 1800.0 1.323888888888889 attacker last_player_standing Attacker won by eliminating all defenders 27 159 73 34 3.52055 3.05882 3 0 2160 0 19.694
31 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_1e_fd8c60a8 11470 4 2 3600.0 1800.0 2.0 defender max_rounds_reached Defender won by surviving maximum rounds 31 356 177 112 2.31073 3.47321 3 2 2267 952 18.046
32 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_1f_53309484 11565 1 4 334.0 3600.0 0.09277777777777778 defender last_player_standing Defender won by eliminating all attackers 1 8 1 0 3 0 0 4 0 3600 0.555
33 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_20_6d9f21cd 11662 9 3 7595.0 2600.0 2.921153846153846 attacker last_player_standing Attacker won by eliminating all defenders 11 174 111 41 3.1982 3.36585 9 0 7070 0 47.504
34 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_21_c536f279 11754 3 3 2470.0 2400.0 1.0291666666666666 defender last_player_standing Defender won by eliminating all attackers 7 55 20 22 3.65 3.54545 0 3 0 1624 10.181
35 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_22_a0f39b09 11941 6 2 4996.0 1800.0 2.7755555555555556 defender max_rounds_reached Defender won by surviving maximum rounds 31 290 186 69 3.46237 3.52174 1 2 165 299 60.206
36 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_23_865a7a0e 11942 12 3 8916.0 2119.0 4.207645115620576 attacker last_player_standing Attacker won by eliminating all defenders 12 232 164 47 2.94512 3.59574 12 0 7940 0 77.700
37 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_24_f0d61a5a 12136 3 2 2089.0 1800.0 1.1605555555555556 attacker last_player_standing Attacker won by eliminating all defenders 15 98 50 25 3.18 3.12 2 0 1384 0 14.063
38 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_25_a19c4d87 12137 2 3 1524.0 2400.0 0.635 defender max_rounds_reached Defender won by surviving maximum rounds 31 148 67 43 2.34328 2.55814 1 3 214 2282 19.123
39 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_26_d897bec7 12138 4 2 3301.0 1400.0 2.3578571428571427 defender max_rounds_reached Defender won by surviving maximum rounds 31 422 290 93 2 3.33333 4 2 3301 1400 6.678
40 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_27_c008989b 12765 2 2 1800.0 1800.0 1.0 defender last_player_standing Defender won by eliminating all attackers 30 108 40 30 3.5 2.86667 0 2 0 1678 9.069
41 no_attacker_unit_decay STANDARD_NO_ATTACKER_UNIT_DECAY STANDARD 389ffb1901903118_28_b33e2868 13752 2 2 1798.0 1600.0 1.12375 defender last_player_standing Defender won by eliminating all attackers 30 189 39 123 3.15385 3.98374 0 2 0 1114 26.389
@@ -0,0 +1,102 @@
# Real Battle All-40 Scoring Experiments
> Superseded: these results were generated before the simulator preserved explicit zero-size
> battalion slots. Saved-game configs with empty slots could therefore receive extra full-capacity
> units. Regenerate this matrix after the zero-size battalion fix before comparing scoring changes.
These runs use all 40 saved battle creation payloads from `game_389ffb1901903118.zip`.
Each config was extracted from `new_battle_proto` plus the nearest prior Eagle `GameState`
snapshot. The matrix uses `max_rounds = 31`, `random_seed = 1`, AI setup placement, and forced
single-option commands.
Shardok has no draw result for these siege battles: if the attacker has not won after 31 rounds,
the defender wins. The simulator now records those cutoffs as `max_rounds_reached` defender wins
instead of `draw` / `Game incomplete`.
Raw rows are recorded in `real_battle_all40_scoring_matrix.csv`.
## Full Matrix Results
| Variant | Attacker wins | Defender wins | Max-round defender wins | Runtime |
| --- | ---: | ---: | ---: | ---: |
| `STANDARD` attacker vs `STANDARD` defender | 11 | 29 | 15 | 8.7 min |
| `NORMALIZED` attacker vs `STANDARD` defender | 1 | 39 | 23 | 8.7 min |
| `MCTS_OPTIMIZED` attacker vs `STANDARD` defender | 10 | 30 | 14 | 8.7 min |
`STANDARD` here is the current branch implementation, including the attacker-side change that keeps
unit value relevant through the end of the battle instead of decaying it by rounds remaining.
`NORMALIZED` is much worse for ID attacker scoring on this real set. It converts 10 baseline
attacker wins into defender wins and is not a promising direction for attacker scoring.
`MCTS_OPTIMIZED` is closer to neutral but still slightly worse than current `STANDARD` for ID
attacker scoring on this set. It loses battle `6705`, which current `STANDARD` wins, and does not
add any attacker wins.
## Outcome Changes Versus Baseline
### `NORMALIZED` Attacker
- `6248`: attacker last-player-standing win -> defender last-player-standing win
- `6408`: attacker last-player-standing win -> defender max-round win
- `6705`: attacker last-player-standing win -> defender last-player-standing win
- `8217`: attacker last-player-standing win -> defender max-round win
- `8394`: attacker last-player-standing win -> defender max-round win
- `11297`: attacker last-player-standing win -> defender last-player-standing win
- `11469`: attacker last-player-standing win -> defender max-round win
- `11662`: attacker last-player-standing win -> defender max-round win
- `11942`: attacker last-player-standing win -> defender max-round win
- `12136`: attacker last-player-standing win -> defender last-player-standing win
Other changes only alter how the defender wins, not who wins.
### `MCTS_OPTIMIZED` Attacker
- `6705`: attacker last-player-standing win -> defender last-player-standing win
Other changes only alter how the defender wins, not who wins.
## Close-Battle Follow-up
The closest current-baseline defender wins by surviving troop margin were:
- `11941`: defender max-round win, attacker 165 troops vs defender 299
- `1472`: defender max-round win, attacker 57 troops vs defender 378
- `307`: defender last-player-standing win with 324 defender troops left
- `7212`: defender max-round win, attacker 1156 troops vs defender 1800
- `10229`: defender max-round win, attacker 56 troops vs defender 727
Before correcting the no-draw rule, earlier max-20 data made several cutoffs look like draws. Those
rows are superseded for effectiveness comparisons by the max-31 matrix above.
## Scoring Tweak Tested
The scoring tweak included in this branch removes the attacker-side late-battle unit-value decay in
`StandardAIScoreCalculator::CombineAttackerScores`.
Rationale:
- Defender hold-castle scoring already removed this decay because it made tactical actions look
worse than waiting when behind on unit value.
- The close real battles showed attackers failing to convert material/position before the round
limit.
- Keeping unit value meaningful late in the battle should make the attacker continue seeking
decisive tactical progress instead of undervaluing combat.
On the original five close production-length cases, this tweak preserved existing attacker wins and
flipped `11941` from defender win to attacker win:
| Battle | Production max-40 | No attacker unit decay |
| --- | --- | --- |
| `306` | attacker win, 239 attacker troops left | attacker win, 239 attacker troops left |
| `11469` | attacker win, 2166 attacker troops left | attacker win, 2160 attacker troops left |
| `11941` | defender win, 131 defender troops left | attacker win, 1052 attacker troops left |
| `6705` | attacker win, 2062 attacker troops left | attacker win, 2393 attacker troops left |
| `307` | defender win, 324 defender troops left | defender win, 324 defender troops left |
Detailed rows are recorded in `real_battle_closest_max40_no_attacker_unit_decay.csv`.
The final-round flee tweak was also tested and is recorded in
`real_battle_closest_final_round_flee_compare.csv` and
`real_battle_closest_final_round_flee_plus_no_decay.csv`. It did not outperform the cleaner
production-length no-decay result, so it is not included as a code change.
@@ -0,0 +1,121 @@
variant,attacker_scoring,defender_scoring,shardok_game_id,action_seq,attacker_units,defender_units,attacker_initial_troops,defender_initial_troops,attacker_troop_ratio,winner,end_reason,result,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
baseline,STANDARD,STANDARD,389ffb1901903118_1_b3e7d8ab,306,2,3,1600.0,2600.0,0.6153846153846154,defender,last_player_standing,Defender won by eliminating all attackers,31,192,67,85,3.61194,3.61176,0,1,0,685,18.768
baseline,STANDARD,STANDARD,389ffb1901903118_2_bf499d3c,307,2,1,1600.0,1000.0,1.6,defender,last_player_standing,Defender won by eliminating all attackers,20,82,32,20,3.09375,2.3,0,1,0,324,5.722
baseline,STANDARD,STANDARD,389ffb1901903118_3_cd9141a,399,1,3,450.0,1454.0,0.30949105914718017,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,188,45,106,2,3.89623,1,3,450,2454,30.201
baseline,STANDARD,STANDARD,389ffb1901903118_4_6e7b9b,631,2,5,1310.0,4200.0,0.3119047619047619,defender,last_player_standing,Defender won by eliminating all attackers,8,70,20,33,3.55,3.90909,0,4,0,2991,9.564
baseline,STANDARD,STANDARD,389ffb1901903118_5_8a57255a,1326,2,4,1800.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,9,61,18,27,2.77778,3.18519,0,5,0,4037,10.772
baseline,STANDARD,STANDARD,389ffb1901903118_6_ab8632ba,1472,2,2,1800.0,1600.0,1.125,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,198,69,84,3.13043,3.5,1,2,57,378,23.999
baseline,STANDARD,STANDARD,389ffb1901903118_7_5cf74e0,2437,5,6,3766.0,3787.0,0.9944547134935305,defender,last_player_standing,Defender won by eliminating all attackers,18,267,82,158,3.30488,3.42405,0,5,0,2948,73.020
baseline,STANDARD,STANDARD,389ffb1901903118_8_41ce0ebd,2584,2,7,1600.0,3668.0,0.4362050163576881,defender,last_player_standing,Defender won by eliminating all attackers,6,78,10,52,3.2,3.53846,0,7,0,3832,21.143
baseline,STANDARD,STANDARD,389ffb1901903118_9_eb43d421,2654,3,2,2599.0,726.0,3.5798898071625342,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,317,186,93,2,4.12903,3,2,2599,726,33.775
baseline,STANDARD,STANDARD,389ffb1901903118_a_950de1c1,3536,5,10,3858.0,2028.0,1.9023668639053255,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,428,138,247,2.52174,3.39271,1,6,375,4511,115.585
baseline,STANDARD,STANDARD,389ffb1901903118_b_5d1d76e0,3701,2,6,1800.0,4240.0,0.42452830188679247,defender,last_player_standing,Defender won by eliminating all attackers,31,255,40,179,2.825,3.45251,0,4,0,2358,56.109
baseline,STANDARD,STANDARD,389ffb1901903118_c_7bdb54dd,4298,4,6,3123.0,2172.0,1.4378453038674033,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,599,247,309,2,3.69579,4,6,2914,4085,128.531
baseline,STANDARD,STANDARD,389ffb1901903118_d_689ee2fa,4576,4,3,3300.0,2135.0,1.5456674473067915,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,415,210,148,2,3.33108,4,3,629,1771,41.509
baseline,STANDARD,STANDARD,389ffb1901903118_e_89c90dc6,6014,2,3,1512.0,2600.0,0.5815384615384616,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,214,97,79,2,3.29114,2,3,1512,2600,27.933
baseline,STANDARD,STANDARD,389ffb1901903118_f_3a9d9136,6248,2,2,1800.0,1600.0,1.125,attacker,last_player_standing,Attacker won by eliminating all defenders,12,86,32,27,3.25,3.37037,2,0,1455,0,7.670
baseline,STANDARD,STANDARD,389ffb1901903118_10_c972d3e1,6408,5,2,4169.0,1223.0,3.4088307440719543,attacker,last_player_standing,Attacker won by eliminating all defenders,15,145,96,24,3.38542,2.54167,5,0,3832,0,30.677
baseline,STANDARD,STANDARD,389ffb1901903118_11_e0611233,6705,5,5,3510.0,4400.0,0.7977272727272727,attacker,last_player_standing,Attacker won by eliminating all defenders,28,219,101,81,2.9802,3.48148,3,0,2150,0,40.168
baseline,STANDARD,STANDARD,389ffb1901903118_12_84a36154,7212,2,2,1156.0,1800.0,0.6422222222222222,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,254,62,124,2,3.45161,2,2,1156,1800,15.591
baseline,STANDARD,STANDARD,389ffb1901903118_13_2e98b1cf,8217,3,2,2270.0,1136.0,1.9982394366197183,attacker,last_player_standing,Attacker won by eliminating all defenders,24,185,90,53,3.46667,3.37736,3,0,1944,0,21.446
baseline,STANDARD,STANDARD,389ffb1901903118_14_b5570863,8394,4,2,3600.0,1212.0,2.9702970297029703,attacker,last_player_standing,Attacker won by eliminating all defenders,20,130,74,26,3.41892,2.84615,4,0,2552,0,25.721
baseline,STANDARD,STANDARD,389ffb1901903118_15_28a14d7d,8628,4,1,3011.0,800.0,3.76375,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,255,186,31,2,3,4,1,3011,800,5.159
baseline,STANDARD,STANDARD,389ffb1901903118_16_3d6ec341,9975,5,11,4049.0,9110.0,0.44445664105378707,defender,last_player_standing,Defender won by eliminating all attackers,5,115,26,65,3.03846,2.98462,0,11,0,7033,33.494
baseline,STANDARD,STANDARD,389ffb1901903118_17_3ba689e3,10229,2,1,1800.0,1000.0,1.8,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,160,81,13,3.85185,2.53846,1,1,56,727,15.586
baseline,STANDARD,STANDARD,389ffb1901903118_18_9e463db3,10631,5,2,4000.0,29.0,137.93103448275863,attacker,last_player_standing,Attacker won by eliminating all defenders,10,104,55,27,2.98182,2.62963,5,0,3955,0,17.981
baseline,STANDARD,STANDARD,389ffb1901903118_19_239725d3,11011,3,5,2092.0,4000.0,0.523,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,274,80,151,2.3625,3.75497,1,4,120,2749,65.354
baseline,STANDARD,STANDARD,389ffb1901903118_1a_16e1ebf,11012,2,2,1600.0,1800.0,0.8888888888888888,defender,last_player_standing,Defender won by eliminating all attackers,21,112,30,48,2.9,3.3125,0,2,0,1328,12.932
baseline,STANDARD,STANDARD,389ffb1901903118_1b_814a8b05,11112,2,11,1800.0,7595.0,0.2369980250164582,defender,last_player_standing,Defender won by eliminating all attackers,5,89,10,60,3.4,3.05,0,11,0,9248,26.113
baseline,STANDARD,STANDARD,389ffb1901903118_1c_529ba6e5,11297,3,2,2400.0,0.0,,attacker,last_player_standing,Attacker won by eliminating all defenders,14,110,46,39,2.95652,3.4359,3,0,1569,0,20.096
baseline,STANDARD,STANDARD,389ffb1901903118_1d_49993012,11469,3,2,2383.0,1800.0,1.323888888888889,attacker,last_player_standing,Attacker won by eliminating all defenders,27,159,73,34,3.52055,3.05882,3,0,2160,0,19.694
baseline,STANDARD,STANDARD,389ffb1901903118_1e_fd8c60a8,11470,4,2,3600.0,1800.0,2.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,356,177,112,2.31073,3.47321,3,2,2267,952,18.046
baseline,STANDARD,STANDARD,389ffb1901903118_1f_53309484,11565,1,4,334.0,3600.0,0.09277777777777778,defender,last_player_standing,Defender won by eliminating all attackers,1,8,1,0,3,0,0,4,0,3600,0.555
baseline,STANDARD,STANDARD,389ffb1901903118_20_6d9f21cd,11662,9,3,7595.0,2600.0,2.921153846153846,attacker,last_player_standing,Attacker won by eliminating all defenders,11,174,111,41,3.1982,3.36585,9,0,7070,0,47.504
baseline,STANDARD,STANDARD,389ffb1901903118_21_c536f279,11754,3,3,2470.0,2400.0,1.0291666666666666,defender,last_player_standing,Defender won by eliminating all attackers,7,55,20,22,3.65,3.54545,0,3,0,1624,10.181
baseline,STANDARD,STANDARD,389ffb1901903118_22_a0f39b09,11941,6,2,4996.0,1800.0,2.7755555555555556,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,290,186,69,3.46237,3.52174,1,2,165,299,60.206
baseline,STANDARD,STANDARD,389ffb1901903118_23_865a7a0e,11942,12,3,8916.0,2119.0,4.207645115620576,attacker,last_player_standing,Attacker won by eliminating all defenders,12,232,164,47,2.94512,3.59574,12,0,7940,0,77.700
baseline,STANDARD,STANDARD,389ffb1901903118_24_f0d61a5a,12136,3,2,2089.0,1800.0,1.1605555555555556,attacker,last_player_standing,Attacker won by eliminating all defenders,15,98,50,25,3.18,3.12,2,0,1384,0,14.063
baseline,STANDARD,STANDARD,389ffb1901903118_25_a19c4d87,12137,2,3,1524.0,2400.0,0.635,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,148,67,43,2.34328,2.55814,1,3,214,2282,19.123
baseline,STANDARD,STANDARD,389ffb1901903118_26_d897bec7,12138,4,2,3301.0,1400.0,2.3578571428571427,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,422,290,93,2,3.33333,4,2,3301,1400,6.678
baseline,STANDARD,STANDARD,389ffb1901903118_27_c008989b,12765,2,2,1800.0,1800.0,1.0,defender,last_player_standing,Defender won by eliminating all attackers,30,108,40,30,3.5,2.86667,0,2,0,1678,9.069
baseline,STANDARD,STANDARD,389ffb1901903118_28_b33e2868,13752,2,2,1798.0,1600.0,1.12375,defender,last_player_standing,Defender won by eliminating all attackers,30,189,39,123,3.15385,3.98374,0,2,0,1114,26.389
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1_b3e7d8ab,306,2,3,1600.0,2600.0,0.6153846153846154,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,190,72,93,3.56944,3.44086,1,2,135,968,14.687
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_2_bf499d3c,307,2,1,1600.0,1000.0,1.6,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,129,62,31,2.24194,2,1,1,800,1000,2.627
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_3_cd9141a,399,1,3,450.0,1454.0,0.30949105914718017,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,189,45,107,2,3.94393,1,3,450,2454,29.873
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_4_6e7b9b,631,2,5,1310.0,4200.0,0.3119047619047619,defender,last_player_standing,Defender won by eliminating all attackers,8,69,19,33,3.15789,3.93939,0,4,0,2985,9.697
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_5_8a57255a,1326,2,4,1800.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,9,64,18,29,2.66667,3.24138,0,5,0,4026,11.132
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_6_ab8632ba,1472,2,2,1800.0,1600.0,1.125,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,218,76,96,2.98684,3.38542,1,2,443,511,26.824
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_7_5cf74e0,2437,5,6,3766.0,3787.0,0.9944547134935305,defender,last_player_standing,Defender won by eliminating all attackers,22,260,67,160,2.32836,3.61875,0,4,0,3090,62.598
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_8_41ce0ebd,2584,2,7,1600.0,3668.0,0.4362050163576881,defender,last_player_standing,Defender won by eliminating all attackers,5,66,9,42,3.22222,3.52381,0,7,0,3979,18.916
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_9_eb43d421,2654,3,2,2599.0,726.0,3.5798898071625342,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,317,186,93,2,4.12903,3,2,2599,726,37.896
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_a_950de1c1,3536,5,10,3858.0,2028.0,1.9023668639053255,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,504,126,338,2.57937,3.25148,1,7,193,4379,148.897
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_b_5d1d76e0,3701,2,6,1800.0,4240.0,0.42452830188679247,defender,last_player_standing,Defender won by eliminating all attackers,30,234,38,168,2.55263,3.28571,0,4,0,1930,54.692
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_c_7bdb54dd,4298,4,6,3123.0,2172.0,1.4378453038674033,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,566,236,286,2,3.63636,3,6,2335,3974,116.403
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_d_689ee2fa,4576,4,3,3300.0,2135.0,1.5456674473067915,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,415,210,148,2,3.26351,4,3,629,1771,41.599
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_e_89c90dc6,6014,2,3,1512.0,2600.0,0.5815384615384616,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,214,97,79,2,3.29114,2,3,1512,2600,28.149
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_f_3a9d9136,6248,2,2,1800.0,1600.0,1.125,defender,last_player_standing,Defender won by eliminating all attackers,17,110,23,52,2.43478,3.40385,0,2,0,974,4.416
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_10_c972d3e1,6408,5,2,4169.0,1223.0,3.4088307440719543,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,318,200,85,3.32,3.50588,3,2,2400,1056,71.256
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_11_e0611233,6705,5,5,3510.0,4400.0,0.7977272727272727,defender,last_player_standing,Defender won by eliminating all attackers,8,69,22,30,3.09091,3.5,0,5,0,3915,13.735
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_12_84a36154,7212,2,2,1156.0,1800.0,0.6422222222222222,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,254,62,124,2,3.45161,2,2,1156,1800,15.036
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_13_2e98b1cf,8217,3,2,2270.0,1136.0,1.9982394366197183,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,236,133,59,3.45865,3.16949,2,1,1120,72,29.584
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_14_b5570863,8394,4,2,3600.0,1212.0,2.9702970297029703,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,249,144,46,3.32639,2.34783,3,1,2560,143,31.143
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_15_28a14d7d,8628,4,1,3011.0,800.0,3.76375,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,255,186,31,2,3,4,1,3011,800,5.578
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_16_3d6ec341,9975,5,11,4049.0,9110.0,0.44445664105378707,defender,last_player_standing,Defender won by eliminating all attackers,25,568,52,472,2.36538,2.625,0,10,0,6047,213.060
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_17_3ba689e3,10229,2,1,1800.0,1000.0,1.8,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,156,77,13,3.79221,2.53846,1,1,56,727,15.511
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_18_9e463db3,10631,5,2,4000.0,29.0,137.93103448275863,attacker,last_player_standing,Attacker won by eliminating all defenders,9,100,60,21,3.08333,2.80952,5,0,3922,0,19.207
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_19_239725d3,11011,3,5,2092.0,4000.0,0.523,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,278,85,151,2.38824,3.78808,1,4,152,2798,63.634
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1a_16e1ebf,11012,2,2,1600.0,1800.0,0.8888888888888888,defender,last_player_standing,Defender won by eliminating all attackers,17,99,29,44,2.82759,3.22727,0,1,0,713,12.109
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1b_814a8b05,11112,2,11,1800.0,7595.0,0.2369980250164582,defender,last_player_standing,Defender won by eliminating all attackers,17,306,24,251,2.58333,2.96016,0,11,0,9231,113.880
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1c_529ba6e5,11297,3,2,2400.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,30,195,88,59,3,3,0,1,0,376,23.692
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1d_49993012,11469,3,2,2383.0,1800.0,1.323888888888889,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,216,104,55,3.66346,2.96364,1,1,529,622,18.457
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1e_fd8c60a8,11470,4,2,3600.0,1800.0,2.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,365,187,108,2.04278,3.23148,3,2,2800,1800,17.991
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_1f_53309484,11565,1,4,334.0,3600.0,0.09277777777777778,defender,last_player_standing,Defender won by eliminating all attackers,1,8,1,0,2,0,0,4,0,3600,0.820
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_20_6d9f21cd,11662,9,3,7595.0,2600.0,2.921153846153846,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,635,434,155,2.04378,3.54194,7,3,5795,2600,78.499
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_21_c536f279,11754,3,3,2470.0,2400.0,1.0291666666666666,defender,last_player_standing,Defender won by eliminating all attackers,8,65,23,26,3.26087,3.57692,0,3,0,1516,10.700
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_22_a0f39b09,11941,6,2,4996.0,1800.0,2.7755555555555556,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,209,126,61,3.09524,2.98361,1,2,532,599,36.122
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_23_865a7a0e,11942,12,3,8916.0,2119.0,4.207645115620576,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,542,385,125,2.53247,3.808,9,3,6230,2008,109.176
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_24_f0d61a5a,12136,3,2,2089.0,1800.0,1.1605555555555556,defender,last_player_standing,Defender won by eliminating all attackers,30,205,59,99,3.20339,3.66667,0,2,0,791,10.465
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_25_a19c4d87,12137,2,3,1524.0,2400.0,0.635,defender,last_player_standing,Defender won by eliminating all attackers,5,34,12,10,3.75,2.7,0,3,0,2201,6.037
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_26_d897bec7,12138,4,2,3301.0,1400.0,2.3578571428571427,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,422,290,93,2,3.33333,4,2,3301,1400,7.590
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_27_c008989b,12765,2,2,1800.0,1800.0,1.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,135,67,31,2.1791,2.93548,1,2,1000,1800,3.512
normalized_attacker,NORMALIZED,STANDARD,389ffb1901903118_28_b33e2868,13752,2,2,1798.0,1600.0,1.12375,defender,last_player_standing,Defender won by eliminating all attackers,6,43,15,14,3.53333,3.64286,0,2,0,1114,3.460
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1_b3e7d8ab,306,2,3,1600.0,2600.0,0.6153846153846154,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,169,69,65,3.4058,3.18462,1,1,173,644,20.231
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_2_bf499d3c,307,2,1,1600.0,1000.0,1.6,defender,last_player_standing,Defender won by eliminating all attackers,20,77,30,19,3.2,2.31579,0,1,0,391,5.495
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_3_cd9141a,399,1,3,450.0,1454.0,0.30949105914718017,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,188,45,106,2,3.90566,1,3,450,2454,30.740
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_4_6e7b9b,631,2,5,1310.0,4200.0,0.3119047619047619,defender,last_player_standing,Defender won by eliminating all attackers,8,70,20,33,3.5,3.87879,0,4,0,2991,10.442
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_5_8a57255a,1326,2,4,1800.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,9,61,18,27,2.77778,3.03704,0,5,0,4037,10.816
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_6_ab8632ba,1472,2,2,1800.0,1600.0,1.125,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,219,76,95,2.98684,3.41053,1,2,499,816,24.422
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_7_5cf74e0,2437,5,6,3766.0,3787.0,0.9944547134935305,defender,last_player_standing,Defender won by eliminating all attackers,7,102,37,49,3.05405,3.4898,0,5,0,3256,26.232
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_8_41ce0ebd,2584,2,7,1600.0,3668.0,0.4362050163576881,defender,last_player_standing,Defender won by eliminating all attackers,5,66,9,42,3.22222,3.47619,0,7,0,3979,18.733
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_9_eb43d421,2654,3,2,2599.0,726.0,3.5798898071625342,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,317,186,93,2,4.16129,3,2,2599,726,37.141
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_a_950de1c1,3536,5,10,3858.0,2028.0,1.9023668639053255,defender,last_player_standing,Defender won by eliminating all attackers,27,304,85,178,2.76471,3.19101,0,8,0,4413,92.026
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_b_5d1d76e0,3701,2,6,1800.0,4240.0,0.42452830188679247,defender,last_player_standing,Defender won by eliminating all attackers,13,141,22,100,2.95455,3.38,0,5,0,1966,38.309
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_c_7bdb54dd,4298,4,6,3123.0,2172.0,1.4378453038674033,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,573,223,306,2.03587,3.53922,1,6,773,3974,122.498
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_d_689ee2fa,4576,4,3,3300.0,2135.0,1.5456674473067915,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,415,210,148,2,3.39189,4,3,629,1771,42.224
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_e_89c90dc6,6014,2,3,1512.0,2600.0,0.5815384615384616,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,214,97,79,2,3.3038,2,3,1512,2600,27.854
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_f_3a9d9136,6248,2,2,1800.0,1600.0,1.125,attacker,last_player_standing,Attacker won by eliminating all defenders,10,75,28,24,3.28571,3.45833,2,0,1417,0,6.284
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_10_c972d3e1,6408,5,2,4169.0,1223.0,3.4088307440719543,attacker,last_player_standing,Attacker won by eliminating all defenders,13,132,88,19,3.31818,2.63158,4,0,3081,0,25.109
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_11_e0611233,6705,5,5,3510.0,4400.0,0.7977272727272727,defender,last_player_standing,Defender won by eliminating all attackers,30,214,98,82,2.92857,3.45122,0,3,0,150,42.999
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_12_84a36154,7212,2,2,1156.0,1800.0,0.6422222222222222,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,254,62,124,2,3.39516,2,2,1156,1800,15.332
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_13_2e98b1cf,8217,3,2,2270.0,1136.0,1.9982394366197183,attacker,last_player_standing,Attacker won by eliminating all defenders,25,184,96,55,3.42708,3.36364,3,0,1985,0,23.348
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_14_b5570863,8394,4,2,3600.0,1212.0,2.9702970297029703,attacker,last_player_standing,Attacker won by eliminating all defenders,11,97,55,20,3.18182,2.45,4,0,2834,0,17.372
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_15_28a14d7d,8628,4,1,3011.0,800.0,3.76375,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,255,186,31,2,3,4,1,3011,800,6.067
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_16_3d6ec341,9975,5,11,4049.0,9110.0,0.44445664105378707,defender,last_player_standing,Defender won by eliminating all attackers,6,131,28,79,2.92857,2.94937,0,10,0,6855,40.968
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_17_3ba689e3,10229,2,1,1800.0,1000.0,1.8,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,157,78,13,3.79487,2.53846,1,1,56,727,15.301
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_18_9e463db3,10631,5,2,4000.0,29.0,137.93103448275863,attacker,last_player_standing,Attacker won by eliminating all defenders,9,100,60,21,2.88333,2.80952,5,0,3921,0,18.076
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_19_239725d3,11011,3,5,2092.0,4000.0,0.523,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,275,80,152,2.3625,3.72368,1,4,120,2749,67.360
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1a_16e1ebf,11012,2,2,1600.0,1800.0,0.8888888888888888,defender,last_player_standing,Defender won by eliminating all attackers,21,111,30,48,2.9,3.25,0,2,0,1356,13.443
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1b_814a8b05,11112,2,11,1800.0,7595.0,0.2369980250164582,defender,last_player_standing,Defender won by eliminating all attackers,5,89,11,59,3.27273,2.9661,0,11,0,9249,28.253
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1c_529ba6e5,11297,3,2,2400.0,0.0,,attacker,last_player_standing,Attacker won by eliminating all defenders,12,101,42,35,2.78571,3.42857,3,0,1479,0,17.617
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1d_49993012,11469,3,2,2383.0,1800.0,1.323888888888889,attacker,last_player_standing,Attacker won by eliminating all defenders,25,148,67,31,3.44776,3.19355,3,0,2061,0,19.041
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1e_fd8c60a8,11470,4,2,3600.0,1800.0,2.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,359,177,115,2.28249,3.46087,3,2,2136,859,18.446
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_1f_53309484,11565,1,4,334.0,3600.0,0.09277777777777778,defender,last_player_standing,Defender won by eliminating all attackers,1,8,1,0,3,0,0,4,0,3600,0.952
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_20_6d9f21cd,11662,9,3,7595.0,2600.0,2.921153846153846,attacker,last_player_standing,Attacker won by eliminating all defenders,12,182,120,40,3.19167,3.2,9,0,6817,0,47.676
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_21_c536f279,11754,3,3,2470.0,2400.0,1.0291666666666666,defender,last_player_standing,Defender won by eliminating all attackers,14,101,28,48,3,3.3125,0,2,0,1191,12.998
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_22_a0f39b09,11941,6,2,4996.0,1800.0,2.7755555555555556,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,261,154,66,3.32468,3.21212,1,1,299,258,41.484
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_23_865a7a0e,11942,12,3,8916.0,2119.0,4.207645115620576,attacker,last_player_standing,Attacker won by eliminating all defenders,11,202,144,39,3.05556,3.46154,12,0,7852,0,68.062
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_24_f0d61a5a,12136,3,2,2089.0,1800.0,1.1605555555555556,attacker,last_player_standing,Attacker won by eliminating all defenders,16,104,53,28,3,3.14286,3,0,1186,0,13.042
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_25_a19c4d87,12137,2,3,1524.0,2400.0,0.635,defender,last_player_standing,Defender won by eliminating all attackers,5,35,13,10,3.61538,2.6,0,3,0,2201,5.724
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_26_d897bec7,12138,4,2,3301.0,1400.0,2.3578571428571427,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,422,290,93,2,3.33333,4,2,3301,1400,7.197
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_27_c008989b,12765,2,2,1800.0,1800.0,1.0,defender,last_player_standing,Defender won by eliminating all attackers,31,135,51,45,3.27451,2.62222,0,2,0,1463,9.382
mcts_optimized_attacker,MCTS_OPTIMIZED,STANDARD,389ffb1901903118_28_b33e2868,13752,2,2,1798.0,1600.0,1.12375,defender,last_player_standing,Defender won by eliminating all attackers,30,189,39,123,3.17949,4.18699,0,2,0,1114,23.782
1 variant attacker_scoring defender_scoring shardok_game_id action_seq attacker_units defender_units attacker_initial_troops defender_initial_troops attacker_troop_ratio winner end_reason result total_rounds total_commands attacker_ai_commands defender_ai_commands attacker_avg_depth defender_avg_depth attacker_surviving_units defender_surviving_units attacker_surviving_troops defender_surviving_troops elapsed_seconds
2 baseline STANDARD STANDARD 389ffb1901903118_1_b3e7d8ab 306 2 3 1600.0 2600.0 0.6153846153846154 defender last_player_standing Defender won by eliminating all attackers 31 192 67 85 3.61194 3.61176 0 1 0 685 18.768
3 baseline STANDARD STANDARD 389ffb1901903118_2_bf499d3c 307 2 1 1600.0 1000.0 1.6 defender last_player_standing Defender won by eliminating all attackers 20 82 32 20 3.09375 2.3 0 1 0 324 5.722
4 baseline STANDARD STANDARD 389ffb1901903118_3_cd9141a 399 1 3 450.0 1454.0 0.30949105914718017 defender max_rounds_reached Defender won by surviving maximum rounds 31 188 45 106 2 3.89623 1 3 450 2454 30.201
5 baseline STANDARD STANDARD 389ffb1901903118_4_6e7b9b 631 2 5 1310.0 4200.0 0.3119047619047619 defender last_player_standing Defender won by eliminating all attackers 8 70 20 33 3.55 3.90909 0 4 0 2991 9.564
6 baseline STANDARD STANDARD 389ffb1901903118_5_8a57255a 1326 2 4 1800.0 0.0 defender last_player_standing Defender won by eliminating all attackers 9 61 18 27 2.77778 3.18519 0 5 0 4037 10.772
7 baseline STANDARD STANDARD 389ffb1901903118_6_ab8632ba 1472 2 2 1800.0 1600.0 1.125 defender max_rounds_reached Defender won by surviving maximum rounds 31 198 69 84 3.13043 3.5 1 2 57 378 23.999
8 baseline STANDARD STANDARD 389ffb1901903118_7_5cf74e0 2437 5 6 3766.0 3787.0 0.9944547134935305 defender last_player_standing Defender won by eliminating all attackers 18 267 82 158 3.30488 3.42405 0 5 0 2948 73.020
9 baseline STANDARD STANDARD 389ffb1901903118_8_41ce0ebd 2584 2 7 1600.0 3668.0 0.4362050163576881 defender last_player_standing Defender won by eliminating all attackers 6 78 10 52 3.2 3.53846 0 7 0 3832 21.143
10 baseline STANDARD STANDARD 389ffb1901903118_9_eb43d421 2654 3 2 2599.0 726.0 3.5798898071625342 defender max_rounds_reached Defender won by surviving maximum rounds 31 317 186 93 2 4.12903 3 2 2599 726 33.775
11 baseline STANDARD STANDARD 389ffb1901903118_a_950de1c1 3536 5 10 3858.0 2028.0 1.9023668639053255 defender max_rounds_reached Defender won by surviving maximum rounds 31 428 138 247 2.52174 3.39271 1 6 375 4511 115.585
12 baseline STANDARD STANDARD 389ffb1901903118_b_5d1d76e0 3701 2 6 1800.0 4240.0 0.42452830188679247 defender last_player_standing Defender won by eliminating all attackers 31 255 40 179 2.825 3.45251 0 4 0 2358 56.109
13 baseline STANDARD STANDARD 389ffb1901903118_c_7bdb54dd 4298 4 6 3123.0 2172.0 1.4378453038674033 defender max_rounds_reached Defender won by surviving maximum rounds 31 599 247 309 2 3.69579 4 6 2914 4085 128.531
14 baseline STANDARD STANDARD 389ffb1901903118_d_689ee2fa 4576 4 3 3300.0 2135.0 1.5456674473067915 defender max_rounds_reached Defender won by surviving maximum rounds 31 415 210 148 2 3.33108 4 3 629 1771 41.509
15 baseline STANDARD STANDARD 389ffb1901903118_e_89c90dc6 6014 2 3 1512.0 2600.0 0.5815384615384616 defender max_rounds_reached Defender won by surviving maximum rounds 31 214 97 79 2 3.29114 2 3 1512 2600 27.933
16 baseline STANDARD STANDARD 389ffb1901903118_f_3a9d9136 6248 2 2 1800.0 1600.0 1.125 attacker last_player_standing Attacker won by eliminating all defenders 12 86 32 27 3.25 3.37037 2 0 1455 0 7.670
17 baseline STANDARD STANDARD 389ffb1901903118_10_c972d3e1 6408 5 2 4169.0 1223.0 3.4088307440719543 attacker last_player_standing Attacker won by eliminating all defenders 15 145 96 24 3.38542 2.54167 5 0 3832 0 30.677
18 baseline STANDARD STANDARD 389ffb1901903118_11_e0611233 6705 5 5 3510.0 4400.0 0.7977272727272727 attacker last_player_standing Attacker won by eliminating all defenders 28 219 101 81 2.9802 3.48148 3 0 2150 0 40.168
19 baseline STANDARD STANDARD 389ffb1901903118_12_84a36154 7212 2 2 1156.0 1800.0 0.6422222222222222 defender max_rounds_reached Defender won by surviving maximum rounds 31 254 62 124 2 3.45161 2 2 1156 1800 15.591
20 baseline STANDARD STANDARD 389ffb1901903118_13_2e98b1cf 8217 3 2 2270.0 1136.0 1.9982394366197183 attacker last_player_standing Attacker won by eliminating all defenders 24 185 90 53 3.46667 3.37736 3 0 1944 0 21.446
21 baseline STANDARD STANDARD 389ffb1901903118_14_b5570863 8394 4 2 3600.0 1212.0 2.9702970297029703 attacker last_player_standing Attacker won by eliminating all defenders 20 130 74 26 3.41892 2.84615 4 0 2552 0 25.721
22 baseline STANDARD STANDARD 389ffb1901903118_15_28a14d7d 8628 4 1 3011.0 800.0 3.76375 defender max_rounds_reached Defender won by surviving maximum rounds 31 255 186 31 2 3 4 1 3011 800 5.159
23 baseline STANDARD STANDARD 389ffb1901903118_16_3d6ec341 9975 5 11 4049.0 9110.0 0.44445664105378707 defender last_player_standing Defender won by eliminating all attackers 5 115 26 65 3.03846 2.98462 0 11 0 7033 33.494
24 baseline STANDARD STANDARD 389ffb1901903118_17_3ba689e3 10229 2 1 1800.0 1000.0 1.8 defender max_rounds_reached Defender won by surviving maximum rounds 31 160 81 13 3.85185 2.53846 1 1 56 727 15.586
25 baseline STANDARD STANDARD 389ffb1901903118_18_9e463db3 10631 5 2 4000.0 29.0 137.93103448275863 attacker last_player_standing Attacker won by eliminating all defenders 10 104 55 27 2.98182 2.62963 5 0 3955 0 17.981
26 baseline STANDARD STANDARD 389ffb1901903118_19_239725d3 11011 3 5 2092.0 4000.0 0.523 defender max_rounds_reached Defender won by surviving maximum rounds 31 274 80 151 2.3625 3.75497 1 4 120 2749 65.354
27 baseline STANDARD STANDARD 389ffb1901903118_1a_16e1ebf 11012 2 2 1600.0 1800.0 0.8888888888888888 defender last_player_standing Defender won by eliminating all attackers 21 112 30 48 2.9 3.3125 0 2 0 1328 12.932
28 baseline STANDARD STANDARD 389ffb1901903118_1b_814a8b05 11112 2 11 1800.0 7595.0 0.2369980250164582 defender last_player_standing Defender won by eliminating all attackers 5 89 10 60 3.4 3.05 0 11 0 9248 26.113
29 baseline STANDARD STANDARD 389ffb1901903118_1c_529ba6e5 11297 3 2 2400.0 0.0 attacker last_player_standing Attacker won by eliminating all defenders 14 110 46 39 2.95652 3.4359 3 0 1569 0 20.096
30 baseline STANDARD STANDARD 389ffb1901903118_1d_49993012 11469 3 2 2383.0 1800.0 1.323888888888889 attacker last_player_standing Attacker won by eliminating all defenders 27 159 73 34 3.52055 3.05882 3 0 2160 0 19.694
31 baseline STANDARD STANDARD 389ffb1901903118_1e_fd8c60a8 11470 4 2 3600.0 1800.0 2.0 defender max_rounds_reached Defender won by surviving maximum rounds 31 356 177 112 2.31073 3.47321 3 2 2267 952 18.046
32 baseline STANDARD STANDARD 389ffb1901903118_1f_53309484 11565 1 4 334.0 3600.0 0.09277777777777778 defender last_player_standing Defender won by eliminating all attackers 1 8 1 0 3 0 0 4 0 3600 0.555
33 baseline STANDARD STANDARD 389ffb1901903118_20_6d9f21cd 11662 9 3 7595.0 2600.0 2.921153846153846 attacker last_player_standing Attacker won by eliminating all defenders 11 174 111 41 3.1982 3.36585 9 0 7070 0 47.504
34 baseline STANDARD STANDARD 389ffb1901903118_21_c536f279 11754 3 3 2470.0 2400.0 1.0291666666666666 defender last_player_standing Defender won by eliminating all attackers 7 55 20 22 3.65 3.54545 0 3 0 1624 10.181
35 baseline STANDARD STANDARD 389ffb1901903118_22_a0f39b09 11941 6 2 4996.0 1800.0 2.7755555555555556 defender max_rounds_reached Defender won by surviving maximum rounds 31 290 186 69 3.46237 3.52174 1 2 165 299 60.206
36 baseline STANDARD STANDARD 389ffb1901903118_23_865a7a0e 11942 12 3 8916.0 2119.0 4.207645115620576 attacker last_player_standing Attacker won by eliminating all defenders 12 232 164 47 2.94512 3.59574 12 0 7940 0 77.700
37 baseline STANDARD STANDARD 389ffb1901903118_24_f0d61a5a 12136 3 2 2089.0 1800.0 1.1605555555555556 attacker last_player_standing Attacker won by eliminating all defenders 15 98 50 25 3.18 3.12 2 0 1384 0 14.063
38 baseline STANDARD STANDARD 389ffb1901903118_25_a19c4d87 12137 2 3 1524.0 2400.0 0.635 defender max_rounds_reached Defender won by surviving maximum rounds 31 148 67 43 2.34328 2.55814 1 3 214 2282 19.123
39 baseline STANDARD STANDARD 389ffb1901903118_26_d897bec7 12138 4 2 3301.0 1400.0 2.3578571428571427 defender max_rounds_reached Defender won by surviving maximum rounds 31 422 290 93 2 3.33333 4 2 3301 1400 6.678
40 baseline STANDARD STANDARD 389ffb1901903118_27_c008989b 12765 2 2 1800.0 1800.0 1.0 defender last_player_standing Defender won by eliminating all attackers 30 108 40 30 3.5 2.86667 0 2 0 1678 9.069
41 baseline STANDARD STANDARD 389ffb1901903118_28_b33e2868 13752 2 2 1798.0 1600.0 1.12375 defender last_player_standing Defender won by eliminating all attackers 30 189 39 123 3.15385 3.98374 0 2 0 1114 26.389
42 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_1_b3e7d8ab 306 2 3 1600.0 2600.0 0.6153846153846154 defender max_rounds_reached Defender won by surviving maximum rounds 31 190 72 93 3.56944 3.44086 1 2 135 968 14.687
43 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_2_bf499d3c 307 2 1 1600.0 1000.0 1.6 defender max_rounds_reached Defender won by surviving maximum rounds 31 129 62 31 2.24194 2 1 1 800 1000 2.627
44 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_3_cd9141a 399 1 3 450.0 1454.0 0.30949105914718017 defender max_rounds_reached Defender won by surviving maximum rounds 31 189 45 107 2 3.94393 1 3 450 2454 29.873
45 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_4_6e7b9b 631 2 5 1310.0 4200.0 0.3119047619047619 defender last_player_standing Defender won by eliminating all attackers 8 69 19 33 3.15789 3.93939 0 4 0 2985 9.697
46 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_5_8a57255a 1326 2 4 1800.0 0.0 defender last_player_standing Defender won by eliminating all attackers 9 64 18 29 2.66667 3.24138 0 5 0 4026 11.132
47 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_6_ab8632ba 1472 2 2 1800.0 1600.0 1.125 defender max_rounds_reached Defender won by surviving maximum rounds 31 218 76 96 2.98684 3.38542 1 2 443 511 26.824
48 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_7_5cf74e0 2437 5 6 3766.0 3787.0 0.9944547134935305 defender last_player_standing Defender won by eliminating all attackers 22 260 67 160 2.32836 3.61875 0 4 0 3090 62.598
49 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_8_41ce0ebd 2584 2 7 1600.0 3668.0 0.4362050163576881 defender last_player_standing Defender won by eliminating all attackers 5 66 9 42 3.22222 3.52381 0 7 0 3979 18.916
50 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_9_eb43d421 2654 3 2 2599.0 726.0 3.5798898071625342 defender max_rounds_reached Defender won by surviving maximum rounds 31 317 186 93 2 4.12903 3 2 2599 726 37.896
51 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_a_950de1c1 3536 5 10 3858.0 2028.0 1.9023668639053255 defender max_rounds_reached Defender won by surviving maximum rounds 31 504 126 338 2.57937 3.25148 1 7 193 4379 148.897
52 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_b_5d1d76e0 3701 2 6 1800.0 4240.0 0.42452830188679247 defender last_player_standing Defender won by eliminating all attackers 30 234 38 168 2.55263 3.28571 0 4 0 1930 54.692
53 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_c_7bdb54dd 4298 4 6 3123.0 2172.0 1.4378453038674033 defender max_rounds_reached Defender won by surviving maximum rounds 31 566 236 286 2 3.63636 3 6 2335 3974 116.403
54 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_d_689ee2fa 4576 4 3 3300.0 2135.0 1.5456674473067915 defender max_rounds_reached Defender won by surviving maximum rounds 31 415 210 148 2 3.26351 4 3 629 1771 41.599
55 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_e_89c90dc6 6014 2 3 1512.0 2600.0 0.5815384615384616 defender max_rounds_reached Defender won by surviving maximum rounds 31 214 97 79 2 3.29114 2 3 1512 2600 28.149
56 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_f_3a9d9136 6248 2 2 1800.0 1600.0 1.125 defender last_player_standing Defender won by eliminating all attackers 17 110 23 52 2.43478 3.40385 0 2 0 974 4.416
57 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_10_c972d3e1 6408 5 2 4169.0 1223.0 3.4088307440719543 defender max_rounds_reached Defender won by surviving maximum rounds 31 318 200 85 3.32 3.50588 3 2 2400 1056 71.256
58 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_11_e0611233 6705 5 5 3510.0 4400.0 0.7977272727272727 defender last_player_standing Defender won by eliminating all attackers 8 69 22 30 3.09091 3.5 0 5 0 3915 13.735
59 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_12_84a36154 7212 2 2 1156.0 1800.0 0.6422222222222222 defender max_rounds_reached Defender won by surviving maximum rounds 31 254 62 124 2 3.45161 2 2 1156 1800 15.036
60 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_13_2e98b1cf 8217 3 2 2270.0 1136.0 1.9982394366197183 defender max_rounds_reached Defender won by surviving maximum rounds 31 236 133 59 3.45865 3.16949 2 1 1120 72 29.584
61 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_14_b5570863 8394 4 2 3600.0 1212.0 2.9702970297029703 defender max_rounds_reached Defender won by surviving maximum rounds 31 249 144 46 3.32639 2.34783 3 1 2560 143 31.143
62 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_15_28a14d7d 8628 4 1 3011.0 800.0 3.76375 defender max_rounds_reached Defender won by surviving maximum rounds 31 255 186 31 2 3 4 1 3011 800 5.578
63 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_16_3d6ec341 9975 5 11 4049.0 9110.0 0.44445664105378707 defender last_player_standing Defender won by eliminating all attackers 25 568 52 472 2.36538 2.625 0 10 0 6047 213.060
64 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_17_3ba689e3 10229 2 1 1800.0 1000.0 1.8 defender max_rounds_reached Defender won by surviving maximum rounds 31 156 77 13 3.79221 2.53846 1 1 56 727 15.511
65 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_18_9e463db3 10631 5 2 4000.0 29.0 137.93103448275863 attacker last_player_standing Attacker won by eliminating all defenders 9 100 60 21 3.08333 2.80952 5 0 3922 0 19.207
66 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_19_239725d3 11011 3 5 2092.0 4000.0 0.523 defender max_rounds_reached Defender won by surviving maximum rounds 31 278 85 151 2.38824 3.78808 1 4 152 2798 63.634
67 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_1a_16e1ebf 11012 2 2 1600.0 1800.0 0.8888888888888888 defender last_player_standing Defender won by eliminating all attackers 17 99 29 44 2.82759 3.22727 0 1 0 713 12.109
68 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_1b_814a8b05 11112 2 11 1800.0 7595.0 0.2369980250164582 defender last_player_standing Defender won by eliminating all attackers 17 306 24 251 2.58333 2.96016 0 11 0 9231 113.880
69 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_1c_529ba6e5 11297 3 2 2400.0 0.0 defender last_player_standing Defender won by eliminating all attackers 30 195 88 59 3 3 0 1 0 376 23.692
70 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_1d_49993012 11469 3 2 2383.0 1800.0 1.323888888888889 defender max_rounds_reached Defender won by surviving maximum rounds 31 216 104 55 3.66346 2.96364 1 1 529 622 18.457
71 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_1e_fd8c60a8 11470 4 2 3600.0 1800.0 2.0 defender max_rounds_reached Defender won by surviving maximum rounds 31 365 187 108 2.04278 3.23148 3 2 2800 1800 17.991
72 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_1f_53309484 11565 1 4 334.0 3600.0 0.09277777777777778 defender last_player_standing Defender won by eliminating all attackers 1 8 1 0 2 0 0 4 0 3600 0.820
73 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_20_6d9f21cd 11662 9 3 7595.0 2600.0 2.921153846153846 defender max_rounds_reached Defender won by surviving maximum rounds 31 635 434 155 2.04378 3.54194 7 3 5795 2600 78.499
74 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_21_c536f279 11754 3 3 2470.0 2400.0 1.0291666666666666 defender last_player_standing Defender won by eliminating all attackers 8 65 23 26 3.26087 3.57692 0 3 0 1516 10.700
75 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_22_a0f39b09 11941 6 2 4996.0 1800.0 2.7755555555555556 defender max_rounds_reached Defender won by surviving maximum rounds 31 209 126 61 3.09524 2.98361 1 2 532 599 36.122
76 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_23_865a7a0e 11942 12 3 8916.0 2119.0 4.207645115620576 defender max_rounds_reached Defender won by surviving maximum rounds 31 542 385 125 2.53247 3.808 9 3 6230 2008 109.176
77 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_24_f0d61a5a 12136 3 2 2089.0 1800.0 1.1605555555555556 defender last_player_standing Defender won by eliminating all attackers 30 205 59 99 3.20339 3.66667 0 2 0 791 10.465
78 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_25_a19c4d87 12137 2 3 1524.0 2400.0 0.635 defender last_player_standing Defender won by eliminating all attackers 5 34 12 10 3.75 2.7 0 3 0 2201 6.037
79 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_26_d897bec7 12138 4 2 3301.0 1400.0 2.3578571428571427 defender max_rounds_reached Defender won by surviving maximum rounds 31 422 290 93 2 3.33333 4 2 3301 1400 7.590
80 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_27_c008989b 12765 2 2 1800.0 1800.0 1.0 defender max_rounds_reached Defender won by surviving maximum rounds 31 135 67 31 2.1791 2.93548 1 2 1000 1800 3.512
81 normalized_attacker NORMALIZED STANDARD 389ffb1901903118_28_b33e2868 13752 2 2 1798.0 1600.0 1.12375 defender last_player_standing Defender won by eliminating all attackers 6 43 15 14 3.53333 3.64286 0 2 0 1114 3.460
82 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_1_b3e7d8ab 306 2 3 1600.0 2600.0 0.6153846153846154 defender max_rounds_reached Defender won by surviving maximum rounds 31 169 69 65 3.4058 3.18462 1 1 173 644 20.231
83 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_2_bf499d3c 307 2 1 1600.0 1000.0 1.6 defender last_player_standing Defender won by eliminating all attackers 20 77 30 19 3.2 2.31579 0 1 0 391 5.495
84 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_3_cd9141a 399 1 3 450.0 1454.0 0.30949105914718017 defender max_rounds_reached Defender won by surviving maximum rounds 31 188 45 106 2 3.90566 1 3 450 2454 30.740
85 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_4_6e7b9b 631 2 5 1310.0 4200.0 0.3119047619047619 defender last_player_standing Defender won by eliminating all attackers 8 70 20 33 3.5 3.87879 0 4 0 2991 10.442
86 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_5_8a57255a 1326 2 4 1800.0 0.0 defender last_player_standing Defender won by eliminating all attackers 9 61 18 27 2.77778 3.03704 0 5 0 4037 10.816
87 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_6_ab8632ba 1472 2 2 1800.0 1600.0 1.125 defender max_rounds_reached Defender won by surviving maximum rounds 31 219 76 95 2.98684 3.41053 1 2 499 816 24.422
88 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_7_5cf74e0 2437 5 6 3766.0 3787.0 0.9944547134935305 defender last_player_standing Defender won by eliminating all attackers 7 102 37 49 3.05405 3.4898 0 5 0 3256 26.232
89 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_8_41ce0ebd 2584 2 7 1600.0 3668.0 0.4362050163576881 defender last_player_standing Defender won by eliminating all attackers 5 66 9 42 3.22222 3.47619 0 7 0 3979 18.733
90 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_9_eb43d421 2654 3 2 2599.0 726.0 3.5798898071625342 defender max_rounds_reached Defender won by surviving maximum rounds 31 317 186 93 2 4.16129 3 2 2599 726 37.141
91 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_a_950de1c1 3536 5 10 3858.0 2028.0 1.9023668639053255 defender last_player_standing Defender won by eliminating all attackers 27 304 85 178 2.76471 3.19101 0 8 0 4413 92.026
92 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_b_5d1d76e0 3701 2 6 1800.0 4240.0 0.42452830188679247 defender last_player_standing Defender won by eliminating all attackers 13 141 22 100 2.95455 3.38 0 5 0 1966 38.309
93 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_c_7bdb54dd 4298 4 6 3123.0 2172.0 1.4378453038674033 defender max_rounds_reached Defender won by surviving maximum rounds 31 573 223 306 2.03587 3.53922 1 6 773 3974 122.498
94 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_d_689ee2fa 4576 4 3 3300.0 2135.0 1.5456674473067915 defender max_rounds_reached Defender won by surviving maximum rounds 31 415 210 148 2 3.39189 4 3 629 1771 42.224
95 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_e_89c90dc6 6014 2 3 1512.0 2600.0 0.5815384615384616 defender max_rounds_reached Defender won by surviving maximum rounds 31 214 97 79 2 3.3038 2 3 1512 2600 27.854
96 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_f_3a9d9136 6248 2 2 1800.0 1600.0 1.125 attacker last_player_standing Attacker won by eliminating all defenders 10 75 28 24 3.28571 3.45833 2 0 1417 0 6.284
97 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_10_c972d3e1 6408 5 2 4169.0 1223.0 3.4088307440719543 attacker last_player_standing Attacker won by eliminating all defenders 13 132 88 19 3.31818 2.63158 4 0 3081 0 25.109
98 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_11_e0611233 6705 5 5 3510.0 4400.0 0.7977272727272727 defender last_player_standing Defender won by eliminating all attackers 30 214 98 82 2.92857 3.45122 0 3 0 150 42.999
99 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_12_84a36154 7212 2 2 1156.0 1800.0 0.6422222222222222 defender max_rounds_reached Defender won by surviving maximum rounds 31 254 62 124 2 3.39516 2 2 1156 1800 15.332
100 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_13_2e98b1cf 8217 3 2 2270.0 1136.0 1.9982394366197183 attacker last_player_standing Attacker won by eliminating all defenders 25 184 96 55 3.42708 3.36364 3 0 1985 0 23.348
101 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_14_b5570863 8394 4 2 3600.0 1212.0 2.9702970297029703 attacker last_player_standing Attacker won by eliminating all defenders 11 97 55 20 3.18182 2.45 4 0 2834 0 17.372
102 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_15_28a14d7d 8628 4 1 3011.0 800.0 3.76375 defender max_rounds_reached Defender won by surviving maximum rounds 31 255 186 31 2 3 4 1 3011 800 6.067
103 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_16_3d6ec341 9975 5 11 4049.0 9110.0 0.44445664105378707 defender last_player_standing Defender won by eliminating all attackers 6 131 28 79 2.92857 2.94937 0 10 0 6855 40.968
104 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_17_3ba689e3 10229 2 1 1800.0 1000.0 1.8 defender max_rounds_reached Defender won by surviving maximum rounds 31 157 78 13 3.79487 2.53846 1 1 56 727 15.301
105 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_18_9e463db3 10631 5 2 4000.0 29.0 137.93103448275863 attacker last_player_standing Attacker won by eliminating all defenders 9 100 60 21 2.88333 2.80952 5 0 3921 0 18.076
106 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_19_239725d3 11011 3 5 2092.0 4000.0 0.523 defender max_rounds_reached Defender won by surviving maximum rounds 31 275 80 152 2.3625 3.72368 1 4 120 2749 67.360
107 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_1a_16e1ebf 11012 2 2 1600.0 1800.0 0.8888888888888888 defender last_player_standing Defender won by eliminating all attackers 21 111 30 48 2.9 3.25 0 2 0 1356 13.443
108 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_1b_814a8b05 11112 2 11 1800.0 7595.0 0.2369980250164582 defender last_player_standing Defender won by eliminating all attackers 5 89 11 59 3.27273 2.9661 0 11 0 9249 28.253
109 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_1c_529ba6e5 11297 3 2 2400.0 0.0 attacker last_player_standing Attacker won by eliminating all defenders 12 101 42 35 2.78571 3.42857 3 0 1479 0 17.617
110 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_1d_49993012 11469 3 2 2383.0 1800.0 1.323888888888889 attacker last_player_standing Attacker won by eliminating all defenders 25 148 67 31 3.44776 3.19355 3 0 2061 0 19.041
111 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_1e_fd8c60a8 11470 4 2 3600.0 1800.0 2.0 defender max_rounds_reached Defender won by surviving maximum rounds 31 359 177 115 2.28249 3.46087 3 2 2136 859 18.446
112 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_1f_53309484 11565 1 4 334.0 3600.0 0.09277777777777778 defender last_player_standing Defender won by eliminating all attackers 1 8 1 0 3 0 0 4 0 3600 0.952
113 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_20_6d9f21cd 11662 9 3 7595.0 2600.0 2.921153846153846 attacker last_player_standing Attacker won by eliminating all defenders 12 182 120 40 3.19167 3.2 9 0 6817 0 47.676
114 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_21_c536f279 11754 3 3 2470.0 2400.0 1.0291666666666666 defender last_player_standing Defender won by eliminating all attackers 14 101 28 48 3 3.3125 0 2 0 1191 12.998
115 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_22_a0f39b09 11941 6 2 4996.0 1800.0 2.7755555555555556 defender max_rounds_reached Defender won by surviving maximum rounds 31 261 154 66 3.32468 3.21212 1 1 299 258 41.484
116 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_23_865a7a0e 11942 12 3 8916.0 2119.0 4.207645115620576 attacker last_player_standing Attacker won by eliminating all defenders 11 202 144 39 3.05556 3.46154 12 0 7852 0 68.062
117 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_24_f0d61a5a 12136 3 2 2089.0 1800.0 1.1605555555555556 attacker last_player_standing Attacker won by eliminating all defenders 16 104 53 28 3 3.14286 3 0 1186 0 13.042
118 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_25_a19c4d87 12137 2 3 1524.0 2400.0 0.635 defender last_player_standing Defender won by eliminating all attackers 5 35 13 10 3.61538 2.6 0 3 0 2201 5.724
119 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_26_d897bec7 12138 4 2 3301.0 1400.0 2.3578571428571427 defender max_rounds_reached Defender won by surviving maximum rounds 31 422 290 93 2 3.33333 4 2 3301 1400 7.197
120 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_27_c008989b 12765 2 2 1800.0 1800.0 1.0 defender last_player_standing Defender won by eliminating all attackers 31 135 51 45 3.27451 2.62222 0 2 0 1463 9.382
121 mcts_optimized_attacker MCTS_OPTIMIZED STANDARD 389ffb1901903118_28_b33e2868 13752 2 2 1798.0 1600.0 1.12375 defender last_player_standing Defender won by eliminating all attackers 30 189 39 123 3.17949 4.18699 0 2 0 1114 23.782
@@ -0,0 +1,6 @@
variant,action_seq,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
final_round_flee_compare,306,draw,draw,21,121,43,45,3.2093,3.46667,1,1,298,312,4.868
final_round_flee_compare,11469,draw,draw,21,130,55,33,3.69091,3.33333,1,1,433,324,12.447
final_round_flee_compare,11941,draw,draw,21,198,125,43,3.608,3.4186,2,1,453,373,33.665
final_round_flee_compare,6705,draw,draw,21,187,98,61,3.28571,3.40984,1,1,796,377,36.167
final_round_flee_compare,307,defender,last_player_standing,20,82,32,20,3.3125,2.3,0,1,0,324,3.675
1 variant action_seq winner end_reason total_rounds total_commands attacker_ai_commands defender_ai_commands attacker_avg_depth defender_avg_depth attacker_surviving_units defender_surviving_units attacker_surviving_troops defender_surviving_troops elapsed_seconds
2 final_round_flee_compare 306 draw draw 21 121 43 45 3.2093 3.46667 1 1 298 312 4.868
3 final_round_flee_compare 11469 draw draw 21 130 55 33 3.69091 3.33333 1 1 433 324 12.447
4 final_round_flee_compare 11941 draw draw 21 198 125 43 3.608 3.4186 2 1 453 373 33.665
5 final_round_flee_compare 6705 draw draw 21 187 98 61 3.28571 3.40984 1 1 796 377 36.167
6 final_round_flee_compare 307 defender last_player_standing 20 82 32 20 3.3125 2.3 0 1 0 324 3.675
@@ -0,0 +1,6 @@
variant,action_seq,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
flee_compare_no_attacker_unit_decay,306,draw,draw,21,123,43,46,3.37209,3.45652,2,1,386,286,5.172
flee_compare_no_attacker_unit_decay,11469,draw,draw,21,130,55,33,3.65455,3.33333,1,1,391,352,12.264
flee_compare_no_attacker_unit_decay,11941,draw,draw,21,169,103,42,3.5534,3.21429,1,1,337,282,30.630
flee_compare_no_attacker_unit_decay,6705,draw,draw,21,172,89,55,3.29213,3.14545,2,1,824,265,32.483
flee_compare_no_attacker_unit_decay,307,defender,last_player_standing,20,82,32,20,3.1875,2.3,0,1,0,324,3.550
1 variant action_seq winner end_reason total_rounds total_commands attacker_ai_commands defender_ai_commands attacker_avg_depth defender_avg_depth attacker_surviving_units defender_surviving_units attacker_surviving_troops defender_surviving_troops elapsed_seconds
2 flee_compare_no_attacker_unit_decay 306 draw draw 21 123 43 46 3.37209 3.45652 2 1 386 286 5.172
3 flee_compare_no_attacker_unit_decay 11469 draw draw 21 130 55 33 3.65455 3.33333 1 1 391 352 12.264
4 flee_compare_no_attacker_unit_decay 11941 draw draw 21 169 103 42 3.5534 3.21429 1 1 337 282 30.630
5 flee_compare_no_attacker_unit_decay 6705 draw draw 21 172 89 55 3.29213 3.14545 2 1 824 265 32.483
6 flee_compare_no_attacker_unit_decay 307 defender last_player_standing 20 82 32 20 3.1875 2.3 0 1 0 324 3.550

Some files were not shown because too many files have changed in this diff Show More