feat(@projects/@magic-civilization): enhance deployment bake scenarios

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-17 16:31:20 -07:00
parent 170cee49a1
commit d871a02f48
15 changed files with 540 additions and 91 deletions

View file

@ -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

View file

@ -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** |
</td><td valign='top' style='padding-left:2em'>
@ -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 |

View file

@ -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

View file

@ -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/<id>/status` — JSON `{ready, totalTurns, frameWidth, frameHeight}`
- `dist/__sim-cache/<id>/frame/<n>` — binary wire format
`[metaLen:uint32LE][metaJSON:utf8][texA+texB+texC Float32]`
- `dist/__sim-cache/<id>/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

View file

@ -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",

View file

@ -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/.

View file

@ -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,

View file

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

View file

@ -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<Action> {
let _ = (state, player, weights, rng);
todo!("port target: simple_heuristic_ai.gd::_assign_citizens + _score_workable_tile")
}

View file

@ -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<Action> {
let _ = (state, player, weights, rng);
todo!("port target: ai_tactical.gd::_predict_combat — delegate to mc_combat::CombatResolver::predict_expected_damage")
}

View file

@ -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<Action>` 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<Action> {
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);
}
}

View file

@ -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<Action> {
let _ = (state, player, weights, rng);
todo!("port target: simple_heuristic_ai.gd::_choose_unit_move + ai_tactical.gd::plan_moves")
}

View file

@ -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<Action> {
let _ = (state, player, weights, rng);
todo!("port target: simple_heuristic_ai.gd::_choose_production + _score_production_candidate")
}

View file

@ -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<Action> {
let _ = (state, player, weights, rng);
todo!("port target: simple_heuristic_ai.gd::_choose_settle_site + _score_tile_as_city_site")
}

View file

@ -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(&params.attacker_bonuses)
+ keywords::keyword_attack_bonus(&params.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(&params.defender_bonuses, ignore_terrain)
+ keywords::keyword_defense_bonus(&params.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(&params.attacker_keywords, is_ranged)
|| defender_cannot_retaliate_melee(&params.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(&params.attacker_bonuses)
+ keywords::keyword_attack_bonus(&params.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(&params.defender_bonuses, ignore_terrain)
+ keywords::keyword_defense_bonus(&params.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(&params.attacker_keywords, is_ranged)
|| defender_cannot_retaliate_melee(&params.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);