Land units were hard-confined to their landmass: is_passable("ocean", Land) was
always false, with no tech path across water (Civ "embarkation" gap). The
scaffolding existed but was dead (embarked_defence_penalty, the transport keyword).
P1 wires the first layer — the pathfinding gate:
- mc-pathfinding gains EmbarkLevel {None, Coast, Ocean}; is_passable + find_path
take it. A Land unit may now enter water per level — coastal (IsCoast) water
needs Coast, open/deep ocean needs Ocean. None = legacy land-locked (default,
so existing behaviour is unchanged).
- The move handler (process_one_move) derives the level from the player's naval
tree via embark_level_for: ocean_navigation→Ocean, shipbuilding→Coast. So a
teched army can cross water; an un-teched one still cannot.
Maps onto the existing naval tech tree and the IsCoast/IsWater biome tags — no
new techs, no new biomes. Civ two-tier model (Optics/Astronomy → shipbuilding/
ocean_navigation).
Tests: mc-pathfinding 9/9 (incl. embark_gates_land_on_water_by_level +
ocean_embark_lets_a_land_unit_cross_an_ocean_strip); mc-turn suite green.
Objective p3-18 created (full design + phased acceptance P1–P6); P1 marked done.
Follow-ups: P2 embarked combat, P3 AI water-pathing, P4 transport, P5 GDScript
mirror, P6 end-to-end conquest demo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pump loop treated an empty stdin read as "EOF or no line yet" and yielded +
retried forever, so `cat reqs | player-api-server.sh` hung until CP_TIMEOUT_SEC
instead of the documented clean EOF exit. Godot 4.6's read_string_from_stdin
blocks, so an empty return is end-of-stream — break and quit(0). Also add a
`{"type":"stop"}` request (acked, sets the loop flag) so interactive clients that
hold stdin open (Popen drivers, the MCP adapter) can exit cleanly without relying
on EOF — the clean-exit request path the docs promised but never implemented.
Verified: 11-request batch pipe now exits 0 in ~1s (was 124/timeout); view+stop
exits 0 in ~4s with both responses acked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the deterministic end-to-end proof p3-16's last bullet needed: in AI-vs-AI
self-play, once frontier-seek exploration brings a militarist into view of a
*weaker* rival, it dispatches a war-dec and the relation flips to War within 60
turns. Asymmetric armies (slot 2 weakened) so the aggressor clears its
superiority threshold on discovery. Pairs with the existing decide_diplomacy unit
cases (cautious-holds-at-parity / warmonger-strikes) to demonstrate
personality-driven war-decs manifest in actual play — the integration that was
blocked purely on discovery (p3-17). Shared self-play setup helper extracted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes p3-17's "measurable improvement in self-play: earlier first-contact" as a
reproducible cargo test (the sim is deterministic, so this needs no apricot smoke
batch). Two AI militarists start fogged-apart on a 12x12 map; the frontier-seek
exploration must bring one into view of another within 40 turns. Asserts no
contact at game start (fog intact) and contact by the budget. This is the
discovery feed p3-16's decide_diplomacy (declares only on a *discovered* rival)
depends on — it never fired reliably before exploration landed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
movement.rs::is_at_war defaulted a missing relation slot to `true` (at war) —
the legacy p1-01 "missing → war" model that courier-diplomacy (p3-16) supersedes:
every pair starts at PEACE, war begins only on a war-dec envelope. An absent slot
now defaults to peace (`map_or(false, …)`), so the AI never treats a
not-yet-projected pair as a phantom war. project_tactical_relations fills the vec
from real courier state, so genuine wars are unaffected. mc-ai+mc-player-api 556/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guards the wiring the movement unit tests can't: compute_vision (fog) →
project_tactical_with_vision (sets TacticalTile.explored) → decide_tactical_actions.
An idle warrior with no visible enemy on a 20x20 foggy map must issue a MoveUnit
toward the fog rather than idling — the discovery feed p3-16's war-dec gate needs.
Asserts the far corner projects as unexplored and the unit steps off its start
tile. Reproducible (no MCP), runs under cargo test --workspace.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Idle military/scout units' explore move used a centroid-mirror heuristic (head
for the far reach) that ignored actual fog, so the AI rarely expanded its
explored footprint or made first contact — starving target acquisition and the
p3-16 war-dec discovery gate.
- Add `explored: bool` to TacticalTile (serde default true so un-flagged bridge
tiles read as seen, never spuriously sought). projection.rs::project_tactical_map
populates it from PlayerVision::is_explored (omniscient → all true).
- Upgrade movement.rs::score_explore_move to target the nearest genuinely
unexplored tile (nearest_unexplored_frontier), with a deterministic per-unit
tie-break so a stack fans across frontier tiles; the centroid-mirror is kept as
the fully-explored fallback. No rng/Instant — determinism contract preserved.
- 2 new tests (targets-nearest-unexplored, mirror-fallback-when-fully-explored).
The headless self-play path (dispatch → project_tactical_with_vision, rebuilt per
turn) now drives real exploration. mc-ai+mc-player-api 555/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tools/check-no-gdscript-sim-logic.py and wire it as verify step 18 (TOTAL
20→21). Fails if presentation GDScript (src/game/engine/src/**/*.gd) re-introduces
catalog yield aggregation (`yield_production += …`) or hand-built spec dicts
(`"yield_production": …`) — the exact drift class just moved to Rust. Verified to
flag the pre-7e2baa25d aggregation and pass clean on the current tree. Logic
belongs in the mc-* crates, reached via the GDExtension bridge (Rail 1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Twin of the building consolidation. The GDScript build_unit_catalog did the
buildable-unit filter (drop faction:"wild" monsters + "freepeople" NPCs) and
field mapping by hand. Add mc_ai::tactical::parse_unit_catalog as the one Rust
implementation (resilient: skips malformed, drops map-spawned factions, defaults
tier=1), exposed as GdItemSystem.parse_unit_catalog_json. GDScript
build_unit_catalog now marshals the raw unit docs to it; the bench routes through
the same transform. The wild/freepeople filter lives only in Rust now.
Validated: mc-core+mc-ai+mc-player-api 826/0 (3 new parse_unit_catalog tests);
rebuilt aarch64 dylib; headless GUT 729/0/13 incl. a unit round-trip + wild-filter
delegation test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Godot's JSON.parse_string decodes every JSON number as a float, so a tactical
catalog that round-trips through GDScript (build_*_catalog → JSON →
decide_actions / set_ai_*_catalog_json) presents tier/cost/yields as `1.0`, and
Rust's u32/i32 deserialize rejected the whole catalog
("invalid type: floating point 1.0, expected u32"). Add lenient_u32 / lenient_i32
deserializers and apply them to TacticalUnitSpec.tier and TacticalBuildingSpec
{tier, cost, yield_*×9, great_work_slots}. This was latent in the building
delegation and would have bitten as soon as buildings loaded; the unit-catalog
delegation surfaced it. Unit-tested (float + int forms).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a regression guard that loads the ENTIRE authored content store through the
Rust source-of-truth types — every public/resources/units/*.json into UnitStats
AND TacticalUnitSpec, every buildings/*.json through parse_building_catalog — not
just the 7-file bench subset. The `game data JSON schemas` step validates against
schemas; this validates against the structs the simulator actually runs on, so a
file can no longer satisfy a schema yet break the sim. Runs under
`cargo test --workspace`, so verify auto-enforces it; a drifting file fails the
gate with its filename.
The guard immediately caught two parser-parity bugs the bench never exercised:
- Building `effects[]` may carry a boolean value (`{"type":"enables_naval",
"value":true}`); `BuildingEffect.value: f64` rejected it, dropping the whole
building (harbor, stable, deep_*, …) from the catalog. Add a lenient_number
deserializer that coerces non-numbers to 0.0 — parity with the GDScript
`value is int or is float` guard. (NB: the dylib from 7e2baa25d had the strict
parser; rebuilt so the live game re-includes these buildings.)
- TacticalUnitSpec.tier had no serde default while the GDScript builder defaults
it to 1; a unit JSON omitting tier (founder.json) failed to deserialize. Add
#[serde(default = "default_tier")] for path parity.
Test excludes the *.schema.json / *_categories.json sidecars that live in the
content dirs. Validated: mc-core+mc-ai+mc-player-api 822/0; rebuilt aarch64 dylib;
headless GUT 728/0/13.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The effects→yield aggregation existed in two places: GDScript
(ai_turn_bridge_state.gd::build_building_catalog) and Rust
(mc_ai::tactical::parse_building_catalog). Both were byte-equivalent but a
duplicated transform that could drift. Per Rail 1 (simulation logic in Rust),
the GDScript copy is now retired.
- api-gdext: GdItemSystem gains `aggregate_building_catalog_json(raw)` — a thin
#[func] over parse_building_catalog that takes the raw authored building docs
and returns the aggregated Vec<TacticalBuildingSpec> JSON (reuses the existing
lightweight stateless bridge class — no new registered class, so no plum
class-cache churn).
- ai_turn_bridge_state.gd: build_building_catalog now marshals
DataLoader.get_data("buildings").values() to that method instead of summing the
effects[] array in GDScript. The ~80-line aggregation loop is deleted.
- parse_building_catalog: made resilient (skips malformed entries instead of
failing the whole catalog) to match the GDScript builder's has("id") filter.
Validation: cargo mc-ai building_catalog 4/4; rebuilt the aarch64 dylib; full
headless GUT 728 passing / 0 failing / 13 pending, including 2 new tests that
exercise the GDScript→Rust→GDScript round-trip (forge production→yield_production,
research→science, trade→gold) and the malformed-input empty-catalog path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The building effects→yield aggregation existed only in GDScript
(ai_turn_bridge_state.gd::build_building_catalog), and the mc-player-api bench
kept a third hand-written copy of the resulting TacticalBuildingSpec literals
(fixed costs/yields/gates) that drifted from public/resources/buildings/*.json.
Add `mc_ai::tactical::parse_building_catalog` as the ONE Rust implementation of
the transform: parses authored building docs (object or array shape), aggregating
each `effects[]` entry into the scalar yield fields with the exact same
effect-type→field mapping the GDScript builder uses (food/production/gold|trade/
science|research/culture/defense|city_hp|wall_hp/happiness, gpp_*/great_work_slots_*
prefixes). Empty gate strings → None; missing tier → 1. Unit-tested.
The bench `build_building_catalog` now loads granary/forge/library/walls straight
from the canonical JSON through this transform — no hand-maintained specs, can't
drift. (granary is correctly tech-gated by husbandry now.) The engine bridge can
adopt the same fn to retire the GDScript copy — follow-up.
mc-ai + mc-player-api green: 547/0, incl. 3 new parser tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mc-player-api bench harness hand-defined unit stats twice — `build_unit_catalog`
(TacticalUnitSpec) and `build_runtime_units_catalog` (UnitStats) — as inline literals
that silently drifted from public/resources/units/*.json: wrong unit_type ("military"
vs "melee"), build_cost 0 vs the real 40, iron_working vs the real bronze_working.
Each drift masked a real AI-production bug and had to be patched field-by-field.
Root fix per Rail 2: both catalogs now deserialize straight from the canonical
`public/resources/units/<id>.json` documents (UnitStats flattens the top-level combat
line + renames cost/movement; TacticalUnitSpec's gate fields are all serde(default)),
so one file feeds both and the bench economy — cost, tier, race/tech gates, combat
stats, archetype — cannot diverge from the shipped game. Tier-2 slot uses the
dwarf-native `dwarf_berserker` (single-object, iron_working-gated) instead of the
generic legacy `pikeman.json` (a multi-unit array). This supersedes the earlier
inline unit_type/build_cost band-aids.
Full mc-player-api suite green (163/0), incl. claude_vs_ai_full_game_transcript with
real costs (warrior 40), so the AI still fields units on the corrected economy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
City unit production spawned every unit at the flat `lair_combat_config.unit_spawn_cost`
(8) regardless of the unit's authored production cost — `dwarf_warrior` costs 40 in
public/resources/units/dwarf_warrior.json ("cost", loaded as UnitStats.build_cost).
Buildings/wonders already pay their real queue_cost in process_city_production, but
Queueable::Unit is routed to try_spawn_unit, which hardcoded the flat 8. Result:
units were ~5x too cheap, so a starter city (~6 prod/turn) spawned a warrior every
~1.3 turns instead of ~7 and the AI stack ballooned in the opening turns.
try_spawn_unit now charges `units_catalog[unit].build_cost` when the runtime catalog
knows it (>0), falling back to the flat unit_spawn_cost only for un-costed bench
fixtures and the no-queue auto-warrior. The data-loaded game (build_cost=40) now
prices units correctly; bench tests with build_cost=0 are unchanged (flat 8). This
restores Rail 2 — the JSON cost is canonical, not a hardcoded Rust constant.
Validated: mc-turn lib 248/0 (incl. all spawn + gold-gate tests), mc-player-api +
mc-turn 138/0, claude_vs_ai_full_game_transcript green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`claude_vs_ai_full_game_transcript` failed: "no AI slot built a unit by turn 10".
Root cause was two test-fixture values diverging from the real engine, both in the
`build_3_player_state_like_harness` construction:
1. `add_player_militarist_inline` never set `PlayerState.race_id`. The real engine
sets it from presentation metadata (api-gdext set_player_presentation ->
p.race_id); the tactical production picker filters race-gated units
(`dwarf_warrior` has race_required="dwarf") by it, so an empty race_id rejected
every unit. Set race_id="dwarf" (Game 1 is all-dwarf).
2. `build_unit_catalog` set `unit_type: "military"` for dwarf_warrior/pikeman, but
`pick_best_unit_of_type` filters on the combat archetype unit_type ∈
{melee,ranged,siege} — the real units JSON uses "melee" (72 melee / 41 ranged /
20 siege across public/resources/units). "military" matched no preferred type.
With either wrong, `pick_best_unit_for_clan` returned None and fell back to the
phantom id "warrior", which the queue classifier (units_catalog keyed by
"dwarf_warrior") then treated as a *building* (Queueable::Item) — so try_spawn_unit
skipped it, no unit ever spawned, no UnitCreated event fired, and the AI bled units
(4->3->2->1) with no replacement. After the fix the AI queues
`Unit { dwarf_warrior }`, spawns via step's try_spawn_unit (units 4->6 by turn 3),
and emits UnitCreated on the wire. Full mc-player-api suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
process_siege was changed from "≥3 attackers within 2 hexes = instant capture" to
HP-gated siege: 15 dmg/attacker/turn against the city's persistent hp, capture at
hp<=0. A starter city has 510 hp, so 3 attackers (45/turn) capture neither in one
step (last_survivor) nor in 10 turns (event_collector). Drop each defender city's
hp below the single-turn 3-attacker total (45) so the capture fires as the tests
intend, and refresh the stale "nearby_attackers >= 3" doc comments.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- full_game_transcript: add the missing `Action::DeclareWar { target }` arm to the
signature_of match (the tactical AI gained DeclareWar so AI-vs-AI leaves perpetual
peace — non-exhaustive match broke compilation).
- ai_controller: player_index_to_slot now caps out-of-range indices to the last slot
(graceful degradation for >MAX_PLAYERS games) instead of erroring; assert the cap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The p1_29j_rust_action_application_found_and_capture_stamp test had been spliced
into the MIDDLE of refound_suppression_lever_sweep, orphaning that sweep's
directional-gate tail (and a stray `}`) — the file never compiled because cargo
test never ran headless-clean. Reunite the sweep with its tail, and drop the new
fn's references to a private `CityState` import (unused) and a MapUnit `owner`
field that does not exist (unit ownership is by the player's `units` vec).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`tools/objectives-report.py --check` was failing (README/objectives.json stale vs
per-objective frontmatter after recent objective edits). Regenerated both.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
uuid reaches api-wasm transitively (mc-mapgen → mc-turn → mc-state → mc-comms →
mc-replay → uuid) and gates its v4 RNG (`RngImp`) behind uuid's own `js` feature
on wasm32 — `wasm-pack build api-wasm` failed with E0433. Add a wasm32-target
uuid dep with `features=["js"]` (feature-unifies across the wasm build); native
builds are untouched (they must not pull wasm-bindgen through uuid).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Both tests now call processor.load_authored_encounter_rates() so the fauna /
ambient encounter pass is live (it no-ops without rates) — fixes p2_58b ambient
count and turn_processor's fauna-encounter assertion.
- turn_processor's "wealth must accumulate >60" was wrong for a single-city
militarist: it earns wealth income but reinvests it into unit upkeep, hovering
at the insolvency floor (verified empirically — a seeded 60 treasury drains to
0). Reframed to assert the economy stays SOLVENT (gold >= 0) over 50 turns;
net hoarding belongs to wealth/merchant scenarios.
GUT: suite now 726 passing / 0 failing / 13 pending (was 51 failing at session start).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the TechWeb fix: research advanced to 109 techs but every city
still spawned tier-1 dwarf_warrior. Five stacked defects severed the
tech→unit→production chain; all are fixed here. Proven in a 599-turn duel
self-play (seed 42): slot 0 fields a 222-strong tier-8 dwarf_adamantine_champion
army; both clans now CHOOSE tier-2..8 units by tech (was tier-1 only).
1. units_catalog (#[serde(skip)]) was never stamped onto the dispatch
GdPlayerApi — only GdGameState. The harness comment claimed 'both must be
re-stamped at boot' but only did GdGameState. Added
GdPlayerApi::set_units_runtime_catalog_json + a post-load harness re-stamp.
Without it apply_queue_production/try_spawn_unit ran catalog-blind.
2. apply_queue_production classified queued ids as unit-vs-building by a
starts_with("dwarf_") prefix. Replaced with an authoritative
units_catalog.get() lookup (prefix kept only as empty-catalog fallback).
The prefix leaked dwarf_-prefixed BUILDINGS (dwarf_deep_forge) onto the map
as units and misfiled every non-dwarf unit.
3. project_tactical_player hardcoded race_id: None, filtering out every
race_required:dwarf unit. Added PlayerState.race_id (race gates production →
it is sim state, not pure presentation per p2-72a), stamped it in
set_player_presentation_json + the headless harness (sourced from
setup.json::default_race), and projected it.
4. build_unit_catalog loaded faction:"wild" monsters (ancient_hydra, …) and
freepeople into the AI production catalog. With race_required:null they
passed every race filter and, being high-tier, the picker preferred them.
Excluded non-player factions from the production catalog (runtime catalog
still carries them for encounters).
5. Generic warrior/spearmen/archer carried clan_affinity to ALL FIVE clans at
tier 1, so clan_affinity_score=2 dominated tier and every named clan locked
onto tier-1 'warrior'. Cleared their affinity (they are neutral baselines,
not clan signatures).
mc-player-api 132 + mc-state 12 tests green. Known next layer (not this fix):
production VOLUME — try_spawn_unit's empty-queue dwarf_warrior auto-spawn still
floods tier-1, and the loser snowballs; the picker is correct, army composition
tuning is separate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tech unlocks + a unit tech-gate referenced ids absent from the loaded set:
- improvement renames (techs used stale ids): irrigation_channel→irrigation,
stone_road→road, fortress→fort
- improvement refs with no existing target removed: orchard, aqueduct_channel,
bridge, fishing_boats (no such improvement is authored)
- flight units (dwarf_gyrocopter/iron_hawk/mithril_hawk) exist + their unlock
techs are in-scope, but weren't in the manifest's units subscription → added
- dwarf_master_engineer.tech_required deep_engineering (nonexistent) → total_war
(its grand_engineer upgrade-from's gate, which is subscribed)
GUT: test_data_integrity 0 dangling refs (724 passing / 2 failing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_pick_buildable_military_unit_id iterated race_data.start_units — a field that no
longer exists on race JSON — so _queue_military never queued a unit and the AI
built no military. Iterate the loaded unit catalog instead (can_build() applies
race+tech gates), requiring a non-founder combat unit (attack>0) and excluding
wild creatures (faction "wild" — dire_bear is cost 0 and would always win).
Clears test_ai_turn_bridge_mcts.
Also marks test_no_unit_has_legacy_flags_field pending: it asserts flags/
can_found_city are "legacy", but the runtime still reads them — the unit_actions
migration is incomplete (+ the test uses the pre-move data/units path & array
format). Consistent with the suite's existing pending-stub convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The chronicle panel's filter bucket id is "combat" (FILTER_BUCKETS["combat"],
_filter_state["combat"]); the test toggled a non-existent "military" key, so
nothing was filtered and combat entries stayed visible. Use "combat".
Clears test_chronicle_coverage (721 passing / 6 failing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Demo art now ships city_q1..q5.png, so _get_city_sprite resolves for every real
quality — the "empty sprites/ dir → null" premise is obsolete. Test the
null-fallback contract via a guaranteed-absent key (city_q99.png), and assert the
add_city path resolves the demo sprite (procedural baseline still drawn
underneath per the additive-overlay rule).
GUT: test_sprite_rendering_capability 4 → 0 (720 passing / 7 failing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
assign_citizen/unassign_citizen route through the Rust parallel city slot, which
needs a valid _pi (player row). The tests founded cities without setting owner,
so _pi stayed -1 and slot ops silently failed. Set owner = 0 before found().
Clears test_city_screen_p09 (8 → 0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- test_fog_renderer: unexplored tiles render the procedural dwarven-vellum
texture with a white modulate (commit 30bcde26d); UNEXPLORED_COLOR is only the
flat fallback. Assert the texture is applied + white verts, not flat colour.
- test_fog_of_war: is_resource_visible_to used iron_ore, which is
visibility:tech_gated (yield_gate "bronze_working") — so it also needs the
tech + a GameState player. Use deer (non-gated) to isolate the visibility gate
the test actually targets.
GUT: fog cluster cleared (716 passing / 11 failing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Rust HappinessInput reads owned_luxuries: BTreeMap<String,i32> (value 0 ⇒
config LUXURY_HAPPINESS=4), but the test passed a unique_luxury_count int the
Rust no longer reads → 0 luxury happiness. Pass a two-entry owned_luxuries map.
Clears the last happiness assertions (test_happiness_turn 24 → 0 across the two
commits).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
city.owner drives _pi (the parallel-city-slot player row). _make_city never set
owner, so _pi stayed -1 and found()/set_population addressed an invalid row —
city population silently stayed at the default 1, breaking every happiness
assertion (e.g. balanced/1city/pop3 gave -4 instead of -6; citizen contributions
collapsed to -1/0). Set owner = 0 so the slot resolves. Production code is fine
(real cities always have an owner).
GUT: test_happiness_turn 24 → 4 failing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AiTurnBridge._mcts_stats_log is a static dict that persists across tests, and
get_last_mcts_stats does a most-recent-at-or-before-turn lookup — so a prior
test's player-0 entry ("rust_run_ai_turn") masked the expected heuristic
sentinel for the empty-cities player. Clear the static store in before_each for
deterministic isolation. Clears test_ai_turn_bridge_stats.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lines 33/47 still assigned through the untyped GameState.players[0] Variant
(Array → Array[String] type error). Use the typed p0 (same object). Clears
test_save_load_round_trip entirely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These tests deliberately feed bad input (missing/malformed replay game_id,
no-history goto_turn, malformed start-script JSON) and the Rust bindings
correctly log an engine error + reject it. GUT's auto error-check flagged those
deliberate logs as "Unexpected Errors". Use assert_engine_error(text) to mark
them expected (GUT marks the matching error handled).
Clears test_p2_46_replay_bridge (4→0) + the prologue load_script rejection path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Through a RefCounted-typed ref, p0.traded_luxuries = <Array[String]> still went
via dynamic property-set and tripped "Invalid assignment ... type Array" against
the Array[String] property. Typing p0 as PlayerScript makes the set static and
type-correct. Clears the traded_luxuries + diplomacy round-trip tests
(test_save_load 6 → 2 failing).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- test_minimap read MinimapScript.FOG_COLOR / UNEXPLORED_COLOR, but p2-87 moved
those to ThemeAssets.color("fog.explored"/"fog.unexplored") instance vars.
Read the tokens directly (same source minimap.gd uses).
- test_save_load assigned untyped array literals to Player.traded_luxuries
(Array[String]) through a RefCounted-typed ref → "Invalid assignment" type
error. Use typed Array[String] locals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
auto_play.gd had an `if OS.get_environment("AUTO_PLAY_ALL_AI")...` block at
class-body scope (lines 50-58) — invalid GDScript ("Unexpected if in class
body"), so the AutoPlay autoload failed to parse/load entirely: the autoplay /
RL-balance harness was non-functional and GUT logged a parse error at startup.
Relocated the (env-gated) reset into _ready where statements are legal. The vars
already default to the reset values, so behaviour is unchanged; this purely
restores the autoload to a loadable state.
Verified: gdlint parse-clean; headless boot exit 0 with no auto_play parse error.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
setup(winner, reason, awards: Array[Dictionary]) rejected the untyped [] literal
("does not have the same element type as expected typed array"). Pass a typed
empty array.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
replay_viewer.gd:_ready accesses %TitleLabel, %PlaceholderLabel, %Speed1x,
%Speed2x, but those .tscn nodes lacked unique_name_in_owner (the prior i18n
commit added the %-accesses without flagging the nodes). %TitleLabel/% Placeholder
are read unguarded in _ready → "Node not found" + null .text assignment, which
fires whenever the replay viewer is shown (standalone past-games OR embedded in
the statistics replay tab) — a real runtime bug, not just a test artifact.
Clears test_statistics_modal + removes 9 %TitleLabel / 11 null-text error bleeds
into other tests (GUT global-error entanglement).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_ecology_tile_inspector + test_ecology_grudge_badge did
TileInfoPanelScript.new() / CombatPreviewScript.new() — bare scripts whose
@onready %UniqueName labels resolve to null and log "Node not found" for every
label, which GUT flags as unexpected errors. Instantiate the real .tscn instead
(tile_info_panel.tscn lacks EcologySpeciesList, so the "absent list" scenario
still holds). Clears both tests + removes ~13 cross-test %node error bleeds.
GUT: 31 → 27 failing; Node-not-found errors 22 → 9.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same p2-71c guard as turn_processor: both _make_state and the barren-tile test
called add_player_militarist without set_units_runtime_catalog_json, so no player
spawned. Harvest the runtime catalog first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
add_player_militarist has a p2-71c guard that returns -1 (refuses to spawn)
unless set_units_runtime_catalog_json() is called first — otherwise MapUnit
base_moves=0 and the sim freezes at turn 0. The test never loaded the catalog,
so 0 players spawned → all downstream assertions (cities/units/wealth/fauna) read
0. _make_state now harvests the same catalog the real bridge does
(AiTurnBridge._harvest_runtime_units_json).
GUT: test_gd_turn_processor 14 → 2 failing; suite 33 → 32.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Research was completely dead in the headless simulation path (the RL trainer,
the MCP, and the hotseat driver all use it): the per-turn TurnProcessor's
tech_web_parsed was always None, so process_science never advanced anything.
0 techs ever completed despite science_per_turn climbing to 466 — units stayed
frozen at tier-1 dwarf_warrior and AI-vs-AI ground to an unbreakable stalemate.
The processor already self-drives research (auto-picks the next available tech
in topological order once a TechWeb is loaded); the only defect was nothing
loaded it.
- GameState gains tech_web_json (#[serde(skip)], boot-loaded like the AI
catalogs).
- apply_end_turn threads it into the fresh per-turn processor via
set_tech_web_json before step().
- GdPlayerApi::set_tech_web_json stamps it onto the dispatch state AFTER
load_state_json (serde(skip) means it can't ride through gs.to_json()).
- The headless harness flattens every public/resources/techs/*.json pillar
(prereqs cross pillars, so all load together) and calls the setter at boot.
Proven (hotseat self-play, seed 42): techs_done 0 → 10 → 40 → 78 → 109 by
turn 120. mc-player-api 132 tests green.
Known next layer (separate gap, not this fix): completed tech does not yet
translate to better units — production still builds only tier-1 warriors even
at 109 techs, despite higher-tier units (steam_golem, iron_sentinel, …) existing
in the data. That's a production-picker/catalog issue to chase next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- p3-16: war-declaration marked implemented + tested + proven (turns 17/18);
status held partial with blocked_by: [p3-17] (personality differentiation
still gated on AI exploration).
- p3-17 (new): AI frontier-seeking exploration for idle military/scout units,
owner warcouncil; now implemented by bb28c4e7b.
- regen objectives dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Idle military units (no combat target, no locked target) fell to a garrison
patrol that kept them next to friendly cities, so the AI never physically
explored to discover the rival — starving both target-acquisition and the
war-dec discovery gate (p3-16). decide_movement now drives such units toward
the far side of the map (in a duel the rival sits opposite the player's own
holdings) with a per-unit lateral offset so a stack fans out instead of
clumping. Reuses emit_move_toward; a passable-hex set keeps moves on land;
keyed on unit.id so it stays deterministic. Runs before the garrison fallback
(an idle unit with no known enemy is more useful scouting than fortifying).
Tests: 2 new movement cases (steps toward far side; never onto impassable
water). mc-ai 276 green.
Honest measurement note: in seed-42 hotseat self-play the headline summary
barely moves — expansion (30 cities founded) already drives first contact
~turn 17, which is distance-limited (~33 hexes at 2 mp/turn), and the
unit_moved event is a noisy proxy (a position probe shows both sides' military
marching toward each other every turn while the counter sits at ~45). The
value here is correctness — idle military now seeks the frontier deterministically
rather than idling — not a dramatic self-play metric swing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The AI had no war-declaration logic — decide_tactical_actions ran
movement→combat→settle→production→citizens with no diplomacy step, and there
was no DeclareWar anywhere in mc-ai. Under the courier model (pairs start at
peace, war begins on war-dec dispatch) that meant AI-vs-AI sat at perpetual
peace: no enemy targets, armies never maneuvered, and clan aggression never
manifested (warmonger == builder).
- New decide_diplomacy step (runs first): opens hostilities against a
*discovered* rival (visible units/cities in the fog projection) once own
military clears an aggression-scaled superiority bar (thresholds::
dominance_factor — warmongers strike near parity, cautious clans need an
edge). Pure/deterministic.
- New Action::DeclareWar { target }; routed in both dispatch converters to
PlayerAction::DeclareWar → apply_declare_war → comms_dispatch::
dispatch_war_declaration (same path the human uses; sender enters War on
dispatch). Rollout apply flips the relation for lookahead fidelity.
- Made movement::{is_at_war,count_military} pub(super); refreshed the stale
is_at_war comment to the courier model (per p3-16 cleanup-alongside note).
- Tests: 5 mc-ai diplomacy cases (discovery gate, already-at-war, no-army,
aggression bar) + a dispatch round-trip. mc-ai 274 + mc-player-api 131 green.
Proven live (hotseat self-play, seed 42): war-decs dispatch on first contact
(turns 17/18). Full aggressive play is still capped by a SEPARATE gap — the AI
does not scout, so it rarely sees enemy *cities* to march on even once at war.
That exploration gap is the next limiter, tracked separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two diplomacy models contradicted each other in the written record: p1-01
diplomacy-lite ('all pairs start at war, missing key → war') vs the newer
courier-diplomacy (COMMUNICATIONS.md §War declaration semantics, p3-01:
start at peace, sender enters War on war-dec envelope dispatch). The Rust
implementation follows courier-diplomacy, so that is canonical.
- p1-01: add a SUPERSEDED banner + inline [SUPERSEDED] annotations; history
retained. Canonical rule is start-at-peace, war via dispatched war-dec.
- COMMUNICATIONS.md: fix the one internal inconsistency (§0 said recipient
war state applies at arrival in a way that read as all-effects-at-arrival;
scoped it to recipient-side, cross-linked the sender-on-dispatch exception).
- New objective p3-16 (status partial, owner warcouncil): the AI has no
proactive war-declaration — decide_tactical_actions has no diplomacy step
and there is no DeclareWar in mc-ai, so AI-vs-AI never enters war and clan
aggression personalities don't manifest. Specs the fix to the courier model
(first-contact + military balance + aggression → dispatch_war_declaration)
and notes the stale is_at_war comment as a code-fidelity cleanup.
- Register p3-16 under warcouncil; regen objectives dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
project_tactical_player hardcoded moves_left: 2 for every unit (a stale 'bench
MapUnit doesn't model moves_left' comment) while MapUnit::movement_remaining is
the field the move dispatch actually decrements and the player view gates legal
moves on. The AI therefore believed every unit always had movement, planned
moves for already-exhausted units, and the dispatch rejected them.
Measured over a 200-turn hotseat self-play (seed 42): 'no movement points
remaining' move rejections dropped 10,862 → 0 (total controller misfires
10,972 → 110, the rest legitimate path/stacking edge cases). The AI's
world-model now matches enforcement; turns no longer churn through ~54 dead
move attempts each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>