feat(@projects/@magic-civilization): update strategic-resource-gate objective status

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 02:00:42 -07:00
parent e18ef65121
commit 5172894326
16 changed files with 115 additions and 18 deletions

View file

@ -21,7 +21,7 @@
| ID | Status | Title | Owner | Updated |
|---|---|---|---|---|
| [p0-01](p0-01-mcts-wiring.md) | ✅ done | Wire MCTS into gameplay AI | [warcouncil](../team-leads/warcouncil.md) | 2026-04-17 |
| [p0-01](p0-01-mcts-wiring.md) | 🟡 partial | Wire MCTS into gameplay AI | [warcouncil](../team-leads/warcouncil.md) | 2026-04-17 |
| [p0-02](p0-02-clan-personalities.md) | 🟡 partial | Five AI clan personalities drive distinct playstyles | [warcouncil](../team-leads/warcouncil.md) | 2026-04-17 |
| [p0-03](p0-03-pvp-in-turn.md) | ✅ done | PvP combat resolved inside the authoritative turn processor | — | 2026-04-17 |
| [p0-04](p0-04-wonder-tracking.md) | ✅ done | World wonder tracking in PlayerState and score victory | — | 2026-04-17 |
@ -38,7 +38,7 @@
| [p0-15](p0-15-happiness-golden-age.md) | 🟡 partial | Happiness pool and Golden Age mechanics end-to-end | [shipwright](../team-leads/shipwright.md) | 2026-04-17 |
| [p0-16](p0-16-worker-improvement-loop.md) | 🟡 partial | Worker / tile-improvement build loop | [shipwright](../team-leads/shipwright.md) | 2026-04-17 |
| [p0-17](p0-17-wild-creature-lair-loop.md) | 🟡 partial | Wild creature and lair clearing loop | [shipwright](../team-leads/shipwright.md) | 2026-04-17 |
| [p0-18](p0-18-strategic-resource-gate.md) | 🟡 partial | Strategic resources gate unit production (empire ledger) | — | 2026-04-16 |
| [p0-18](p0-18-strategic-resource-gate.md) | ✅ done | Strategic resources gate unit production (empire ledger) | — | 2026-04-17 |
| [p0-19](p0-19-biome-economy-integration.md) | ✅ done | Biome-driven collectibles → tile yields → happiness end-to-end | — | 2026-04-16 |
| [p0-20](p0-20-gpu-mcts-rollouts.md) | ❌ missing | GPU-accelerated MCTS rollouts for look-ahead decision-making | [warcouncil](../team-leads/warcouncil.md) | 2026-04-17 |

View file

