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