From f73bfd589eb6ba7ef479dc717215a52882bccea6 Mon Sep 17 00:00:00 2001 From: Natalie Date: Sat, 2 May 2026 18:30:14 -0400 Subject: [PATCH] =?UTF-8?q?feat(@projects):=20=E2=9C=A8=20add=20per-player?= =?UTF-8?q?=20tile=20observation=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../p2-54b-player-observation-cache.md | 56 ++++ ...4c-renderer-observations-and-indicators.md | 43 +++ public/games/age-of-dwarves/docs/RESOURCES.md | 312 ++++++++++++++++++ public/resources/resources.json | 270 +++++++++++---- src/game/engine/scenes/hud/unit_panel.gd | 36 ++ src/game/engine/scenes/world_map/world_map.gd | 7 +- .../crates/mc-ai/src/tactical/movement.rs | 4 + src/simulator/crates/mc-combat/src/lib.rs | 2 + .../crates/mc-combat/src/status_effect.rs | 79 +++++ src/simulator/crates/mc-core/src/action.rs | 223 +++++++++++++ src/simulator/crates/mc-core/src/lib.rs | 1 + .../crates/mc-core/src/multi_turn_action.rs | 109 ++++++ .../crates/mc-turn/src/action_handlers.rs | 247 ++++++++++++++ .../crates/mc-turn/src/game_state.rs | 25 ++ tools/migrate-resources-visibility.py | 246 ++++++++++++++ 15 files changed, 1593 insertions(+), 67 deletions(-) create mode 100644 .project/objectives/p2-54b-player-observation-cache.md create mode 100644 .project/objectives/p2-54c-renderer-observations-and-indicators.md create mode 100644 public/games/age-of-dwarves/docs/RESOURCES.md create mode 100644 src/simulator/crates/mc-combat/src/status_effect.rs create mode 100644 src/simulator/crates/mc-core/src/multi_turn_action.rs create mode 100644 tools/migrate-resources-visibility.py diff --git a/.project/objectives/p2-54b-player-observation-cache.md b/.project/objectives/p2-54b-player-observation-cache.md new file mode 100644 index 00000000..18841640 --- /dev/null +++ b/.project/objectives/p2-54b-player-observation-cache.md @@ -0,0 +1,56 @@ +--- +id: p2-54b +title: Per-player tile observation cache — flora/fauna last-observed state +priority: p2 +status: missing +scope: game1 +owner: terraformer +parent: p2-54 +canonical_doc: public/games/age-of-dwarves/docs/RESOURCES.md +coordinates_with: + - p2-50 + - p2-52 + - p2-54 +blockedBy: [p2-54] +--- + +## Summary + +Per the user's 2026-05-01 design decision: flora and fauna are NOT omniscient-always-visible. Each player remembers what their scout/unit last saw, not the simulator's current state. Since flora evolves (forests grow/regress, swamps drain) and fauna migrates (populations shift, herds move), the displayed map for a player reflects their **last observation**, not ground truth. + +This adds a per-player per-tile observation cache, persisted in saves via the mc-save crate (p2-50). + +## Acceptance + +- ◻ **TileObservation struct** in `mc-core` (or new `mc-observation` crate): + ```rust + pub struct TileObservation { + pub observed_turn: u32, + pub flora_cover_id_seen: Option, + pub biome_label_id_seen: Option, + pub fauna_species_seen: Vec, + pub indicator_decorations_seen: Vec, // for tech-gated resources + pub observer_unit_id: Option, + } + ``` +- ◻ **PlayerObservations** struct keyed on `(col, row)`: + ```rust + pub struct PlayerObservations { + pub by_tile: HashMap<(i32, i32), TileObservation>, + } + ``` +- ◻ **Population hook**: when a unit's vision encloses a tile (line-of-sight from the existing vision system), the simulator writes the current tile state into that player's PlayerObservations +- ◻ **Save format integration**: PlayerObservations serialized in `mc-save::SaveFile` per player +- ◻ **GDExt + WASM bridges**: `playerTileObservation(player_id, col, row) -> Option` +- ◻ **Determinism preserved**: cross_build_determinism golden vector unchanged +- ◻ **Doc**: RESOURCES.md §7 "Observation model" written or updated to describe the cache + the flora-evolves-over-time rationale + +## Non-goals + +- Renderer changes (those are p2-54c) +- AI changes (those are p2-54d) +- Real-time scout-visibility radius tuning (separate from this objective; uses whatever vision system is current) + +## Why partial-blocked-by-p2-54 + +The schema must land first so the observation cache knows what fields to mirror. p2-54 (three-axis schema) is the prerequisite. diff --git a/.project/objectives/p2-54c-renderer-observations-and-indicators.md b/.project/objectives/p2-54c-renderer-observations-and-indicators.md new file mode 100644 index 00000000..577fe7c2 --- /dev/null +++ b/.project/objectives/p2-54c-renderer-observations-and-indicators.md @@ -0,0 +1,43 @@ +--- +id: p2-54c +title: Renderer reads observations + indicator decorations for tech-gated resources +priority: p2 +status: missing +scope: game1 +owner: terraformer +parent: p2-54 +canonical_doc: public/games/age-of-dwarves/docs/RESOURCES.md +coordinates_with: + - p1-46 + - p2-52 + - p2-54 + - p2-54b +blockedBy: [p2-54, p2-54b] +--- + +## Summary + +After the three-axis schema (p2-54) and per-player observation cache (p2-54b) land, the renderer must: +1. Render flora/fauna from the **player's PlayerObservations**, not the simulator's current state +2. Render `indicator_decorations` on tech-gated resource tiles (rust-red iron-oxide soil for iron, malachite stains for copper, etc.) — these are visual cues that exist before the resource's HUD icon is unlocked +3. Render the explicit resource icon when the player's tech satisfies `yield_gate` + +Affects both the design app's `MapCanvas` (TS/WASM consumer) and Godot's `hex_renderer.gd`. + +## Acceptance + +- ◻ **MapCanvas reads observations** — when a `playerId` prop is provided, the canvas pulls flora_cover_id and biome decorations from `playerTileObservation(playerId, col, row)` instead of `tileFloraCoverJson`. With no playerId (lab/spectator mode), uses live state. +- ◻ **Indicator decorations rendered** — for each tech-gated resource on a tile, look up `indicator_decorations[*].decoration_id` and draw a small terrain-decoration glyph (rust speckle, oil seep, malachite vein, etc.) using the existing decoration primitives in `hexCanvas.ts::drawDecorations`. Decoration assets live in `public/games/age-of-dwarves/data/terrain/indicator_decorations.json` (new file authored by p2-54). +- ◻ **Resource icon visibility** — explicit resource icons render when (`visibility == "always"` OR (`visibility == "tech_gated"` AND player has `yield_gate` tech)). Otherwise icon hidden, indicator decoration still visible. +- ◻ **Godot parity** — `hex_renderer.gd` follows the same composition logic via api-gdext bridge to `playerTileObservation`. +- ◻ **HUD test** — playwright spec asserts that visiting `/hud?playerId=stoneguard` shows different flora/fauna for that clan than for `?playerId=ashpeak`, demonstrating the observation cache is consulted (requires a small fixture: scouted-some-tiles seed). +- ◻ **Lab unaffected** — `/world-gen/lab` continues to use live state (no playerId), all existing e2e tests pass. + +## Non-goals + +- Animating the appearance of an indicator decoration when a player gets close enough to a tile (that's later polish) +- Per-decoration sprites — vector glyphs are fine for now (decoration_id resolves to a draw function in hexCanvas.ts) + +## Why blocked-by-p2-54-and-p2-54b + +The schema (p2-54) and the observation cache (p2-54b) are prerequisites. This objective only handles the visual consumption. diff --git a/public/games/age-of-dwarves/docs/RESOURCES.md b/public/games/age-of-dwarves/docs/RESOURCES.md new file mode 100644 index 00000000..fb9f41e0 --- /dev/null +++ b/public/games/age-of-dwarves/docs/RESOURCES.md @@ -0,0 +1,312 @@ +# Resource Visibility Model + +Canonical design document for the three-axis resource visibility / yield-gate schema. +Filed under: game design — economy. + +--- + +## §1 Three-Axis Model + +Each resource entry carries three orthogonal axes that govern what players see, when +yields activate, and when improvements unlock. + +### Field Definitions + +| Field | Type | Description | +|---|---|---| +| `visibility` | `"always"` \| `"scout"` \| `"tech_gated"` | When the resource **icon** appears on the map | +| `yield_gate` | `tech_id \| null` | Tech required before the tile yields the listed bonuses | +| `improvement_gate` | `tech_id \| null` | Tech required before the improvement becomes buildable | + +`improvement_required` (the improvement ID to build) is a fourth field that already +exists on every entry and is unchanged by this migration. The term "improvement_gate" +in this doc refers to the tech that unlocks buildability, not the improvement ID itself. + +### Visibility Axis Values + +- `"always"` — icon renders from turn 1, regardless of fog-of-war or tech. Flora, fauna, + and surface resources a primitive clan would recognise belong here. +- `"scout"` — icon appears once a scout unit has walked the tile (fog-of-war cleared), + but does not require any tech. Reserved for resources that are physically obvious but + require proximity. Currently unused in Age of Dwarves; reserved for future episodes. +- `"tech_gated"` — icon is suppressed until the player researches `yield_gate`. Before + that tech, `indicator_decorations` (§8) may render terrain tells, but no resource icon + appears. + +### Relationship to `improvement_required` + +`improvement_gate` and `improvement_required` are distinct: + +- `improvement_required` — the improvement ID that must be built on the tile (e.g. `"mine"`) +- `improvement_gate` — the tech required before that improvement can be **queued** at all + +For most resources `improvement_gate` equals `yield_gate`, because you can't build the +improvement before you understand the resource. Set `improvement_gate: null` only for +resources whose improvement is available from the start of the game (e.g. `farm` for wheat). + +--- + +## §2 Always-Visible Policy for Flora and Fauna + +**Flora cover and fauna density render across the entire visible map at the player's +last-observed state.** This is a core readability guarantee: forests, herds, grassland +flora, and wetland ecology are never hidden by a tech wall. + +- Flora: `forest`, `jungle`, `savanna`, `wetland`, `tundra_scrub`, and similar biome-level + cover features render at the biome layer, independent of resource icons. +- Fauna: herd density indicators (deer, bison, horses) are terrain features, not resource + icons. They render whenever the tile has been revealed by fog-of-war. +- Resource icons for flora/fauna resources (deer, timber, furs, silk, etc.) follow the + `visibility` axis — typically `"always"` — but this controls the **icon**, not the + underlying terrain feature. + +The observation model for per-player last-seen state is specified in §7. + +--- + +## §3 Per-Resource Classification Table + +### Luxury Resources (15 entries) + +| ID | visibility | yield_gate | improvement_gate | Notes | +|---|---|---|---|---| +| `furs` | `always` | `trapping` | `trapping` | Fauna tag — animal always visible, yield needs skill | +| `ivory` | `always` | `trapping` | `trapping` | Fauna tag | +| `silk` | `always` | `scholarship` | `scholarship` | Flora tag — cave moth cocoons, literacy unlocks trade | +| `spices` | `always` | `herbalism` | `herbalism` | Flora tag | +| `dyes` | `always` | `culture` | `culture` | Flora/mineral — cultural knowledge unlocks extraction | +| `amber` | `tech_gated` | `scholarship` | `scholarship` | Subsurface/embedded — scholars identify it | +| `salt` | `always` | `null` | `null` | Salt licks and surface rock salt: universally recognizable | +| `wine` | `always` | `agriculture` | `agriculture` | Flora (grape vine) — cultivation knowledge required | +| `incense` | `always` | `culture` | `culture` | Flora — ceremonial use unlocked by cultural knowledge | +| `pearls` | `tech_gated` | `fishing` | `fishing` | Aquatic — fishing tech required to locate river beds | +| `copper` | `always` | `bronze_working` | `bronze_working` | Surface mineral visible; smelting/working needed | +| `marble` | `always` | `masonry` | `masonry` | Surface stone visible; quarrying skill required | +| `gems` | `tech_gated` | `mining` | `mining` | Deep pockets — mining skill + excavation required | +| `gold` | `tech_gated` | `mining` | `mining` | Vein gold — subsurface; placer indicators exist (§8) | +| `obsidian_glass` | `always` | `metallurgy` | `metallurgy` | Volcanic glass surface-visible; knapping skill needed | + +**Footnote — amber classification edge case.** Amber is fossilized resin found embedded +in forest sediment or riverbank clay. A forager _could_ spot surface fragments, but +identifying it as amber vs. ordinary cloudy stone requires scholarly knowledge. Classification +`tech_gated` with `yield_gate: scholarship` is deliberate. If future balance calls for making +amber surface-detectable, the field swap is: `visibility: always`, `yield_gate: scholarship`. + +### Bonus Resources + +All 9 bonus resources use `visibility: "always"`, `yield_gate: null`. + +| ID | visibility | yield_gate | Notes | +|---|---|---|---| +| `deer` | `always` | `null` | Fauna | +| `bison` | `always` | `null` | Fauna | +| `fish` | `always` | `null` | Aquatic | +| `crabs` | `always` | `null` | Aquatic | +| `wheat` | `always` | `null` | Flora | +| `cattle` | `always` | `null` | Fauna — old value `animal_husbandry` removed; herds are visible | +| `sheep` | `always` | `null` | Fauna — old value `animal_husbandry` removed | +| `stone` | `always` | `null` | Surface mineral | +| `timber` | `always` | `null` | Flora | + +**Footnote — cattle and sheep.** The original `revealed_by_tech: animal_husbandry` was +carried over from Civ5. In this model, wild herds are always visible (per the fauna +always-visible policy in §2). `animal_husbandry` remains the `improvement_required` +prerequisite tech for building a pasture. + +### Strategic Resources + +All strategic resources use `visibility: "tech_gated"`. Their `yield_gate` is the +discovery tech; `improvement_gate` mirrors it. + +| ID | visibility | yield_gate | Notes | +|---|---|---|---| +| `iron` | `tech_gated` | `bronze_working` | Surface ore recognised when smelting begins | +| `horses` | `tech_gated` | `animal_husbandry` | Wild horses require husbandry to utilise | +| `coal` | `tech_gated` | `metallurgy` | Deep seams; metallurgy opens extraction | +| `saltpeter` | `tech_gated` | `alchemy` | Cave karst precipitation; alchemy identifies it | +| `hardwood` | `tech_gated` | `engineering` | Old-growth ironwood; engineering unlocks use | +| `hides` | `tech_gated` | `trapping` | Heavy hides distinguish from general game; trapping needed | +| `flint` | `always` | `null` | Flint is always visible and immediately usable; no tech gate | + +**Footnote — flint exception.** Flint is categorised as `strategic` (it gates `warrior` +and `archer` units) but is `visibility: always`, `yield_gate: null`. It is a surface +rock type immediately recognisable and usable. No tech required for the basic improvement +(`quarry` still applies as `improvement_required`, but the improvement is available from +the start). This is the only `strategic` resource that is always-visible. + +**Footnote — horses as strategic.** Wild horse herds ARE visible (fauna policy), but +controlled utilisation requires `animal_husbandry` tech. The `visibility: tech_gated` +reflects that the strategic utility (cavalry production) is hidden, not the animals. +This is a known tension with the fauna always-visible policy (§2): the herd density +_terrain_ indicator renders freely, but the strategic resource _icon_ is suppressed +until `animal_husbandry`. + +--- + +## §4 Migration Notes — `revealed_by_tech` → Three-Axis Fields + +**Old field:** `revealed_by_tech: string | null` + +The old field conflated two things: +1. Whether the resource icon was visible (visibility axis) +2. Whether the yields were active (yield_gate axis) + +**New fields (added, old field removed):** + +```json +{ + "visibility": "always", + "yield_gate": "trapping", + "improvement_gate": "trapping", + "indicator_decorations": [] +} +``` + +**Mapping rule:** + +| Old `revealed_by_tech` | New `visibility` | New `yield_gate` | +|---|---|---| +| `null` + bonus category | `"always"` | `null` | +| `null` + luxury category | `"always"` | `null` (salt case) | +| any tech string + luxury, surface indicator | `"always"` | same tech string | +| any tech string + luxury, subsurface | `"tech_gated"` | same tech string | +| any tech string + strategic | `"tech_gated"` | same tech string | +| `null` + strategic (flint) | `"always"` | `null` | + +Consumers that previously read `revealed_by_tech` and compared it to a player's tech list +should now call `is_yield_active()` (Rust) or read `visibility` / `yield_gate` directly +(GDScript, TS). + +--- + +## §5 AI Consumption Pattern + +AI personality agents (mc-ai) read resource visibility data to guide tech priorities: + +**Visible-but-yield-gated luxuries** (visibility: always, yield_gate: non-null) in the +AI's territory provide a direct priority signal: the player can see the resource, knows +it will yield once the tech is researched, and benefits from researching it soon. + +Pattern: +1. For each tile in territory: collect resources with `visibility == "always"` AND + `yield_gate != null` AND yield_gate not yet researched. +2. Score each missing tech by the number + value of visible-but-gated resources it unlocks. +3. Blend this signal into the AI tech-priority evaluator with a configurable weight + (personality-specific; resource-hungry personalities weight this higher). + +**Tech-gated resources** with `indicator_decorations` (§8): the AI can observe decoration +IDs on explored tiles even before the resource is revealed. Personalities with a +"mining/exploration" bias should weight decoration presence as a tech-priority hint. + +This AI consumption work belongs in `mc-ai` and is scheduled for the next cycle. + +--- + +## §6 Renderer Pattern + +Resource icon visibility follows the `visibility` axis: + +- `"always"`: render icon whenever the tile is revealed (fog-of-war cleared). +- `"scout"`: render icon whenever the tile has been visited by a scout unit. +- `"tech_gated"`: suppress icon until `yield_gate` is researched by the local player. + +When `visibility == "tech_gated"` but `indicator_decorations` is non-empty, render +the decoration sprites instead (see §8). Decorations are dimmed/stylistically distinct +from the full resource icon. + +When `visibility == "always"` AND `yield_gate != null` AND player lacks `yield_gate`: +render the icon with a **dimmed / desaturated overlay** and a tooltip: "Requires [tech] +to extract yields." The resource is visible and tempting; the overlay communicates +"not yet active." + +Renderer implementation belongs to `godot-renderer` and is scheduled for the next cycle. + +--- + +## §7 Observation Model — Per-Player Last-Observed State (Forward Design) + +Flora cover and fauna density are **not omniscient** — they render at the player's +**last-observed state**. Since forests grow/regress and fauna migrates between turns, +the simulator has the truth; each player holds a per-tile cache of what their scouts saw. + +### Proposed Rust Structure (mc-save extension, next cycle) + +```rust +pub struct TileObservation { + pub player_id: String, + pub tile: (i32, i32), + pub observed_turn: u32, + pub flora_cover_id_seen: Option, + pub biome_label_id_seen: Option, + pub fauna_species_seen: Vec, + pub observer_unit_id: Option, +} + +pub struct PlayerObservations { + pub by_tile: HashMap<(i32, i32), TileObservation>, +} +``` + +The renderer reads `PlayerObservations` for the active player to paint the map state. +`observed_turn` allows a future "stale intel" visual (fog-of-history), though that is +not scheduled for Game 1. + +Implementation is blocked on: `mc-save` struct authoring, GDExtension bridge for +`get_player_observations`, and renderer integration in `godot-renderer`. + +--- + +## §8 Indicator Decorations — Tech-Gated Resource Terrain Tells + +Subsurface and tech-gated resources leave observable terrain indicators even before +the discovery tech is researched. These are tile decoration sprites, not resource icons. + +### Purpose + +- Environmental storytelling: the world feels geologically consistent. +- Soft gameplay signal: experienced players recognise the tells and prioritise the + corresponding tech. +- Forward-compat hook for AI (§5): AI can read `indicator_decorations` as priority hints. + +### Schema Extension + +Resources with `visibility: "tech_gated"` may carry: + +```json +"indicator_decorations": [ + { + "decoration_id": "rust_red_soil", + "name": "Iron-oxide stained soil", + "description": "Reddish staining on exposed bedrock — the tell-tale sign of iron-bearing strata." + } +] +``` + +For resources with `visibility: "always"` or `visibility: "scout"`, `indicator_decorations` +is an empty array (the icon itself is the indicator). + +### Planned Decoration Assignments + +| Resource | `decoration_id` | Display Name | +|---|---|---| +| `iron` | `rust_red_soil` | Iron-oxide stained soil | +| `horses` | (none — fauna visible) | — | +| `coal` | `coal_seam_outcrop` | Coal seam outcrop | +| `saltpeter` | `white_crystal_efflorescence` | White crystal efflorescence | +| `hardwood` | (none — flora visible) | — | +| `hides` | (none — fauna visible) | — | +| `copper` | `malachite_green_stain` | Malachite-green mineral stain | +| `amber` | `fossilized_resin_fragments` | Fossilized resin fragments | +| `pearls` | `oyster_bed_shells` | Oyster bed shells | +| `gems` | `quartz_outcrop_glint` | Quartz outcrop glint | +| `gold` | `river_gold_specks`, `gold_quartz_vein` | Placer gold specks / Gold-bearing quartz vein | + +### Rendering Rules + +1. `indicator_decorations` sprites render on explored tiles regardless of tech. +2. They occupy the decoration layer, not the resource-icon layer. +3. Hovering a decoration tile shows the `name` and `description` as a tooltip, not + the resource name or yields. +4. Once the gating tech is researched, decorations are replaced by the resource icon. + +Renderer work for decorations is scheduled for the next cycle (godot-renderer). diff --git a/public/resources/resources.json b/public/resources/resources.json index 9e834544..c6440e0c 100644 --- a/public/resources/resources.json +++ b/public/resources/resources.json @@ -4,30 +4,30 @@ "description": "Multiplier applied to all yield bonuses based on deposit richness. Five discrete levels.", "1": { "multiplier": 0.5, - "rationale": "Marginal vein \u2014 barely worth extracting. Civ5 equivalent of a sparse tile that barely tips a worker's time investment." + "rationale": "Marginal vein — barely worth extracting. Civ5 equivalent of a sparse tile that barely tips a worker's time investment." }, "2": { "multiplier": 0.75, - "rationale": "Workable seam \u2014 typical poor-mountain find. Yields something but cities won't grow around it." + "rationale": "Workable seam — typical poor-mountain find. Yields something but cities won't grow around it." }, "3": { "multiplier": 1.0, - "rationale": "Standard vein \u2014 the baseline all listed yields are balanced against. Map-gen targets this as the median." + "rationale": "Standard vein — the baseline all listed yields are balanced against. Map-gen targets this as the median." }, "4": { "multiplier": 1.25, - "rationale": "Rich vein \u2014 notable enough players reroute borders to capture it. Civ5 luxury equivalent with a visible bonus." + "rationale": "Rich vein — notable enough players reroute borders to capture it. Civ5 luxury equivalent with a visible bonus." }, "5": { "multiplier": 1.5, - "rationale": "Mother lode \u2014 rarest 5% of spawns. Justifies war to hold it. Civ5 analogue: natural wonder\u2013adjacent resource tile." + "rationale": "Mother lode — rarest 5% of spawns. Justifies war to hold it. Civ5 analogue: natural wonder–adjacent resource tile." } }, - "quantity": "Integer 1\u20135. How many units a tile yields when improved. Stacks with quality multiplier. Luxury happiness applies once per unique type regardless of quantity \u2014 quantity increases trade value only.", + "quantity": "Integer 1–5. How many units a tile yields when improved. Stacks with quality multiplier. Luxury happiness applies once per unique type regardless of quantity — quantity increases trade value only.", "category_rules": { "bonus": "Always visible. No tech gate, no trade. Yields food/production only. happiness_per_copy: 0.", "luxury": "Tech-gated. Tradeable (1 copy = +happiness_per_copy per turn to receiving empire). Primary diplomatic trade good. Gates no units.", - "strategic": "Tech-gated. Required to train gates_units entries. Tradeable. happiness_per_copy: 0 \u2014 strategic value is military, not comfort." + "strategic": "Tech-gated. Required to train gates_units entries. Tradeable. happiness_per_copy: 0 — strategic value is military, not comfort." } }, "bonus": [ @@ -41,7 +41,6 @@ "tundra", "grassland" ], - "revealed_by_tech": null, "yields": { "food": 2, "production": 0, @@ -63,7 +62,11 @@ "tags": [ "fauna", "food" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] }, { "id": "bison", @@ -74,7 +77,6 @@ "plains", "grassland" ], - "revealed_by_tech": null, "yields": { "food": 1, "production": 1, @@ -96,7 +98,11 @@ "tags": [ "fauna", "food" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] }, { "id": "fish", @@ -108,7 +114,6 @@ "lake", "river" ], - "revealed_by_tech": null, "yields": { "food": 2, "production": 0, @@ -130,7 +135,11 @@ "tags": [ "aquatic", "food" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] }, { "id": "crabs", @@ -140,7 +149,6 @@ "terrains": [ "coast" ], - "revealed_by_tech": null, "yields": { "food": 2, "production": 0, @@ -162,7 +170,11 @@ "tags": [ "aquatic", "food" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] }, { "id": "wheat", @@ -173,7 +185,6 @@ "plains", "grassland" ], - "revealed_by_tech": null, "yields": { "food": 2, "production": 0, @@ -195,7 +206,11 @@ "tags": [ "flora", "food" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] }, { "id": "cattle", @@ -206,7 +221,6 @@ "grassland", "plains" ], - "revealed_by_tech": "animal_husbandry", "yields": { "food": 1, "production": 1, @@ -228,7 +242,11 @@ "tags": [ "fauna", "food" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] }, { "id": "sheep", @@ -240,7 +258,6 @@ "grassland", "tundra" ], - "revealed_by_tech": "animal_husbandry", "yields": { "food": 2, "production": 0, @@ -262,7 +279,11 @@ "tags": [ "fauna", "food" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] }, { "id": "stone", @@ -274,7 +295,6 @@ "plains", "grassland" ], - "revealed_by_tech": null, "yields": { "food": 0, "production": 2, @@ -296,7 +316,11 @@ "tags": [ "mineral", "production" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] }, { "id": "timber", @@ -307,7 +331,6 @@ "forest", "jungle" ], - "revealed_by_tech": null, "yields": { "food": 0, "production": 2, @@ -329,7 +352,11 @@ "tags": [ "flora", "production" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] } ], "luxury": [ @@ -337,13 +364,12 @@ "id": "furs", "name": "Furs", "category": "luxury", - "description": "Premium arctic pelts \u2014 warmth and clan status in cold lands.", + "description": "Premium arctic pelts — warmth and clan status in cold lands.", "terrains": [ "tundra", "forest", "arctic" ], - "revealed_by_tech": "trapping", "yields": { "food": 0, "production": 0, @@ -366,7 +392,11 @@ "fauna", "luxury", "cold" - ] + ], + "visibility": "always", + "yield_gate": "trapping", + "improvement_gate": "trapping", + "indicator_decorations": [] }, { "id": "ivory", @@ -378,7 +408,6 @@ "grassland", "desert" ], - "revealed_by_tech": "trapping", "yields": { "food": 0, "production": 1, @@ -400,18 +429,21 @@ "tags": [ "fauna", "luxury" - ] + ], + "visibility": "always", + "yield_gate": "trapping", + "improvement_gate": "trapping", + "indicator_decorations": [] }, { "id": "silk", "name": "Silk", "category": "luxury", - "description": "Thread from rare cave moth cocoons \u2014 lightweight and lustrous.", + "description": "Thread from rare cave moth cocoons — lightweight and lustrous.", "terrains": [ "forest", "jungle" ], - "revealed_by_tech": "scholarship", "yields": { "food": 0, "production": 0, @@ -433,7 +465,11 @@ "tags": [ "flora", "luxury" - ] + ], + "visibility": "always", + "yield_gate": "scholarship", + "improvement_gate": "scholarship", + "indicator_decorations": [] }, { "id": "spices", @@ -444,7 +480,6 @@ "jungle", "forest" ], - "revealed_by_tech": "herbalism", "yields": { "food": 1, "production": 0, @@ -466,7 +501,11 @@ "tags": [ "flora", "luxury" - ] + ], + "visibility": "always", + "yield_gate": "herbalism", + "improvement_gate": "herbalism", + "indicator_decorations": [] }, { "id": "dyes", @@ -478,7 +517,6 @@ "plains", "desert" ], - "revealed_by_tech": "culture", "yields": { "food": 0, "production": 0, @@ -501,7 +539,11 @@ "flora", "luxury", "culture" - ] + ], + "visibility": "always", + "yield_gate": "culture", + "improvement_gate": "culture", + "indicator_decorations": [] }, { "id": "amber", @@ -512,7 +554,6 @@ "forest", "plains" ], - "revealed_by_tech": "scholarship", "yields": { "food": 0, "production": 0, @@ -534,6 +575,16 @@ "tags": [ "mineral", "luxury" + ], + "visibility": "tech_gated", + "yield_gate": "scholarship", + "improvement_gate": "scholarship", + "indicator_decorations": [ + { + "decoration_id": "fossilized_resin_fragments", + "name": "Fossilized resin fragments", + "description": "Amber-coloured fragments in riverbank sediment — fossilized tree resin that hardened over millennia. Scholars know its value." + } ] }, { @@ -545,7 +596,6 @@ "desert", "hills" ], - "revealed_by_tech": null, "yields": { "food": 1, "production": 0, @@ -567,7 +617,11 @@ "tags": [ "mineral", "luxury" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] }, { "id": "wine", @@ -579,7 +633,6 @@ "hills", "plains" ], - "revealed_by_tech": "agriculture", "yields": { "food": 1, "production": 0, @@ -601,7 +654,11 @@ "tags": [ "flora", "luxury" - ] + ], + "visibility": "always", + "yield_gate": "agriculture", + "improvement_gate": "agriculture", + "indicator_decorations": [] }, { "id": "incense", @@ -612,7 +669,6 @@ "desert", "plains" ], - "revealed_by_tech": "culture", "yields": { "food": 0, "production": 0, @@ -635,7 +691,11 @@ "flora", "luxury", "culture" - ] + ], + "visibility": "always", + "yield_gate": "culture", + "improvement_gate": "culture", + "indicator_decorations": [] }, { "id": "pearls", @@ -647,7 +707,6 @@ "lake", "river" ], - "revealed_by_tech": "fishing", "yields": { "food": 0, "production": 0, @@ -669,6 +728,16 @@ "tags": [ "aquatic", "luxury" + ], + "visibility": "tech_gated", + "yield_gate": "fishing", + "improvement_gate": "fishing", + "indicator_decorations": [ + { + "decoration_id": "oyster_bed_shells", + "name": "Oyster bed shells", + "description": "Clusters of freshwater mussel shells along riverbanks. Fishers know which beds hide pearls." + } ] }, { @@ -680,7 +749,6 @@ "hills", "plains" ], - "revealed_by_tech": "bronze_working", "yields": { "food": 0, "production": 1, @@ -702,7 +770,11 @@ "tags": [ "mineral", "luxury" - ] + ], + "visibility": "always", + "yield_gate": "bronze_working", + "improvement_gate": "bronze_working", + "indicator_decorations": [] }, { "id": "marble", @@ -713,7 +785,6 @@ "hills", "mountains" ], - "revealed_by_tech": "masonry", "yields": { "food": 0, "production": 1, @@ -736,7 +807,11 @@ "mineral", "luxury", "culture" - ] + ], + "visibility": "always", + "yield_gate": "masonry", + "improvement_gate": "masonry", + "indicator_decorations": [] }, { "id": "gems", @@ -747,7 +822,6 @@ "mountains", "hills" ], - "revealed_by_tech": "mining", "yields": { "food": 0, "production": 0, @@ -769,6 +843,16 @@ "tags": [ "mineral", "luxury" + ], + "visibility": "tech_gated", + "yield_gate": "mining", + "improvement_gate": "mining", + "indicator_decorations": [ + { + "decoration_id": "quartz_outcrop_glint", + "name": "Quartz outcrop glint", + "description": "Sparkling mineral pockets in mountain cleft rock. Mining skills are needed to extract the gem-bearing strata below." + } ] }, { @@ -781,7 +865,6 @@ "mountains", "desert" ], - "revealed_by_tech": "mining", "yields": { "food": 0, "production": 0, @@ -803,6 +886,21 @@ "tags": [ "mineral", "luxury" + ], + "visibility": "tech_gated", + "yield_gate": "mining", + "improvement_gate": "mining", + "indicator_decorations": [ + { + "decoration_id": "river_gold_specks", + "name": "Placer gold specks", + "description": "Glittering specks in stream gravel. Experienced miners recognise the placer-deposit signature of an upstream gold vein." + }, + { + "decoration_id": "gold_quartz_vein", + "name": "Gold-bearing quartz vein", + "description": "White quartz bands with metallic inclusions. Mining expertise required to extract and assay the ore." + } ] }, { @@ -815,7 +913,6 @@ "mountains", "desert" ], - "revealed_by_tech": "metallurgy", "yields": { "food": 0, "production": 1, @@ -838,7 +935,11 @@ "mineral", "luxury", "volcanic" - ] + ], + "visibility": "always", + "yield_gate": "metallurgy", + "improvement_gate": "metallurgy", + "indicator_decorations": [] } ], "strategic": [ @@ -851,7 +952,6 @@ "hills", "mountains" ], - "revealed_by_tech": "bronze_working", "yields": { "food": 0, "production": 3, @@ -878,6 +978,16 @@ "mineral", "strategic", "military" + ], + "visibility": "tech_gated", + "yield_gate": "bronze_working", + "improvement_gate": "bronze_working", + "indicator_decorations": [ + { + "decoration_id": "rust_red_soil", + "name": "Iron-oxide stained soil", + "description": "Reddish-brown staining on exposed bedrock — the tell-tale sign of iron-bearing strata beneath." + } ] }, { @@ -890,7 +1000,6 @@ "grassland", "tundra" ], - "revealed_by_tech": "animal_husbandry", "yields": { "food": 1, "production": 1, @@ -915,7 +1024,11 @@ "fauna", "strategic", "military" - ] + ], + "visibility": "tech_gated", + "yield_gate": "animal_husbandry", + "improvement_gate": "animal_husbandry", + "indicator_decorations": [] }, { "id": "coal", @@ -926,7 +1039,6 @@ "hills", "mountains" ], - "revealed_by_tech": "metallurgy", "yields": { "food": 0, "production": 4, @@ -951,6 +1063,16 @@ "mineral", "strategic", "production" + ], + "visibility": "tech_gated", + "yield_gate": "metallurgy", + "improvement_gate": "metallurgy", + "indicator_decorations": [ + { + "decoration_id": "coal_seam_outcrop", + "name": "Coal seam outcrop", + "description": "Black streaks in cliff faces where a coal seam breaches the surface. Metallurgists know its fuel value." + } ] }, { @@ -963,7 +1085,6 @@ "hills", "mountains" ], - "revealed_by_tech": "alchemy", "yields": { "food": 0, "production": 1, @@ -985,6 +1106,16 @@ "tags": [ "mineral", "strategic" + ], + "visibility": "tech_gated", + "yield_gate": "alchemy", + "improvement_gate": "alchemy", + "indicator_decorations": [ + { + "decoration_id": "white_crystal_efflorescence", + "name": "White crystal efflorescence", + "description": "Chalky white crystal bloom on cave walls and desert margins. Alchemists recognize potassium nitrate." + } ] }, { @@ -996,7 +1127,6 @@ "forest", "jungle" ], - "revealed_by_tech": "engineering", "yields": { "food": 0, "production": 3, @@ -1019,7 +1149,11 @@ "flora", "strategic", "production" - ] + ], + "visibility": "tech_gated", + "yield_gate": "engineering", + "improvement_gate": "engineering", + "indicator_decorations": [] }, { "id": "hides", @@ -1031,7 +1165,6 @@ "tundra", "plains" ], - "revealed_by_tech": "trapping", "yields": { "food": 0, "production": 2, @@ -1056,19 +1189,22 @@ "fauna", "strategic", "military" - ] + ], + "visibility": "tech_gated", + "yield_gate": "trapping", + "improvement_gate": "trapping", + "indicator_decorations": [] }, { "id": "flint", "name": "Flint", "category": "strategic", - "description": "Flint-bearing chert beds in chalk plains. The first worked stone \u2014 essential for arrow tips.", + "description": "Flint-bearing chert beds in chalk plains. The first worked stone — essential for arrow tips.", "terrains": [ "plains", "hills", "desert" ], - "revealed_by_tech": null, "yields": { "food": 0, "production": 1, @@ -1093,7 +1229,11 @@ "tags": [ "mineral", "strategic" - ] + ], + "visibility": "always", + "yield_gate": null, + "improvement_gate": null, + "indicator_decorations": [] } ] } diff --git a/src/game/engine/scenes/hud/unit_panel.gd b/src/game/engine/scenes/hud/unit_panel.gd index 01611aaf..2af60b70 100644 --- a/src/game/engine/scenes/hud/unit_panel.gd +++ b/src/game/engine/scenes/hud/unit_panel.gd @@ -700,3 +700,39 @@ func _get_has_adjacent_land(unit: RefCounted) -> bool: if unit is UnitScript: return (unit as UnitScript).has_adjacent_land return bool(unit.get("has_adjacent_land") if "has_adjacent_land" in unit else false) + + +## p2-53g: fire arrows toggle state — read by legal_actions_for bridge. +func _get_is_fire_arrows(unit: RefCounted) -> bool: + if unit is UnitScript: + return bool((unit as UnitScript).get("is_fire_arrows") if "is_fire_arrows" in (unit as UnitScript) else false) + return bool(unit.get("is_fire_arrows") if "is_fire_arrows" in unit else false) + + +## p2-53f: shield wall toggle state. +func _get_is_shield_wall(unit: RefCounted) -> bool: + if unit is UnitScript: + return bool((unit as UnitScript).get("is_shield_wall") if "is_shield_wall" in (unit as UnitScript) else false) + return bool(unit.get("is_shield_wall") if "is_shield_wall" in unit else false) + + +## p2-53f: brace toggle state. +func _get_is_braced(unit: RefCounted) -> bool: + if unit is UnitScript: + return bool((unit as UnitScript).get("is_braced") if "is_braced" in (unit as UnitScript) else false) + return bool(unit.get("is_braced") if "is_braced" in unit else false) + + +## p2-53f: rage active (turns remaining > 0). +func _get_is_raging(unit: RefCounted) -> bool: + if unit is UnitScript: + var turns: int = int((unit as UnitScript).get("rage_turns_remaining") if "rage_turns_remaining" in (unit as UnitScript) else 0) + return turns > 0 + return int(unit.get("rage_turns_remaining") if "rage_turns_remaining" in unit else 0) > 0 + + +## p2-53f: war cry already used this battle. +func _get_war_cry_used(unit: RefCounted) -> bool: + if unit is UnitScript: + return bool((unit as UnitScript).get("war_cry_used_this_battle") if "war_cry_used_this_battle" in (unit as UnitScript) else false) + return bool(unit.get("war_cry_used_this_battle") if "war_cry_used_this_battle" in unit else false) diff --git a/src/game/engine/scenes/world_map/world_map.gd b/src/game/engine/scenes/world_map/world_map.gd index bc3a004d..9865ef75 100644 --- a/src/game/engine/scenes/world_map/world_map.gd +++ b/src/game/engine/scenes/world_map/world_map.gd @@ -1223,13 +1223,16 @@ func _on_archetype_action_pressed_from_panel(kind: String) -> void: "brace", "unbrace", \ "rage", "war_cry", \ "aimed_shot", \ - "fire_arrows", "unfire_arrows", \ - "wheel", "pursue", \ + "fire_arrows", "stop_fire_arrows", \ + "pursue", \ "fortify_hex", "clear_terrain", "repair_improvement", \ "field_aura", "stabilise", \ "stealth", "unstealth", "lookout", "ambush", \ "pack_march", "supply_aura", "light_beacon", "claim_territory": _invoke_unit_action_direct(kind) + # Edge-slot picker — deferred until bridge method signature confirmed by combat-archetypes. + "wheel": + push_warning("WorldMap: wheel edge-slot picker not yet wired — awaiting bridge API from combat-archetypes") # Target-pick — enter pick mode; validator supplied once bridge kind confirmed "shove", "cleave": enter_action_pick_mode(kind, _make_adjacent_enemy_validator()) diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs index 9ef18a72..460fb4b2 100644 --- a/src/simulator/crates/mc-ai/src/tactical/movement.rs +++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs @@ -177,6 +177,10 @@ fn non_motion_macro(unit: &TacticalUnit) -> Option { is_braced: false, rage_turns_remaining: 0, war_cry_used_this_battle: false, + multi_turn_in_progress: false, + is_stealthed: false, + is_field_aura: false, + is_ambushing: false, }; let available = legal_actions(&cap); let fortify_available = available diff --git a/src/simulator/crates/mc-combat/src/lib.rs b/src/simulator/crates/mc-combat/src/lib.rs index b79bcbe3..64fa72e4 100644 --- a/src/simulator/crates/mc-combat/src/lib.rs +++ b/src/simulator/crates/mc-combat/src/lib.rs @@ -5,6 +5,7 @@ pub mod promotions; pub mod requirements; pub mod resolver; pub mod siege; +pub mod status_effect; pub mod wilds; pub use loot::{ @@ -29,6 +30,7 @@ pub use siege::{ melee_wall_penalty, resolve_bombard, siege_city_bonus, split_ranged_damage_vs_city, BombardResult, BombardTarget, }; +pub use status_effect::StatusEffect; pub use wilds::wild_combat_stats; #[cfg(test)] diff --git a/src/simulator/crates/mc-combat/src/status_effect.rs b/src/simulator/crates/mc-combat/src/status_effect.rs new file mode 100644 index 00000000..d4e37497 --- /dev/null +++ b/src/simulator/crates/mc-combat/src/status_effect.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; + +/// A persistent negative condition applied to a unit. Cleared by Medic RemoveStatus. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StatusEffect { + /// Loses 10 HP/turn for 3 turns. Applied by the `poison` combat keyword. + Poison { turns_remaining: u8 }, + /// Loses 5 HP/turn for 3 turns. Applied by bleed-type attacks. + Bleed { turns_remaining: u8 }, + /// Loses 8 HP/turn for 2 turns. Applied by fire-type attacks. + Burn { turns_remaining: u8 }, +} + +impl StatusEffect { + /// Returns the HP damage per turn this effect deals. + pub fn damage_per_turn(self) -> i32 { + match self { + StatusEffect::Poison { .. } => 10, + StatusEffect::Bleed { .. } => 5, + StatusEffect::Burn { .. } => 8, + } + } + + /// Returns the remaining turns on this effect (0 = expired). + pub fn turns_remaining(self) -> u8 { + match self { + StatusEffect::Poison { turns_remaining } + | StatusEffect::Bleed { turns_remaining } + | StatusEffect::Burn { turns_remaining } => turns_remaining, + } + } + + /// Decrement the turn counter. Returns `None` when the effect expires. + pub fn tick(self) -> Option { + match self { + StatusEffect::Poison { turns_remaining } => { + turns_remaining.checked_sub(1).map(|t| StatusEffect::Poison { turns_remaining: t }) + } + StatusEffect::Bleed { turns_remaining } => { + turns_remaining.checked_sub(1).map(|t| StatusEffect::Bleed { turns_remaining: t }) + } + StatusEffect::Burn { turns_remaining } => { + turns_remaining.checked_sub(1).map(|t| StatusEffect::Burn { turns_remaining: t }) + } + } + } + + /// Vocabulary key for tooltip display. + pub fn vocab_key(self) -> &'static str { + match self { + StatusEffect::Poison { .. } => "status_effect_poison", + StatusEffect::Bleed { .. } => "status_effect_bleed", + StatusEffect::Burn { .. } => "status_effect_burn", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poison_ticks_down_and_expires() { + let e = StatusEffect::Poison { turns_remaining: 2 }; + let e1 = e.tick().expect("should tick"); + assert_eq!(e1.turns_remaining(), 1); + let e2 = e1.tick().expect("should tick"); + assert_eq!(e2.turns_remaining(), 0); + assert!(e2.tick().is_none(), "expires at 0"); + } + + #[test] + fn damage_per_turn_values() { + assert_eq!(StatusEffect::Poison { turns_remaining: 1 }.damage_per_turn(), 10); + assert_eq!(StatusEffect::Bleed { turns_remaining: 1 }.damage_per_turn(), 5); + assert_eq!(StatusEffect::Burn { turns_remaining: 1 }.damage_per_turn(), 8); + } +} diff --git a/src/simulator/crates/mc-core/src/action.rs b/src/simulator/crates/mc-core/src/action.rs index cf255b77..27692317 100644 --- a/src/simulator/crates/mc-core/src/action.rs +++ b/src/simulator/crates/mc-core/src/action.rs @@ -291,6 +291,22 @@ impl ActionKind { "rage" => Some(ActionKind::Rage), "cleave" => Some(ActionKind::Cleave), "war_cry" => Some(ActionKind::WarCry), + "build_bridge" => Some(ActionKind::BuildBridge), + "sap_wall" => Some(ActionKind::SapWall), + "breach_charge" => Some(ActionKind::BreachCharge), + "fortify_hex" => Some(ActionKind::FortifyHex), + "demolish" => Some(ActionKind::Demolish), + "clear_terrain" => Some(ActionKind::ClearTerrain), + "repair_improvement" => Some(ActionKind::RepairImprovement), + "triage" => Some(ActionKind::Triage), + "field_aura" => Some(ActionKind::FieldAura), + "stabilise" => Some(ActionKind::Stabilise), + "remove_status" => Some(ActionKind::RemoveStatus), + "stealth" => Some(ActionKind::Stealth), + "unstealth" => Some(ActionKind::Unstealth), + "lookout" => Some(ActionKind::Lookout), + "ambush" => Some(ActionKind::Ambush), + "mark_trail" => Some(ActionKind::MarkTrail), _ => None, } } @@ -339,6 +355,21 @@ pub enum DisabledReason { WarCryUsed, NoAdjacentTarget, ShoveBlocked, + // p2-53i support + NotEngineer, + NotMedic, + NotScout, + AlreadyStealthed, + NotStealthed, + AlreadyFieldAura, + NotFieldAura, + AlreadyAmbush, + MultiTurnInProgress, + NoAdjacentEnemy, + NoAdjacentFriendly, + NoAdjacentImprovement, + NoDamagedImprovement, + ImprovementAtFullHp, } impl DisabledReason { @@ -380,6 +411,20 @@ impl DisabledReason { DisabledReason::WarCryUsed => "disabled_reason_war_cry_used", DisabledReason::NoAdjacentTarget => "disabled_reason_no_adjacent_target", DisabledReason::ShoveBlocked => "disabled_reason_shove_blocked", + DisabledReason::NotEngineer => "disabled_reason_not_engineer", + DisabledReason::NotMedic => "disabled_reason_not_medic", + DisabledReason::NotScout => "disabled_reason_not_scout", + DisabledReason::AlreadyStealthed => "disabled_reason_already_stealthed", + DisabledReason::NotStealthed => "disabled_reason_not_stealthed", + DisabledReason::AlreadyFieldAura => "disabled_reason_already_field_aura", + DisabledReason::NotFieldAura => "disabled_reason_not_field_aura", + DisabledReason::AlreadyAmbush => "disabled_reason_already_ambush", + DisabledReason::MultiTurnInProgress => "disabled_reason_multi_turn_in_progress", + DisabledReason::NoAdjacentEnemy => "disabled_reason_no_adjacent_enemy", + DisabledReason::NoAdjacentFriendly => "disabled_reason_no_adjacent_friendly", + DisabledReason::NoAdjacentImprovement => "disabled_reason_no_adjacent_improvement", + DisabledReason::NoDamagedImprovement => "disabled_reason_no_damaged_improvement", + DisabledReason::ImprovementAtFullHp => "disabled_reason_improvement_at_full_hp", } } } @@ -453,6 +498,15 @@ pub struct UnitCapability { pub rage_turns_remaining: u8, /// True if WarCry has been used this battle. pub war_cry_used_this_battle: bool, + // p2-53i support posture state + /// True if a multi-turn action is currently in progress (Engineer build actions). + pub multi_turn_in_progress: bool, + /// True if the unit is in stealth posture (scout). + pub is_stealthed: bool, + /// True if field-aura mode is active (medic passive toggle). + pub is_field_aura: bool, + /// True if an ambush is set on this hex (scout). + pub is_ambushing: bool, } /// Compute the set of actions available to a unit. @@ -787,6 +841,151 @@ pub fn legal_actions(capability: &UnitCapability) -> Vec { }); } + // p2-53i: Engineer actions (gate: "engineer" keyword) + let is_engineer = capability.keywords.iter().any(|k| k == "engineer"); + if is_engineer { + let multi_turn = capability.multi_turn_in_progress; + + // Build Bridge — disabled if multi-turn in progress or no movement + out.push(if multi_turn { + ActionAvailability::disabled(ActionKind::BuildBridge, DisabledReason::MultiTurnInProgress) + } else if !has_movement { + ActionAvailability::disabled(ActionKind::BuildBridge, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::BuildBridge) + }); + + // Sap Wall — disabled if multi-turn in progress or no movement + out.push(if multi_turn { + ActionAvailability::disabled(ActionKind::SapWall, DisabledReason::MultiTurnInProgress) + } else if !has_movement { + ActionAvailability::disabled(ActionKind::SapWall, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::SapWall) + }); + + // Breach Charge — disabled if multi-turn in progress or no movement + out.push(if multi_turn { + ActionAvailability::disabled(ActionKind::BreachCharge, DisabledReason::MultiTurnInProgress) + } else if !has_movement { + ActionAvailability::disabled(ActionKind::BreachCharge, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::BreachCharge) + }); + + // Fortify Hex — disabled if multi-turn in progress or no movement + out.push(if multi_turn { + ActionAvailability::disabled(ActionKind::FortifyHex, DisabledReason::MultiTurnInProgress) + } else if !has_movement { + ActionAvailability::disabled(ActionKind::FortifyHex, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::FortifyHex) + }); + + // Demolish — needs movement + out.push(if !has_movement { + ActionAvailability::disabled(ActionKind::Demolish, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::Demolish) + }); + } + + // p2-53i: Pioneer additions (gate: "worker" keyword, these are extra beyond BuildImprovement) + let is_worker = capability.keywords.iter().any(|k| k == "worker"); + if is_worker { + // ClearTerrain — needs movement + out.push(if !has_movement { + ActionAvailability::disabled(ActionKind::ClearTerrain, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::ClearTerrain) + }); + + // RepairImprovement — needs movement + out.push(if !has_movement { + ActionAvailability::disabled(ActionKind::RepairImprovement, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::RepairImprovement) + }); + } + + // p2-53i: Medic actions (gate: "medic" keyword) + let is_medic = capability.keywords.iter().any(|k| k == "medic"); + if is_medic { + // Triage — needs movement (replaces attack action for that turn) + out.push(if !has_movement { + ActionAvailability::disabled(ActionKind::Triage, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::Triage) + }); + + // FieldAura — toggle on/off + if capability.is_field_aura { + out.push(ActionAvailability::disabled( + ActionKind::FieldAura, + DisabledReason::AlreadyFieldAura, + )); + } else { + out.push(ActionAvailability::enabled(ActionKind::FieldAura)); + } + + // Stabilise — always present for medics + out.push(ActionAvailability::enabled(ActionKind::Stabilise)); + + // RemoveStatus — needs movement + out.push(if !has_movement { + ActionAvailability::disabled(ActionKind::RemoveStatus, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::RemoveStatus) + }); + } + + // p2-53i: Scout actions (gate: "scout" keyword) + let is_scout = capability.keywords.iter().any(|k| k == "scout"); + if is_scout { + // Stealth / Unstealth toggle — broken on attack or Fortify posture + if capability.is_stealthed { + out.push(ActionAvailability::enabled(ActionKind::Unstealth)); + out.push(ActionAvailability::disabled( + ActionKind::Stealth, + DisabledReason::AlreadyStealthed, + )); + } else if is_fortified { + out.push(ActionAvailability::disabled( + ActionKind::Stealth, + DisabledReason::AlreadyFortified, + )); + } else { + out.push(if !has_movement { + ActionAvailability::disabled(ActionKind::Stealth, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::Stealth) + }); + } + + // Lookout — skip-turn action; needs movement + out.push(if !has_movement { + ActionAvailability::disabled(ActionKind::Lookout, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::Lookout) + }); + + // Ambush — set on tile; disabled if already ambushing + out.push(if capability.is_ambushing { + ActionAvailability::disabled(ActionKind::Ambush, DisabledReason::AlreadyAmbush) + } else if !has_movement { + ActionAvailability::disabled(ActionKind::Ambush, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::Ambush) + }); + + // MarkTrail — needs movement + out.push(if !has_movement { + ActionAvailability::disabled(ActionKind::MarkTrail, DisabledReason::NoMovement) + } else { + ActionAvailability::enabled(ActionKind::MarkTrail) + }); + } + out.sort_by_key(|a| a.kind.display_order()); out } @@ -814,6 +1013,10 @@ mod tests { is_braced: false, rage_turns_remaining: 0, war_cry_used_this_battle: false, + multi_turn_in_progress: false, + is_stealthed: false, + is_field_aura: false, + is_ambushing: false, } } @@ -836,6 +1039,10 @@ mod tests { is_braced: false, rage_turns_remaining: 0, war_cry_used_this_battle: false, + multi_turn_in_progress: false, + is_stealthed: false, + is_field_aura: false, + is_ambushing: false, } } @@ -896,6 +1103,10 @@ mod tests { is_braced: false, rage_turns_remaining: 0, war_cry_used_this_battle: false, + multi_turn_in_progress: false, + is_stealthed: false, + is_field_aura: false, + is_ambushing: false, }; let actions = legal_actions(&cap); let kinds: Vec = actions.iter().map(|a| a.kind).collect(); @@ -961,6 +1172,10 @@ mod tests { is_braced: false, rage_turns_remaining: 0, war_cry_used_this_battle: false, + multi_turn_in_progress: false, + is_stealthed: false, + is_field_aura: false, + is_ambushing: false, }; let actions = legal_actions(&cap); let unsentry = actions.iter().find(|a| a.kind == ActionKind::Unsentry) @@ -1008,6 +1223,10 @@ mod tests { is_braced: false, rage_turns_remaining: 0, war_cry_used_this_battle: false, + multi_turn_in_progress: false, + is_stealthed: false, + is_field_aura: false, + is_ambushing: false, } } @@ -1030,6 +1249,10 @@ mod tests { is_braced: false, rage_turns_remaining: 0, war_cry_used_this_battle: false, + multi_turn_in_progress: false, + is_stealthed: false, + is_field_aura: false, + is_ambushing: false, } } diff --git a/src/simulator/crates/mc-core/src/lib.rs b/src/simulator/crates/mc-core/src/lib.rs index f3fb9f8b..8ea13fc2 100644 --- a/src/simulator/crates/mc-core/src/lib.rs +++ b/src/simulator/crates/mc-core/src/lib.rs @@ -6,6 +6,7 @@ pub mod formation; pub mod gd_compat; pub mod grid; pub mod improvement; +pub mod multi_turn_action; pub mod perf; pub mod player; pub mod wonder; diff --git a/src/simulator/crates/mc-core/src/multi_turn_action.rs b/src/simulator/crates/mc-core/src/multi_turn_action.rs new file mode 100644 index 00000000..e3fe2e07 --- /dev/null +++ b/src/simulator/crates/mc-core/src/multi_turn_action.rs @@ -0,0 +1,109 @@ +use serde::{Deserialize, Serialize}; + +/// A multi-turn action currently in progress on a unit. Tracks the type and +/// remaining turns until completion. Stored on `MapUnit::current_action`. +/// +/// When `turns_remaining` reaches 0, the `TurnProcessor` fires the completion +/// effect and clears the field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MultiTurnAction { + /// Engineer: spanning a river edge (2 turns total). + BuildBridge { + /// Axial hex this engineer is working from. + hex: (i32, i32), + turns_remaining: u8, + }, + /// Engineer: tunnelling under a city wall (3 turns total). + SapWall { + /// Axial hex of the wall being sapped. + target_hex: (i32, i32), + turns_remaining: u8, + }, + /// Engineer: constructing field works (2 turns total). + FortifyHex { + /// Axial hex being fortified. + hex: (i32, i32), + turns_remaining: u8, + }, + /// Pioneer: clearing terrain (2 turns total — forest/marsh). + ClearTerrain { + hex: (i32, i32), + turns_remaining: u8, + }, + /// Pioneer: repairing a damaged improvement (2 turns total). + RepairImprovement { + hex: (i32, i32), + turns_remaining: u8, + }, +} + +impl MultiTurnAction { + /// Returns the turns remaining on this action. + pub fn turns_remaining(&self) -> u8 { + match self { + MultiTurnAction::BuildBridge { turns_remaining, .. } + | MultiTurnAction::SapWall { turns_remaining, .. } + | MultiTurnAction::FortifyHex { turns_remaining, .. } + | MultiTurnAction::ClearTerrain { turns_remaining, .. } + | MultiTurnAction::RepairImprovement { turns_remaining, .. } => *turns_remaining, + } + } + + /// Decrement the turn counter. Returns `None` when the action completes. + pub fn tick(self) -> Option { + match self { + MultiTurnAction::BuildBridge { hex, turns_remaining } => { + turns_remaining.checked_sub(1).map(|t| MultiTurnAction::BuildBridge { hex, turns_remaining: t }) + } + MultiTurnAction::SapWall { target_hex, turns_remaining } => { + turns_remaining.checked_sub(1).map(|t| MultiTurnAction::SapWall { target_hex, turns_remaining: t }) + } + MultiTurnAction::FortifyHex { hex, turns_remaining } => { + turns_remaining.checked_sub(1).map(|t| MultiTurnAction::FortifyHex { hex, turns_remaining: t }) + } + MultiTurnAction::ClearTerrain { hex, turns_remaining } => { + turns_remaining.checked_sub(1).map(|t| MultiTurnAction::ClearTerrain { hex, turns_remaining: t }) + } + MultiTurnAction::RepairImprovement { hex, turns_remaining } => { + turns_remaining.checked_sub(1).map(|t| MultiTurnAction::RepairImprovement { hex, turns_remaining: t }) + } + } + } + + /// Vocabulary key for UI progress display. + pub fn vocab_key(&self) -> &'static str { + match self { + MultiTurnAction::BuildBridge { .. } => "multi_turn_build_bridge", + MultiTurnAction::SapWall { .. } => "multi_turn_sap_wall", + MultiTurnAction::FortifyHex { .. } => "multi_turn_fortify_hex", + MultiTurnAction::ClearTerrain { .. } => "multi_turn_clear_terrain", + MultiTurnAction::RepairImprovement { .. } => "multi_turn_repair_improvement", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_bridge_ticks_to_completion() { + let a = MultiTurnAction::BuildBridge { hex: (3, 5), turns_remaining: 2 }; + let a1 = a.tick().expect("should tick"); + assert_eq!(a1.turns_remaining(), 1); + let a2 = a1.tick().expect("should tick"); + assert_eq!(a2.turns_remaining(), 0); + assert!(a2.tick().is_none(), "should complete at 0"); + } + + #[test] + fn sap_wall_3_turns() { + let a = MultiTurnAction::SapWall { target_hex: (1, 2), turns_remaining: 3 }; + let a1 = a.tick().unwrap(); + let a2 = a1.tick().unwrap(); + let a3 = a2.tick().unwrap(); + assert_eq!(a3.turns_remaining(), 0); + assert!(a3.tick().is_none()); + } +} diff --git a/src/simulator/crates/mc-turn/src/action_handlers.rs b/src/simulator/crates/mc-turn/src/action_handlers.rs index 4296010b..e1f4c1a2 100644 --- a/src/simulator/crates/mc-turn/src/action_handlers.rs +++ b/src/simulator/crates/mc-turn/src/action_handlers.rs @@ -79,6 +79,59 @@ 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, + }), + 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-53f infantry line actions + ActionKind::ShieldWall => handle_shield_wall(state, player_idx, unit_idx), + ActionKind::UnshieldWall => handle_unshield_wall(state, player_idx, unit_idx), + ActionKind::Brace => handle_brace(state, player_idx, unit_idx), + ActionKind::Unbrace => handle_unbrace(state, player_idx, unit_idx), + ActionKind::Shove | ActionKind::Cleave => Err(ActionError { + kind, + reason: DisabledReason::WrongTerrain, + }), + ActionKind::Rage => handle_rage(state, player_idx, unit_idx), + ActionKind::WarCry => handle_war_cry(state, player_idx, unit_idx), + // p2-53i engineer actions — multi-turn; require hex targets from bridge. + ActionKind::BuildBridge + | ActionKind::SapWall + | ActionKind::BreachCharge + | ActionKind::FortifyHex + | ActionKind::Demolish => Err(ActionError { + kind, + reason: DisabledReason::WrongTerrain, + }), + // p2-53i pioneer additions + ActionKind::ClearTerrain | ActionKind::RepairImprovement => Err(ActionError { + kind, + reason: DisabledReason::WrongTerrain, + }), + // p2-53i medic actions + ActionKind::Triage | ActionKind::Stabilise | ActionKind::RemoveStatus => Err(ActionError { + kind, + reason: DisabledReason::WrongTerrain, + }), + ActionKind::FieldAura => handle_field_aura(state, player_idx, unit_idx), + // p2-53i scout actions + ActionKind::Stealth => handle_stealth(state, player_idx, unit_idx), + ActionKind::Unstealth => handle_unstealth(state, player_idx, unit_idx), + ActionKind::Lookout => handle_lookout(state, player_idx, unit_idx), + ActionKind::Ambush => handle_ambush(state, player_idx, unit_idx), + ActionKind::MarkTrail => Err(ActionError { + kind, + reason: DisabledReason::WrongTerrain, + }), } } @@ -354,6 +407,200 @@ fn handle_disembark( Ok(()) } +// ── p2-53g stubs (MapUnit fields owned by ranged-archetypes agent) ────────── + +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 })?; + Ok(()) +} + +fn handle_stop_fire_arrows( + 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::StopFireArrows, reason: DisabledReason::WrongTerrain })?; + Ok(()) +} + +// ── p2-53f stubs (MapUnit fields owned by infantry-archetypes agent) ──────── + +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 })?; + Ok(()) +} + +fn handle_unshield_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::UnshieldWall, reason: DisabledReason::WrongTerrain })?; + Ok(()) +} + +fn handle_brace( + 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::Brace, reason: DisabledReason::WrongTerrain })?; + Ok(()) +} + +fn handle_unbrace( + 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::Unbrace, reason: DisabledReason::WrongTerrain })?; + Ok(()) +} + +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 })?; + Ok(()) +} + +fn handle_war_cry( + 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::WarCry, reason: DisabledReason::WrongTerrain })?; + Ok(()) +} + +// ── p2-53i: Scout action handlers ─────────────────────────────────────────── + +fn handle_stealth( + state: &mut GameState, + player_idx: usize, + unit_idx: usize, +) -> Result<(), ActionError> { + let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Stealth)?; + if unit.is_stealthed { + return Err(ActionError { + kind: ActionKind::Stealth, + reason: DisabledReason::AlreadyStealthed, + }); + } + if unit.is_fortified { + return Err(ActionError { + kind: ActionKind::Stealth, + reason: DisabledReason::AlreadyFortified, + }); + } + unit.is_stealthed = true; + // Clear ambush when entering stealth — incompatible postures. + unit.is_ambushing = false; + Ok(()) +} + +fn handle_unstealth( + state: &mut GameState, + player_idx: usize, + unit_idx: usize, +) -> Result<(), ActionError> { + let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Unstealth)?; + if !unit.is_stealthed { + return Err(ActionError { + kind: ActionKind::Unstealth, + reason: DisabledReason::NotStealthed, + }); + } + unit.is_stealthed = false; + Ok(()) +} + +fn handle_lookout( + state: &mut GameState, + player_idx: usize, + unit_idx: usize, +) -> Result<(), ActionError> { + // Lookout: skip-turn for +2 vision and reveal one full enemy stack. + // Vision expansion is applied by the GDScript bridge after this gate passes. + // State mutation here: clear any active stealth (concentrating on observation). + let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Lookout)?; + unit.is_stealthed = false; + Ok(()) +} + +fn handle_ambush( + state: &mut GameState, + player_idx: usize, + unit_idx: usize, +) -> Result<(), ActionError> { + let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Ambush)?; + if unit.is_ambushing { + return Err(ActionError { + kind: ActionKind::Ambush, + reason: DisabledReason::AlreadyAmbush, + }); + } + unit.is_ambushing = true; + // Ambush coexists with stealth but not fortify. + Ok(()) +} + +// ── p2-53i: Medic action handler ───────────────────────────────────────────── + +fn handle_field_aura( + state: &mut GameState, + player_idx: usize, + unit_idx: usize, +) -> Result<(), ActionError> { + let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::FieldAura)?; + // Toggle: activate if off, deactivate if on. + unit.is_field_aura = !unit.is_field_aura; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/simulator/crates/mc-turn/src/game_state.rs b/src/simulator/crates/mc-turn/src/game_state.rs index efd0346f..1f1bca2e 100644 --- a/src/simulator/crates/mc-turn/src/game_state.rs +++ b/src/simulator/crates/mc-turn/src/game_state.rs @@ -3,7 +3,9 @@ use mc_ai::evaluator::ScoringWeights; use mc_city::CityState; +use mc_combat::StatusEffect; use mc_core::building_action::BuildingActionKind; +use mc_core::multi_turn_action::MultiTurnAction; use mc_core::formation::{ AutoJoinRequest, Formation, FormationCommandRequest, FormationShapeRequest, RallyPointRequest, SplitFormationRequest, @@ -491,6 +493,29 @@ pub struct MapUnit { /// `apply_rally_arrival_actions` to detect arrival. #[serde(default, skip_serializing_if = "Option::is_none")] pub rally_destination: Option<(i32, i32)>, + /// Active status effects (poison/bleed/burn). Ticked in the health phase; + /// cleared by Medic RemoveStatus. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub status_effects: Vec, + /// Multi-turn action currently in progress (Engineer/Pioneer builds). + /// `None` when idle. Ticked in the production phase; completion fires the effect. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_action: Option, + /// True if the unit is in stealth posture (Scout). Invisible to enemies + /// further than 1 hex. Cleared on attack or entering Fortify posture. + #[serde(default)] + pub is_stealthed: bool, + /// True if the scout has set an ambush on its current hex. Cleared when + /// triggered or when the unit moves. + #[serde(default)] + pub is_ambushing: bool, + /// True if the medic's Field Aura is active — friendly co-hex units + /// regenerate +5 HP/turn. Toggle off with FieldAura action. + #[serde(default)] + pub is_field_aura: bool, + /// Remaining turns on a Mark Trail tag this unit has placed (0 = none). + #[serde(default)] + pub mark_trail_turns: u8, } fn default_auto_join() -> bool { diff --git a/tools/migrate-resources-visibility.py b/tools/migrate-resources-visibility.py new file mode 100644 index 00000000..36f0668d --- /dev/null +++ b/tools/migrate-resources-visibility.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Migrate public/resources/resources.json to the three-axis visibility model. + +Removes: revealed_by_tech +Adds: visibility, yield_gate, improvement_gate, indicator_decorations + +Run from any directory — resolves paths from the script location. +""" + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +RESOURCES_JSON = REPO_ROOT / "public" / "resources" / "resources.json" + +# ── Classification tables ──────────────────────────────────────────────────── + +# Luxury resources: (visibility, yield_gate) +LUXURY_MAP: dict[str, tuple[str, str | None]] = { + "furs": ("always", "trapping"), + "ivory": ("always", "trapping"), + "silk": ("always", "scholarship"), + "spices": ("always", "herbalism"), + "dyes": ("always", "culture"), + "amber": ("tech_gated", "scholarship"), + "salt": ("always", None), + "wine": ("always", "agriculture"), + "incense": ("always", "culture"), + "pearls": ("tech_gated", "fishing"), + "copper": ("always", "bronze_working"), + "marble": ("always", "masonry"), + "gems": ("tech_gated", "mining"), + "gold": ("tech_gated", "mining"), + "obsidian_glass": ("always", "metallurgy"), +} + +# Bonus resources: always visible, no yield gate +BONUS_IDS: set[str] = { + "deer", "bison", "fish", "crabs", "wheat", + "cattle", "sheep", "stone", "timber", +} + +# Strategic resources: tech_gated, yield_gate == old revealed_by_tech +# Exception: flint is always-visible strategic. +FLINT_ID = "flint" + +# Indicator decorations for tech_gated resources +INDICATOR_DECORATIONS: dict[str, list[dict]] = { + "amber": [ + { + "decoration_id": "fossilized_resin_fragments", + "name": "Fossilized resin fragments", + "description": "Amber-coloured fragments in riverbank sediment — fossilized tree resin that hardened over millennia. Scholars know its value.", + } + ], + "pearls": [ + { + "decoration_id": "oyster_bed_shells", + "name": "Oyster bed shells", + "description": "Clusters of freshwater mussel shells along riverbanks. Fishers know which beds hide pearls.", + } + ], + "gems": [ + { + "decoration_id": "quartz_outcrop_glint", + "name": "Quartz outcrop glint", + "description": "Sparkling mineral pockets in mountain cleft rock. Mining skills are needed to extract the gem-bearing strata below.", + } + ], + "gold": [ + { + "decoration_id": "river_gold_specks", + "name": "Placer gold specks", + "description": "Glittering specks in stream gravel. Experienced miners recognise the placer-deposit signature of an upstream gold vein.", + }, + { + "decoration_id": "gold_quartz_vein", + "name": "Gold-bearing quartz vein", + "description": "White quartz bands with metallic inclusions. Mining expertise required to extract and assay the ore.", + }, + ], + "iron": [ + { + "decoration_id": "rust_red_soil", + "name": "Iron-oxide stained soil", + "description": "Reddish-brown staining on exposed bedrock — the tell-tale sign of iron-bearing strata beneath.", + } + ], + "coal": [ + { + "decoration_id": "coal_seam_outcrop", + "name": "Coal seam outcrop", + "description": "Black streaks in cliff faces where a coal seam breaches the surface. Metallurgists know its fuel value.", + } + ], + "saltpeter": [ + { + "decoration_id": "white_crystal_efflorescence", + "name": "White crystal efflorescence", + "description": "Chalky white crystal bloom on cave walls and desert margins. Alchemists recognize potassium nitrate.", + } + ], + "hardwood": [], # Flora always visible at terrain level + "hides": [], # Fauna always visible at terrain level + "horses": [], # Fauna always visible at terrain level +} + + +def _make_indicator_decorations(resource_id: str, visibility: str) -> list[dict]: + if visibility != "tech_gated": + return [] + return INDICATOR_DECORATIONS.get(resource_id, []) + + +def migrate_entry(entry: dict) -> dict: + """Return a new dict with the three-axis fields added and revealed_by_tech removed.""" + entry = dict(entry) + resource_id: str = entry["id"] + category: str = entry.get("category", "") + + old_tech: str | None = entry.pop("revealed_by_tech", None) or None + if old_tech == "": + old_tech = None + + if category == "bonus": + visibility = "always" + yield_gate: str | None = None + improvement_gate: str | None = None + indicator_decorations: list[dict] = [] + + elif category == "luxury": + if resource_id not in LUXURY_MAP: + print( + f" WARNING: luxury '{resource_id}' not in LUXURY_MAP — " + f"using fallback (always, {old_tech})", + file=sys.stderr, + ) + visibility = "always" + yield_gate = old_tech + else: + visibility, yield_gate = LUXURY_MAP[resource_id] + improvement_gate = yield_gate + indicator_decorations = _make_indicator_decorations(resource_id, visibility) + + elif category == "strategic": + if resource_id == FLINT_ID: + visibility = "always" + yield_gate = None + improvement_gate = None + else: + visibility = "tech_gated" + yield_gate = old_tech + improvement_gate = old_tech + indicator_decorations = _make_indicator_decorations(resource_id, visibility) + + else: + # Unknown category — preserve old tech as yield_gate, default always + print( + f" WARNING: unknown category '{category}' for '{resource_id}' — " + "defaulting to always-visible", + file=sys.stderr, + ) + visibility = "always" + yield_gate = old_tech + improvement_gate = old_tech + indicator_decorations = [] + + entry["visibility"] = visibility + entry["yield_gate"] = yield_gate + entry["improvement_gate"] = improvement_gate + entry["indicator_decorations"] = indicator_decorations + return entry + + +def validate(data: dict) -> list[str]: + errors: list[str] = [] + for section in ("luxury", "bonus", "strategic"): + for entry in data.get(section, []): + rid = entry.get("id", "") + if "revealed_by_tech" in entry: + errors.append(f"{section}/{rid}: revealed_by_tech still present") + if "visibility" not in entry: + errors.append(f"{section}/{rid}: missing visibility") + if "yield_gate" not in entry: + errors.append(f"{section}/{rid}: missing yield_gate") + if "improvement_gate" not in entry: + errors.append(f"{section}/{rid}: missing improvement_gate") + if "indicator_decorations" not in entry: + errors.append(f"{section}/{rid}: missing indicator_decorations") + vis = entry.get("visibility", "") + if vis not in ("always", "scout", "tech_gated"): + errors.append(f"{section}/{rid}: invalid visibility '{vis}'") + # Audit all 15 luxuries present + luxury_ids = {e["id"] for e in data.get("luxury", [])} + for expected_id in LUXURY_MAP: + if expected_id not in luxury_ids: + errors.append(f"luxury/{expected_id}: missing from data — not migrated") + return errors + + +def main() -> None: + print(f"Reading {RESOURCES_JSON}") + raw = RESOURCES_JSON.read_text(encoding="utf-8") + data: dict = json.loads(raw) + + migrated: dict = {} + for key, value in data.items(): + if isinstance(value, list): + migrated[key] = [migrate_entry(e) if isinstance(e, dict) else e for e in value] + else: + migrated[key] = value + + errors = validate(migrated) + if errors: + print("VALIDATION ERRORS:", file=sys.stderr) + for err in errors: + print(f" {err}", file=sys.stderr) + sys.exit(1) + + output = json.dumps(migrated, indent=2, ensure_ascii=False) + RESOURCES_JSON.write_text(output + "\n", encoding="utf-8") + print(f"Wrote {RESOURCES_JSON}") + + # Summary audit + luxury_count = len(migrated.get("luxury", [])) + bonus_count = len(migrated.get("bonus", [])) + strategic_count = len(migrated.get("strategic", [])) + print(f"Migrated: {luxury_count} luxuries, {bonus_count} bonus, {strategic_count} strategic") + + # Spot-check three entries + for section, target_id in [("luxury", "amber"), ("bonus", "cattle"), ("strategic", "iron")]: + entry = next((e for e in migrated.get(section, []) if e.get("id") == target_id), None) + if entry: + print( + f" {section}/{target_id}: visibility={entry['visibility']!r}," + f" yield_gate={entry['yield_gate']!r}," + f" improvement_gate={entry['improvement_gate']!r}," + f" decorations={len(entry['indicator_decorations'])}" + ) + else: + print(f" {section}/{target_id}: NOT FOUND", file=sys.stderr) + + +if __name__ == "__main__": + main()