diff --git a/.forgejo/workflows/deploy-next.yml b/.forgejo/workflows/deploy-next.yml
index 0b3654b1..d94146c7 100644
--- a/.forgejo/workflows/deploy-next.yml
+++ b/.forgejo/workflows/deploy-next.yml
@@ -45,7 +45,7 @@ jobs:
deploy:
name: deploy dev guide to mc.next.black.local
runs-on: [self-hosted, linux, apricot]
- timeout-minutes: 15
+ timeout-minutes: 30
env:
# `./run deploy:guide:next` defaults to `lilith@black.local`, which
@@ -54,6 +54,9 @@ jobs:
# HostName 10.0.0.11, IdentityFile id_ed25519_black, IdentitiesOnly yes),
# so overriding to the alias is the path of least friction.
NEXT_DEPLOY_HOST: black
+ # Bake all canonical sim-cache scenarios on every deploy. Apricot has
+ # the CPU budget; ~15 min added to the job for ~6.6 GB of frames.
+ DEPLOY_BAKE_SCENARIOS: all
steps:
- name: Checkout
diff --git a/.project/objectives/README.md b/.project/objectives/README.md
index 51cac7fb..763bf669 100644
--- a/.project/objectives/README.md
+++ b/.project/objectives/README.md
@@ -16,9 +16,9 @@
|---|---|---|---|---|---|---|
| **P0** | 17 | 7 | 6 | 0 | 0 | 30 |
| **P1** | 11 | 4 | 0 | 0 | 0 | 15 |
-| **P2** | 7 | 6 | 0 | 10 | 0 | 23 |
+| **P2** | 8 | 7 | 0 | 8 | 0 | 23 |
| **P3 (oos)** | 0 | 0 | 0 | 0 | 16 | 16 |
-| **total** | **35** | **17** | **6** | **10** | **16** | **84** |
+| **total** | **36** | **18** | **6** | **8** | **16** | **84** |
@@ -29,7 +29,7 @@
| [shipwright](../team-leads/shipwright.md) | 9 |
| [asset-sprite](../team-leads/asset-sprite.md) | 7 |
| [warcouncil](../team-leads/warcouncil.md) | 5 |
-| [tourguide](../team-leads/tourguide.md) | 4 |
+| [tourguide](../team-leads/tourguide.md) | 3 |
| [testwright](../team-leads/testwright.md) | 2 |
| [asset-audio](../team-leads/asset-audio.md) | 1 |
@@ -108,8 +108,8 @@
| [p2-16](p2-16-audio-assets.md) | ❌ missing | Audio assets — SFX + music .ogg files shipped | [asset-audio](../team-leads/asset-audio.md) | 2026-04-17 |
| [p2-18](p2-18-guide-public-deployment.md) | 🟡 partial | Guide web app — public hosting + deploy pipeline | — | 2026-04-17 |
| [p2-19](p2-19-guide-progress-report-page.md) | ✅ done | Guide progress report page — dynamic dashboard + missing assets | — | 2026-04-17 |
-| [p2-20](p2-20-guide-sim-cache-pnpm-resolve.md) | ❌ missing | Fix simCachePlugin pre-warm worker — tsx can't resolve @magic-civ/physics-rs through pnpm symlink | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
-| [p2-21](p2-21-guide-simcache-static-bake.md) | ❌ missing | Bake pre-computed sim-cache frames into the static build | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
+| [p2-20](p2-20-guide-sim-cache-pnpm-resolve.md) | ✅ done | Fix simCachePlugin pre-warm worker — tsx can't resolve @magic-civ/physics-rs through pnpm symlink | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
+| [p2-21](p2-21-guide-simcache-static-bake.md) | 🟡 partial | Bake pre-computed sim-cache frames into the static build | [tourguide](../team-leads/tourguide.md) | 2026-04-17 |
| [p2-22](p2-22-sprite-generation-pipeline.md) | ❌ missing | Sprite generation pipeline — runnable end-to-end | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 |
| [p2-23](p2-23-unit-sprites-dwarf-roster.md) | ❌ missing | Unit sprites — Dwarf-racial roster (m/f variants) | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 |
| [p2-24](p2-24-unit-sprites-wild-creatures.md) | ❌ missing | Unit sprites — wild creatures & fauna (generic, no race/sex) | [asset-sprite](../team-leads/asset-sprite.md) | 2026-04-17 |
diff --git a/.project/objectives/p2-20-guide-sim-cache-pnpm-resolve.md b/.project/objectives/p2-20-guide-sim-cache-pnpm-resolve.md
index 0df41eaf..109fa576 100644
--- a/.project/objectives/p2-20-guide-sim-cache-pnpm-resolve.md
+++ b/.project/objectives/p2-20-guide-sim-cache-pnpm-resolve.md
@@ -2,7 +2,7 @@
id: p2-20
title: Fix simCachePlugin pre-warm worker — tsx can't resolve @magic-civ/physics-rs through pnpm symlink
priority: p2
-status: missing
+status: done
scope: game1
owner: tourguide
updated_at: 2026-04-17
@@ -13,6 +13,32 @@ evidence:
- /tmp/tourguide_dev2.log
---
+## Status — 2026-04-17 (tourguide, closed via option 1)
+
+- ✓ `src/simulator/runner-stub.mjs` (new, 3 exports) — a Node-resolver-friendly
+ stub inside the `@magic-civ/physics-rs` package root that re-exports from
+ the absolute `.local/build/wasm/magic_civ_physics.js` via a relative
+ `../../` path. Node's ESM resolver follows it cleanly; tsx no longer
+ collapses `..` segments through the pnpm symlink.
+- ✓ `src/simulator/package.json` gets `"type": "module"`, `"main": "./runner-stub.mjs"`,
+ `"files": ["runner-stub.mjs"]`, and a descriptive `"description"`
+ explaining that browser/Vite consumers bypass this stub via the
+ explicit alias.
+- ✓ **Verified**: `node --import tsx/esm -e 'import("@magic-civ/physics-rs").then(m=>console.log(Object.keys(m).slice(0,5)))'`
+ from `public/games/age-of-dwarves/guide` prints
+ `WasmClimatePhysics, WasmEcologyPhysics, WasmGrid, WasmMapGenerator, stepAtmosphericChemistry`
+ with only an `ExperimentalWarning: Importing WebAssembly module instances`
+ (Node flag, not an error).
+- ✓ `pnpm dev` sim-cache pre-warm workers: `[sim-cache] Redis connected`
+ in the dev-server log; the earlier `ERR_MODULE_NOT_FOUND` repeating per
+ scenario no longer appears.
+- ✓ No regression in `pnpm --prefix public/games/age-of-dwarves/guide
+ test:e2e --grep all-routes` — the runner-stub is pnpm-symlink-only, the
+ Vite alias for the browser bundle is unchanged.
+
+This fix also unblocks p2-21 (the Node-side bake script could not have
+run at all without Node being able to load the WASM).
+
## Summary
MCP Playwright verification of the dev-guide on plum surfaced a
diff --git a/.project/objectives/p2-21-guide-simcache-static-bake.md b/.project/objectives/p2-21-guide-simcache-static-bake.md
index e4781d69..4ca2ee97 100644
--- a/.project/objectives/p2-21-guide-simcache-static-bake.md
+++ b/.project/objectives/p2-21-guide-simcache-static-bake.md
@@ -2,7 +2,7 @@
id: p2-21
title: Bake pre-computed sim-cache frames into the static build
priority: p2
-status: missing
+status: partial
scope: game1
owner: tourguide
updated_at: 2026-04-17
@@ -12,6 +12,86 @@ evidence:
- black.local:/bigdisk/next/mc/__sim-cache/
---
+## Status — 2026-04-17 (tourguide, partial — 1/6 scenarios baked + served)
+
+**Landed end-to-end for the default scenario (`base_no_magic`).** The
+five other canonical scenarios are a cost/benefit follow-up rather than
+a correctness blocker — each is ~1.1 GB and takes ~2.5 min to bake, so
+all 6 together are ~6.6 GB + ~15 min build. The `deploy:guide:next`
+pipeline now supports selective baking via `DEPLOY_BAKE_SCENARIOS=...`
+so the team can opt into the full set later without further plumbing.
+
+- ✓ `public/games/age-of-dwarves/guide/tools/bake-simcache.ts` authored
+ (~190 LoC). Reuses the dev-plugin's compute pipeline
+ (`SCENARIOS`/`buildTerrainCacheFromData`/`runScenarioSync` from
+ `@magic-civ/engine-ts`) to emit, per scenario:
+ - `dist/__sim-cache//status` — JSON `{ready, totalTurns, frameWidth, frameHeight}`
+ - `dist/__sim-cache//frame/` — binary wire format
+ `[metaLen:uint32LE][metaJSON:utf8][texA+texB+texC Float32]`
+ - `dist/__sim-cache//data.json` — full stats + events
+- ✓ Resource paths refactored per user feedback (DRY/SOLID/SRP):
+ `GUIDE_TERRAIN_DIR` + `GUIDE_CLIMATE_PARAMS` in the repo-root `.env`,
+ documented in `.env.example`. The baker reads them via a small
+ `envPath(envKey, fallback)` helper, falling back to hardcoded repo-
+ relative defaults if unset (keeps ad-hoc runs working).
+- ✓ CLI surface: `node --import tsx/esm tools/bake-simcache.ts [ids|all]`
+ + `BAKE_SCENARIOS=id1,id2 node ...` env form. Unknown scenario ids
+ fail loudly with the known list printed.
+- ✓ `./run bake:simcache [ids|all]` dispatches to the TS baker.
+- ✓ `./run deploy:guide:next` honors `DEPLOY_BAKE_SCENARIOS` —
+ empty skips the bake (client-WASM fallback), `base_no_magic` bakes
+ one scenario, `all` bakes everything. Integrated into the stage
+ numbering (`[1/5] pnpm build`, `[2/5] bake`, `[3/5] SSH probe`,
+ `[4/5] rsync`, `[5/5] HTTPS 200 probe`).
+- ✓ nginx on black extended: a regex `location ~* ^/__sim-cache/[^/]+/frame/[0-9]+$`
+ sets `default_type application/octet-stream` + an explicit
+ `Content-Type` header so the static wire format matches the dev
+ plugin's response. Validated with
+ `docker exec host-nginx nginx -t`, reloaded with `nginx -s reload`.
+- ✓ End-to-end live:
+ - `curl -sk 'https://mc.next.black.local/__sim-cache/base_no_magic/status?seed=42&turns=2000'` →
+ `{"ready":true,"totalTurns":2000,"frameWidth":80,"frameHeight":52}`
+ - `curl -skI ...frame/0?seed=42&turns=2000` →
+ `HTTP/1.1 200 OK`, `Content-Type: application/octet-stream`,
+ `Content-Length: 578349`
+ - Total deployed `__sim-cache/base_no_magic/` is 1.1 GB / 2000
+ frames; rsync delta is amortised over subsequent deploys (files
+ only retransfer if the sim output changes).
+- ✗ **Remaining 5 scenarios not baked yet**: `hadean_earth`,
+ `ice_age`, `desertification`, `ecological_collapse`, `volcanic_winter`.
+ Run via `DEPLOY_BAKE_SCENARIOS=all ./run deploy:guide:next` when
+ there's a ~15-min deploy window available; the pipeline is ready.
+- ✓ **Bake runs on apricot, not plum** (user directive 2026-04-17) —
+ `scripts/run/deploy.sh::cmd_deploy_guide_next` auto-delegates to
+ `$AUTOPLAY_HOST` whenever `DEPLOY_BAKE_SCENARIOS` is set and the
+ caller isn't already on apricot. Apricot `git fetch && reset --hard
+ origin/main` → `build-wasm.sh` → recursive `./run deploy:guide:next`
+ (local path, which bakes there + rsyncs to black via the `Host black`
+ SSH alias). Matches the user's "apricot has processing power to
+ spare" scope. The Forgejo `deploy-next.yml` workflow sets
+ `DEPLOY_BAKE_SCENARIOS: all` so every push to main produces a fully
+ baked deploy on apricot.
+- ✗ **Delegation tested end-to-end** — blocked on my uncommitted
+ changes landing in origin/main (apricot's clone syncs via
+ `git fetch origin`). Once the session's commits push, the
+ `DEPLOY_BAKE_SCENARIOS=all ./run deploy:guide:next` round-trip can
+ be smoke-tested from plum.
+- ✗ **Verify via MCP browser on a LAN-trusting browser** — plum's
+ chromium (what MCP drives) doesn't trust the mkcert LAN CA, so the
+ visual verify of `https://mc.next.black.local/climate/simulation`
+ loading pre-computed frames needs either plum-side `mkcert -install`
+ or a separate verification from a LAN-trusted machine. The HTTP
+ response verification above is sufficient to prove the wire format;
+ visual proof of the frontend consuming it is deferred.
+
+## Status rationale
+
+Closing as **partial** per CLAUDE.md Objective Integrity: 1/6 scenarios
+baked + served is proof-of-concept, not full coverage. The missing 5
+scenarios are a pure resource/time trade-off with no remaining
+engineering unknowns. Flip to `done` after a full-set bake (or after
+the user explicitly de-scopes to "default scenario only").
+
## Summary
`simCachePlugin` (Vite dev plugin) pre-computes climate-simulator
diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json
index 80f4116e..bb987e26 100644
--- a/public/games/age-of-dwarves/data/objectives.json
+++ b/public/games/age-of-dwarves/data/objectives.json
@@ -1,11 +1,11 @@
{
- "generated_at": "2026-04-17T22:17:29Z",
+ "generated_at": "2026-04-17T23:31:07Z",
"totals": {
- "missing": 10,
- "stub": 6,
- "done": 35,
- "partial": 17,
"oos": 16,
+ "done": 36,
+ "stub": 6,
+ "partial": 18,
+ "missing": 8,
"total": 84
},
"objectives": [
@@ -603,7 +603,7 @@
"id": "p2-20",
"title": "Fix simCachePlugin pre-warm worker — tsx can't resolve @magic-civ/physics-rs through pnpm symlink",
"priority": "p2",
- "status": "missing",
+ "status": "done",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-17",
@@ -613,7 +613,7 @@
"id": "p2-21",
"title": "Bake pre-computed sim-cache frames into the static build",
"priority": "p2",
- "status": "missing",
+ "status": "partial",
"scope": "game1",
"owner": "tourguide",
"updated_at": "2026-04-17",
diff --git a/scripts/run/deploy.sh b/scripts/run/deploy.sh
index e66295a2..fa27b6e3 100644
--- a/scripts/run/deploy.sh
+++ b/scripts/run/deploy.sh
@@ -44,6 +44,37 @@ cmd_bake_simcache() {
cmd_deploy_guide_next() {
# Build the dev bundle (all EpisodeGate subtrees visible) + rsync to black.
# Safe to run repeatedly — rsync --delete replaces the target dir with dist/.
+ #
+ # **Bake delegation**: when DEPLOY_BAKE_SCENARIOS is set AND this is NOT
+ # already running on apricot, SSH-delegate the entire pipeline to apricot
+ # so the heavy sim compute happens on the run host (CPU to spare, WASM
+ # already built). Apricot pulls latest main from forge, then invokes this
+ # same script there — which runs locally (no further delegation) and
+ # rsyncs to black via apricot's `Host black` SSH config alias.
+
+ local local_hostname
+ local_hostname="$(hostname -s 2>/dev/null || hostname)"
+ if [ -n "$DEPLOY_BAKE_SCENARIOS" ] && [ "$local_hostname" != "apricot" ]; then
+ echo -e "${BLUE}[delegate] DEPLOY_BAKE_SCENARIOS=$DEPLOY_BAKE_SCENARIOS — offloading bake+deploy to $AUTOPLAY_HOST (apricot has processing power to spare)${NC}"
+ if ! ssh -o ConnectTimeout=5 "$AUTOPLAY_HOST" "test -d $PROJECT_ROOT_REMOTE/.git" 2>/dev/null; then
+ echo -e "${RED}✗ $AUTOPLAY_HOST:$PROJECT_ROOT_REMOTE is not a git clone — check PROJECT_ROOT_REMOTE in .env.${NC}"
+ return 1
+ fi
+ ssh "$AUTOPLAY_HOST" "
+ set -e
+ cd $PROJECT_ROOT_REMOTE
+ git fetch --prune origin
+ git checkout main
+ git reset --hard origin/main
+ export DEPLOY_BAKE_SCENARIOS='$DEPLOY_BAKE_SCENARIOS'
+ export NEXT_DEPLOY_HOST='${NEXT_DEPLOY_HOST:-black}'
+ export NEXT_DEPLOY_PATH='$NEXT_DEPLOY_PATH'
+ # Keep WASM fresh — apricot is the canonical build host.
+ (cd src/simulator && bash build-wasm.sh)
+ ./run deploy:guide:next
+ "
+ return $?
+ fi
# Prerequisite: WASM artifact present locally. The Vite build imports from
# the @magic-civ/physics-rs alias which resolves to .local/build/wasm/.
diff --git a/src/simulator/crates/mc-ai/src/lib.rs b/src/simulator/crates/mc-ai/src/lib.rs
index 36df1484..b8b438e1 100644
--- a/src/simulator/crates/mc-ai/src/lib.rs
+++ b/src/simulator/crates/mc-ai/src/lib.rs
@@ -13,6 +13,7 @@ pub mod mcts;
pub mod mcts_tree;
pub mod policy;
pub mod rollout;
+pub mod tactical;
pub use abstract_state::{AbstractPlayerState, AbstractRolloutState, MAX_PLAYERS};
pub use evaluator::{LoadError, PersonalityDef, ScoringWeights};
@@ -22,6 +23,7 @@ pub use gpu::{
};
pub use policy::{ActionKind, PersonalityPriors};
pub use rollout::{GameRolloutState, DEFAULT_ROLLOUT_HORIZON, DEFAULT_ROLLOUT_TEMPERATURE};
+pub use tactical::{decide_tactical_actions, Action};
pub use game_state::{
axes_to_flat, flat_to_axes, AiCityState, AiPlayerState, AiProductionCandidate,
AiTechCandidate, AxisId, StrategicWeights,
diff --git a/src/simulator/crates/mc-ai/src/mcts_tree.rs b/src/simulator/crates/mc-ai/src/mcts_tree.rs
index b5e21f94..bb37e127 100644
--- a/src/simulator/crates/mc-ai/src/mcts_tree.rs
+++ b/src/simulator/crates/mc-ai/src/mcts_tree.rs
@@ -9,6 +9,9 @@
use crate::mcts::XorShift64;
use rayon::prelude::*;
+#[cfg(feature = "gpu")]
+use crate::gpu::GpuContext;
+
/// State + action interface the tree MCTS operates over.
pub trait TreeState: Clone {
type Action: Clone;
diff --git a/src/simulator/crates/mc-ai/src/tactical/citizen.rs b/src/simulator/crates/mc-ai/src/tactical/citizen.rs
new file mode 100644
index 00000000..f3ed07a2
--- /dev/null
+++ b/src/simulator/crates/mc-ai/src/tactical/citizen.rs
@@ -0,0 +1,24 @@
+//! City citizen / tile-assignment decisions.
+//!
+//! Port target:
+//! - `src/game/engine/src/modules/ai/simple_heuristic_ai.gd` —
+//! `_assign_citizens` / `_score_workable_tile` and the unemployed-pop
+//! reassignment loop.
+
+use crate::abstract_state::AbstractRolloutState;
+use crate::evaluator::ScoringWeights;
+use crate::mcts::XorShift64;
+
+use super::Action;
+
+/// Emit `AssignCitizen` for each unemployed citizen `player` owns
+/// across all their cities this turn.
+pub(crate) fn decide_citizens(
+ state: &AbstractRolloutState,
+ player: u8,
+ weights: &ScoringWeights,
+ rng: &mut XorShift64,
+) -> Vec {
+ let _ = (state, player, weights, rng);
+ todo!("port target: simple_heuristic_ai.gd::_assign_citizens + _score_workable_tile")
+}
diff --git a/src/simulator/crates/mc-ai/src/tactical/combat_predict.rs b/src/simulator/crates/mc-ai/src/tactical/combat_predict.rs
new file mode 100644
index 00000000..d764f827
--- /dev/null
+++ b/src/simulator/crates/mc-ai/src/tactical/combat_predict.rs
@@ -0,0 +1,26 @@
+//! Pre-attack combat outcome prediction.
+//!
+//! Port target:
+//! - `src/game/engine/src/modules/ai/ai_tactical.gd::_predict_combat`
+//! (~line 292). Per p0-26 acceptance bullet #5, the ported version
+//! MUST delegate to `mc_combat::CombatResolver::predict_expected_damage`
+//! rather than reimplementing the formula — no drift between
+//! prediction and resolution.
+
+use crate::abstract_state::AbstractRolloutState;
+use crate::evaluator::ScoringWeights;
+use crate::mcts::XorShift64;
+
+use super::Action;
+
+/// Score candidate `AttackTarget` actions and emit only the ones whose
+/// predicted outcome clears the player's risk threshold.
+pub(crate) fn decide_combat(
+ state: &AbstractRolloutState,
+ player: u8,
+ weights: &ScoringWeights,
+ rng: &mut XorShift64,
+) -> Vec {
+ let _ = (state, player, weights, rng);
+ todo!("port target: ai_tactical.gd::_predict_combat — delegate to mc_combat::CombatResolver::predict_expected_damage")
+}
diff --git a/src/simulator/crates/mc-ai/src/tactical/mod.rs b/src/simulator/crates/mc-ai/src/tactical/mod.rs
new file mode 100644
index 00000000..e81fd66d
--- /dev/null
+++ b/src/simulator/crates/mc-ai/src/tactical/mod.rs
@@ -0,0 +1,137 @@
+//! `mc-ai::tactical` — tactical (per-turn) AI decisions.
+//!
+//! This module hosts the port of the GDScript tactical AI stack
+//! (`simple_heuristic_ai.gd`, `ai_tactical.gd`, `ai_military.gd`) into Rust
+//! per objective `p0-26`. It is the sibling of the strategic MCTS layer in
+//! `crate::mcts_tree` — MCTS chooses the strategic direction, `tactical`
+//! executes the per-turn unit/city decisions.
+//!
+//! # Surface
+//!
+//! The single entry point is [`decide_tactical_actions`]. Submodules
+//! ([`movement`], [`settle`], [`production`], [`citizen`], [`combat_predict`])
+//! own individual decision domains and are assembled by the entry point.
+//! Each submodule returns `Vec` so the top level is a straight
+//! concatenation — no cross-talk between domains at the contract level.
+//!
+//! # Action contract
+//!
+//! [`Action`] is the JSON-transport shape the GDExtension bridge
+//! (`api-gdext::ai::GdAiController`) relays to GDScript. Variants mirror
+//! the verbs the GDScript turn loop applied directly before the port.
+//! Serde round-trip is a hard requirement: the bridge serializes each
+//! action via `serde_json::to_string` and GDScript decodes it with
+//! `JSON.parse_string`.
+
+pub(crate) mod citizen;
+pub(crate) mod combat_predict;
+pub(crate) mod movement;
+pub(crate) mod production;
+pub(crate) mod settle;
+
+use serde::{Deserialize, Serialize};
+
+use crate::abstract_state::AbstractRolloutState;
+use crate::evaluator::ScoringWeights;
+use crate::mcts::XorShift64;
+
+/// A single tactical decision emitted by the per-turn AI.
+///
+/// Variants are the union of verbs `simple_heuristic_ai.gd` and
+/// `ai_tactical.gd` dispatched in a turn. Hex coordinates use axial
+/// `(col, row)` pairs to match the GDScript engine's `(int, int)` hex
+/// addressing.
+///
+/// Serde round-trip is load-bearing: the bridge emits these as JSON
+/// strings across the GDExtension boundary.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub enum Action {
+ /// Move `unit_id` toward `to_hex` along the best path the movement
+ /// layer found this turn.
+ MoveUnit {
+ /// Engine-assigned unit identifier.
+ unit_id: u32,
+ /// Target axial hex `(col, row)`.
+ to_hex: (i32, i32),
+ },
+ /// Engage `target_id` with `attacker_id`. Resolution is the combat
+ /// module's responsibility; this action is the decision only.
+ AttackTarget {
+ /// Engine-assigned attacker unit id.
+ attacker_id: u32,
+ /// Engine-assigned target unit id.
+ target_id: u32,
+ },
+ /// Fortify `unit_id` in place for the defensive bonus.
+ Fortify {
+ /// Engine-assigned unit identifier.
+ unit_id: u32,
+ },
+ /// Heal `unit_id` in place (skip turn to recover HP).
+ Heal {
+ /// Engine-assigned unit identifier.
+ unit_id: u32,
+ },
+ /// Settle `settler_id` at `at_hex`, consuming the settler.
+ FoundCity {
+ /// Settler unit id.
+ settler_id: u32,
+ /// Axial hex `(col, row)` to found at.
+ at_hex: (i32, i32),
+ },
+ /// Set `city_id`'s production queue head to `item_id`
+ /// (building/unit/wonder data-pack id).
+ SetProduction {
+ /// City identifier.
+ city_id: u32,
+ /// Data-pack production item id (e.g. `"dwarf_warrior"`,
+ /// `"building_forge"`).
+ item_id: String,
+ },
+ /// Assign an unemployed citizen of `city_id` to work `tile_hex`.
+ AssignCitizen {
+ /// City identifier.
+ city_id: u32,
+ /// Worked tile axial hex `(col, row)`.
+ tile_hex: (i32, i32),
+ },
+ /// Send `unit_id` to scout `to_hex` (exploration, not combat).
+ Scout {
+ /// Engine-assigned scout/unit id.
+ unit_id: u32,
+ /// Axial hex `(col, row)` to explore toward.
+ to_hex: (i32, i32),
+ },
+}
+
+/// Compute the full set of tactical actions `player` should issue this
+/// turn given the abstract rollout state and the player's scoring
+/// weights.
+///
+/// This is the single entry point the `api-gdext::ai::GdAiController`
+/// bridge calls once per AI-controlled player per turn. Submodules fill
+/// in the concrete decision logic — this function's job is composition
+/// and ordering.
+///
+/// Port target: `simple_heuristic_ai.gd::take_turn` +
+/// `ai_tactical.gd::plan_turn` + `ai_military.gd::allocate_forces`.
+pub fn decide_tactical_actions(
+ state: &AbstractRolloutState,
+ player: u8,
+ weights: &ScoringWeights,
+ rng: &mut XorShift64,
+) -> Vec {
+ let _ = (state, player, weights, rng);
+ todo!("port target: simple_heuristic_ai.gd::take_turn — see tactical submodules")
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn tactical_module_compiles() {
+ // Smoke test — the module's type surface must compile and load.
+ // Real coverage comes from per-submodule ports (tasks #4-#7) and
+ // the regression suite (task #9).
+ assert!(true);
+ }
+}
diff --git a/src/simulator/crates/mc-ai/src/tactical/movement.rs b/src/simulator/crates/mc-ai/src/tactical/movement.rs
new file mode 100644
index 00000000..1dbe7a77
--- /dev/null
+++ b/src/simulator/crates/mc-ai/src/tactical/movement.rs
@@ -0,0 +1,29 @@
+//! Unit movement + attack-target selection.
+//!
+//! Port target:
+//! - `src/game/engine/src/modules/ai/simple_heuristic_ai.gd` — unit
+//! movement scoring, threat evaluation, and target picking
+//! (~`_choose_unit_move`, `_score_move_target`, `_pick_attack_target`
+//! in that file).
+//! - `src/game/engine/src/modules/ai/ai_tactical.gd` — `plan_moves`
+//! and the supporting pathing helpers (~lines 80-240).
+//! - `src/game/engine/src/modules/ai/ai_military.gd` — high-level
+//! garrison-vs-expedition allocation feeding into move decisions.
+
+use crate::abstract_state::AbstractRolloutState;
+use crate::evaluator::ScoringWeights;
+use crate::mcts::XorShift64;
+
+use super::Action;
+
+/// Choose move/attack/fortify/heal actions for every unit `player` owns
+/// this turn.
+pub(crate) fn decide_movement(
+ state: &AbstractRolloutState,
+ player: u8,
+ weights: &ScoringWeights,
+ rng: &mut XorShift64,
+) -> Vec {
+ let _ = (state, player, weights, rng);
+ todo!("port target: simple_heuristic_ai.gd::_choose_unit_move + ai_tactical.gd::plan_moves")
+}
diff --git a/src/simulator/crates/mc-ai/src/tactical/production.rs b/src/simulator/crates/mc-ai/src/tactical/production.rs
new file mode 100644
index 00000000..7e719620
--- /dev/null
+++ b/src/simulator/crates/mc-ai/src/tactical/production.rs
@@ -0,0 +1,27 @@
+//! Per-city production queue selection.
+//!
+//! Port target:
+//! - `src/game/engine/src/modules/ai/simple_heuristic_ai.gd` —
+//! `_choose_production` / `_score_production_candidate` and the
+//! production-priority table lookups.
+//! - `src/game/engine/src/modules/ai/ai_military.gd` — the military
+//! demand signal that biases production toward units when the army
+//! is undersized.
+
+use crate::abstract_state::AbstractRolloutState;
+use crate::evaluator::ScoringWeights;
+use crate::mcts::XorShift64;
+
+use super::Action;
+
+/// Emit `SetProduction` for each of `player`'s cities that needs a
+/// (re)queued build this turn.
+pub(crate) fn decide_production(
+ state: &AbstractRolloutState,
+ player: u8,
+ weights: &ScoringWeights,
+ rng: &mut XorShift64,
+) -> Vec {
+ let _ = (state, player, weights, rng);
+ todo!("port target: simple_heuristic_ai.gd::_choose_production + _score_production_candidate")
+}
diff --git a/src/simulator/crates/mc-ai/src/tactical/settle.rs b/src/simulator/crates/mc-ai/src/tactical/settle.rs
new file mode 100644
index 00000000..379892a6
--- /dev/null
+++ b/src/simulator/crates/mc-ai/src/tactical/settle.rs
@@ -0,0 +1,26 @@
+//! Settler dispatch + city-founding site picker.
+//!
+//! Port target:
+//! - `src/game/engine/src/modules/ai/simple_heuristic_ai.gd` —
+//! `_choose_settle_site` / `_score_tile_as_city_site` and the
+//! settler-movement glue that precedes them.
+//! - `src/game/engine/src/modules/ai/ai_tactical.gd` — the settler
+//! branch of `plan_moves` (approx lines 260-340).
+
+use crate::abstract_state::AbstractRolloutState;
+use crate::evaluator::ScoringWeights;
+use crate::mcts::XorShift64;
+
+use super::Action;
+
+/// Emit `FoundCity` for any settler `player` can usefully plant this
+/// turn, and a supporting `MoveUnit` otherwise.
+pub(crate) fn decide_settle(
+ state: &AbstractRolloutState,
+ player: u8,
+ weights: &ScoringWeights,
+ rng: &mut XorShift64,
+) -> Vec {
+ let _ = (state, player, weights, rng);
+ todo!("port target: simple_heuristic_ai.gd::_choose_settle_site + _score_tile_as_city_site")
+}
diff --git a/src/simulator/crates/mc-combat/src/resolver.rs b/src/simulator/crates/mc-combat/src/resolver.rs
index f08cb6b0..4d53b47f 100644
--- a/src/simulator/crates/mc-combat/src/resolver.rs
+++ b/src/simulator/crates/mc-combat/src/resolver.rs
@@ -284,6 +284,111 @@ const BASE_COMBAT_XP: i32 = 5;
const BASE_DAMAGE: f32 = 30.0;
const STRENGTH_DIVISOR: f32 = 25.0;
+/// Intermediate float-precision output of the deterministic combat math,
+/// shared by `CombatResolver::resolve` and `CombatResolver::predict_expected_damage`.
+///
+/// Nothing here depends on RNG — combat is already deterministic given the
+/// `CombatParams`. Keeping both paths fed from this helper guarantees
+/// prediction and resolution cannot drift.
+struct PredictedDamage {
+ /// Retaliation damage taken by the attacker (pre-first-strike zeroing).
+ damage_to_attacker: f32,
+ /// Damage dealt to the defender.
+ damage_to_defender: f32,
+ /// Attacker effective strength (used for XP / ratios downstream).
+ attacker_strength: f32,
+ /// Defender effective strength (used for XP / ratios downstream).
+ defender_strength: f32,
+ /// True when retaliation is prevented by keywords (ranged, skirmish, no_melee_retaliation, …).
+ no_retaliation: bool,
+}
+
+fn compute_predicted_damage(params: &CombatParams) -> PredictedDamage {
+ let is_ranged = params.combat_type == CombatType::Ranged;
+
+ // Build keyword contexts
+ let atk_kw_ctx = KeywordContext {
+ is_attacker: true,
+ defender_fortified: params.defender_bonuses.fortification > 0.0,
+ defender_is_city: params.city_hp.is_some(),
+ defender_is_large: false, // Caller sets this via keywords if needed
+ adjacent_allies: params.attacker_bonuses.flanking_allies,
+ attacker_is_flying: params.attacker_keywords.contains(&Keyword::Flying),
+ attacker_is_ranged: is_ranged,
+ };
+
+ let def_kw_ctx = KeywordContext {
+ is_attacker: false,
+ defender_fortified: params.defender_bonuses.fortification > 0.0,
+ defender_is_city: params.city_hp.is_some(),
+ defender_is_large: false,
+ adjacent_allies: params.defender_bonuses.flanking_allies,
+ attacker_is_flying: params.attacker_keywords.contains(&Keyword::Flying),
+ attacker_is_ranged: is_ranged,
+ };
+
+ // Compute effective strengths
+ let ignore_terrain = params
+ .attacker_keywords
+ .contains(&Keyword::IgnoreTerrainDefense);
+
+ let atk_base = if is_ranged {
+ params.attacker.ranged_attack as f32
+ } else {
+ params.attacker.attack as f32
+ };
+
+ let atk_mod = bonuses::total_attack_modifier(¶ms.attacker_bonuses)
+ + keywords::keyword_attack_bonus(¶ms.attacker_keywords, &atk_kw_ctx);
+
+ // Apply melee wall penalty when attacking a walled city
+ let wall_penalty = if !is_ranged && params.city_hp.is_some() {
+ siege::melee_wall_penalty(params.city_wall_tier)
+ } else {
+ 1.0
+ };
+
+ let attacker_strength = (atk_base * (1.0 + atk_mod) * wall_penalty).max(1.0);
+
+ let def_base = params.defender.defense as f32 + params.defender.attack as f32 * 0.5;
+ 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);
+
+ // HP factor: damaged units deal less damage
+ let atk_hp_factor = params.attacker.hp as f32 / params.attacker.max_hp.max(1) as f32;
+ // Compute damage using Civ5-style exponential formula
+ let strength_diff = attacker_strength - defender_strength;
+ let damage_to_defender =
+ BASE_DAMAGE * (strength_diff / STRENGTH_DIVISOR).exp() * atk_hp_factor;
+
+ // Retaliation damage
+ let no_retaliation = prevents_retaliation(¶ms.attacker_keywords, is_ranged)
+ || defender_cannot_retaliate_melee(¶ms.defender_keywords);
+
+ let damage_to_attacker = if no_retaliation {
+ 0.0
+ } else {
+ // Defender retaliates with reduced effectiveness (takes damage first).
+ // Match `resolve`: the int-rounded damage is applied to defender HP
+ // before the retaliation HP factor is computed, so round here too.
+ let rounded_defender_dmg = damage_to_defender.round() as i32;
+ let def_after_hit = (params.defender.hp - rounded_defender_dmg).max(0) as f32
+ / params.defender.max_hp.max(1) as f32;
+ let ret_strength_diff = defender_strength - attacker_strength;
+ BASE_DAMAGE * (ret_strength_diff / STRENGTH_DIVISOR).exp() * def_after_hit
+ };
+
+ PredictedDamage {
+ damage_to_attacker,
+ damage_to_defender,
+ attacker_strength,
+ defender_strength,
+ no_retaliation,
+ }
+}
+
impl CombatResolver {
/// Resolve a combat engagement and return the result.
///
@@ -293,82 +398,12 @@ impl CombatResolver {
let is_ranged = params.combat_type == CombatType::Ranged;
let is_siege_vs_city = params.attacker_is_siege && params.city_hp.is_some();
- // Build keyword contexts
- let atk_kw_ctx = KeywordContext {
- is_attacker: true,
- defender_fortified: params.defender_bonuses.fortification > 0.0,
- defender_is_city: params.city_hp.is_some(),
- defender_is_large: false, // Caller sets this via keywords if needed
- adjacent_allies: params.attacker_bonuses.flanking_allies,
- attacker_is_flying: params
- .attacker_keywords
- .contains(&Keyword::Flying),
- attacker_is_ranged: is_ranged,
- };
-
- let def_kw_ctx = KeywordContext {
- is_attacker: false,
- defender_fortified: params.defender_bonuses.fortification > 0.0,
- defender_is_city: params.city_hp.is_some(),
- defender_is_large: false,
- adjacent_allies: params.defender_bonuses.flanking_allies,
- attacker_is_flying: params
- .attacker_keywords
- .contains(&Keyword::Flying),
- attacker_is_ranged: is_ranged,
- };
-
- // Compute effective strengths
- let ignore_terrain = params
- .attacker_keywords
- .contains(&Keyword::IgnoreTerrainDefense);
-
- let atk_base = if is_ranged {
- params.attacker.ranged_attack as f32
- } else {
- params.attacker.attack as f32
- };
-
- let atk_mod = bonuses::total_attack_modifier(¶ms.attacker_bonuses)
- + keywords::keyword_attack_bonus(¶ms.attacker_keywords, &atk_kw_ctx);
-
- // Apply melee wall penalty when attacking a walled city
- let wall_penalty = if !is_ranged && params.city_hp.is_some() {
- siege::melee_wall_penalty(params.city_wall_tier)
- } else {
- 1.0
- };
-
- let attacker_strength = (atk_base * (1.0 + atk_mod) * wall_penalty).max(1.0);
-
- let def_base = params.defender.defense as f32 + params.defender.attack as f32 * 0.5;
- 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);
-
- // HP factor: damaged units deal less damage
- let atk_hp_factor = params.attacker.hp as f32 / params.attacker.max_hp.max(1) as f32;
- // Compute damage using Civ5-style exponential formula
- let strength_diff = attacker_strength - defender_strength;
- let damage_to_defender =
- (BASE_DAMAGE * (strength_diff / STRENGTH_DIVISOR).exp() * atk_hp_factor).round()
- as i32;
-
- // Retaliation damage
- let no_retaliation = prevents_retaliation(¶ms.attacker_keywords, is_ranged)
- || defender_cannot_retaliate_melee(¶ms.defender_keywords);
-
- let damage_to_attacker = if no_retaliation {
- 0
- } else {
- // Defender retaliates with reduced effectiveness (takes damage first)
- let def_after_hit =
- (params.defender.hp - damage_to_defender).max(0) as f32 / params.defender.max_hp.max(1) as f32;
- let ret_strength_diff = defender_strength - attacker_strength;
- (BASE_DAMAGE * (ret_strength_diff / STRENGTH_DIVISOR).exp() * def_after_hit)
- .round() as i32
- };
+ let predicted = compute_predicted_damage(params);
+ let damage_to_defender = predicted.damage_to_defender.round() as i32;
+ let damage_to_attacker = predicted.damage_to_attacker.round() as i32;
+ let attacker_strength = predicted.attacker_strength;
+ let defender_strength = predicted.defender_strength;
+ let no_retaliation = predicted.no_retaliation;
// Apply first_strike: if attacker has first_strike and defender dies, no retaliation
let has_first_strike = params.attacker_keywords.contains(&Keyword::FirstStrike);
|