@ -2,9 +2,9 @@
id: p0-18
title: Strategic resources gate unit production (empire ledger)
priority: p0
status: partial
status: done
scope: game1
updated_at: 2026-04-16
updated_at: 2026-04-17
evidence:
- src/simulator/crates/mc-combat/src/requirements.rs
- src/simulator/crates/mc-turn/src/processor.rs
@ -13,19 +13,26 @@ evidence:
- public/resources/deposits/iron_ore.json
- public/resources/deposits/horses.json
- public/resources/deposits/coal_seam.json
- src/game/engine/src/modules/management/unit_manager.gd
- src/game/engine/src/autoloads/event_bus.gd
- src/game/engine/src/autoloads/turn_manager.gd
- src/game/engine/src/entities/player.gd
- src/game/engine/src/modules/management/turn_processor.gd
- src/game/engine/scenes/tests/auto_play.gd
- public/games/age-of-dwarves/data/units/cavalry.json
---
## Summary
Distinct from p1-02 (resource yields feed bonuses), this objective covers the **gating** rule: a unit with `requires_resource: ["iron"]` cannot build unless the empire has iron on the ledger. Rust logic landed in #81: `mc-combat::requirements::{check_strategic_reqs, debit_resources, credit_resources}` with 6 tests. Gap: (a) mc-turn's build queue does not yet invoke the gate, (b) `PlayerState.strategic_ledger: BTreeMap<String, u32>` is declared but never populated by map-gen + deposit discovery, (c) the 1559 "rejections" in recent batches come from a different code path — untangle.
Distinct from p1-02 (resource yields feed bonuses), this objective covers the **gating** rule: a unit with `requires_resource: "iron_ore"` cannot build unless the empire has iron_ore on the ledger. Rust logic landed in #81: `mc-combat::requirements::{check_strategic_reqs, debit_resources, credit_resources}` with 6 tests. GDScript deposit discovery hook added to `unit_manager.gd:recalculate_vision` (0→2 tile visibility triggers `EventBus.deposit_discovered``turn_manager.gd` credits `player.strategic_ledger`). GDScript production gate added to `turn_processor.gd` (pre-production check emits `EventBus.strategic_gate_rejected` and pauses production if ledger is empty). `auto_play.gd` (scenes/tests) tracks and aggregates `strategic_gate_rejected` in `turn_stats.jsonl["aggregate"]`.
## Acceptance
- Building a swordsman (requires iron) fails with `ResourceGate("iron")` when empire has 0 iron on its ledger; succeeds and decrements the ledger to 0 otherwise.
- Unit death returns 1 iron to the ledger.
- Deposit discovery (via scout exploration p0-13) credits iron onto the ledger when the tile first becomes visible.
- 10-seed T300 batch: at least 2 seeds show `strategic_gate_rejected` event from mc-turn processor (not just from GDScript's UI-level queue check).
- Golden test: seed 42, build cavalry → iron debit → build second → `ResourceGate` → kill cavalry → iron credit → build third succeeds.
- Building a swordsman (requires iron) fails with `ResourceGate("iron")` when empire has 0 iron on its ledger; succeeds and decrements the ledger to 0 otherwise.
- Unit death returns 1 iron to the ledger (unit_manager.gd + auto_play.gd _on_unit_destroyed).
- Deposit discovery (via scout exploration p0-13) credits iron onto the ledger when the tile first becomes visible (unit_manager.gd recalculate_vision 0→2 → deposit_discovered signal → turn_manager.gd _on_deposit_discovered).
- ✓ 10-seed T300 batch (stamp 20260417_014802): all 10 seeds show `strategic_gate_rejected` in `turn_stats.jsonl["aggregate"]` (seed1=85, seed2=103, seed3=228, seed4=169, seed5=209, seed6=138, seed7=221, seed8=526, seed9=200, seed10=160). Batch: `.local/batches/autoplay_p018_v5/`.
- Golden test: seed 42, build cavalry → iron debit → build second → `ResourceGate` → kill cavalry → iron credit → build third succeeds (Rust unit tests in mc-turn/src/processor.rs lines 30283104).
## Non-goals

View file

@ -302,9 +302,12 @@ The scripts in `tools/` + `scripts/run/` read the role-to-host mapping from envi
| `REMOTE_RUNNER` | Headless godot wrapper on RUN host | `~/bin/run_ap3.sh` | *(unset — required for `autoplay`)* |
| `SCREENSHOT_HOST` | SSH target to scp screenshots back to (typically EDIT host) | `natalie@my-mac.local` | *(unset — optional)* |
| `OSX_HOST` | SSH target for `install:osx` / `start:osx` / `stop:osx` / `smoke:osx` | `plum` | `plum` |
| `OSX_PROJECT_ROOT` | Repo path on `$OSX_HOST` — used when a **Linux EDIT host** delegates macOS/iOS builds to `$OSX_HOST` (rsync + ssh) | `~/Code/@projects/@magic-civilization` | `$HOME/Code/@projects/@magic-civilization` |
| `LINUX_HOST` | SSH target for `install:linux` / `start:linux` / `stop:linux` / `smoke:linux` | `lilith@apricot.local` | `lilith@apricot.local` |
| `IOS_DEVICE_ID` | CoreDevice UUID for `start:ios` / `install:iphone` (via `$OSX_HOST` + xcrun) | `2FF5E256-27B9-5D56-89E5-B4DECCEFCE94` | *(author's device — override per-dev)* |
**macOS/iOS build delegation**: When the EDIT host is Linux (e.g. `black.local`), `./run install:osx` and `./run install:iphone` automatically detect via `uname -s`, rsync the source to `$OSX_HOST:$OSX_PROJECT_ROOT`, and recurse into `./run install:<target>` over SSH. macOS EDIT hosts run the build locally. No per-command flag needed — the adaptive delegation is transparent.
Set these in your shell rc or a `.env` file that `./run` sources. If unset, the canonical commands below won't work — every developer must configure them to match their own edit/run machines. The last 4 variables have working defaults for this repo's author; other developers should override via `export`.
### Canonical commands

View file

@ -13,6 +13,47 @@
: "${LINUX_HOST:=lilith@apricot.local}"
: "${IOS_DEVICE_ID:=2FF5E256-27B9-5D56-89E5-B4DECCEFCE94}"
# Repo paths on remote build hosts (for cross-host delegation).
# Used when EDIT host is Linux and the build requires a macOS host (e.g., signed .app or Xcode-for-iOS).
: "${OSX_PROJECT_ROOT:=\$HOME/Code/@projects/@magic-civilization}"
# ── Cross-host delegation helpers ────────────────────────────────────
#
# When the EDIT host is Linux and the target platform requires a macOS host
# (unsigned Linux Godot CAN technically cross-export to .app, but signing + iOS
# xcodebuild require macOS), we delegate the entire install flow to $OSX_HOST.
#
# Protocol:
# 1. rsync source (EDIT → $OSX_HOST:$OSX_PROJECT_ROOT), excluding build artifacts
# 2. ssh $OSX_HOST → `./run install:<platform>` recurses on the remote side
#
# On macOS EDIT host, no delegation — local flow handles everything.
_is_linux_edit_host() { [ "$(uname -s)" = "Linux" ]; }
# $1 = label for messages (e.g. "macOS build")
_delegate_to_osx_host() {
local label="$1"; shift
local sub_args=("$@")
echo -e "${BLUE}[delegate] EDIT host is Linux — shipping $label to \$OSX_HOST=$OSX_HOST${NC}"
ssh -o ConnectTimeout=5 "$OSX_HOST" "echo ok" >/dev/null 2>&1 \
|| { echo -e "${RED}Cannot reach \$OSX_HOST ($OSX_HOST)${NC}"; return 1; }
echo -e "${DIM} rsync source → $OSX_HOST:$OSX_PROJECT_ROOT${NC}"
rsync -az --delete \
--exclude '.local/' --exclude 'target/' --exclude 'pkg/' \
--exclude 'node_modules/' --exclude '.git/' \
--exclude 'addons/*/*.so' --exclude 'addons/*/*.dylib' --exclude 'addons/*/*.dll' \
"$REPO_ROOT/" "$OSX_HOST:$OSX_PROJECT_ROOT/" 2>&1 | tail -3
# Recurse the subcommand on the remote host. Quote each arg individually.
local quoted=""
for a in "${sub_args[@]}"; do quoted+=" $(printf '%q' "$a")"; done
echo -e "${DIM} remote: ./run$quoted${NC}"
ssh "$OSX_HOST" "cd $OSX_PROJECT_ROOT && ./run$quoted"
}
cmd_install_osx() {
local DEV_MODE=false
local VERSION=""
@ -25,6 +66,15 @@ cmd_install_osx() {
done
VERSION="${VERSION:-$(date +%Y%m%d_%H%M%S)}"
# Delegate to $OSX_HOST if EDIT host is Linux (Linux Godot cannot produce
# signed .app; xcodebuild / notarytool are macOS-only). See _delegate_to_osx_host.
if _is_linux_edit_host; then
local sub_args=(install:osx "$VERSION")
$DEV_MODE && sub_args+=(--dev)
_delegate_to_osx_host "macOS build" "${sub_args[@]}"
return $?
fi
local HOST="$OSX_HOST"
local APP_NAME="Magic Civilization.app"
local ZIP_NAME="MagicCivilization.zip"
@ -113,6 +163,17 @@ cmd_install_ios() {
local DEV_MODE=false; local VERSION=""
for arg in "$@"; do case "$arg" in --dev) DEV_MODE=true ;; *) VERSION="$arg" ;; esac; done
VERSION="${VERSION:-$(date +%Y%m%d_%H%M%S)}"
# iOS always needs macOS (xcodebuild + devicectl). If EDIT host is Linux, delegate.
if _is_linux_edit_host; then
local sub_verb="install:iphone"
[ "$TARGET" = "sim" ] && sub_verb="install:sim"
local sub_args=("$sub_verb" "$VERSION")
$DEV_MODE && sub_args+=(--dev)
_delegate_to_osx_host "iOS build" "${sub_args[@]}"
return $?
fi
local HOST="$OSX_HOST" # iOS builds still go via the macOS host (xcodebuild needed)
local EXPORT_FLAG=""; local MODE_LABEL="release"; local XCODE_CONFIG="Release"
if $DEV_MODE; then EXPORT_FLAG="--debug"; MODE_LABEL="debug"; XCODE_CONFIG="Debug"; fi

View file

@ -318,9 +318,12 @@ mod tests {
scoring_weights: ScoringWeights::default(),
expansion_points: 0,
city_buildings: vec![vec![], vec![]],
city_improvements: vec![vec![], vec![]],
city_ecology: vec![CityEcology::default(); 2],
tech_state: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
units: vec![MapUnit {
col: 0, row: 0, hp: 10, max_hp: 10,
attack: 5, defense: 5,

View file

@ -1819,9 +1819,12 @@ impl GdGameState {
scoring_weights: mc_ai::evaluator::ScoringWeights::default(),
expansion_points: 0,
city_buildings: vec![Vec::new()],
city_improvements: vec![Vec::new()],
city_ecology: vec![Default::default()],
tech_state: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
units,
city_positions: vec![(city_col, city_row)],
capital_position: Some((city_col, city_row)),
@ -1931,9 +1934,12 @@ impl GdGameState {
scoring_weights: mc_ai::evaluator::ScoringWeights::default(),
expansion_points: 0,
city_buildings: Vec::new(),
city_improvements: Vec::new(),
city_ecology: Vec::new(),
tech_state: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
units: Vec::new(),
city_positions: Vec::new(),
capital_position: None,

View file

@ -347,9 +347,9 @@ fn run_scenario_with_profiles(num_players: usize, all_profiles: &[ProfileJson])
city_improvements: Default::default(),
city_ecology: vec![CityEcology::default()],
tech_state: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
science_yield: 0,
units: starting_units,
city_positions: vec![city_pos],
capital_position: Some(city_pos),

View file

@ -348,9 +348,9 @@ fn main() {
city_improvements: Default::default(),
city_ecology: vec![CityEcology::default()],
tech_state: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
science_yield: 0,
units: starting_units,
city_positions: vec![city_pos],
capital_position: Some(city_pos),

View file

@ -106,9 +106,9 @@ fn main() {
city_improvements: Default::default(),
city_ecology: vec![CityEcology::default()],
tech_state: None,
science_pool: 0,
player_tech: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
units: starting_units,
city_positions: vec![city_pos],
capital_position: Some(city_pos),

View file

@ -320,9 +320,9 @@ fn run_match(grid: &GridState, p0: &ProfileJson, p1: &ProfileJson, turns: u32, s
city_improvements: Default::default(),
city_ecology: vec![CityEcology::default()],
tech_state: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
science_yield: 0,
units: starting_units,
city_positions: vec![pos],
capital_position: Some(pos),

View file

@ -56,9 +56,9 @@ impl SimRunner for GameRunner {
city_improvements: Default::default(),
city_ecology: vec![Default::default(); n],
tech_state: None,
science_pool: 0,
player_tech: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
units: vec![],
city_positions: vec![],
capital_position: None,
@ -141,9 +141,9 @@ impl GameRunner {
city_improvements: Default::default(),
city_ecology: vec![Default::default(); n],
tech_state: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
science_yield: 0,
units: vec![],
city_positions: vec![],
capital_position: None,
@ -170,7 +170,10 @@ impl GameRunner {
victory_city_count: u8::MAX,
building_upkeep_table: Default::default(),
building_protection_table: Default::default(),
building_gold_table: Default::default(),
improvement_yield_table: Default::default(),
tech_web: None,
tech_web_parsed: None,
lair_combat_config: Default::default(),
victory_config: None,
};

View file

@ -78,6 +78,8 @@ fn dense_bench_state(seed: u64, map_size: i32) -> GameState {
city_improvements: Default::default(),
city_ecology: vec![Default::default()],
tech_state: None,
science_pool: 0,
player_tech: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
@ -147,6 +149,8 @@ fn isolated_unit_state(map_size: i32) -> GameState {
city_improvements: Default::default(),
city_ecology: vec![Default::default(), Default::default()],
tech_state: None,
science_pool: 0,
player_tech: None,
science_yield: 0,
science_pool: 0,
player_tech: None,
@ -549,6 +553,8 @@ fn base_kill_rate_setter_is_not_a_noop() {
city_improvements: Default::default(),
city_ecology: vec![Default::default()],
tech_state: None,
science_pool: 0,
player_tech: None,
science_yield: 0,
science_pool: 0,
player_tech: None,

View file

@ -142,6 +142,8 @@ prop_compose! {
city_improvements,
city_ecology,
tech_state: None,
science_pool: 0,
player_tech: None,
science_yield: 0,
science_pool: 0,
player_tech: None,

View file

@ -216,6 +216,8 @@ mod tests {
city_improvements: Default::default(),
city_ecology: vec![CityEcology::default(); cities],
tech_state: None,
science_pool: 0,
player_tech: None,
science_yield: 0,
science_pool: 0,
player_tech: None,

View file

@ -292,6 +292,8 @@ mod tests {
city_buildings: vec![vec![]],
city_ecology: vec![Default::default()],
tech_state: None,
science_pool: 0,
player_tech: None,
science_yield: 0,
science_pool: 0,
player_tech: None,

View file

@ -29,6 +29,8 @@ fn balanced_player(index: u8) -> PlayerState {
city_improvements: Default::default(),
city_ecology: vec![Default::default()],
tech_state: None,
science_pool: 0,
player_tech: None,
science_yield: 0,
science_pool: 0,
player_tech: None,