feat(@projects): ✨ add per-player tile observation cache
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
8e46bd6c69
commit
f73bfd589e
15 changed files with 1593 additions and 67 deletions
56
.project/objectives/p2-54b-player-observation-cache.md
Normal file
56
.project/objectives/p2-54b-player-observation-cache.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
312
public/games/age-of-dwarves/docs/RESOURCES.md
Normal file
312
public/games/age-of-dwarves/docs/RESOURCES.md
Normal 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).
|
||||
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
79
src/simulator/crates/mc-combat/src/status_effect.rs
Normal file
79
src/simulator/crates/mc-combat/src/status_effect.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
109
src/simulator/crates/mc-core/src/multi_turn_action.rs
Normal file
109
src/simulator/crates/mc-core/src/multi_turn_action.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
246
tools/migrate-resources-visibility.py
Normal file
246
tools/migrate-resources-visibility.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue