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>
Exposed by a new hotseat full-game driver (drives both player seats over the
multi-slot wire, no AI dependency) — a 31-turn 2-player game surfaced these.
- mc-player-api: the AI→PlayerAction converter (apply_ai_action + the suggest
sibling) emitted the bare tactical city index ("0") for QueueProduction, but
find_city_indices needs the projector wire id "{player}_{c_idx}" — so every
AI/suggested queue_production failed UnknownCity. This silently broke the
in-box AI's production-steering, not just the wire. Emit the wire id at all
three sites; thread slot into the suggest converter; add a regression test.
Result in the playthrough: roundtrip failures 58→1, city_building_completed 0→18.
- api-gdext: advance_round_phase/end_player_round_phase did not compile at HEAD —
godot-rust 0.2.4 Array::push needs &Dictionary (AsArg); Pcg64 builds via ::seed
not ::seed_from_u64; dropped a dead rng binding. The gdext crate could not be
rebuilt from source until this.
- mc-worldsim: pub use GamePhase/RoundPhase (api-gdext references them through
mc_worldsim; they were a private re-export → E0603).
- tooling: add hotseat_playthrough.py — applies each seat's suggested actions
and flags any offered action that fails to apply, with severity triage.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ranker: dedicated POI_GATES/POI_QUALITY for landmarks & lairs — they were
routed through BUILDING_GATES (roof_visible/single_building/no_front_facade),
so volcanoes, ley-confluence formations and lair camps failed every gate and
burned generations to the regen cap.
- grok_generator: _ensure_grok_sdk re-checks the import per candidate path and
raises a clear 'set GROK_BUILD_SDK_PATH' error instead of a cryptic ImportError.
- grok_generator: submit_batch generates a sprite's variants concurrently via
asyncio.gather so the client's max_concurrent semaphore is actually used.
- add test_grok_pipeline.py — 49 headless checks (factory, POI-gate routing
regression, prompt adaptation, PNG validation, starter manifest).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two committed-code bugs surfaced once the concurrent session landed its changes:
- game_state.gd never declared `trade_ledger_json` (diplomacy.gd writes/reads
GameState.trade_ledger_json) → "Invalid access" on every diplomacy render, and
serialize()/deserialize() omitted both `diplomacy` and `trade_ledger_json` so
they were lost on save/load round-trip. Declared the field + added both to
serialize/deserialize.
- diplomacy.gd called GdTradeLedger.from_json("") unconditionally → "EOF while
parsing". Guard: keep the fresh empty ledger when the JSON is empty.
Clears test_diplomacy_panel entirely + the trade_ledger save round-trips.
GUT: 40 → 33 failing (trade_ledger_json "Invalid access" 18→0, from_json EOF 18→0).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>