feat(@projects): add per-player tile observation cache

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-02 18:30:14 -04:00
parent 8e46bd6c69
commit f73bfd589e
15 changed files with 1593 additions and 67 deletions

View file

@ -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<String>,
pub biome_label_id_seen: Option<String>,
pub fauna_species_seen: Vec<String>,
pub indicator_decorations_seen: Vec<String>, // for tech-gated resources
pub observer_unit_id: Option<u32>,
}
```
- ◻ **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<TileObservationDto>`
- ◻ **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.

View file

@ -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.

View file

@ -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<String>,
pub biome_label_id_seen: Option<String>,
pub fauna_species_seen: Vec<String>,
pub observer_unit_id: Option<u32>,
}
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).

View file

@ -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 wonderadjacent 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 15. 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": []
}
]
}

View file

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

View file

@ -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())

View file

@ -177,6 +177,10 @@ fn non_motion_macro(unit: &TacticalUnit) -> Option<Action> {
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

View file

@ -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)]

View file

@ -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<Self> {
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);
}
}

View file

@ -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<ActionAvailability> {
});
}
// 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<ActionKind> = 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,
}
}

View file

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

View file

@ -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<Self> {
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());
}
}

View file

@ -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::*;

View file

@ -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<StatusEffect>,
/// 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<MultiTurnAction>,
/// 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 {

View file

@ -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", "<unknown>")
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()