feat(@projects/@magic-civilization): ✨ enhance deployment bake scenarios
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
170cee49a1
commit
d871a02f48
15 changed files with 540 additions and 91 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
24
src/simulator/crates/mc-ai/src/tactical/citizen.rs
Normal file
24
src/simulator/crates/mc-ai/src/tactical/citizen.rs
Normal 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")
|
||||
}
|
||||
26
src/simulator/crates/mc-ai/src/tactical/combat_predict.rs
Normal file
26
src/simulator/crates/mc-ai/src/tactical/combat_predict.rs
Normal 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")
|
||||
}
|
||||
137
src/simulator/crates/mc-ai/src/tactical/mod.rs
Normal file
137
src/simulator/crates/mc-ai/src/tactical/mod.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
29
src/simulator/crates/mc-ai/src/tactical/movement.rs
Normal file
29
src/simulator/crates/mc-ai/src/tactical/movement.rs
Normal 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")
|
||||
}
|
||||
27
src/simulator/crates/mc-ai/src/tactical/production.rs
Normal file
27
src/simulator/crates/mc-ai/src/tactical/production.rs
Normal 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")
|
||||
}
|
||||
26
src/simulator/crates/mc-ai/src/tactical/settle.rs
Normal file
26
src/simulator/crates/mc-ai/src/tactical/settle.rs
Normal 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")
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue