fix(@projects/magic-civilization): 🐛 remove unused assets
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 820 KiB |
|
Before Width: | Height: | Size: 876 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 859 KiB |
|
Before Width: | Height: | Size: 746 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 859 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 859 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 859 KiB |
|
Before Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 136 KiB |
|
|
@ -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 |
|
||||
|
|
|
|||
130
.project/objectives/p2-54-resource-visibility-three-axis.md
Normal 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
|
||||
72
.project/objectives/p2-54a-deposits-three-axis-migration.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -193,6 +193,19 @@ pub struct CombatParams {
|
|||
pub city_has_garrison: bool,
|
||||
/// Whether the attacker's unit type is "siege".
|
||||
pub attacker_is_siege: bool,
|
||||
// p2-53g/h/f archetype state
|
||||
/// True when the attacker charged 2+ hexes this turn (Charge action).
|
||||
pub attacker_charging: bool,
|
||||
/// True when the defender is in Brace posture (cancels Charge bonus, grants first-strike).
|
||||
pub defender_is_braced: bool,
|
||||
/// True when the defender is in ShieldWall posture (+50% def vs ranged).
|
||||
pub defender_shield_wall: bool,
|
||||
/// Rage turns remaining on the attacker (> 0 = +40% attack).
|
||||
pub attacker_rage_turns: u8,
|
||||
/// True when the attacker has aimed_shot_pending (ignores 50% of defender's defence modifier).
|
||||
pub attacker_aimed_shot: bool,
|
||||
/// True when this unit (as attacker) is debuffed by an enemy WarCry (-10% attack).
|
||||
pub attacker_war_cry_debuff: bool,
|
||||
}
|
||||
|
||||
impl Default for CombatParams {
|
||||
|
|
@ -225,6 +238,12 @@ impl Default for CombatParams {
|
|||
city_wall_tier: 0,
|
||||
city_has_garrison: false,
|
||||
attacker_is_siege: false,
|
||||
attacker_charging: false,
|
||||
defender_is_braced: false,
|
||||
defender_shield_wall: false,
|
||||
attacker_rage_turns: 0,
|
||||
attacker_aimed_shot: false,
|
||||
attacker_war_cry_debuff: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -315,6 +334,12 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
|
|||
adjacent_allies: params.attacker_bonuses.flanking_allies,
|
||||
attacker_is_flying: params.attacker_keywords.contains(&Keyword::Flying),
|
||||
attacker_is_ranged: is_ranged,
|
||||
attacker_charging: params.attacker_charging,
|
||||
defender_is_braced: params.defender_is_braced,
|
||||
defender_shield_wall: params.defender_shield_wall,
|
||||
attacker_rage_turns: params.attacker_rage_turns,
|
||||
war_cry_debuff: params.attacker_war_cry_debuff,
|
||||
attacker_aimed_shot: params.attacker_aimed_shot,
|
||||
};
|
||||
|
||||
let def_kw_ctx = KeywordContext {
|
||||
|
|
@ -325,6 +350,8 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
|
|||
adjacent_allies: params.defender_bonuses.flanking_allies,
|
||||
attacker_is_flying: params.attacker_keywords.contains(&Keyword::Flying),
|
||||
attacker_is_ranged: is_ranged,
|
||||
defender_shield_wall: params.defender_shield_wall,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Compute effective strengths
|
||||
|
|
@ -354,7 +381,13 @@ fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
|
|||
let def_mod = bonuses::total_defense_modifier(¶ms.defender_bonuses, ignore_terrain)
|
||||
+ keywords::keyword_defense_bonus(¶ms.defender_keywords, &def_kw_ctx);
|
||||
|
||||
let defender_strength = (def_base * (1.0 + def_mod)).max(1.0);
|
||||
// p2-53g AimedShot: attacker ignores 50% of the defender's defence modifier bonus.
|
||||
let effective_def_mod = if keywords::attacker_has_aimed_shot(&atk_kw_ctx) {
|
||||
def_mod * (1.0 - keywords::aimed_shot_defence_reduction())
|
||||
} else {
|
||||
def_mod
|
||||
};
|
||||
let defender_strength = (def_base * (1.0 + effective_def_mod)).max(1.0);
|
||||
|
||||
// HP factor: damaged units deal less damage
|
||||
let atk_hp_factor = params.attacker.hp as f32 / params.attacker.max_hp.max(1) as f32;
|
||||
|
|
@ -544,6 +577,7 @@ impl CombatResolver {
|
|||
city_wall_tier: 0,
|
||||
city_has_garrison: false,
|
||||
attacker_is_siege: false,
|
||||
..Default::default()
|
||||
};
|
||||
Self::predict_expected_damage_params(¶ms)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -305,6 +305,22 @@ impl TurnProcessor {
|
|||
crate::patrol::advance_on_turn(&mut player.units);
|
||||
}
|
||||
|
||||
// p2-53g/h/f: cross-turn archetype state ticks.
|
||||
// Rage countdown, AimedShot pending clear (if not consumed by an attack this
|
||||
// turn — the attack handler clears it on use; this ensures it cannot persist
|
||||
// more than one turn even if the unit took no action).
|
||||
// WarCry is cleared once per battle; battlefield reset happens on peace/end-of-combat.
|
||||
for player in state.players.iter_mut() {
|
||||
for unit in player.units.iter_mut() {
|
||||
if unit.rage_turns_remaining > 0 {
|
||||
unit.rage_turns_remaining -= 1;
|
||||
}
|
||||
// aimed_shot_pending is consumed by the attack handler; clear defensively
|
||||
// here so it cannot persist across a turn where the unit didn't attack.
|
||||
unit.aimed_shot_pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 4f: drain formation requests from GDScript (rally, command, shape, split, auto-join).
|
||||
Self::drain_formation_requests(state);
|
||||
crate::building_action_handlers::drain_pending_building_actions(state);
|
||||
|
|
@ -1573,6 +1589,7 @@ impl TurnProcessor {
|
|||
city_wall_tier: 0,
|
||||
city_has_garrison: false,
|
||||
attacker_is_siege: false,
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1731,6 +1748,7 @@ impl TurnProcessor {
|
|||
city_wall_tier: 0,
|
||||
city_has_garrison: false,
|
||||
attacker_is_siege: false,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let combat_result = CombatResolver::resolve(¶ms);
|
||||
|
|
|
|||