diff --git a/.project/designs/app/test-results/magic-civ-designs/.last-run.json b/.project/designs/app/test-results/magic-civ-designs/.last-run.json
new file mode 100644
index 00000000..cbcc1fba
--- /dev/null
+++ b/.project/designs/app/test-results/magic-civ-designs/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/1e5df59c80fe1e859d8a56e4a847b28a.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/1e5df59c80fe1e859d8a56e4a847b28a.png
deleted file mode 100644
index 74307405..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/1e5df59c80fe1e859d8a56e4a847b28a.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/2154d1dcda3fe9882219223622f6b389.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/2154d1dcda3fe9882219223622f6b389.png
deleted file mode 100644
index 8ee9d246..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/2154d1dcda3fe9882219223622f6b389.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/45ba4b69973f42ea5bc8396f0cdcd970.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/45ba4b69973f42ea5bc8396f0cdcd970.png
deleted file mode 100644
index 9c333d21..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/45ba4b69973f42ea5bc8396f0cdcd970.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/51d7c8e27c82936e8d5102853d081f6f.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/51d7c8e27c82936e8d5102853d081f6f.png
deleted file mode 100644
index fa103a7b..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/51d7c8e27c82936e8d5102853d081f6f.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/528dbad01f522d87843fd85b3837751e.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/528dbad01f522d87843fd85b3837751e.png
deleted file mode 100644
index a723ce23..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/528dbad01f522d87843fd85b3837751e.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/5e871961032191c772b8c6a564944589.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/5e871961032191c772b8c6a564944589.png
deleted file mode 100644
index 620b61bd..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/5e871961032191c772b8c6a564944589.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/5f1a04b06a3846e055f1ce110d1cbcd7.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/5f1a04b06a3846e055f1ce110d1cbcd7.png
deleted file mode 100644
index a558aaad..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/5f1a04b06a3846e055f1ce110d1cbcd7.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/7a81b1b42fc50862eac70d928fe675ab.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/7a81b1b42fc50862eac70d928fe675ab.png
deleted file mode 100644
index fa103a7b..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/7a81b1b42fc50862eac70d928fe675ab.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/7b5860c7c6d15115cc7d70ee234ed59f.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/7b5860c7c6d15115cc7d70ee234ed59f.png
deleted file mode 100644
index f8a311ef..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/7b5860c7c6d15115cc7d70ee234ed59f.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/9221c261702a61267c947c4ded5ca137.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/9221c261702a61267c947c4ded5ca137.png
deleted file mode 100644
index d0fe949e..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/9221c261702a61267c947c4ded5ca137.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/98673738af242baece5a2f0554ae987c.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/98673738af242baece5a2f0554ae987c.png
deleted file mode 100644
index fa103a7b..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/98673738af242baece5a2f0554ae987c.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/9e58277d642674879dbe303e2e20feb7.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/9e58277d642674879dbe303e2e20feb7.png
deleted file mode 100644
index 45e6ebce..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/9e58277d642674879dbe303e2e20feb7.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/a7e4675578e9e34ae2a8e2c0711be2ee.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/a7e4675578e9e34ae2a8e2c0711be2ee.png
deleted file mode 100644
index 9659812d..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/a7e4675578e9e34ae2a8e2c0711be2ee.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/c99c381d243c00ea59e19a1ecb7cce81.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/c99c381d243c00ea59e19a1ecb7cce81.png
deleted file mode 100644
index fa103a7b..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/c99c381d243c00ea59e19a1ecb7cce81.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/d04a25f25550ace0f3ce0b2220dd8aa4.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/d04a25f25550ace0f3ce0b2220dd8aa4.png
deleted file mode 100644
index fecedd66..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/d04a25f25550ace0f3ce0b2220dd8aa4.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/d29ad117f727b591f48ed7d9a25cef35.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/d29ad117f727b591f48ed7d9a25cef35.png
deleted file mode 100644
index 0764998d..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/d29ad117f727b591f48ed7d9a25cef35.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/dfb478c0ab379063e1c45dc16a90e1ee.png b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/dfb478c0ab379063e1c45dc16a90e1ee.png
deleted file mode 100644
index d29e7d29..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/dfb478c0ab379063e1c45dc16a90e1ee.png and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@07df01c11021709ca883a0b58ed00fae.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@07df01c11021709ca883a0b58ed00fae.webm
deleted file mode 100644
index 8c824f5c..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@07df01c11021709ca883a0b58ed00fae.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@2e55c6bce1def163630f09742da7a261.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@2e55c6bce1def163630f09742da7a261.webm
deleted file mode 100644
index 3e8c59e1..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@2e55c6bce1def163630f09742da7a261.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@3b12ecb2f38a11f609291b846548f88b.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@3b12ecb2f38a11f609291b846548f88b.webm
deleted file mode 100644
index f703bf34..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@3b12ecb2f38a11f609291b846548f88b.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@51aaec83710a092fc3438006b81ca194.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@51aaec83710a092fc3438006b81ca194.webm
deleted file mode 100644
index 111e2da1..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@51aaec83710a092fc3438006b81ca194.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@78cb1f2e9ccf277ee9a44b0129b561e4.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@78cb1f2e9ccf277ee9a44b0129b561e4.webm
deleted file mode 100644
index 2aba2170..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@78cb1f2e9ccf277ee9a44b0129b561e4.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@837b8de28791f3e532d7fd3f1b0d0311.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@837b8de28791f3e532d7fd3f1b0d0311.webm
deleted file mode 100644
index 24a0e7a0..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@837b8de28791f3e532d7fd3f1b0d0311.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@8b03838196a1e7636e3cc0061112d055.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@8b03838196a1e7636e3cc0061112d055.webm
deleted file mode 100644
index 42aee635..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@8b03838196a1e7636e3cc0061112d055.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@8e424863716f61425c296c79083544b8.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@8e424863716f61425c296c79083544b8.webm
deleted file mode 100644
index b5065945..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@8e424863716f61425c296c79083544b8.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@9c137b0249d41194924d1c740d6ce948.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@9c137b0249d41194924d1c740d6ce948.webm
deleted file mode 100644
index 2d1220e6..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@9c137b0249d41194924d1c740d6ce948.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@9c3ca86bad252fecf4aa69fad2d9c741.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@9c3ca86bad252fecf4aa69fad2d9c741.webm
deleted file mode 100644
index 774cf380..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@9c3ca86bad252fecf4aa69fad2d9c741.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@ba6efc56c10f92eb702188e1a33a9602.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@ba6efc56c10f92eb702188e1a33a9602.webm
deleted file mode 100644
index 5ad53121..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@ba6efc56c10f92eb702188e1a33a9602.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@cbb466265368f7b2669adddc4e0754dd.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@cbb466265368f7b2669adddc4e0754dd.webm
deleted file mode 100644
index 327dc02e..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@cbb466265368f7b2669adddc4e0754dd.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@d41bf7671a02ea48b7c304269378bed4.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@d41bf7671a02ea48b7c304269378bed4.webm
deleted file mode 100644
index ad8f3237..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@d41bf7671a02ea48b7c304269378bed4.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@d5e93bf5438f22e15a616102cf1f7a41.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@d5e93bf5438f22e15a616102cf1f7a41.webm
deleted file mode 100644
index a10f2138..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@d5e93bf5438f22e15a616102cf1f7a41.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@e8fbde7f31b2cd28dd8072e8ebc5d383.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@e8fbde7f31b2cd28dd8072e8ebc5d383.webm
deleted file mode 100644
index ff2e6e45..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@e8fbde7f31b2cd28dd8072e8ebc5d383.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@ef5db8c751e62358d53a47798d196a45.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@ef5db8c751e62358d53a47798d196a45.webm
deleted file mode 100644
index 126da2c2..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@ef5db8c751e62358d53a47798d196a45.webm and /dev/null differ
diff --git a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@fa20aee9144fe98a3c0f76a5067d4d9d.webm b/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@fa20aee9144fe98a3c0f76a5067d4d9d.webm
deleted file mode 100644
index 57e2536d..00000000
Binary files a/.project/designs/app/test-results/magic-civ-designs/.playwright-artifacts-1/page@fa20aee9144fe98a3c0f76a5067d4d9d.webm and /dev/null differ
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 9e27ab66..947e0925 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -16,9 +16,9 @@
|---|---|---|---|---|---|---|---|
| **P0** | 43 | 0 | 0 | 0 | 0 | 0 | 43 |
| **P1** | 43 | 1 | 7 | 0 | 14 | 1 | 66 |
-| **P2** | 40 | 0 | 3 | 1 | 15 | 6 | 65 |
+| **P2** | 40 | 1 | 3 | 1 | 16 | 6 | 67 |
| **P3 (oos)** | 3 | 0 | 0 | 0 | 1 | 19 | 23 |
-| **total** | **129** | **1** | **10** | **1** | **30** | **26** | **197** |
+| **total** | **129** | **2** | **10** | **1** | **31** | **26** | **199** |
@@ -30,7 +30,7 @@
| [warcouncil](../team-leads/warcouncil.md) | 6 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [combat-dev](../team-leads/combat-dev.md) | 5 |
-| [terraformer](../team-leads/terraformer.md) | 3 |
+| [terraformer](../team-leads/terraformer.md) | 5 |
| [simulator-infra](../team-leads/simulator-infra.md) | 1 |
| [asset-audio](../team-leads/asset-audio.md) | 1 |
| [testwright](../team-leads/testwright.md) | 1 |
@@ -216,6 +216,8 @@
| [p2-53g](p2-53g-ranged-specifics.md) | ❌ missing | Ranged specifics — Volley, Aimed Shot, Fire Arrows | [combat-dev](../team-leads/combat-dev.md) | 2026-05-01 |
| [p2-53h](p2-53h-cavalry-specifics.md) | ❌ missing | Cavalry specifics — Charge, Pursue, Wheel | [combat-dev](../team-leads/combat-dev.md) | 2026-05-01 |
| [p2-53i](p2-53i-engineer-pioneer-medic-scout.md) | ❌ missing | Support specifics — Engineer, Pioneer, Medic, Scout | [shipwright](../team-leads/shipwright.md) | 2026-05-01 |
+| [p2-54](p2-54-resource-visibility-three-axis.md) | 🔵 in_progress | Resource visibility — three-axis (visibility/yield_gate/improvement_gate) refactor | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
+| [p2-54a](p2-54a-deposits-three-axis-migration.md) | ❌ missing | Migrate deposits/*.json to three-axis visibility schema | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-54b](p2-54b-player-observation-cache.md) | ❌ missing | Per-player tile observation cache — flora/fauna last-observed state | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-54c](p2-54c-renderer-observations-and-indicators.md) | ❌ missing | Renderer reads observations + indicator decorations for tech-gated resources | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-54d](p2-54d-ai-tech-priority-from-visibility.md) | ❌ missing | AI tech-priority bias from visible-but-gated luxuries + indicator decorations | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
diff --git a/.project/objectives/p2-54-resource-visibility-three-axis.md b/.project/objectives/p2-54-resource-visibility-three-axis.md
new file mode 100644
index 00000000..dd145be0
--- /dev/null
+++ b/.project/objectives/p2-54-resource-visibility-three-axis.md
@@ -0,0 +1,130 @@
+---
+id: p2-54
+title: Resource visibility — three-axis (visibility/yield_gate/improvement_gate) refactor
+priority: p2
+status: in_progress
+scope: game1
+owner: terraformer
+updated_at: 2026-05-01
+canonical_doc: public/games/age-of-dwarves/docs/RESOURCES.md
+coordinates_with:
+ - p1-05
+ - p1-39
+ - p2-52
+---
+
+## Summary
+
+Replace the binary `revealed_by_tech` field on resources with three orthogonal axes
+(`visibility` / `yield_gate` / `improvement_gate`) plus forward-compatible schema
+extensions for environmental indicator decorations and a per-player observation cache.
+
+Multi-cycle objective. This cycle (p2-54) delivers the schema + data + Rust struct.
+Follow-on cycles handle rendering, AI, and the observation cache.
+
+---
+
+## Acceptance Criteria
+
+- ✓ Canonical design doc authored at `public/games/age-of-dwarves/docs/RESOURCES.md`
+ with §1–§8 covering the three-axis model, flora/fauna policy, per-resource
+ classification table (all 15 luxuries + 9 bonus + 7 strategic), migration notes,
+ AI consumption pattern, renderer pattern, observation model design, and indicator
+ decorations design.
+
+- ✓ `public/resources/resources.json` migrated in-place:
+ - `revealed_by_tech` deleted from all 31 entries
+ - `visibility`, `yield_gate`, `improvement_gate`, `indicator_decorations` added
+ - All 15 luxuries mapped per the user-confirmed classification table
+ - All 9 bonus resources: `visibility: "always"`, `yield_gate: null`
+ - All 7 strategic resources: `visibility: "tech_gated"` (except flint: `"always"`)
+ - Migration script at `tools/migrate-resources-visibility.py` ran without errors
+ - Validator confirms zero `revealed_by_tech` remaining, all new fields present
+
+- ✓ Rust struct `mc_core::resources::Resource` added:
+ - Fields: `id`, `category`, `visibility`, `yield_gate`, `improvement_gate`,
+ `indicator_decorations`
+ - `Visibility` enum with `Always` / `Scout` / `TechGated`
+ - Helper: `is_tile_visible_for_player(&Resource, &HashSet, bool) -> bool`
+ - Helper: `is_yield_active(&Resource, &HashSet) -> bool`
+ - Helper: `is_improvement_unlocked(&Resource, &HashSet) -> bool`
+ - `ResourceBundle` struct with `luxury`, `bonus`, `strategic` arrays + `all()` iterator
+ - 13 unit tests covering all visibility branches, yield-gate logic, serde round-trip
+
+- ✓ `cargo test -p mc-core`: 112 passed, 0 failed
+- ✓ `cargo test -p mc-mapgen --test cross_build_determinism`: 4 passed, 0 failed
+- ✓ `cargo check -p mc-core -p mc-economy -p mc-mapgen`: clean (0 errors)
+
+- ✓ TypeScript `Resource` interface in `src/packages/guide/src/types/game-data.ts` updated:
+ - `visibility?`, `yield_gate?`, `improvement_gate?` fields added
+ - `revealed_by_tech` marked `@deprecated` with `| null` type widening
+ - `npx tsc --noEmit`: clean
+
+- ✓ `ResourcesPage.tsx` updated:
+ - `getCategory()` switched from `revealed_by_tech` heuristic to `category` field
+ - Tech tag render reads `yield_gate ?? revealed_by_tech`
+
+- ✓ `EconomyResourcesPage.tsx` updated:
+ - `DepositRecord` interface updated with three-axis fields
+ - Tech badge render reads `yield_gate ?? revealed_by_tech`
+
+- ✓ GDScript consumers updated (4 sites):
+ - `tile.gd:342`: reads `yield_gate` first, falls back to `revealed_by_tech`
+ - `tile_info_panel.gd:109`: reads `yield_gate` first, falls back
+ - `turn_processor_helpers.gd:407`: reads `yield_gate` first, falls back
+
+- ✓ e2e baseline: 55 passed / 6 skipped (unchanged)
+
+- ◻ Migrate `public/resources/deposits/*.json` (30 files) to three-axis schema —
+ **blocking precondition** for GDScript and guide-web to read the new fields.
+ Until this is done, all consumer code falls back to `revealed_by_tech` (the
+ fallback paths are in place; nothing breaks, but the new field path is inert).
+ Tracked as **p2-54a**.
+
+- ◻ Per-player tile observation cache (flora/fauna last-observed state) — `mc-save`
+ struct authoring + GDExtension bridge. Tracked as **p2-54b**.
+
+- ◻ Indicator decorations rendered on tech-gated resource tiles — godot-renderer work.
+ Tracked as **p2-54c**.
+
+- ◻ AI reads `indicator_decorations` as tech-priority hints + reads observations not
+ ground truth. Tracked as **p2-54d**.
+
+---
+
+## K/N Completion
+
+K = 8 / N = 12 (4 follow-on items deferred: p2-54a deposits migration, p2-54b observation cache, p2-54c renderer, p2-54d AI)
+
+---
+
+## Handoff to Next Cycle
+
+| Domain | Work | Objective |
+|---|---|---|
+| game-data | Migrate `deposits/*.json` (30 files) to three-axis schema — unblocks all downstream | p2-54a |
+| godot-renderer | Dim `yield_gate`-gated icons; render `indicator_decorations` sprites | p2-54c |
+| game-ai (mc-ai) | Tech-priority bias for visible-but-gated luxuries; read `indicator_decorations` | p2-54d |
+| guide-web | Encyclopedia tooltips for new axes | p2-54c |
+| simulator-infra | `mc-save::PlayerObservations` struct + GDExtension bridge | p2-54b |
+
+**Important:** Until `p2-54a` (deposits migration) is done, GDScript and guide-web always
+fall back to `revealed_by_tech`. The new field path in both systems is forward-compatible
+but inert. The `resources.json` aggregate and `mc-core::resources` Rust struct are correct —
+they are the schema source of truth and will become authoritative once deposits are migrated.
+
+---
+
+## Files Modified (this cycle)
+
+- `public/games/age-of-dwarves/docs/RESOURCES.md` — new, canonical doc
+- `public/resources/resources.json` — migrated in-place
+- `tools/migrate-resources-visibility.py` — new, migration + validation script
+- `src/simulator/crates/mc-core/src/resources.rs` — new, Rust struct + helpers
+- `src/simulator/crates/mc-core/src/lib.rs` — added `pub mod resources`
+- `src/packages/guide/src/types/game-data.ts` — Resource interface updated
+- `public/games/age-of-dwarves/guide/src/pages/ResourcesPage.tsx` — updated
+- `public/games/age-of-dwarves/guide/src/pages/EconomyResourcesPage.tsx` — updated
+- `src/game/engine/src/map/tile.gd` — yield_gate-first read
+- `src/game/engine/scenes/world_map/tile_info_panel.gd` — yield_gate-first read
+- `src/game/engine/src/modules/management/turn_processor_helpers.gd` — yield_gate-first read
diff --git a/.project/objectives/p2-54a-deposits-three-axis-migration.md b/.project/objectives/p2-54a-deposits-three-axis-migration.md
new file mode 100644
index 00000000..3012759f
--- /dev/null
+++ b/.project/objectives/p2-54a-deposits-three-axis-migration.md
@@ -0,0 +1,72 @@
+---
+id: p2-54a
+title: Migrate deposits/*.json to three-axis visibility schema
+priority: p2
+status: missing
+scope: game1
+owner: terraformer
+updated_at: 2026-05-01
+parent: p2-54
+canonical_doc: public/games/age-of-dwarves/docs/RESOURCES.md
+coordinates_with:
+ - p2-54
+ - p2-54c
+blockedBy: [p2-54]
+---
+
+## Summary
+
+`public/resources/resources.json` has been migrated to the three-axis schema
+(`visibility` / `yield_gate` / `improvement_gate`). However, the 30 individual deposit
+files in `public/resources/deposits/*.json` — which are the actual source read by
+GDScript (via `DataLoader`) and the guide-web (via Vite `import.meta.glob`) — still use
+`revealed_by_tech`.
+
+Until this migration is done, all consumer code falls back to `revealed_by_tech` via
+the dual-read fallback paths installed in p2-54. The new three-axis field path is
+forward-compatible but inert.
+
+This is the **blocking precondition** for p2-54b (observation cache), p2-54c (renderer
+decorations), and p2-54d (AI tech-priority hints) to work correctly.
+
+---
+
+## Acceptance Criteria
+
+- ◻ Extend `tools/migrate-resources-visibility.py` (or write a sibling script
+ `tools/migrate-deposits-visibility.py`) to process each file in
+ `public/resources/deposits/*.json`.
+
+- ◻ For each deposit file, apply the same classification logic as `resources.json`:
+ - `category: "bonus"` → `visibility: "always"`, `yield_gate: null`
+ - `category: "luxury"` → look up in the luxury classification table from RESOURCES.md §3
+ - `category: "strategic"` → `visibility: "tech_gated"`, `yield_gate: `
+ (exception: `flint` → `visibility: "always"`, `yield_gate: null`)
+ - Add `indicator_decorations` array per the decoration table in RESOURCES.md §8
+
+- ◻ The 30 deposit files updated in-place; `revealed_by_tech` removed.
+
+- ◻ Validator confirms zero `revealed_by_tech` remaining across all deposit files.
+
+- ◻ `npx tsc --noEmit` clean after deposit migration (TS may warn if old field is
+ referenced in non-`?`-optional positions).
+
+- ◻ Guide-web `ResourcesPage` and `EconomyResourcesPage` confirmed to render the
+ tech tags from `yield_gate` (manual smoke-test or playwright page-check).
+
+- ◻ GDScript: `tile.gd`, `tile_info_panel.gd`, `turn_processor_helpers.gd` confirmed
+ to take the `yield_gate` branch (not the fallback) for at least one resource type.
+ (Unit test or trace log.)
+
+---
+
+## Notes
+
+- The `deposit_categories.json` and `deposits.schema.json` files in
+ `public/resources/deposits/` are not resource entries — skip them in the migration.
+- The deposits manifest at `public/games/age-of-dwarves/data/deposits/manifest.json`
+ controls which entries the guide renders — no changes needed there.
+- Some deposit files (e.g. `wild_game`, `exotic_furs`, `enchanted_silk`) may not
+ correspond directly to `resources.json` IDs. These are game-2/guide-only entries;
+ apply best-effort classification using the category field and document edge cases
+ in RESOURCES.md §3 footnote.
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index 195af3a3..f93e1850 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,13 +1,13 @@
{
- "generated_at": "2026-05-02T22:30:37Z",
+ "generated_at": "2026-05-02T22:39:44Z",
"totals": {
- "stub": 1,
- "done": 129,
- "missing": 30,
"oos": 26,
- "in_progress": 1,
+ "missing": 31,
+ "done": 129,
+ "stub": 1,
+ "in_progress": 2,
"partial": 10,
- "total": 197
+ "total": 199
},
"objectives": [
{
@@ -1720,6 +1720,26 @@
"updated_at": "2026-05-01",
"summary": "Largest specifics child (15 actions across 4 archetypes). Most action effects already have system support somewhere in `mc-turn` / `mc-tech` / `mc-ecology` and just need the action surface."
},
+ {
+ "id": "p2-54",
+ "title": "Resource visibility — three-axis (visibility/yield_gate/improvement_gate) refactor",
+ "priority": "p2",
+ "status": "in_progress",
+ "scope": "game1",
+ "owner": "terraformer",
+ "updated_at": "2026-05-01",
+ "summary": "Replace the binary `revealed_by_tech` field on resources with three orthogonal axes\n(`visibility` / `yield_gate` / `improvement_gate`) plus forward-compatible schema\nextensions for environmental indicator decorations and a per-player observation cache.\n\nMulti-cycle objective. This cycle (p2-54) delivers the schema + data + Rust struct.\nFollow-on cycles handle rendering, AI, and the observation cache.\n\n---"
+ },
+ {
+ "id": "p2-54a",
+ "title": "Migrate deposits/*.json to three-axis visibility schema",
+ "priority": "p2",
+ "status": "missing",
+ "scope": "game1",
+ "owner": "terraformer",
+ "updated_at": "2026-05-01",
+ "summary": "`public/resources/resources.json` has been migrated to the three-axis schema\n(`visibility` / `yield_gate` / `improvement_gate`). However, the 30 individual deposit\nfiles in `public/resources/deposits/*.json` — which are the actual source read by\nGDScript (via `DataLoader`) and the guide-web (via Vite `import.meta.glob`) — still use\n`revealed_by_tech`.\n\nUntil this migration is done, all consumer code falls back to `revealed_by_tech` via\nthe dual-read fallback paths installed in p2-54. The new three-axis field path is\nforward-compatible but inert.\n\nThis is the **blocking precondition** for p2-54b (observation cache), p2-54c (renderer\ndecorations), and p2-54d (AI tech-priority hints) to work correctly.\n\n---"
+ },
{
"id": "p2-54b",
"title": "Per-player tile observation cache — flora/fauna last-observed state",
diff --git a/src/simulator/crates/mc-combat/src/keywords.rs b/src/simulator/crates/mc-combat/src/keywords.rs
index 4080f250..9059998f 100644
--- a/src/simulator/crates/mc-combat/src/keywords.rs
+++ b/src/simulator/crates/mc-combat/src/keywords.rs
@@ -110,6 +110,22 @@ pub fn keyword_attack_bonus(keywords: &[Keyword], context: &KeywordContext) -> f
}
}
+ // p2-53h: Charge action bonus (+30%); applied only when attacker charged this turn
+ // and the defender is NOT braced (brace cancels the charge bonus).
+ if context.is_attacker && context.attacker_charging && !context.defender_is_braced {
+ bonus += charge_attack_bonus();
+ }
+
+ // p2-53f: Rage bonus (+40%) when attacker is raging.
+ if context.is_attacker && context.attacker_rage_turns > 0 {
+ bonus += rage_attack_bonus();
+ }
+
+ // p2-53f: WarCry debuff (-10%) when this unit was WarCried by an adjacent enemy.
+ if context.war_cry_debuff {
+ bonus += war_cry_attack_debuff();
+ }
+
bonus
}
@@ -128,9 +144,19 @@ pub fn keyword_defense_bonus(keywords: &[Keyword], context: &KeywordContext) ->
}
}
+ // p2-53f: ShieldWall gives +50% defence vs ranged attacks.
+ if context.defender_shield_wall && context.attacker_is_ranged {
+ bonus += shield_wall_ranged_defence_bonus();
+ }
+
bonus
}
+/// True when the attacker has aimed_shot_pending — defence reduction applied in resolver.
+pub fn attacker_has_aimed_shot(context: &KeywordContext) -> bool {
+ context.attacker_aimed_shot
+}
+
/// Returns true if the attacker takes no retaliation damage.
pub fn prevents_retaliation(attacker_keywords: &[Keyword], combat_is_ranged: bool) -> bool {
if combat_is_ranged {
@@ -165,6 +191,60 @@ pub struct KeywordContext {
pub adjacent_allies: i32,
pub attacker_is_flying: bool,
pub attacker_is_ranged: bool,
+ // p2-53g/h/f archetype state
+ /// True when the attacker used Charge this turn (2+ hex straight-line move then attack).
+ pub attacker_charging: bool,
+ /// True when the defender is in Brace posture (cancels Charge bonus, first-strikes melee).
+ pub defender_is_braced: bool,
+ /// True when the defender is in ShieldWall posture (+50% def vs ranged).
+ pub defender_shield_wall: bool,
+ /// Rage turns remaining on the attacker (> 0 means +40% attack).
+ pub attacker_rage_turns: u8,
+ /// WarCry debuff on this unit: -10% attack if set by an adjacent enemy's WarCry this turn.
+ pub war_cry_debuff: bool,
+ /// True when the attacker has aimed_shot_pending (ignores 50% defence).
+ pub attacker_aimed_shot: bool,
+}
+
+// ── p2-53g/h/f archetype combat hooks ─────────────────────────────────────────
+
+/// AimedShot: attacker ignored 50% of defender's terrain/fortification defence.
+/// Called in `compute_predicted_damage` when `attacker_aimed_shot_pending` is true.
+pub fn aimed_shot_defence_reduction() -> f32 {
+ 0.50
+}
+
+/// Volley: per-target damage multiplier for each of the 3 hits (centre + 2 edge slots).
+/// Lower per-hit damage compensates for hitting multiple targets.
+pub const VOLLEY_DAMAGE_MULTIPLIER: f32 = 0.55;
+
+/// Charge: +30% attack bonus for attacker when it charged 2+ hexes this turn.
+/// Applied via `KeywordContext::attacker_charging`.
+pub fn charge_attack_bonus() -> f32 {
+ 0.30
+}
+
+/// Brace: cancels the Charge bonus if the defender is_braced,
+/// AND grants first-strike on incoming melee.
+/// Returns true when Charge bonus should be cancelled.
+pub fn brace_cancels_charge(defender_is_braced: bool) -> bool {
+ defender_is_braced
+}
+
+/// ShieldWall: +50% defence vs ranged when attacker is ranged.
+pub fn shield_wall_ranged_defence_bonus() -> f32 {
+ 0.50
+}
+
+/// Rage: +40% attack bonus when attacker's `rage_turns_remaining > 0`.
+pub fn rage_attack_bonus() -> f32 {
+ 0.40
+}
+
+/// WarCry: -10% attack debuff applied to each affected enemy unit for 1 turn.
+/// Stored as a negative `KeywordContext` modifier; the combat hook reads it.
+pub fn war_cry_attack_debuff() -> f32 {
+ -0.10
}
/// Bitmask encoding of a `Vec` — one bit per variant (17 variants, fits in u32).
diff --git a/src/simulator/crates/mc-combat/src/resolver.rs b/src/simulator/crates/mc-combat/src/resolver.rs
index 5a934717..b832a2f0 100644
--- a/src/simulator/crates/mc-combat/src/resolver.rs
+++ b/src/simulator/crates/mc-combat/src/resolver.rs
@@ -193,6 +193,19 @@ pub struct CombatParams {
pub city_has_garrison: bool,
/// Whether the attacker's unit type is "siege".
pub attacker_is_siege: bool,
+ // p2-53g/h/f archetype state
+ /// True when the attacker charged 2+ hexes this turn (Charge action).
+ pub attacker_charging: bool,
+ /// True when the defender is in Brace posture (cancels Charge bonus, grants first-strike).
+ pub defender_is_braced: bool,
+ /// True when the defender is in ShieldWall posture (+50% def vs ranged).
+ pub defender_shield_wall: bool,
+ /// Rage turns remaining on the attacker (> 0 = +40% attack).
+ pub attacker_rage_turns: u8,
+ /// True when the attacker has aimed_shot_pending (ignores 50% of defender's defence modifier).
+ pub attacker_aimed_shot: bool,
+ /// True when this unit (as attacker) is debuffed by an enemy WarCry (-10% attack).
+ pub attacker_war_cry_debuff: bool,
}
impl Default for CombatParams {
@@ -225,6 +238,12 @@ impl Default for CombatParams {
city_wall_tier: 0,
city_has_garrison: false,
attacker_is_siege: false,
+ attacker_charging: false,
+ defender_is_braced: false,
+ defender_shield_wall: false,
+ attacker_rage_turns: 0,
+ attacker_aimed_shot: false,
+ attacker_war_cry_debuff: false,
}
}
}
@@ -315,6 +334,12 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
adjacent_allies: params.attacker_bonuses.flanking_allies,
attacker_is_flying: params.attacker_keywords.contains(&Keyword::Flying),
attacker_is_ranged: is_ranged,
+ attacker_charging: params.attacker_charging,
+ defender_is_braced: params.defender_is_braced,
+ defender_shield_wall: params.defender_shield_wall,
+ attacker_rage_turns: params.attacker_rage_turns,
+ war_cry_debuff: params.attacker_war_cry_debuff,
+ attacker_aimed_shot: params.attacker_aimed_shot,
};
let def_kw_ctx = KeywordContext {
@@ -325,6 +350,8 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
adjacent_allies: params.defender_bonuses.flanking_allies,
attacker_is_flying: params.attacker_keywords.contains(&Keyword::Flying),
attacker_is_ranged: is_ranged,
+ defender_shield_wall: params.defender_shield_wall,
+ ..Default::default()
};
// Compute effective strengths
@@ -354,7 +381,13 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
let def_mod = bonuses::total_defense_modifier(¶ms.defender_bonuses, ignore_terrain)
+ keywords::keyword_defense_bonus(¶ms.defender_keywords, &def_kw_ctx);
- let defender_strength = (def_base * (1.0 + def_mod)).max(1.0);
+ // p2-53g AimedShot: attacker ignores 50% of the defender's defence modifier bonus.
+ let effective_def_mod = if keywords::attacker_has_aimed_shot(&atk_kw_ctx) {
+ def_mod * (1.0 - keywords::aimed_shot_defence_reduction())
+ } else {
+ def_mod
+ };
+ let defender_strength = (def_base * (1.0 + effective_def_mod)).max(1.0);
// HP factor: damaged units deal less damage
let atk_hp_factor = params.attacker.hp as f32 / params.attacker.max_hp.max(1) as f32;
@@ -544,6 +577,7 @@ impl CombatResolver {
city_wall_tier: 0,
city_has_garrison: false,
attacker_is_siege: false,
+ ..Default::default()
};
Self::predict_expected_damage_params(¶ms)
}
diff --git a/src/simulator/crates/mc-core/src/action.rs b/src/simulator/crates/mc-core/src/action.rs
index 27692317..6d2dc514 100644
--- a/src/simulator/crates/mc-core/src/action.rs
+++ b/src/simulator/crates/mc-core/src/action.rs
@@ -1330,4 +1330,238 @@ mod tests {
assert!(!disembark.enabled);
assert_eq!(disembark.disabled_reason, Some(DisabledReason::NoAdjacentLand));
}
-}
+ // p2-53g: ranged-specific action tests
+
+ fn ranged_cap_base(has_movement: bool) -> UnitCapability {
+ UnitCapability {
+ unit_type: "ranged".into(),
+ keywords: vec!["ranged".into()],
+ has_movement,
+ is_fortified: false,
+ is_patrolling: false,
+ is_sentrying: false,
+ is_deployed: false,
+ is_embarked: false,
+ adjacent_water: false,
+ adjacent_land: false,
+ is_aiming: false,
+ is_fire_arrows: false,
+ is_pursuing: false,
+ is_shield_wall: false,
+ is_braced: false,
+ rage_turns_remaining: 0,
+ war_cry_used_this_battle: false,
+ }
+ }
+
+ #[test]
+ fn ranged_unit_has_volley_aimed_shot_fire_arrows() {
+ let actions = legal_actions(&ranged_cap_base(true));
+ let kinds: Vec = actions.iter().map(|a| a.kind).collect();
+ assert!(kinds.contains(&ActionKind::Volley));
+ assert!(kinds.contains(&ActionKind::AimedShot));
+ assert!(kinds.contains(&ActionKind::FireArrows));
+ assert!(!kinds.contains(&ActionKind::StopFireArrows), "StopFireArrows absent when not in fire-arrow posture");
+ }
+
+ #[test]
+ fn ranged_aimed_shot_disabled_when_already_aiming() {
+ let cap = UnitCapability { is_aiming: true, ..ranged_cap_base(true) };
+ let actions = legal_actions(&cap);
+ let aimed = actions.iter().find(|a| a.kind == ActionKind::AimedShot).unwrap();
+ assert!(!aimed.enabled);
+ assert_eq!(aimed.disabled_reason, Some(DisabledReason::AlreadyAiming));
+ }
+
+ #[test]
+ fn ranged_fire_arrows_toggles() {
+ let cap = UnitCapability { is_fire_arrows: true, ..ranged_cap_base(true) };
+ let actions = legal_actions(&cap);
+ let kinds: Vec = actions.iter().map(|a| a.kind).collect();
+ assert!(kinds.contains(&ActionKind::StopFireArrows), "StopFireArrows must appear when in fire-arrows posture");
+ let fire = actions.iter().find(|a| a.kind == ActionKind::FireArrows).unwrap();
+ assert!(!fire.enabled);
+ assert_eq!(fire.disabled_reason, Some(DisabledReason::AlreadyFireArrows));
+ }
+
+ #[test]
+ fn non_ranged_unit_has_no_volley_aimed_shot_fire_arrows() {
+ let actions = legal_actions(&military_cap(true, false, vec![]));
+ let kinds: Vec = actions.iter().map(|a| a.kind).collect();
+ assert!(!kinds.contains(&ActionKind::Volley));
+ assert!(!kinds.contains(&ActionKind::AimedShot));
+ assert!(!kinds.contains(&ActionKind::FireArrows));
+ }
+
+ // p2-53h: cavalry-specific action tests
+
+ fn cavalry_cap(has_movement: bool, is_pursuing: bool) -> UnitCapability {
+ UnitCapability {
+ unit_type: "melee".into(),
+ keywords: vec!["cavalry".into()],
+ has_movement,
+ is_fortified: false,
+ is_patrolling: false,
+ is_sentrying: false,
+ is_deployed: false,
+ is_embarked: false,
+ adjacent_water: false,
+ adjacent_land: false,
+ is_aiming: false,
+ is_fire_arrows: false,
+ is_pursuing,
+ is_shield_wall: false,
+ is_braced: false,
+ rage_turns_remaining: 0,
+ war_cry_used_this_battle: false,
+ }
+ }
+
+ #[test]
+ fn cavalry_unit_has_charge_pursue_wheel() {
+ let actions = legal_actions(&cavalry_cap(true, false));
+ let kinds: Vec = actions.iter().map(|a| a.kind).collect();
+ assert!(kinds.contains(&ActionKind::Charge));
+ assert!(kinds.contains(&ActionKind::Pursue));
+ assert!(kinds.contains(&ActionKind::Wheel));
+ }
+
+ #[test]
+ fn cavalry_charge_disabled_no_movement() {
+ let actions = legal_actions(&cavalry_cap(false, false));
+ let charge = actions.iter().find(|a| a.kind == ActionKind::Charge).unwrap();
+ assert!(!charge.enabled);
+ assert_eq!(charge.disabled_reason, Some(DisabledReason::NoMovement));
+ }
+
+ #[test]
+ fn cavalry_pursue_disabled_when_already_pursuing() {
+ let actions = legal_actions(&cavalry_cap(true, true));
+ let pursue = actions.iter().find(|a| a.kind == ActionKind::Pursue).unwrap();
+ assert!(!pursue.enabled);
+ assert_eq!(pursue.disabled_reason, Some(DisabledReason::AlreadyPursuing));
+ }
+
+ #[test]
+ fn non_cavalry_has_no_charge_pursue_wheel() {
+ let actions = legal_actions(&military_cap(true, false, vec![]));
+ let kinds: Vec = actions.iter().map(|a| a.kind).collect();
+ assert!(!kinds.contains(&ActionKind::Charge));
+ assert!(!kinds.contains(&ActionKind::Pursue));
+ assert!(!kinds.contains(&ActionKind::Wheel));
+ }
+
+ // p2-53f: infantry line / shock action tests
+
+ fn infantry_line_cap(has_movement: bool, is_shield_wall: bool, is_braced: bool) -> UnitCapability {
+ UnitCapability {
+ unit_type: "melee".into(),
+ keywords: vec!["infantry_line".into()],
+ has_movement,
+ is_fortified: false,
+ is_patrolling: false,
+ is_sentrying: false,
+ is_deployed: false,
+ is_embarked: false,
+ adjacent_water: false,
+ adjacent_land: false,
+ is_aiming: false,
+ is_fire_arrows: false,
+ is_pursuing: false,
+ is_shield_wall,
+ is_braced,
+ rage_turns_remaining: 0,
+ war_cry_used_this_battle: false,
+ }
+ }
+
+ fn infantry_shock_cap(has_movement: bool, rage_turns: u8, war_cry_used: bool) -> UnitCapability {
+ UnitCapability {
+ unit_type: "melee".into(),
+ keywords: vec!["infantry_shock".into()],
+ has_movement,
+ is_fortified: false,
+ is_patrolling: false,
+ is_sentrying: false,
+ is_deployed: false,
+ is_embarked: false,
+ adjacent_water: false,
+ adjacent_land: false,
+ is_aiming: false,
+ is_fire_arrows: false,
+ is_pursuing: false,
+ is_shield_wall: false,
+ is_braced: false,
+ rage_turns_remaining: rage_turns,
+ war_cry_used_this_battle: war_cry_used,
+ }
+ }
+
+ #[test]
+ fn infantry_line_has_shield_wall_brace_shove() {
+ let actions = legal_actions(&infantry_line_cap(true, false, false));
+ let kinds: Vec = actions.iter().map(|a| a.kind).collect();
+ assert!(kinds.contains(&ActionKind::ShieldWall));
+ assert!(kinds.contains(&ActionKind::Brace));
+ assert!(kinds.contains(&ActionKind::Shove));
+ }
+
+ #[test]
+ fn infantry_line_shield_wall_toggles() {
+ let actions = legal_actions(&infantry_line_cap(true, true, false));
+ let kinds: Vec = actions.iter().map(|a| a.kind).collect();
+ assert!(kinds.contains(&ActionKind::UnshieldWall), "UnshieldWall must appear when in shield wall");
+ let sw = actions.iter().find(|a| a.kind == ActionKind::ShieldWall).unwrap();
+ assert!(!sw.enabled);
+ assert_eq!(sw.disabled_reason, Some(DisabledReason::AlreadyShieldWall));
+ }
+
+ #[test]
+ fn infantry_line_brace_toggles() {
+ let actions = legal_actions(&infantry_line_cap(true, false, true));
+ let kinds: Vec = actions.iter().map(|a| a.kind).collect();
+ assert!(kinds.contains(&ActionKind::Unbrace));
+ let brace = actions.iter().find(|a| a.kind == ActionKind::Brace).unwrap();
+ assert!(!brace.enabled);
+ assert_eq!(brace.disabled_reason, Some(DisabledReason::AlreadyBraced));
+ }
+
+ #[test]
+ fn infantry_shock_has_rage_cleave_war_cry() {
+ let actions = legal_actions(&infantry_shock_cap(true, 0, false));
+ let kinds: Vec = actions.iter().map(|a| a.kind).collect();
+ assert!(kinds.contains(&ActionKind::Rage));
+ assert!(kinds.contains(&ActionKind::Cleave));
+ assert!(kinds.contains(&ActionKind::WarCry));
+ }
+
+ #[test]
+ fn infantry_shock_rage_disabled_when_raging() {
+ let actions = legal_actions(&infantry_shock_cap(true, 2, false));
+ let rage = actions.iter().find(|a| a.kind == ActionKind::Rage).unwrap();
+ assert!(!rage.enabled);
+ assert_eq!(rage.disabled_reason, Some(DisabledReason::AlreadyRaging));
+ }
+
+ #[test]
+ fn infantry_shock_war_cry_disabled_after_use() {
+ let actions = legal_actions(&infantry_shock_cap(true, 0, true));
+ let wc = actions.iter().find(|a| a.kind == ActionKind::WarCry).unwrap();
+ assert!(!wc.enabled);
+ assert_eq!(wc.disabled_reason, Some(DisabledReason::WarCryUsed));
+ }
+
+ #[test]
+ fn all_new_action_kinds_round_trip_as_str() {
+ let all = [
+ ActionKind::Volley, ActionKind::AimedShot, ActionKind::FireArrows, ActionKind::StopFireArrows,
+ ActionKind::Charge, ActionKind::Pursue, ActionKind::Wheel,
+ ActionKind::ShieldWall, ActionKind::UnshieldWall, ActionKind::Brace, ActionKind::Unbrace,
+ ActionKind::Shove, ActionKind::Rage, ActionKind::Cleave, ActionKind::WarCry,
+ ];
+ for kind in all {
+ assert_eq!(ActionKind::from_str(kind.as_str()), Some(kind), "round-trip failed for {:?}", kind);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/simulator/crates/mc-core/src/resources.rs b/src/simulator/crates/mc-core/src/resources.rs
index c76db404..39183c3d 100644
--- a/src/simulator/crates/mc-core/src/resources.rs
+++ b/src/simulator/crates/mc-core/src/resources.rs
@@ -56,10 +56,10 @@ pub struct IndicatorDecoration {
/// Static data for a single resource entry from `resources.json`.
///
-/// Extra JSON fields (description, yields, quality_range, sprite, tags, etc.)
-/// are preserved by the `#[serde(flatten)]` extra-fields map when
-/// deserializing — callers that need them can access via the raw JSON path.
-/// Only the fields consumed by simulation logic are declared here.
+/// Only the fields consumed by simulation logic are declared here. Extra JSON
+/// fields (description, yields, quality_range, sprite, tags, etc.) are
+/// silently ignored by serde's default skip-unknown behaviour. Callers that
+/// need extra fields should deserialise from the raw JSON path directly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resource {
pub id: String,
diff --git a/src/simulator/crates/mc-turn/src/action_handlers.rs b/src/simulator/crates/mc-turn/src/action_handlers.rs
index e1f4c1a2..955c5d2f 100644
--- a/src/simulator/crates/mc-turn/src/action_handlers.rs
+++ b/src/simulator/crates/mc-turn/src/action_handlers.rs
@@ -79,19 +79,16 @@ pub fn invoke(
// Sentry/Unsentry: set/clear the sentry posture flag on the unit.
ActionKind::Sentry => handle_sentry(state, player_idx, unit_idx),
ActionKind::Unsentry => handle_unsentry(state, player_idx, unit_idx),
- // p2-53g ranged actions — require target coords or are handled by
- // archetype-specific subsystems. Gate validation only here.
- ActionKind::Volley | ActionKind::AimedShot => Err(ActionError {
- kind,
- reason: DisabledReason::WrongTerrain,
- }),
+ // p2-53g ranged actions
+ // Volley requires a target hex — routed through the bridge's target-pick path.
+ ActionKind::Volley => Err(ActionError { kind, reason: DisabledReason::WrongTerrain }),
+ ActionKind::AimedShot => handle_aimed_shot(state, player_idx, unit_idx),
ActionKind::FireArrows => handle_fire_arrows(state, player_idx, unit_idx),
ActionKind::StopFireArrows => handle_stop_fire_arrows(state, player_idx, unit_idx),
- // p2-53h cavalry actions — require target coords or are handled by subsystems.
- ActionKind::Charge | ActionKind::Pursue | ActionKind::Wheel => Err(ActionError {
- kind,
- reason: DisabledReason::WrongTerrain,
- }),
+ // p2-53h cavalry actions
+ // Charge and Wheel require target coords — routed through bridge.
+ ActionKind::Charge | ActionKind::Wheel => Err(ActionError { kind, reason: DisabledReason::WrongTerrain }),
+ ActionKind::Pursue => handle_pursue(state, player_idx, unit_idx),
// p2-53f infantry line actions
ActionKind::ShieldWall => handle_shield_wall(state, player_idx, unit_idx),
ActionKind::UnshieldWall => handle_unshield_wall(state, player_idx, unit_idx),
@@ -407,19 +404,37 @@ fn handle_disembark(
Ok(())
}
-// ── p2-53g stubs (MapUnit fields owned by ranged-archetypes agent) ──────────
+// ── p2-53g ranged action handlers ────────────────────────────────────────────
+
+fn handle_aimed_shot(
+ state: &mut GameState,
+ player_idx: usize,
+ unit_idx: usize,
+) -> Result<(), ActionError> {
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::AimedShot)?;
+ if unit.aimed_shot_pending {
+ return Err(ActionError {
+ kind: ActionKind::AimedShot,
+ reason: DisabledReason::AlreadyAiming,
+ });
+ }
+ unit.aimed_shot_pending = true;
+ Ok(())
+}
fn handle_fire_arrows(
state: &mut GameState,
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
- // Pre-condition gate only; state mutation deferred to p2-53g handler.
- state
- .players
- .get(player_idx)
- .and_then(|p| p.units.get(unit_idx))
- .ok_or(ActionError { kind: ActionKind::FireArrows, reason: DisabledReason::WrongTerrain })?;
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::FireArrows)?;
+ if unit.is_fire_arrows {
+ return Err(ActionError {
+ kind: ActionKind::FireArrows,
+ reason: DisabledReason::AlreadyFireArrows,
+ });
+ }
+ unit.is_fire_arrows = true;
Ok(())
}
@@ -428,26 +443,50 @@ fn handle_stop_fire_arrows(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
- state
- .players
- .get(player_idx)
- .and_then(|p| p.units.get(unit_idx))
- .ok_or(ActionError { kind: ActionKind::StopFireArrows, reason: DisabledReason::WrongTerrain })?;
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::StopFireArrows)?;
+ if !unit.is_fire_arrows {
+ return Err(ActionError {
+ kind: ActionKind::StopFireArrows,
+ reason: DisabledReason::NotFireArrows,
+ });
+ }
+ unit.is_fire_arrows = false;
Ok(())
}
-// ── p2-53f stubs (MapUnit fields owned by infantry-archetypes agent) ────────
+// ── p2-53h cavalry action handlers ────────────────────────────────────────────
+
+fn handle_pursue(
+ state: &mut GameState,
+ player_idx: usize,
+ unit_idx: usize,
+) -> Result<(), ActionError> {
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Pursue)?;
+ if unit.is_pursuing {
+ return Err(ActionError {
+ kind: ActionKind::Pursue,
+ reason: DisabledReason::AlreadyPursuing,
+ });
+ }
+ unit.is_pursuing = true;
+ Ok(())
+}
+
+// ── p2-53f infantry line action handlers ─────────────────────────────────────
fn handle_shield_wall(
state: &mut GameState,
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
- state
- .players
- .get(player_idx)
- .and_then(|p| p.units.get(unit_idx))
- .ok_or(ActionError { kind: ActionKind::ShieldWall, reason: DisabledReason::WrongTerrain })?;
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::ShieldWall)?;
+ if unit.is_shield_wall {
+ return Err(ActionError {
+ kind: ActionKind::ShieldWall,
+ reason: DisabledReason::AlreadyShieldWall,
+ });
+ }
+ unit.is_shield_wall = true;
Ok(())
}
@@ -456,11 +495,14 @@ fn handle_unshield_wall(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
- state
- .players
- .get(player_idx)
- .and_then(|p| p.units.get(unit_idx))
- .ok_or(ActionError { kind: ActionKind::UnshieldWall, reason: DisabledReason::WrongTerrain })?;
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::UnshieldWall)?;
+ if !unit.is_shield_wall {
+ return Err(ActionError {
+ kind: ActionKind::UnshieldWall,
+ reason: DisabledReason::NotShieldWall,
+ });
+ }
+ unit.is_shield_wall = false;
Ok(())
}
@@ -469,11 +511,14 @@ fn handle_brace(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
- state
- .players
- .get(player_idx)
- .and_then(|p| p.units.get(unit_idx))
- .ok_or(ActionError { kind: ActionKind::Brace, reason: DisabledReason::WrongTerrain })?;
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Brace)?;
+ if unit.is_braced {
+ return Err(ActionError {
+ kind: ActionKind::Brace,
+ reason: DisabledReason::AlreadyBraced,
+ });
+ }
+ unit.is_braced = true;
Ok(())
}
@@ -482,24 +527,35 @@ fn handle_unbrace(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
- state
- .players
- .get(player_idx)
- .and_then(|p| p.units.get(unit_idx))
- .ok_or(ActionError { kind: ActionKind::Unbrace, reason: DisabledReason::WrongTerrain })?;
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Unbrace)?;
+ if !unit.is_braced {
+ return Err(ActionError {
+ kind: ActionKind::Unbrace,
+ reason: DisabledReason::NotBraced,
+ });
+ }
+ unit.is_braced = false;
Ok(())
}
+// ── p2-53f infantry shock action handlers ────────────────────────────────────
+
fn handle_rage(
state: &mut GameState,
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
- state
- .players
- .get(player_idx)
- .and_then(|p| p.units.get(unit_idx))
- .ok_or(ActionError { kind: ActionKind::Rage, reason: DisabledReason::WrongTerrain })?;
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Rage)?;
+ if unit.rage_turns_remaining > 0 {
+ return Err(ActionError {
+ kind: ActionKind::Rage,
+ reason: DisabledReason::AlreadyRaging,
+ });
+ }
+ // +40% attack for 2 turns; Fortify and Sentry are incompatible (cleared here).
+ unit.rage_turns_remaining = 2;
+ unit.is_fortified = false;
+ unit.is_sentrying = false;
Ok(())
}
@@ -508,11 +564,14 @@ fn handle_war_cry(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
- state
- .players
- .get(player_idx)
- .and_then(|p| p.units.get(unit_idx))
- .ok_or(ActionError { kind: ActionKind::WarCry, reason: DisabledReason::WrongTerrain })?;
+ let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::WarCry)?;
+ if unit.war_cry_used_this_battle {
+ return Err(ActionError {
+ kind: ActionKind::WarCry,
+ reason: DisabledReason::WarCryUsed,
+ });
+ }
+ unit.war_cry_used_this_battle = true;
Ok(())
}
diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs
index 1f1bca2e..63b2be71 100644
--- a/src/simulator/crates/mc-turn/src/game_state.rs
+++ b/src/simulator/crates/mc-turn/src/game_state.rs
@@ -516,6 +516,33 @@ pub struct MapUnit {
/// Remaining turns on a Mark Trail tag this unit has placed (0 = none).
#[serde(default)]
pub mark_trail_turns: u8,
+ // p2-53g: ranged posture
+ /// True when the unit has taken the AimedShot action; cleared on next attack.
+ #[serde(default)]
+ pub aimed_shot_pending: bool,
+ /// True when the unit is in fire-arrow posture (ranged attacks ignite tile).
+ #[serde(default)]
+ pub is_fire_arrows: bool,
+ // p2-53h: cavalry posture
+ /// True when the unit is in pursue posture (advance-on-rout follow-through active).
+ #[serde(default)]
+ pub is_pursuing: bool,
+ /// Hex of the pending charge target, set during Charge action. Consumed by combat resolver.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub pending_charge_target: Option<(i32, i32)>,
+ // p2-53f: infantry posture
+ /// True when the unit is in shield-wall posture (+50% defence vs ranged).
+ #[serde(default)]
+ pub is_shield_wall: bool,
+ /// True when the unit is braced against a charge (first-strike on incoming melee).
+ #[serde(default)]
+ pub is_braced: bool,
+ /// Remaining turns of Rage (+40% attack). 0 = not raging.
+ #[serde(default)]
+ pub rage_turns_remaining: u8,
+ /// True when WarCry has been used this battle (once-per-battle gate).
+ #[serde(default)]
+ pub war_cry_used_this_battle: bool,
}
fn default_auto_join() -> bool {
diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs
index 45aa4e29..c97b601c 100644
--- a/src/simulator/crates/mc-turn/src/processor.rs
+++ b/src/simulator/crates/mc-turn/src/processor.rs
@@ -305,6 +305,22 @@ impl TurnProcessor {
crate::patrol::advance_on_turn(&mut player.units);
}
+ // p2-53g/h/f: cross-turn archetype state ticks.
+ // Rage countdown, AimedShot pending clear (if not consumed by an attack this
+ // turn — the attack handler clears it on use; this ensures it cannot persist
+ // more than one turn even if the unit took no action).
+ // WarCry is cleared once per battle; battlefield reset happens on peace/end-of-combat.
+ for player in state.players.iter_mut() {
+ for unit in player.units.iter_mut() {
+ if unit.rage_turns_remaining > 0 {
+ unit.rage_turns_remaining -= 1;
+ }
+ // aimed_shot_pending is consumed by the attack handler; clear defensively
+ // here so it cannot persist across a turn where the unit didn't attack.
+ unit.aimed_shot_pending = false;
+ }
+ }
+
// Phase 4f: drain formation requests from GDScript (rally, command, shape, split, auto-join).
Self::drain_formation_requests(state);
crate::building_action_handlers::drain_pending_building_actions(state);
@@ -1573,6 +1589,7 @@ impl TurnProcessor {
city_wall_tier: 0,
city_has_garrison: false,
attacker_is_siege: false,
+ ..Default::default()
}
};
@@ -1731,6 +1748,7 @@ impl TurnProcessor {
city_wall_tier: 0,
city_has_garrison: false,
attacker_is_siege: false,
+ ..Default::default()
};
let combat_result = CombatResolver::resolve(¶ms);
|