From d871a02f485bcf3f6f8642efbce5112bd95afdba Mon Sep 17 00:00:00 2001 From: Natalie Date: Fri, 17 Apr 2026 16:31:20 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20enhance=20deployment=20bake=20scenarios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .forgejo/workflows/deploy-next.yml | 5 +- .project/objectives/README.md | 10 +- .../p2-20-guide-sim-cache-pnpm-resolve.md | 28 ++- .../p2-21-guide-simcache-static-bake.md | 82 +++++++- .../games/age-of-dwarves/data/objectives.json | 14 +- scripts/run/deploy.sh | 31 +++ src/simulator/crates/mc-ai/src/lib.rs | 2 + src/simulator/crates/mc-ai/src/mcts_tree.rs | 3 + .../crates/mc-ai/src/tactical/citizen.rs | 24 +++ .../mc-ai/src/tactical/combat_predict.rs | 26 +++ .../crates/mc-ai/src/tactical/mod.rs | 137 +++++++++++++ .../crates/mc-ai/src/tactical/movement.rs | 29 +++ .../crates/mc-ai/src/tactical/production.rs | 27 +++ .../crates/mc-ai/src/tactical/settle.rs | 26 +++ .../crates/mc-combat/src/resolver.rs | 187 +++++++++++------- 15 files changed, 540 insertions(+), 91 deletions(-) create mode 100644 src/simulator/crates/mc-ai/src/tactical/citizen.rs create mode 100644 src/simulator/crates/mc-ai/src/tactical/combat_predict.rs create mode 100644 src/simulator/crates/mc-ai/src/tactical/mod.rs create mode 100644 src/simulator/crates/mc-ai/src/tactical/movement.rs create mode 100644 src/simulator/crates/mc-ai/src/tactical/production.rs create mode 100644 src/simulator/crates/mc-ai/src/tactical/settle.rs 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);