diff --git a/.project/objectives/README.md b/.project/objectives/README.md index d0a39ff6..8589519a 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -16,9 +16,9 @@ |---|---|---|---|---|---|---|---| | **P0** | 43 | 0 | 0 | 0 | 0 | 0 | 43 | | **P1** | 43 | 1 | 7 | 0 | 14 | 1 | 66 | -| **P2** | 46 | 1 | 6 | 1 | 7 | 6 | 67 | +| **P2** | 48 | 1 | 5 | 1 | 6 | 6 | 67 | | **P3 (oos)** | 3 | 0 | 0 | 0 | 1 | 19 | 23 | -| **total** | **135** | **2** | **13** | **1** | **22** | **26** | **199** | +| **total** | **137** | **2** | **12** | **1** | **21** | **26** | **199** | @@ -29,7 +29,7 @@ | [warcouncil](../team-leads/warcouncil.md) | 6 | | [asset-sprite](../team-leads/asset-sprite.md) | 6 | | [shipwright](../team-leads/shipwright.md) | 5 | -| [combat-dev](../team-leads/combat-dev.md) | 4 | +| [combat-dev](../team-leads/combat-dev.md) | 2 | | [terraformer](../team-leads/terraformer.md) | 2 | | [simulator-infra](../team-leads/simulator-infra.md) | 1 | | [asset-audio](../team-leads/asset-audio.md) | 1 | @@ -206,15 +206,15 @@ | [p2-50](p2-50-rng-determinism-pin.md) | ✅ done | Deterministic RNG + seed-derivation pin across mc-mapgen / mc-climate / mc-ecology | [terraformer](../team-leads/terraformer.md) | 2026-05-01 | | [p2-51](p2-51-world-shape-knobs.md) | ✅ done | Player-facing world-shape parameters on new-game screen | [terraformer](../team-leads/terraformer.md) | 2026-05-01 | | [p2-52](p2-52-substrate-flora-cover-ontology-split.md) | ✅ done | Split terrain enum into substrate × flora-cover layers (resolve biome ontology) | [terraformer](../team-leads/terraformer.md) | 2026-05-01 | -| [p2-53](p2-53-action-vocabulary-design-game-gap.md) | ❌ missing | Action vocabulary — gap analysis between design page and shipped Rust/Godot game | [wireguard](../team-leads/wireguard.md) | 2026-05-01 | +| [p2-53](p2-53-action-vocabulary-design-game-gap.md) | 🟡 partial | Action vocabulary — gap analysis between design page and shipped Rust/Godot game | [wireguard](../team-leads/wireguard.md) | 2026-05-03 | | [p2-53a](p2-53a-sentry-guard-action-kind.md) | ✅ done | Sentry/Guard ActionKind — add Sentry/Unsentry to mc-core with wake-on-vision | [wireguard](../team-leads/wireguard.md) | 2026-05-01 | | [p2-53b](p2-53b-building-action-registry.md) | ✅ done | Building action registry — `BuildingActionKind`, `building_actions.json`, `GdBuildingActions` bridge | [simulator-infra](../team-leads/simulator-infra.md) | 2026-05-01 | | [p2-53c](p2-53c-rally-vocabulary-expansion.md) | ✅ done | Rally vocabulary expansion — Hold / Fortify / JoinFormation + two-waypoint Patrol | [shipwright](../team-leads/shipwright.md) | 2026-05-01 | | [p2-53d](p2-53d-building-specifics.md) | ✅ done | Building specifics — Garrison, Repair, Toggle Active + 18 archetype-specific actions | [shipwright](../team-leads/shipwright.md) | 2026-05-03 | | [p2-53e](p2-53e-siege-pillage-embark.md) | 🟡 partial | Siege handlers (Pack/Deploy/Bombard) + Pillage UI wiring + Embark/Disembark handlers | [combat-dev](../team-leads/combat-dev.md) | 2026-05-01 | | [p2-53f](p2-53f-infantry-specifics.md) | ✅ done | Infantry specifics — Shield Wall, Brace, Shove, Rage, Cleave, War Cry | [combat-dev](../team-leads/combat-dev.md) | 2026-05-01 | -| [p2-53g](p2-53g-ranged-specifics.md) | 🟡 partial | Ranged specifics — Volley, Aimed Shot, Fire Arrows | [combat-dev](../team-leads/combat-dev.md) | 2026-05-01 | -| [p2-53h](p2-53h-cavalry-specifics.md) | 🟡 partial | Cavalry specifics — Charge, Pursue, Wheel | [combat-dev](../team-leads/combat-dev.md) | 2026-05-01 | +| [p2-53g](p2-53g-ranged-specifics.md) | ✅ done | Ranged specifics — Volley, Aimed Shot, Fire Arrows | [combat-dev](../team-leads/combat-dev.md) | 2026-05-03 | +| [p2-53h](p2-53h-cavalry-specifics.md) | ✅ done | Cavalry specifics — Charge, Pursue, Wheel | [combat-dev](../team-leads/combat-dev.md) | 2026-05-03 | | [p2-53i](p2-53i-engineer-pioneer-medic-scout.md) | ✅ done | Support specifics — Engineer, Pioneer, Medic, Scout | [shipwright](../team-leads/shipwright.md) | 2026-05-03 | | [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) | ✅ done | Migrate deposits/*.json to three-axis visibility schema | [terraformer](../team-leads/terraformer.md) | 2026-05-01 | diff --git a/.project/objectives/p2-54c-renderer-observations-and-indicators.md b/.project/objectives/p2-54c-renderer-observations-and-indicators.md index e307dbd9..991f75ee 100644 --- a/.project/objectives/p2-54c-renderer-observations-and-indicators.md +++ b/.project/objectives/p2-54c-renderer-observations-and-indicators.md @@ -2,7 +2,7 @@ id: p2-54c title: Renderer reads observations + indicator decorations for tech-gated resources priority: p2 -status: partial +status: done scope: game1 owner: terraformer updated_at: 2026-05-01 diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index d800d8f6..aa661eba 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -1,12 +1,12 @@ { - "generated_at": "2026-05-03T00:07:00Z", + "generated_at": "2026-05-03T01:01:16Z", "totals": { - "in_progress": 2, - "missing": 22, - "stub": 1, + "partial": 12, "oos": 26, - "partial": 13, - "done": 135, + "in_progress": 2, + "missing": 21, + "stub": 1, + "done": 137, "total": 199 }, "objectives": [ @@ -1624,10 +1624,10 @@ "id": "p2-53", "title": "Action vocabulary — gap analysis between design page and shipped Rust/Godot game", "priority": "p2", - "status": "missing", + "status": "partial", "scope": "game1", "owner": "wireguard", - "updated_at": "2026-05-01", + "updated_at": "2026-05-03", "summary": "The design page at `/unit-actions` (`.project/designs/app/src/pages/UnitActions.tsx`) curates an exemplar per unit/building category and lists per-archetype action vocabularies. Cross-checking that vocabulary against the shipped Rust action registry (`mc-core/src/action.rs::ActionKind`), the JSON capability map (`unit_actions.json`), and the Godot panel (`scenes/hud/unit_panel.gd`) reveals four classes of gap. This objective is the gap analysis only — implementation work splits out into child objectives once the design vocabulary is ratified." }, { @@ -1694,20 +1694,20 @@ "id": "p2-53g", "title": "Ranged specifics — Volley, Aimed Shot, Fire Arrows", "priority": "p2", - "status": "partial", + "status": "done", "scope": "game1", "owner": "combat-dev", - "updated_at": "2026-05-01", + "updated_at": "2026-05-03", "summary": "Three new `ActionKind` variants gating on `keywords: [\"ranged\"]`:\n\n- **Volley** — area attack: hits target hex's centre + 2 random edge slots; lower per-target damage, more chances to hit\n- **Aimed Shot** — skip-turn that ignores 50% of target's defence on next attack; sets `aimed_shot_pending = true`\n- **Fire Arrows** — toggle posture; ranged attacks ignite tile (uses Fire system from `mc-ecology` if available); persistent damage, smoke obscures vision" }, { "id": "p2-53h", "title": "Cavalry specifics — Charge, Pursue, Wheel", "priority": "p2", - "status": "partial", + "status": "done", "scope": "game1", "owner": "combat-dev", - "updated_at": "2026-05-01", + "updated_at": "2026-05-03", "summary": "Three new `ActionKind` variants gating on `keywords: [\"cavalry\"]`:\n\n- **Charge** — move 2+ hexes in straight line, then attack: +30% damage; may push target back; cancellable by Brace (p2-53f)\n- **Pursue** — if target dies/routs, advance into its hex without spending movement (passive trigger; manual confirm if multiple options)\n- **Wheel** — reorient to a new edge slot without leaving the hex; useful to dodge first-strike" }, { diff --git a/src/simulator/api-wasm/src/lib.rs b/src/simulator/api-wasm/src/lib.rs index dcb66708..3758654b 100644 --- a/src/simulator/api-wasm/src/lib.rs +++ b/src/simulator/api-wasm/src/lib.rs @@ -11,6 +11,8 @@ use mc_climate::{ClimatePhysics, EcologyPhysics, step_atmospheric_chemistry}; use mc_mapgen::{MapGenerator, seed::{derive as derive_seed, SeedDomain}}; use mc_save::{PlayerObservations, TileObservation}; +pub mod resources; + /// WASM-exposed grid handle wrapping GridState. #[wasm_bindgen] pub struct WasmGrid { @@ -304,6 +306,15 @@ impl WasmGrid { ) }) } + + /// Return resource metadata for a single tile as a JSON string. + /// Includes visibility, yield_gate, and indicator_decorations from the + /// 3-axis schema (p2-54). Returns `null` if the tile has no resource or + /// the coordinates are out of range. + #[wasm_bindgen(js_name = "tileResourceJson")] + pub fn tile_resource_json(&self, col: i32, row: i32) -> Option { + crate::resources::tile_resource_json_impl(&self.inner, col, row) + } } /// WASM-exposed climate physics engine. diff --git a/src/simulator/api-wasm/src/resources.rs b/src/simulator/api-wasm/src/resources.rs new file mode 100644 index 00000000..7e167fa6 --- /dev/null +++ b/src/simulator/api-wasm/src/resources.rs @@ -0,0 +1,154 @@ +//! Resource catalog WASM bridge — exposes per-tile resource metadata +//! including the 3-axis visibility schema landed in p2-54. +//! +//! The catalog is baked at compile time from `public/resources/resources.json` +//! and parsed once via `OnceLock` on first access. + +use mc_core::grid::GridState; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::OnceLock; + +#[derive(Debug, Clone, Deserialize)] +struct CatalogEntry { + id: String, + name: String, + #[serde(default)] + visibility: mc_core::resources::Visibility, + #[serde(default)] + yield_gate: Option, + #[serde(default)] + indicator_decorations: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct IndicatorDecorationRaw { + decoration_id: String, + #[allow(dead_code)] + name: String, + #[allow(dead_code)] + description: String, +} + +#[derive(Debug, Deserialize)] +struct ResourcesJson { + #[serde(default)] + bonus: Vec, + #[serde(default)] + luxury: Vec, + #[serde(default)] + strategic: Vec, +} + +static CATALOG: OnceLock> = OnceLock::new(); + +fn catalog() -> &'static HashMap { + CATALOG.get_or_init(|| { + const JSON: &str = include_str!("../../../../public/resources/resources.json"); + let bundle: ResourcesJson = serde_json::from_str(JSON).unwrap_or(ResourcesJson { + bonus: vec![], + luxury: vec![], + strategic: vec![], + }); + bundle + .bonus + .into_iter() + .chain(bundle.luxury) + .chain(bundle.strategic) + .map(|e| (e.id.clone(), e)) + .collect() + }) +} + +#[derive(Serialize)] +pub struct TileResourceDto { + pub id: String, + pub name: String, + pub visibility: String, + pub yield_gate: Option, + pub indicator_decorations: Vec, +} + +pub fn tile_resource_json_impl(grid: &GridState, col: i32, row: i32) -> Option { + let tile = grid.tile(col, row)?; + if tile.resource_id.is_empty() { + return None; + } + let entry = catalog().get(&tile.resource_id)?; + let dto = TileResourceDto { + id: entry.id.clone(), + name: entry.name.clone(), + visibility: visibility_str(&entry.visibility).to_owned(), + yield_gate: entry.yield_gate.clone(), + indicator_decorations: entry + .indicator_decorations + .iter() + .map(|d| d.decoration_id.clone()) + .collect(), + }; + serde_json::to_string(&dto).ok() +} + +fn visibility_str(v: &mc_core::resources::Visibility) -> &'static str { + match v { + mc_core::resources::Visibility::Always => "always", + mc_core::resources::Visibility::Scout => "scout", + mc_core::resources::Visibility::TechGated => "tech_gated", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mc_core::grid::GridState; + + fn make_grid_with_resource(resource_id: &str) -> GridState { + let mut g = GridState::new(2, 2); + g.tiles[0].resource_id = resource_id.to_owned(); + g + } + + #[test] + fn iron_deposit_returns_correct_dto() { + let grid = make_grid_with_resource("iron"); + let json = tile_resource_json_impl(&grid, 0, 0).expect("should return Some for iron"); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["id"], "iron"); + assert_eq!(val["visibility"], "tech_gated"); + } + + #[test] + fn always_visible_resource_has_no_decorations() { + let grid = make_grid_with_resource("deer"); + let json = tile_resource_json_impl(&grid, 0, 0) + .expect("should return Some for deer"); + let val: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(val["visibility"], "always"); + assert_eq!(val["indicator_decorations"].as_array().unwrap().len(), 0); + } + + #[test] + fn empty_resource_id_returns_none() { + let grid = make_grid_with_resource(""); + assert!(tile_resource_json_impl(&grid, 0, 0).is_none()); + } + + #[test] + fn out_of_bounds_returns_none() { + let grid = GridState::new(2, 2); + assert!(tile_resource_json_impl(&grid, 99, 99).is_none()); + } + + #[test] + fn unknown_resource_id_returns_none() { + let grid = make_grid_with_resource("dragon_bones"); + assert!(tile_resource_json_impl(&grid, 0, 0).is_none()); + } + + #[test] + fn catalog_loads_known_entries() { + let cat = catalog(); + assert!(cat.contains_key("iron")); + assert!(cat.contains_key("furs")); + } +} diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 914c5d1b..d45a9003 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -369,6 +369,12 @@ impl TurnProcessor { // their rally destination (Hold/Fortify/JoinFormation). Self::apply_rally_arrival_actions(state); + // Phase 5a-pillage: drain GDScript-queued Pillage requests (worker units). + // Runs before bombard so the order is: pillage (worker), bombard (siege), + // volley (ranged AoE), charge (cavalry melee). Each phase has been + // confirmed to leave subsequent phases' inputs valid. + Self::process_pillage_requests(state, &mut result); + // Phase 5a-bombard: drain GDScript-queued Bombard requests (siege units). Self::process_bombard_requests(state, &mut result); @@ -1975,6 +1981,42 @@ impl TurnProcessor { /// /// Calls `mc_combat::siege::resolve_bombard` for each request; applies damage /// to any unit or city on the target hex. The queue is cleared after drain. + /// Drain `GameState::pending_pillage_requests`. + /// + /// Each request: validate the worker unit exists; call + /// `GameState::pillage_improvement(col, row)`. Severable improvements stay + /// with `pillaged = true`; non-severable get removed entirely. Negative or + /// out-of-`u16`-range coordinates skip silently. Bad player or unit indices + /// skip silently. The TurnResult counter `improvements_pillaged` records + /// each successful pillage for telemetry. + fn process_pillage_requests(state: &mut GameState, result: &mut TurnResult) { + let requests = std::mem::take(&mut state.pending_pillage_requests); + for req in requests { + let pi = req.player_index as usize; + if pi >= state.players.len() { + continue; + } + if state.players[pi].units.get(req.unit_index).is_none() { + continue; + } + // Reject negative or out-of-u16 coords (tile_improvements is keyed on u16). + let Ok(col) = u16::try_from(req.target_col) else { + continue; + }; + let Ok(row) = u16::try_from(req.target_row) else { + continue; + }; + // pillage_improvement returns true for severable (marked but kept), + // false for non-severable (removed) or no improvement. Either of the + // first two counts as a successful pillage event. + let had_improvement = state.tile_improvements.contains_key(&(col, row)); + state.pillage_improvement(col, row); + if had_improvement { + result.improvements_pillaged = result.improvements_pillaged.saturating_add(1); + } + } + } + fn process_bombard_requests(state: &mut GameState, result: &mut TurnResult) { use mc_combat::siege::{resolve_bombard, BombardTarget};