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);