fix(@projects/magic-civilization): 🐛 remove unused assets

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-02 18:41:43 -04:00
parent f8bf111483
commit 37094f0fdb
46 changed files with 749 additions and 69 deletions

View file

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View file

@ -16,9 +16,9 @@
|---|---|---|---|---|---|---|---|
| **P0** | 43 | 0 | 0 | 0 | 0 | 0 | 43 |
| **P1** | 43 | 1 | 7 | 0 | 14 | 1 | 66 |
| **P2** | 40 | 0 | 3 | 1 | 15 | 6 | 65 |
| **P2** | 40 | 1 | 3 | 1 | 16 | 6 | 67 |
| **P3 (oos)** | 3 | 0 | 0 | 0 | 1 | 19 | 23 |
| **total** | **129** | **1** | **10** | **1** | **30** | **26** | **197** |
| **total** | **129** | **2** | **10** | **1** | **31** | **26** | **199** |
</td><td valign='top' style='padding-left:2em'>
@ -30,7 +30,7 @@
| [warcouncil](../team-leads/warcouncil.md) | 6 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [combat-dev](../team-leads/combat-dev.md) | 5 |
| [terraformer](../team-leads/terraformer.md) | 3 |
| [terraformer](../team-leads/terraformer.md) | 5 |
| [simulator-infra](../team-leads/simulator-infra.md) | 1 |
| [asset-audio](../team-leads/asset-audio.md) | 1 |
| [testwright](../team-leads/testwright.md) | 1 |
@ -216,6 +216,8 @@
| [p2-53g](p2-53g-ranged-specifics.md) | ❌ missing | Ranged specifics — Volley, Aimed Shot, Fire Arrows | [combat-dev](../team-leads/combat-dev.md) | 2026-05-01 |
| [p2-53h](p2-53h-cavalry-specifics.md) | ❌ missing | Cavalry specifics — Charge, Pursue, Wheel | [combat-dev](../team-leads/combat-dev.md) | 2026-05-01 |
| [p2-53i](p2-53i-engineer-pioneer-medic-scout.md) | ❌ missing | Support specifics — Engineer, Pioneer, Medic, Scout | [shipwright](../team-leads/shipwright.md) | 2026-05-01 |
| [p2-54](p2-54-resource-visibility-three-axis.md) | 🔵 in_progress | Resource visibility — three-axis (visibility/yield_gate/improvement_gate) refactor | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-54a](p2-54a-deposits-three-axis-migration.md) | ❌ missing | Migrate deposits/*.json to three-axis visibility schema | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-54b](p2-54b-player-observation-cache.md) | ❌ missing | Per-player tile observation cache — flora/fauna last-observed state | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-54c](p2-54c-renderer-observations-and-indicators.md) | ❌ missing | Renderer reads observations + indicator decorations for tech-gated resources | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-54d](p2-54d-ai-tech-priority-from-visibility.md) | ❌ missing | AI tech-priority bias from visible-but-gated luxuries + indicator decorations | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |

View file

@ -0,0 +1,130 @@
---
id: p2-54
title: Resource visibility — three-axis (visibility/yield_gate/improvement_gate) refactor
priority: p2
status: in_progress
scope: game1
owner: terraformer
updated_at: 2026-05-01
canonical_doc: public/games/age-of-dwarves/docs/RESOURCES.md
coordinates_with:
- p1-05
- p1-39
- p2-52
---
## Summary
Replace the binary `revealed_by_tech` field on resources with three orthogonal axes
(`visibility` / `yield_gate` / `improvement_gate`) plus forward-compatible schema
extensions for environmental indicator decorations and a per-player observation cache.
Multi-cycle objective. This cycle (p2-54) delivers the schema + data + Rust struct.
Follow-on cycles handle rendering, AI, and the observation cache.
---
## Acceptance Criteria
- ✓ Canonical design doc authored at `public/games/age-of-dwarves/docs/RESOURCES.md`
with §1§8 covering the three-axis model, flora/fauna policy, per-resource
classification table (all 15 luxuries + 9 bonus + 7 strategic), migration notes,
AI consumption pattern, renderer pattern, observation model design, and indicator
decorations design.
- ✓ `public/resources/resources.json` migrated in-place:
- `revealed_by_tech` deleted from all 31 entries
- `visibility`, `yield_gate`, `improvement_gate`, `indicator_decorations` added
- All 15 luxuries mapped per the user-confirmed classification table
- All 9 bonus resources: `visibility: "always"`, `yield_gate: null`
- All 7 strategic resources: `visibility: "tech_gated"` (except flint: `"always"`)
- Migration script at `tools/migrate-resources-visibility.py` ran without errors
- Validator confirms zero `revealed_by_tech` remaining, all new fields present
- ✓ Rust struct `mc_core::resources::Resource` added:
- Fields: `id`, `category`, `visibility`, `yield_gate`, `improvement_gate`,
`indicator_decorations`
- `Visibility` enum with `Always` / `Scout` / `TechGated`
- Helper: `is_tile_visible_for_player(&Resource, &HashSet<String>, bool) -> bool`
- Helper: `is_yield_active(&Resource, &HashSet<String>) -> bool`
- Helper: `is_improvement_unlocked(&Resource, &HashSet<String>) -> bool`
- `ResourceBundle` struct with `luxury`, `bonus`, `strategic` arrays + `all()` iterator
- 13 unit tests covering all visibility branches, yield-gate logic, serde round-trip
- ✓ `cargo test -p mc-core`: 112 passed, 0 failed
- ✓ `cargo test -p mc-mapgen --test cross_build_determinism`: 4 passed, 0 failed
- ✓ `cargo check -p mc-core -p mc-economy -p mc-mapgen`: clean (0 errors)
- ✓ TypeScript `Resource` interface in `src/packages/guide/src/types/game-data.ts` updated:
- `visibility?`, `yield_gate?`, `improvement_gate?` fields added
- `revealed_by_tech` marked `@deprecated` with `| null` type widening
- `npx tsc --noEmit`: clean
- ✓ `ResourcesPage.tsx` updated:
- `getCategory()` switched from `revealed_by_tech` heuristic to `category` field
- Tech tag render reads `yield_gate ?? revealed_by_tech`
- ✓ `EconomyResourcesPage.tsx` updated:
- `DepositRecord` interface updated with three-axis fields
- Tech badge render reads `yield_gate ?? revealed_by_tech`
- ✓ GDScript consumers updated (4 sites):
- `tile.gd:342`: reads `yield_gate` first, falls back to `revealed_by_tech`
- `tile_info_panel.gd:109`: reads `yield_gate` first, falls back
- `turn_processor_helpers.gd:407`: reads `yield_gate` first, falls back
- ✓ e2e baseline: 55 passed / 6 skipped (unchanged)
- ◻ Migrate `public/resources/deposits/*.json` (30 files) to three-axis schema —
**blocking precondition** for GDScript and guide-web to read the new fields.
Until this is done, all consumer code falls back to `revealed_by_tech` (the
fallback paths are in place; nothing breaks, but the new field path is inert).
Tracked as **p2-54a**.
- ◻ Per-player tile observation cache (flora/fauna last-observed state) — `mc-save`
struct authoring + GDExtension bridge. Tracked as **p2-54b**.
- ◻ Indicator decorations rendered on tech-gated resource tiles — godot-renderer work.
Tracked as **p2-54c**.
- ◻ AI reads `indicator_decorations` as tech-priority hints + reads observations not
ground truth. Tracked as **p2-54d**.
---
## K/N Completion
K = 8 / N = 12 (4 follow-on items deferred: p2-54a deposits migration, p2-54b observation cache, p2-54c renderer, p2-54d AI)
---
## Handoff to Next Cycle
| Domain | Work | Objective |
|---|---|---|
| game-data | Migrate `deposits/*.json` (30 files) to three-axis schema — unblocks all downstream | p2-54a |
| godot-renderer | Dim `yield_gate`-gated icons; render `indicator_decorations` sprites | p2-54c |
| game-ai (mc-ai) | Tech-priority bias for visible-but-gated luxuries; read `indicator_decorations` | p2-54d |
| guide-web | Encyclopedia tooltips for new axes | p2-54c |
| simulator-infra | `mc-save::PlayerObservations` struct + GDExtension bridge | p2-54b |
**Important:** Until `p2-54a` (deposits migration) is done, GDScript and guide-web always
fall back to `revealed_by_tech`. The new field path in both systems is forward-compatible
but inert. The `resources.json` aggregate and `mc-core::resources` Rust struct are correct —
they are the schema source of truth and will become authoritative once deposits are migrated.
---
## Files Modified (this cycle)
- `public/games/age-of-dwarves/docs/RESOURCES.md` — new, canonical doc
- `public/resources/resources.json` — migrated in-place
- `tools/migrate-resources-visibility.py` — new, migration + validation script
- `src/simulator/crates/mc-core/src/resources.rs` — new, Rust struct + helpers
- `src/simulator/crates/mc-core/src/lib.rs` — added `pub mod resources`
- `src/packages/guide/src/types/game-data.ts` — Resource interface updated
- `public/games/age-of-dwarves/guide/src/pages/ResourcesPage.tsx` — updated
- `public/games/age-of-dwarves/guide/src/pages/EconomyResourcesPage.tsx` — updated
- `src/game/engine/src/map/tile.gd` — yield_gate-first read
- `src/game/engine/scenes/world_map/tile_info_panel.gd` — yield_gate-first read
- `src/game/engine/src/modules/management/turn_processor_helpers.gd` — yield_gate-first read

View file

@ -0,0 +1,72 @@
---
id: p2-54a
title: Migrate deposits/*.json to three-axis visibility schema
priority: p2
status: missing
scope: game1
owner: terraformer
updated_at: 2026-05-01
parent: p2-54
canonical_doc: public/games/age-of-dwarves/docs/RESOURCES.md
coordinates_with:
- p2-54
- p2-54c
blockedBy: [p2-54]
---
## Summary
`public/resources/resources.json` has been migrated to the three-axis schema
(`visibility` / `yield_gate` / `improvement_gate`). However, the 30 individual deposit
files in `public/resources/deposits/*.json` — which are the actual source read by
GDScript (via `DataLoader`) and the guide-web (via Vite `import.meta.glob`) — still use
`revealed_by_tech`.
Until this migration is done, all consumer code falls back to `revealed_by_tech` via
the dual-read fallback paths installed in p2-54. The new three-axis field path is
forward-compatible but inert.
This is the **blocking precondition** for p2-54b (observation cache), p2-54c (renderer
decorations), and p2-54d (AI tech-priority hints) to work correctly.
---
## Acceptance Criteria
- ◻ Extend `tools/migrate-resources-visibility.py` (or write a sibling script
`tools/migrate-deposits-visibility.py`) to process each file in
`public/resources/deposits/*.json`.
- ◻ For each deposit file, apply the same classification logic as `resources.json`:
- `category: "bonus"``visibility: "always"`, `yield_gate: null`
- `category: "luxury"` → look up in the luxury classification table from RESOURCES.md §3
- `category: "strategic"``visibility: "tech_gated"`, `yield_gate: <old revealed_by_tech>`
(exception: `flint``visibility: "always"`, `yield_gate: null`)
- Add `indicator_decorations` array per the decoration table in RESOURCES.md §8
- ◻ The 30 deposit files updated in-place; `revealed_by_tech` removed.
- ◻ Validator confirms zero `revealed_by_tech` remaining across all deposit files.
- ◻ `npx tsc --noEmit` clean after deposit migration (TS may warn if old field is
referenced in non-`?`-optional positions).
- ◻ Guide-web `ResourcesPage` and `EconomyResourcesPage` confirmed to render the
tech tags from `yield_gate` (manual smoke-test or playwright page-check).
- ◻ GDScript: `tile.gd`, `tile_info_panel.gd`, `turn_processor_helpers.gd` confirmed
to take the `yield_gate` branch (not the fallback) for at least one resource type.
(Unit test or trace log.)
---
## Notes
- The `deposit_categories.json` and `deposits.schema.json` files in
`public/resources/deposits/` are not resource entries — skip them in the migration.
- The deposits manifest at `public/games/age-of-dwarves/data/deposits/manifest.json`
controls which entries the guide renders — no changes needed there.
- Some deposit files (e.g. `wild_game`, `exotic_furs`, `enchanted_silk`) may not
correspond directly to `resources.json` IDs. These are game-2/guide-only entries;
apply best-effort classification using the category field and document edge cases
in RESOURCES.md §3 footnote.

View file

@ -1,13 +1,13 @@
{
"generated_at": "2026-05-02T22:30:37Z",
"generated_at": "2026-05-02T22:39:44Z",
"totals": {
"stub": 1,
"done": 129,
"missing": 30,
"oos": 26,
"in_progress": 1,
"missing": 31,
"done": 129,
"stub": 1,
"in_progress": 2,
"partial": 10,
"total": 197
"total": 199
},
"objectives": [
{
@ -1720,6 +1720,26 @@
"updated_at": "2026-05-01",
"summary": "Largest specifics child (15 actions across 4 archetypes). Most action effects already have system support somewhere in `mc-turn` / `mc-tech` / `mc-ecology` and just need the action surface."
},
{
"id": "p2-54",
"title": "Resource visibility — three-axis (visibility/yield_gate/improvement_gate) refactor",
"priority": "p2",
"status": "in_progress",
"scope": "game1",
"owner": "terraformer",
"updated_at": "2026-05-01",
"summary": "Replace the binary `revealed_by_tech` field on resources with three orthogonal axes\n(`visibility` / `yield_gate` / `improvement_gate`) plus forward-compatible schema\nextensions for environmental indicator decorations and a per-player observation cache.\n\nMulti-cycle objective. This cycle (p2-54) delivers the schema + data + Rust struct.\nFollow-on cycles handle rendering, AI, and the observation cache.\n\n---"
},
{
"id": "p2-54a",
"title": "Migrate deposits/*.json to three-axis visibility schema",
"priority": "p2",
"status": "missing",
"scope": "game1",
"owner": "terraformer",
"updated_at": "2026-05-01",
"summary": "`public/resources/resources.json` has been migrated to the three-axis schema\n(`visibility` / `yield_gate` / `improvement_gate`). However, the 30 individual deposit\nfiles in `public/resources/deposits/*.json` — which are the actual source read by\nGDScript (via `DataLoader`) and the guide-web (via Vite `import.meta.glob`) — still use\n`revealed_by_tech`.\n\nUntil this migration is done, all consumer code falls back to `revealed_by_tech` via\nthe dual-read fallback paths installed in p2-54. The new three-axis field path is\nforward-compatible but inert.\n\nThis is the **blocking precondition** for p2-54b (observation cache), p2-54c (renderer\ndecorations), and p2-54d (AI tech-priority hints) to work correctly.\n\n---"
},
{
"id": "p2-54b",
"title": "Per-player tile observation cache — flora/fauna last-observed state",

View file

@ -110,6 +110,22 @@ pub fn keyword_attack_bonus(keywords: &[Keyword], context: &KeywordContext) -> f
}
}
// p2-53h: Charge action bonus (+30%); applied only when attacker charged this turn
// and the defender is NOT braced (brace cancels the charge bonus).
if context.is_attacker && context.attacker_charging && !context.defender_is_braced {
bonus += charge_attack_bonus();
}
// p2-53f: Rage bonus (+40%) when attacker is raging.
if context.is_attacker && context.attacker_rage_turns > 0 {
bonus += rage_attack_bonus();
}
// p2-53f: WarCry debuff (-10%) when this unit was WarCried by an adjacent enemy.
if context.war_cry_debuff {
bonus += war_cry_attack_debuff();
}
bonus
}
@ -128,9 +144,19 @@ pub fn keyword_defense_bonus(keywords: &[Keyword], context: &KeywordContext) ->
}
}
// p2-53f: ShieldWall gives +50% defence vs ranged attacks.
if context.defender_shield_wall && context.attacker_is_ranged {
bonus += shield_wall_ranged_defence_bonus();
}
bonus
}
/// True when the attacker has aimed_shot_pending — defence reduction applied in resolver.
pub fn attacker_has_aimed_shot(context: &KeywordContext) -> bool {
context.attacker_aimed_shot
}
/// Returns true if the attacker takes no retaliation damage.
pub fn prevents_retaliation(attacker_keywords: &[Keyword], combat_is_ranged: bool) -> bool {
if combat_is_ranged {
@ -165,6 +191,60 @@ pub struct KeywordContext {
pub adjacent_allies: i32,
pub attacker_is_flying: bool,
pub attacker_is_ranged: bool,
// p2-53g/h/f archetype state
/// True when the attacker used Charge this turn (2+ hex straight-line move then attack).
pub attacker_charging: bool,
/// True when the defender is in Brace posture (cancels Charge bonus, first-strikes melee).
pub defender_is_braced: bool,
/// True when the defender is in ShieldWall posture (+50% def vs ranged).
pub defender_shield_wall: bool,
/// Rage turns remaining on the attacker (> 0 means +40% attack).
pub attacker_rage_turns: u8,
/// WarCry debuff on this unit: -10% attack if set by an adjacent enemy's WarCry this turn.
pub war_cry_debuff: bool,
/// True when the attacker has aimed_shot_pending (ignores 50% defence).
pub attacker_aimed_shot: bool,
}
// ── p2-53g/h/f archetype combat hooks ─────────────────────────────────────────
/// AimedShot: attacker ignored 50% of defender's terrain/fortification defence.
/// Called in `compute_predicted_damage` when `attacker_aimed_shot_pending` is true.
pub fn aimed_shot_defence_reduction() -> f32 {
0.50
}
/// Volley: per-target damage multiplier for each of the 3 hits (centre + 2 edge slots).
/// Lower per-hit damage compensates for hitting multiple targets.
pub const VOLLEY_DAMAGE_MULTIPLIER: f32 = 0.55;
/// Charge: +30% attack bonus for attacker when it charged 2+ hexes this turn.
/// Applied via `KeywordContext::attacker_charging`.
pub fn charge_attack_bonus() -> f32 {
0.30
}
/// Brace: cancels the Charge bonus if the defender is_braced,
/// AND grants first-strike on incoming melee.
/// Returns true when Charge bonus should be cancelled.
pub fn brace_cancels_charge(defender_is_braced: bool) -> bool {
defender_is_braced
}
/// ShieldWall: +50% defence vs ranged when attacker is ranged.
pub fn shield_wall_ranged_defence_bonus() -> f32 {
0.50
}
/// Rage: +40% attack bonus when attacker's `rage_turns_remaining > 0`.
pub fn rage_attack_bonus() -> f32 {
0.40
}
/// WarCry: -10% attack debuff applied to each affected enemy unit for 1 turn.
/// Stored as a negative `KeywordContext` modifier; the combat hook reads it.
pub fn war_cry_attack_debuff() -> f32 {
-0.10
}
/// Bitmask encoding of a `Vec<Keyword>` — one bit per variant (17 variants, fits in u32).

View file

@ -193,6 +193,19 @@ pub struct CombatParams {
pub city_has_garrison: bool,
/// Whether the attacker's unit type is "siege".
pub attacker_is_siege: bool,
// p2-53g/h/f archetype state
/// True when the attacker charged 2+ hexes this turn (Charge action).
pub attacker_charging: bool,
/// True when the defender is in Brace posture (cancels Charge bonus, grants first-strike).
pub defender_is_braced: bool,
/// True when the defender is in ShieldWall posture (+50% def vs ranged).
pub defender_shield_wall: bool,
/// Rage turns remaining on the attacker (> 0 = +40% attack).
pub attacker_rage_turns: u8,
/// True when the attacker has aimed_shot_pending (ignores 50% of defender's defence modifier).
pub attacker_aimed_shot: bool,
/// True when this unit (as attacker) is debuffed by an enemy WarCry (-10% attack).
pub attacker_war_cry_debuff: bool,
}
impl Default for CombatParams {
@ -225,6 +238,12 @@ impl Default for CombatParams {
city_wall_tier: 0,
city_has_garrison: false,
attacker_is_siege: false,
attacker_charging: false,
defender_is_braced: false,
defender_shield_wall: false,
attacker_rage_turns: 0,
attacker_aimed_shot: false,
attacker_war_cry_debuff: false,
}
}
}
@ -315,6 +334,12 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
adjacent_allies: params.attacker_bonuses.flanking_allies,
attacker_is_flying: params.attacker_keywords.contains(&Keyword::Flying),
attacker_is_ranged: is_ranged,
attacker_charging: params.attacker_charging,
defender_is_braced: params.defender_is_braced,
defender_shield_wall: params.defender_shield_wall,
attacker_rage_turns: params.attacker_rage_turns,
war_cry_debuff: params.attacker_war_cry_debuff,
attacker_aimed_shot: params.attacker_aimed_shot,
};
let def_kw_ctx = KeywordContext {
@ -325,6 +350,8 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
adjacent_allies: params.defender_bonuses.flanking_allies,
attacker_is_flying: params.attacker_keywords.contains(&Keyword::Flying),
attacker_is_ranged: is_ranged,
defender_shield_wall: params.defender_shield_wall,
..Default::default()
};
// Compute effective strengths
@ -354,7 +381,13 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
let def_mod = bonuses::total_defense_modifier(&params.defender_bonuses, ignore_terrain)
+ keywords::keyword_defense_bonus(&params.defender_keywords, &def_kw_ctx);
let defender_strength = (def_base * (1.0 + def_mod)).max(1.0);
// p2-53g AimedShot: attacker ignores 50% of the defender's defence modifier bonus.
let effective_def_mod = if keywords::attacker_has_aimed_shot(&atk_kw_ctx) {
def_mod * (1.0 - keywords::aimed_shot_defence_reduction())
} else {
def_mod
};
let defender_strength = (def_base * (1.0 + effective_def_mod)).max(1.0);
// HP factor: damaged units deal less damage
let atk_hp_factor = params.attacker.hp as f32 / params.attacker.max_hp.max(1) as f32;
@ -544,6 +577,7 @@ impl CombatResolver {
city_wall_tier: 0,
city_has_garrison: false,
attacker_is_siege: false,
..Default::default()
};
Self::predict_expected_damage_params(&params)
}

View file

@ -1330,4 +1330,238 @@ mod tests {
assert!(!disembark.enabled);
assert_eq!(disembark.disabled_reason, Some(DisabledReason::NoAdjacentLand));
}
}
// p2-53g: ranged-specific action tests
fn ranged_cap_base(has_movement: bool) -> UnitCapability {
UnitCapability {
unit_type: "ranged".into(),
keywords: vec!["ranged".into()],
has_movement,
is_fortified: false,
is_patrolling: false,
is_sentrying: false,
is_deployed: false,
is_embarked: false,
adjacent_water: false,
adjacent_land: false,
is_aiming: false,
is_fire_arrows: false,
is_pursuing: false,
is_shield_wall: false,
is_braced: false,
rage_turns_remaining: 0,
war_cry_used_this_battle: false,
}
}
#[test]
fn ranged_unit_has_volley_aimed_shot_fire_arrows() {
let actions = legal_actions(&ranged_cap_base(true));
let kinds: Vec<ActionKind> = actions.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&ActionKind::Volley));
assert!(kinds.contains(&ActionKind::AimedShot));
assert!(kinds.contains(&ActionKind::FireArrows));
assert!(!kinds.contains(&ActionKind::StopFireArrows), "StopFireArrows absent when not in fire-arrow posture");
}
#[test]
fn ranged_aimed_shot_disabled_when_already_aiming() {
let cap = UnitCapability { is_aiming: true, ..ranged_cap_base(true) };
let actions = legal_actions(&cap);
let aimed = actions.iter().find(|a| a.kind == ActionKind::AimedShot).unwrap();
assert!(!aimed.enabled);
assert_eq!(aimed.disabled_reason, Some(DisabledReason::AlreadyAiming));
}
#[test]
fn ranged_fire_arrows_toggles() {
let cap = UnitCapability { is_fire_arrows: true, ..ranged_cap_base(true) };
let actions = legal_actions(&cap);
let kinds: Vec<ActionKind> = actions.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&ActionKind::StopFireArrows), "StopFireArrows must appear when in fire-arrows posture");
let fire = actions.iter().find(|a| a.kind == ActionKind::FireArrows).unwrap();
assert!(!fire.enabled);
assert_eq!(fire.disabled_reason, Some(DisabledReason::AlreadyFireArrows));
}
#[test]
fn non_ranged_unit_has_no_volley_aimed_shot_fire_arrows() {
let actions = legal_actions(&military_cap(true, false, vec![]));
let kinds: Vec<ActionKind> = actions.iter().map(|a| a.kind).collect();
assert!(!kinds.contains(&ActionKind::Volley));
assert!(!kinds.contains(&ActionKind::AimedShot));
assert!(!kinds.contains(&ActionKind::FireArrows));
}
// p2-53h: cavalry-specific action tests
fn cavalry_cap(has_movement: bool, is_pursuing: bool) -> UnitCapability {
UnitCapability {
unit_type: "melee".into(),
keywords: vec!["cavalry".into()],
has_movement,
is_fortified: false,
is_patrolling: false,
is_sentrying: false,
is_deployed: false,
is_embarked: false,
adjacent_water: false,
adjacent_land: false,
is_aiming: false,
is_fire_arrows: false,
is_pursuing,
is_shield_wall: false,
is_braced: false,
rage_turns_remaining: 0,
war_cry_used_this_battle: false,
}
}
#[test]
fn cavalry_unit_has_charge_pursue_wheel() {
let actions = legal_actions(&cavalry_cap(true, false));
let kinds: Vec<ActionKind> = actions.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&ActionKind::Charge));
assert!(kinds.contains(&ActionKind::Pursue));
assert!(kinds.contains(&ActionKind::Wheel));
}
#[test]
fn cavalry_charge_disabled_no_movement() {
let actions = legal_actions(&cavalry_cap(false, false));
let charge = actions.iter().find(|a| a.kind == ActionKind::Charge).unwrap();
assert!(!charge.enabled);
assert_eq!(charge.disabled_reason, Some(DisabledReason::NoMovement));
}
#[test]
fn cavalry_pursue_disabled_when_already_pursuing() {
let actions = legal_actions(&cavalry_cap(true, true));
let pursue = actions.iter().find(|a| a.kind == ActionKind::Pursue).unwrap();
assert!(!pursue.enabled);
assert_eq!(pursue.disabled_reason, Some(DisabledReason::AlreadyPursuing));
}
#[test]
fn non_cavalry_has_no_charge_pursue_wheel() {
let actions = legal_actions(&military_cap(true, false, vec![]));
let kinds: Vec<ActionKind> = actions.iter().map(|a| a.kind).collect();
assert!(!kinds.contains(&ActionKind::Charge));
assert!(!kinds.contains(&ActionKind::Pursue));
assert!(!kinds.contains(&ActionKind::Wheel));
}
// p2-53f: infantry line / shock action tests
fn infantry_line_cap(has_movement: bool, is_shield_wall: bool, is_braced: bool) -> UnitCapability {
UnitCapability {
unit_type: "melee".into(),
keywords: vec!["infantry_line".into()],
has_movement,
is_fortified: false,
is_patrolling: false,
is_sentrying: false,
is_deployed: false,
is_embarked: false,
adjacent_water: false,
adjacent_land: false,
is_aiming: false,
is_fire_arrows: false,
is_pursuing: false,
is_shield_wall,
is_braced,
rage_turns_remaining: 0,
war_cry_used_this_battle: false,
}
}
fn infantry_shock_cap(has_movement: bool, rage_turns: u8, war_cry_used: bool) -> UnitCapability {
UnitCapability {
unit_type: "melee".into(),
keywords: vec!["infantry_shock".into()],
has_movement,
is_fortified: false,
is_patrolling: false,
is_sentrying: false,
is_deployed: false,
is_embarked: false,
adjacent_water: false,
adjacent_land: false,
is_aiming: false,
is_fire_arrows: false,
is_pursuing: false,
is_shield_wall: false,
is_braced: false,
rage_turns_remaining: rage_turns,
war_cry_used_this_battle: war_cry_used,
}
}
#[test]
fn infantry_line_has_shield_wall_brace_shove() {
let actions = legal_actions(&infantry_line_cap(true, false, false));
let kinds: Vec<ActionKind> = actions.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&ActionKind::ShieldWall));
assert!(kinds.contains(&ActionKind::Brace));
assert!(kinds.contains(&ActionKind::Shove));
}
#[test]
fn infantry_line_shield_wall_toggles() {
let actions = legal_actions(&infantry_line_cap(true, true, false));
let kinds: Vec<ActionKind> = actions.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&ActionKind::UnshieldWall), "UnshieldWall must appear when in shield wall");
let sw = actions.iter().find(|a| a.kind == ActionKind::ShieldWall).unwrap();
assert!(!sw.enabled);
assert_eq!(sw.disabled_reason, Some(DisabledReason::AlreadyShieldWall));
}
#[test]
fn infantry_line_brace_toggles() {
let actions = legal_actions(&infantry_line_cap(true, false, true));
let kinds: Vec<ActionKind> = actions.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&ActionKind::Unbrace));
let brace = actions.iter().find(|a| a.kind == ActionKind::Brace).unwrap();
assert!(!brace.enabled);
assert_eq!(brace.disabled_reason, Some(DisabledReason::AlreadyBraced));
}
#[test]
fn infantry_shock_has_rage_cleave_war_cry() {
let actions = legal_actions(&infantry_shock_cap(true, 0, false));
let kinds: Vec<ActionKind> = actions.iter().map(|a| a.kind).collect();
assert!(kinds.contains(&ActionKind::Rage));
assert!(kinds.contains(&ActionKind::Cleave));
assert!(kinds.contains(&ActionKind::WarCry));
}
#[test]
fn infantry_shock_rage_disabled_when_raging() {
let actions = legal_actions(&infantry_shock_cap(true, 2, false));
let rage = actions.iter().find(|a| a.kind == ActionKind::Rage).unwrap();
assert!(!rage.enabled);
assert_eq!(rage.disabled_reason, Some(DisabledReason::AlreadyRaging));
}
#[test]
fn infantry_shock_war_cry_disabled_after_use() {
let actions = legal_actions(&infantry_shock_cap(true, 0, true));
let wc = actions.iter().find(|a| a.kind == ActionKind::WarCry).unwrap();
assert!(!wc.enabled);
assert_eq!(wc.disabled_reason, Some(DisabledReason::WarCryUsed));
}
#[test]
fn all_new_action_kinds_round_trip_as_str() {
let all = [
ActionKind::Volley, ActionKind::AimedShot, ActionKind::FireArrows, ActionKind::StopFireArrows,
ActionKind::Charge, ActionKind::Pursue, ActionKind::Wheel,
ActionKind::ShieldWall, ActionKind::UnshieldWall, ActionKind::Brace, ActionKind::Unbrace,
ActionKind::Shove, ActionKind::Rage, ActionKind::Cleave, ActionKind::WarCry,
];
for kind in all {
assert_eq!(ActionKind::from_str(kind.as_str()), Some(kind), "round-trip failed for {:?}", kind);
}
}
}

View file

@ -56,10 +56,10 @@ pub struct IndicatorDecoration {
/// Static data for a single resource entry from `resources.json`.
///
/// Extra JSON fields (description, yields, quality_range, sprite, tags, etc.)
/// are preserved by the `#[serde(flatten)]` extra-fields map when
/// deserializing — callers that need them can access via the raw JSON path.
/// Only the fields consumed by simulation logic are declared here.
/// Only the fields consumed by simulation logic are declared here. Extra JSON
/// fields (description, yields, quality_range, sprite, tags, etc.) are
/// silently ignored by serde's default skip-unknown behaviour. Callers that
/// need extra fields should deserialise from the raw JSON path directly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resource {
pub id: String,

View file

@ -79,19 +79,16 @@ pub fn invoke(
// Sentry/Unsentry: set/clear the sentry posture flag on the unit.
ActionKind::Sentry => handle_sentry(state, player_idx, unit_idx),
ActionKind::Unsentry => handle_unsentry(state, player_idx, unit_idx),
// p2-53g ranged actions — require target coords or are handled by
// archetype-specific subsystems. Gate validation only here.
ActionKind::Volley | ActionKind::AimedShot => Err(ActionError {
kind,
reason: DisabledReason::WrongTerrain,
}),
// p2-53g ranged actions
// Volley requires a target hex — routed through the bridge's target-pick path.
ActionKind::Volley => Err(ActionError { kind, reason: DisabledReason::WrongTerrain }),
ActionKind::AimedShot => handle_aimed_shot(state, player_idx, unit_idx),
ActionKind::FireArrows => handle_fire_arrows(state, player_idx, unit_idx),
ActionKind::StopFireArrows => handle_stop_fire_arrows(state, player_idx, unit_idx),
// p2-53h cavalry actions — require target coords or are handled by subsystems.
ActionKind::Charge | ActionKind::Pursue | ActionKind::Wheel => Err(ActionError {
kind,
reason: DisabledReason::WrongTerrain,
}),
// p2-53h cavalry actions
// Charge and Wheel require target coords — routed through bridge.
ActionKind::Charge | ActionKind::Wheel => Err(ActionError { kind, reason: DisabledReason::WrongTerrain }),
ActionKind::Pursue => handle_pursue(state, player_idx, unit_idx),
// p2-53f infantry line actions
ActionKind::ShieldWall => handle_shield_wall(state, player_idx, unit_idx),
ActionKind::UnshieldWall => handle_unshield_wall(state, player_idx, unit_idx),
@ -407,19 +404,37 @@ fn handle_disembark(
Ok(())
}
// ── p2-53g stubs (MapUnit fields owned by ranged-archetypes agent) ──────────
// ── p2-53g ranged action handlers ────────────────────────────────────────────
fn handle_aimed_shot(
state: &mut GameState,
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::AimedShot)?;
if unit.aimed_shot_pending {
return Err(ActionError {
kind: ActionKind::AimedShot,
reason: DisabledReason::AlreadyAiming,
});
}
unit.aimed_shot_pending = true;
Ok(())
}
fn handle_fire_arrows(
state: &mut GameState,
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
// Pre-condition gate only; state mutation deferred to p2-53g handler.
state
.players
.get(player_idx)
.and_then(|p| p.units.get(unit_idx))
.ok_or(ActionError { kind: ActionKind::FireArrows, reason: DisabledReason::WrongTerrain })?;
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::FireArrows)?;
if unit.is_fire_arrows {
return Err(ActionError {
kind: ActionKind::FireArrows,
reason: DisabledReason::AlreadyFireArrows,
});
}
unit.is_fire_arrows = true;
Ok(())
}
@ -428,26 +443,50 @@ fn handle_stop_fire_arrows(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
state
.players
.get(player_idx)
.and_then(|p| p.units.get(unit_idx))
.ok_or(ActionError { kind: ActionKind::StopFireArrows, reason: DisabledReason::WrongTerrain })?;
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::StopFireArrows)?;
if !unit.is_fire_arrows {
return Err(ActionError {
kind: ActionKind::StopFireArrows,
reason: DisabledReason::NotFireArrows,
});
}
unit.is_fire_arrows = false;
Ok(())
}
// ── p2-53f stubs (MapUnit fields owned by infantry-archetypes agent) ────────
// ── p2-53h cavalry action handlers ────────────────────────────────────────────
fn handle_pursue(
state: &mut GameState,
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Pursue)?;
if unit.is_pursuing {
return Err(ActionError {
kind: ActionKind::Pursue,
reason: DisabledReason::AlreadyPursuing,
});
}
unit.is_pursuing = true;
Ok(())
}
// ── p2-53f infantry line action handlers ─────────────────────────────────────
fn handle_shield_wall(
state: &mut GameState,
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
state
.players
.get(player_idx)
.and_then(|p| p.units.get(unit_idx))
.ok_or(ActionError { kind: ActionKind::ShieldWall, reason: DisabledReason::WrongTerrain })?;
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::ShieldWall)?;
if unit.is_shield_wall {
return Err(ActionError {
kind: ActionKind::ShieldWall,
reason: DisabledReason::AlreadyShieldWall,
});
}
unit.is_shield_wall = true;
Ok(())
}
@ -456,11 +495,14 @@ fn handle_unshield_wall(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
state
.players
.get(player_idx)
.and_then(|p| p.units.get(unit_idx))
.ok_or(ActionError { kind: ActionKind::UnshieldWall, reason: DisabledReason::WrongTerrain })?;
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::UnshieldWall)?;
if !unit.is_shield_wall {
return Err(ActionError {
kind: ActionKind::UnshieldWall,
reason: DisabledReason::NotShieldWall,
});
}
unit.is_shield_wall = false;
Ok(())
}
@ -469,11 +511,14 @@ fn handle_brace(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
state
.players
.get(player_idx)
.and_then(|p| p.units.get(unit_idx))
.ok_or(ActionError { kind: ActionKind::Brace, reason: DisabledReason::WrongTerrain })?;
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Brace)?;
if unit.is_braced {
return Err(ActionError {
kind: ActionKind::Brace,
reason: DisabledReason::AlreadyBraced,
});
}
unit.is_braced = true;
Ok(())
}
@ -482,24 +527,35 @@ fn handle_unbrace(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
state
.players
.get(player_idx)
.and_then(|p| p.units.get(unit_idx))
.ok_or(ActionError { kind: ActionKind::Unbrace, reason: DisabledReason::WrongTerrain })?;
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Unbrace)?;
if !unit.is_braced {
return Err(ActionError {
kind: ActionKind::Unbrace,
reason: DisabledReason::NotBraced,
});
}
unit.is_braced = false;
Ok(())
}
// ── p2-53f infantry shock action handlers ────────────────────────────────────
fn handle_rage(
state: &mut GameState,
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
state
.players
.get(player_idx)
.and_then(|p| p.units.get(unit_idx))
.ok_or(ActionError { kind: ActionKind::Rage, reason: DisabledReason::WrongTerrain })?;
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::Rage)?;
if unit.rage_turns_remaining > 0 {
return Err(ActionError {
kind: ActionKind::Rage,
reason: DisabledReason::AlreadyRaging,
});
}
// +40% attack for 2 turns; Fortify and Sentry are incompatible (cleared here).
unit.rage_turns_remaining = 2;
unit.is_fortified = false;
unit.is_sentrying = false;
Ok(())
}
@ -508,11 +564,14 @@ fn handle_war_cry(
player_idx: usize,
unit_idx: usize,
) -> Result<(), ActionError> {
state
.players
.get(player_idx)
.and_then(|p| p.units.get(unit_idx))
.ok_or(ActionError { kind: ActionKind::WarCry, reason: DisabledReason::WrongTerrain })?;
let unit = get_unit_mut(state, player_idx, unit_idx, ActionKind::WarCry)?;
if unit.war_cry_used_this_battle {
return Err(ActionError {
kind: ActionKind::WarCry,
reason: DisabledReason::WarCryUsed,
});
}
unit.war_cry_used_this_battle = true;
Ok(())
}

View file

@ -516,6 +516,33 @@ pub struct MapUnit {
/// Remaining turns on a Mark Trail tag this unit has placed (0 = none).
#[serde(default)]
pub mark_trail_turns: u8,
// p2-53g: ranged posture
/// True when the unit has taken the AimedShot action; cleared on next attack.
#[serde(default)]
pub aimed_shot_pending: bool,
/// True when the unit is in fire-arrow posture (ranged attacks ignite tile).
#[serde(default)]
pub is_fire_arrows: bool,
// p2-53h: cavalry posture
/// True when the unit is in pursue posture (advance-on-rout follow-through active).
#[serde(default)]
pub is_pursuing: bool,
/// Hex of the pending charge target, set during Charge action. Consumed by combat resolver.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_charge_target: Option<(i32, i32)>,
// p2-53f: infantry posture
/// True when the unit is in shield-wall posture (+50% defence vs ranged).
#[serde(default)]
pub is_shield_wall: bool,
/// True when the unit is braced against a charge (first-strike on incoming melee).
#[serde(default)]
pub is_braced: bool,
/// Remaining turns of Rage (+40% attack). 0 = not raging.
#[serde(default)]
pub rage_turns_remaining: u8,
/// True when WarCry has been used this battle (once-per-battle gate).
#[serde(default)]
pub war_cry_used_this_battle: bool,
}
fn default_auto_join() -> bool {

View file

@ -305,6 +305,22 @@ impl TurnProcessor {
crate::patrol::advance_on_turn(&mut player.units);
}
// p2-53g/h/f: cross-turn archetype state ticks.
// Rage countdown, AimedShot pending clear (if not consumed by an attack this
// turn — the attack handler clears it on use; this ensures it cannot persist
// more than one turn even if the unit took no action).
// WarCry is cleared once per battle; battlefield reset happens on peace/end-of-combat.
for player in state.players.iter_mut() {
for unit in player.units.iter_mut() {
if unit.rage_turns_remaining > 0 {
unit.rage_turns_remaining -= 1;
}
// aimed_shot_pending is consumed by the attack handler; clear defensively
// here so it cannot persist across a turn where the unit didn't attack.
unit.aimed_shot_pending = false;
}
}
// Phase 4f: drain formation requests from GDScript (rally, command, shape, split, auto-join).
Self::drain_formation_requests(state);
crate::building_action_handlers::drain_pending_building_actions(state);
@ -1573,6 +1589,7 @@ impl TurnProcessor {
city_wall_tier: 0,
city_has_garrison: false,
attacker_is_siege: false,
..Default::default()
}
};
@ -1731,6 +1748,7 @@ impl TurnProcessor {
city_wall_tier: 0,
city_has_garrison: false,
attacker_is_siege: false,
..Default::default()
};
let combat_result = CombatResolver::resolve(&params);