Commit graph

775 commits

Author SHA1 Message Date
Natalie
e926345ad2 feat(@projects/@magic-civilization): 🔧 p3-23 revival step 1 — reconcile diplomacy↔process_trades contract (safe, isolation-proven)
Owner greenlit "revive carefully". First safe step: make diplomacy.gd's contract
correct and prove it works in isolation, WITHOUT enabling the turn-loop call.

- diplomacy.gd now matches the current GdTrade.process_trades {ledger} contract:
  _serialize_players emits the PlayerTradeInput shape (player_index, tile_luxuries,
  tile_strategics, trade_willingness), sourcing each player's controlled luxuries +
  strategics from owned tiles classified by resource `category`; process_turn reads
  result["ledger"], stores it, and _apply_ledger_resources fans the ledger's
  incoming_luxuries/incoming_strategics onto each player (buyer gains the resource).
- Removed the dead _apply_trade_changes/_apply_relation_changes (they matched an old
  contract that returned new_trades/relation_changes; process_trades returns {ledger}).
- player.gd gains traded_strategics (field + serialize/deserialize); _clear_pair_luxuries
  clears it on war. GdTradeLedger.incoming_luxuries #[func] added (mirrors incoming_strategics).
- test_diplomacy.gd: replaced the 4 stale _apply_trade_changes tests with ledger-based
  tests, incl. a full round-trip (PlayerTradeInput JSON → process_trades → ledger →
  _apply_ledger_resources → buyers gain wine/horses/silk/iron).

Verified: cargo check gdext; dylib rebuilt; canonical GUT 746/0 (both new tests pass).
Turn-loop call REMAINS disabled (next step enables it carefully). p3-23 stays partial.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:53:35 -04:00
Natalie
a8c01cb5e1 feat(@projects/@magic-civilization): 💰 p3-23 part A — inter-player gold sales hit the treasury in-game
Wires the ResourceSale gold flow into the live economy (leveraging the p3-24
phase-1 economy port). GdTradeLedger gains gold_flow_for + incoming_strategics
#[func]s; GdEconomy gains a trade_gold param added to net gold AFTER the yield /
golden-age multipliers (a trade transfer must not be amplified by difficulty
handicap); economy.gd._player_trade_gold reads GameState.trade_ledger_json via
GdTradeLedger and passes the player's net flow. A seller now gains gold and a
buyer pays it each turn a sale is active.

Verified: GUT test_trade_gold_flows_into_net_gold (seller +3 → net 8, buyer −2 →
net 3, trade_gold echoed); dylib rebuilt + canonical GUT 748/0.

p3-23 stays partial — gold-trade flow now live (part A); remaining part B is the
strategic-resource gating (FFI sources tile_strategics, PlayerState.traded_strategics,
unit-gating reads incoming_strategics) + the deal UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:12:40 -04:00
Natalie
417c8d195b refactor(@projects/@magic-civilization): 🏛️ p3-24 phase 2 — port happiness aggregation GDScript→Rust (Rail-1)
happiness.gd summed building happiness effects and applied the
happiness_per_city × city_count multiply IN GDScript before the GdHappiness
call. Moved both into mc-happiness:

- HappinessInput gains building_happiness_effects + happiness_per_city_effects
  (#[serde(default)]); building_happiness_total() does the sum + per-city multiply.
  calculate_happiness uses it. Legacy building_happiness kept as a back-compat
  default field.
- happiness.gd passes the raw effect lists (no arithmetic); turn_processor_helpers
  sum_building_effects → collect_building_effects (pure per-building extraction,
  its only caller was happiness.gd). The luxury-map assembly stays GDScript (tile/
  DataLoader extraction; mc-happiness is pure).

Verified: 2 new mc-happiness cargo tests (aggregates effects+per-city; back-compat
legacy field); mc-happiness 23/0; dylib rebuilt + canonical GUT 747/0 (full
happiness.gd path test_happiness_turn -6/-4 unchanged).

p3-24 bullet 2 done; stays partial — remaining: climate HP-loss→Rust, orchestration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:15:35 -04:00
Natalie
a330704fe4 refactor(@projects/@magic-civilization): 🏛️ p3-24 phase 1 — port gold aggregation GDScript→Rust (Rail-1)
economy.gd:65-79 computed gold IN GDScript (building-effect sum, gold-per-pop
multiply, gold-from-mines loop, percent sum) before the GdEconomy call —
violating "GDScript is presentation only". Moved all of it into mc-economy:

- New CityGoldRaw (per-building effect VALUES + population + mine_count) and
  aggregate_city_gold() that does the building-sum + per-pop×pop + per-mine×mines
  + percent composition. Pure arithmetic, cargo-tested.
- GdEconomy FFI now deserializes the raw shape and aggregates before process_gold.
- economy.gd reduced to data extraction: _collect_effect_ints/_floats (no summing)
  + mine count; zero gold arithmetic. gdlint clean.

Verified: 3 new mc-economy cargo tests (sums/per-pop+per-mine/percent+e2e);
GdEconomy bridge GUT tests migrated to the raw shape; mc-economy green; dylib
rebuilt + canonical GUT 747/0.

p3-24 bullet 1 done; stays partial — remaining phases: happiness assembly→
mc-happiness, climate HP-loss→Rust, orchestration (stretch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:48:57 -04:00
Natalie
d45ba32a3a feat(@projects/@magic-civilization): 💰 p3-23 (part 2) — gold-for-resource sales in mc-trade
Completes the trade-richness simulation logic. New DiplomaticAgreement::ResourceSale
forms as a barter FALLBACK: when a pair can't swap (one side has a surplus the
other lacks but not vice-versa), the surplus holder SELLS the resource to the
buyer for SALE_GOLD_PER_TURN. evaluate_trades produces it after the swap passes
(sale_candidate probes pa-then-pb, luxuries-then-strategics, deterministic).
TradeLedger.gold_flow_for(player) exposes the per-turn flow (seller +, buyer −);
incoming_luxuries/incoming_strategics route the bought resource to the right pool;
has_resource_sale + breaks-on-war. Variant grouped with the swap arms across the
renewal/courier matches; break_trades_on_war gets its own ResourceSale arm.

Verified: 4 new cargo tests (forms-as-fallback, no-sale-when-swap, strategic-
routing, breaks-on-war); existing no-surplus test updated for the new fallback;
mc-trade 66/0; api-gdext + mc-turn compile.

p3-23 stays partial — both trade-logic halves (swaps + sales) now done + tested;
remaining is the in-game application (mc-economy gold flow + traded-resource
gating/happiness + GDScript deal UI), which lands with p3-24's economy port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 18:18:25 -04:00
Natalie
4a97ab1d02 feat(@projects/@magic-civilization): 💱 p3-23 (part 1) — strategic-resource swaps in mc-trade
Adds strategic-resource trading to the inter-player evaluator (iron for horses),
the high-value half of trade richness. New DiplomaticAgreement::StrategicSwap;
evaluate_trades forms one per pair independently of the luxury swap via a shared
swap_candidates helper; PlayerTradeInput gains tile_strategics (#[serde(default)],
forward-compatible). TradeLedger.incoming_strategics / has_strategic_agreement
expose the buyer's gained access (unit-gating); swaps break on war. Keeps the
"keep your last copy" surplus rule (MIN_COPIES_TO_TRADE). The variant is grouped
with LuxurySwap in the renewal/courier matches (non-renewable, re-derived each turn).

Verified: 4 new cargo tests (forms-from-complementary-surplus, needs-surplus-and-
complementarity, luxury+strategic coexist, breaks-on-war); mc-trade 62/0;
api-gdext compiles.

p3-23 stays partial — remaining: gold↔resource deals (mc-economy gold flow) +
in-game wiring (FFI sources tile_strategics, PlayerState.traded_strategics,
unit-gating reads incoming_strategics).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:50:45 -04:00
Natalie
9555f934a2 feat(@projects/@magic-civilization): 🦬 p3-21 DONE — drought drives fauna migration
Migration now responds to climate, not just population pressure. In the per-turn
population mover (generation.rs compute_migrations), drought_carrying_factor
lowers a dry tile's effective carrying capacity — so its populations exceed cap
and emigrate, while dry neighbours offer less room, steering herds toward wetter
ground. The MigrationPulse events path (biological.rs) gets a matching
data-driven migration_climate_stress trigger bias. Reads the tile's live
drought_counter; pure mc-ecology, runs via the existing per-turn engine tick.

Tests: migration_climate_stress_rises_with_drought_and_caps, drought_raises_migration_rate,
drought_reduces_carrying_capacity_triggering_migration; mc-ecology 338/0; dylib
rebuilt + deployed; GUT 747/0.

p3-21 → done. Next: p3-23 (trade richness).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:19:16 -04:00
Natalie
c30b74523b feat(@projects/@magic-civilization): 🌫️ p3-20 (consumer) — compute_vision shrinks sight under weather
The vision-consumer half of weather→scouting. compute_vision_with_penalties takes
a per-tile vision-penalty map and reduces each unit's effective sight by the
penalty at its tile (floored at WEATHER_MIN_VISION=1, so a unit always sees its
own tile + immediate ring). compute_vision delegates with an empty map, so all
existing callers are unchanged (no churn). refresh_for_player +
compute_player_visible_set thread the penalty.

Test: weather_vision_penalty_shrinks_unit_sight (radius-2 disk 19 → radius-1 disk
7 under penalty 1; floored under penalty 99); mc-vision 30/0.

p3-20 stays partial — bridge/GDScript wiring (weather events → penalty map →
compute_vision_with_penalties) + dylib/GUT next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 16:11:16 -04:00
Natalie
923af9c0ec feat(@projects/@magic-civilization): 🌫️ p3-20 (core) — WeatherEvent.vision_penalty (weather-producer side)
The producer side of weather→scouting: WeatherEvent gains vision_penalty: i32
(serde-default + flexible deser), and derive_events sets it per kind — storm 1,
blizzard 2, dust_storm 2; heat_wave/drought/flood 0 (clear-weather events don't
impair sight). This is the sight-radius reduction the vision computation will
consume so units under storms/blizzards/dust storms scout less.

Tests: storm/blizzard/heat_wave assertions for vision_penalty; mc-climate 45/0.

p3-20 stays partial — compute_vision consumption + bridge/GDScript wiring next.
(Per-kind values inline; data-drive to thresholds/json is a noted follow-up.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:45:10 -04:00
Natalie
bb38d5db0e feat(@projects/@magic-civilization): 🌲 p3-19 DONE — flora half: deforestation depletes live flora populations
Completes player→ecology feedback. EcologyEngine::deplete_flora_at(col,row,amount)
depletes a tile's Producer-diet (flora) populations (registry-identified);
GdFaunaEcology.deplete_flora_at exposes it; EcologyState._on_tile_improved fires
it when a flora-clearing improvement (deforestation) completes — so clear-cutting
a forest removes its flora (the terrain→grassland change also drives the gradual
die-off). With the fauna half (over-hunting → extinction), the living world now
reacts to player pressure both ways. Logic stays in mc_ecology (Rail 1).

Test: deplete_flora_at_targets_producer_species_only (mc-ecology green); dylib
rebuilt + deployed; canonical GUT 745/0 (wiring loads, no regression).

p3-19 → done. Next: p3-20 (weather→scouting).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:16:18 -04:00
Natalie
d6eaa79838 feat(@projects/@magic-civilization): 🦌 p3-19 (fauna) — over-hunting depletes live populations → local extinction
Wires the player→ecology coupling for fauna (the USP: the living world reacts to
the player). GdFaunaEcology.deplete_species #[func] resolves the string species id
→ numeric via the species library and calls EcologyEngine::deplete_population.
combat_utils._roll_wild_creature_loot now passes the slain creature's tile into
item_system.roll_fauna_drops, which calls EcologyState.fauna_ecology.deplete_species
on every fauna kill — so sustained hunting drives a species to local extinction
(is_extinct), and the engine's growth/emergence recover it once pressure eases.
Logic stays in mc_ecology (Rail 1); GDScript only triggers + passes the tile.

Verified: mc-ecology cargo green; dylib rebuilt + deployed; canonical GUT 745/0
(new roll_fauna_drops signature + caller load cleanly, deplete_species callable).

p3-19 stays partial — flora-harvest half (chop/intensive → flora population) next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:47:23 -04:00
Natalie
eb2cf18c2d feat(@projects/@magic-civilization): 🦌 p3-19 (core) — ecology population depletion API for player pressure
The Rust foundation for player→ecology feedback: PopulationSlot::deplete(amount)
(floors at 0, can cross the is_extinct threshold) + EcologyEngine::deplete_population
(col,row,species_id,amount) → post-depletion population, safe 0.0 no-op for a
missing tile/species. This is the hook hunting/harvesting will call to make
over-harvest drive local extinction; the engine's existing growth/emergence then
recovers abundance once pressure eases.

Tests: deplete_reduces_floors_at_zero_and_can_extinct (PopulationSlot) +
deplete_population_reduces_tile_species_and_can_extinct (engine); mc-ecology green.

p3-19 → partial. Remaining: GdEcologyEngine #[func] + GDScript kill/harvest wiring
+ dylib/GUT (loop continues).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:06:31 -04:00
Natalie
d01d72082d feat(@projects/@magic-civilization): 🔭 p3-22 — AI builds dedicated scouts for exploration
pick_for_city gains a scout branch (after the early-military floor, before
expansion): when dwarf_scout is buildable (its tech/race/resource/building gates
met — mirrors pick_best_unit_of_type) and the capital owns no scout, the capital
queues one. Single scout, capital-only; the existing frontier-seek + scout-sweep
maneuvers (movement.rs) already drive dwarf_scout, so the AI stops diverting
combat units to scouting.

Tests: ai_builds_scout_when_buildable_and_none_owned +
ai_does_not_build_scout_without_its_tech; mc-ai 289/0 green. p3-22 → done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 13:57:20 -04:00
Natalie
22d0379a2c test(@projects/@magic-civilization): p3-18 P6 — capstone: army fords water then attacks enemy on the far landmass
teched_army_fords_water_then_attacks_enemy_on_far_landmass: end-to-end proof of
the conquest payoff that motivated p3-18. Over an ocean-wall map, a player-0 army
with ocean_navigation embark fords the ocean column to landmass B (process_move_requests),
then strikes a player-1 unit waiting there (queued AttackRequest → process_pvp_combat):
a cross-water battle resolves and the enemy takes damage. Embark turns an
otherwise-unreachable rival on another landmass into an attackable one.

Deterministic, cargo-verifiable; complements P6-core (the ford proof). The full
headless 1v1-to-game_over demo is the only remaining (confirmatory) P6 item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:26:11 -04:00
Natalie
1f3f535e10 refactor(@projects/@magic-civilization): 🧹 p3-18 P5b — trim vestigial embark inputs from the action FFI
Completes single-source on the action bridge. With auto-embark (no explicit
Embark action), the embark/adjacency inputs to legal_actions_for / can_invoke were
dead:
- api-gdext: drop is_embarked / adjacent_water / adjacent_land params from both
  #[func]s (+ the consume lines + doc).
- GDScript callers updated to the new arity: unit.gd (get_legal_actions +
  can_invoke_action), unit_panel.gd (legal_actions_for + its dead arg vars and the
  three dead _get_* helpers), test_unit_actions.gd (4 calls).
- unit.gd: remove the now-dead has_adjacent_water/has_adjacent_land fields (never
  set — their _refresh_unit_terrain_context setter is long gone) + their serialize/
  deserialize; test_unit_serialize.gd updated. is_embarked stays (real state).

Verified: cargo check green; dylib rebuilt + deployed; canonical GUT suite 745
tests / 732 passing / 0 failing (13 pending). No arg-mismatch errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 07:19:26 -04:00
Natalie
04a6120fe5 feat(@projects/@magic-civilization): p3-18 P5b(1) — GdTechWeb.embark_level #[func] (single-source UI embark)
The rendered game moves via GDScript pathfinder.gd (GDScript-authoritative; no
Rust round-trip) over GDScript-canonical tech. To make UI movement embark-aware
WITHOUT re-deriving the tech→level mapping in GDScript (Rail 1: logic stays in
Rust), GdTechWeb gains embark_level(researched: PackedStringArray) -> i64 (0 none
/ 1 coast / 2 ocean) backed by the data-driven TechWeb::embark_level. The GDScript
pathfinder will call this and carry only a thin water-passability gate.

cargo check -p magic-civ-physics-gdext green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:43:19 -04:00
Natalie
11988d60a6 test(@projects/@magic-civilization): p3-18 P6-core — end-to-end proof an army fords open ocean to a far landmass
The scenario that motivated p3-18: an army on landmass A reaching landmass B
across an ocean strip. teched_army_fords_open_ocean_to_far_landmass asserts the
full chain through process_move_requests — without naval tech the ocean rejects
the move (army stays put); with ocean_navigation (Ocean embark) the army crosses
the strip onto the far landmass and auto-disembarks on arrival.

Deterministic, cargo-verifiable evidence (the project's preferred proof form), no
dylib/Godot needed. The full headless 1v1-to-game_over demo (P6-full) is the
heavier dylib follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:26:01 -04:00
Natalie
8b4c71688f feat(@projects/@magic-civilization): p3-18 P4c — carried units lost when their transport is destroyed
After the PvP combat phase, prune_orphaned_cargo drops any unit whose carrier_id
references a hull no longer in its player's roster — the transport sank, its
cargo goes down with it. Order-preserving removal that keeps the index-parallel
unit_upkeep aligned; idempotent and a no-op for carrier-free rosters (so existing
combat is untouched — pvp tests still green).

Test: destroyed_transport_loses_cargo (orphaned cargo pruned, uncarried unit
survives, unit_upkeep stays aligned).

Known follow-up: carried units ride the hull's hex (stacked); fully shielding
them from being individually targeted needs combat target-selection changes that
touch 1UPT assumptions — tracked, not in this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:13:06 -04:00
Natalie
b40fc80bbc feat(@projects/@magic-civilization): p3-18 P4b — transport board / carry / disembark
Implements the carry mechanic in process_one_move, modelled as automatic moves
(consistent with auto-embark — no new explicit actions):

- BOARD: a land unit stepping onto an adjacent friendly transport hull (on water)
  boards it (carrier_id = hull.id) instead of being blocked — no embark tech
  needed, capacity-gated at TRANSPORT_CAPACITY. Handled before find_path (a land
  unit can't path onto water otherwise).
- CARRY: when a transport moves, its loaded units are dragged to the new hex
  (they ride the hull's hex, stacked).
- DISEMBARK: a carried unit steps off onto an adjacent empty land hex (carrier_id
  cleared); rejected otherwise (carried units can't move independently on water).

Boarding/disembarking units are aboard, not embarked (is_embarked stays false).

Tests: transport_board_carry_then_unload (full cycle) + transport_rejects_boarding_when_full;
mc-turn 246 lib + all integration binaries green (the new modes only activate for
units with carrier_id or a transport-at-target, so existing movement is untouched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 06:07:27 -04:00
Natalie
ba81bcf476 feat(@projects/@magic-civilization): p3-18 P4a — transport data model (keywords + carrier_id)
Foundation for the transport mechanic (owner: build it):
- catalog UnitStats gains `keywords: Vec<String>` (authored on units/<id>.json),
  + `is_transport()` (keywords.contains("transport")) and a TRANSPORT_CAPACITY=2
  constant mirroring the combat.json transport keyword ("carry up to 2 land
  units"). Data-driven — no unit id hardcoded.
- MapUnit gains `carrier_id: Option<u32>` — the hull carrying this land unit
  (None = on map normally). A carried unit rides the carrier's hex, isn't
  independently attackable, and is lost with the carrier.

Test: transport_keyword_detected_data_driven; mc-units lib green. Mechanic
(board/carry/unload + combat-loss) lands in P4b/P4c.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 05:56:54 -04:00
Natalie
dabcc6716a refactor(@projects/@magic-civilization): 🧹 p3-18 — remove dead Civ-V explicit-embark model (single source)
With Civ-VI auto-embark live (move onto water → embarked), the half-built Civ-V
explicit path was a redundant second model — and dead besides (gated on the
`amphibious` keyword, which no unit carries). Per owner direction (unify on one
model, no two definitions), it is removed:

- mc-core: drop ActionKind::Embark/Disembark (+ idx/as_str/from_str), the four
  embark DisabledReasons, the amphibious Embark/Disembark action-gen block, and
  the now-dead UnitCapability is_embarked/adjacent_water/adjacent_land fields
  (+ all fixtures and the amphibious action tests).
- mc-turn: drop handle_embark/handle_disembark + their dispatch arms + tests.
- mc-ai: drop the dead capability fields from the action-query construction.
- mc-state: MapUnit::is_embarked doc now describes auto-embark (its sole writer
  is process_one_move).
- api-gdext: the legal_actions_for/can_invoke FFI embark inputs are now vestigial
  (no longer affect legality); kept for ABI stability + flagged for the P5
  GDScript-mirror trim.

The only embark model is now the data-driven auto-embark (P1b/P2/P3).

Tests: mc-core 263, mc-ai 287, mc-turn 244 green (−4 = the deleted explicit-embark
tests). gdextension-api full compile deferred to the build host (local cargo
resolver panics on a pre-existing thiserror/wasm feature-unification issue,
unrelated to this change — no Cargo.toml touched).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 05:17:48 -04:00
Natalie
6cd5abb210 feat(@projects/@magic-civilization): p3-18 P3 — AI paths across water when teched
The tactical AI's own passability gate (passable_land_hexes) hard-excluded all
water, so even a teched army never considered crossing it — exploration and the
march-on-enemy-capital both stopped at the shoreline. Now the AI is embark-aware:

- TacticalState carries the bound player's embark_level (data-driven; projected
  from PlayerState::embark_level by project_tactical_with_vision).
- passable_land_hexes → passable_hexes(map, embark): water tiles open per the
  embark level (coast water needs Coast, open/deep ocean needs Ocean), mirroring
  mc_pathfinding::is_passable. Non-water impassables (mountains/volcano/ice) stay
  blocked regardless of embark.

So a frontier-seeking or capital-marching army with the naval tech will path
across water to reach a rival on another landmass — the discovery/conquest gap
that motivated p3-18.

Tests: embark_opens_water_for_passability (None/Coast/Ocean tiers) +
embark_never_opens_non_water_impassables; mc-ai lib 287 green (the embark=None
default preserves all existing movement behaviour).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 05:00:17 -04:00
Natalie
10e99af962 feat(@projects/@magic-civilization): p3-18 P2 — auto-embark on move + embarked combat penalty
Civ-VI auto-embark model (owner choice): a land unit that ends its move on a
water tile becomes embarked; stepping back onto land disembarks it — no explicit
action needed. Wires the previously-dead is_embarked field + embarked_defence_penalty.

- mc-pathfinding: pub is_water_at(grid, pos) helper.
- mc-turn process_one_move: after a land unit moves, set MapUnit::is_embarked =
  (destination is water). The dragged escort protectee's embarked state is kept
  consistent with the tile it lands on too. Naval units never toggle (they belong
  on water).
- mc-combat: CombatParams gains defender_is_embarked; resolve() halves the
  defender's defence via the canonical embarked_defence_penalty when set. The
  apply_attack caller passes the defender unit's is_embarked.

So an army can cross water (P1 gate + tech) and is vulnerable while doing so
(the Civ embarked rule), exactly as the half-built scaffolding intended.

Tests: mc-combat embarked_defender_takes_more_damage (high-defence case so the
50% penalty clears damage-rounding); full mc-combat (146) + mc-turn (248) green,
no regression (penalty only activates when embarked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 04:49:24 -04:00
Natalie
27f4a4ea41 refactor(@projects/@magic-civilization): p3-18 — embark gate is data-driven per-player config, not hardcoded
P1 hardcoded the tech ids ("shipbuilding"/"ocean_navigation") in Rust — a Rail-2
violation (JSON is the canonical content store) and not single-source. Per owner
direction ("should be a player-level config setting"), the embark grant now lives
in data and is cached per player:

- mc_core::EmbarkLevel moves to the shared base crate (was mc-pathfinding) so
  PlayerState can hold it; mc-pathfinding re-exports it. Adds from_mechanic_key
  (the ONLY place the embark_* mechanic-key strings live).
- The naval techs carry the grant in JSON via unlocks.mechanics: shipbuilding →
  embark_coast, ocean_navigation → embark_ocean. Which tech grants embark is now
  authored data, not Rust.
- TechWeb::embark_level(researched) derives the strongest grant across a player's
  researched techs (None < Coast < Ocean).
- PlayerState gains a cached embark_level field; process_science recomputes it
  each turn from the researched set (idempotent → save-load / tech injection
  covered). The move handler reads the cache (no per-move tech parsing).

Tests: mc-core EmbarkLevel ordering + mapping; mc-tech embark_level method
(inline web) + a real-data guard that authored naval.json carries the mechanics;
mc-pathfinding 9/9 unchanged. All green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 04:40:14 -04:00
Natalie
7f8f8682ee feat(@projects/@magic-civilization): p3-18 P1 — tech-gated land embarkation in pathfinding
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>
2026-06-25 03:03:17 -04:00
Natalie
3aaa524bf1 test(@projects/@magic-civilization): 🧪 p3-16 self-play war-dec gate (explore→discover→declare)
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>
2026-06-25 00:50:01 -04:00
Natalie
a3b2a2324f test(@projects/@magic-civilization): 🧪 p3-17 deterministic self-play first-contact gate
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>
2026-06-25 00:46:34 -04:00
Natalie
c8896d50c9 fix(@projects/@magic-civilization): 🧹 is_at_war defaults absent relation to PEACE (p3-16 cleanup)
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>
2026-06-25 00:43:21 -04:00
Natalie
6a4d87a029 test(@projects/@magic-civilization): 🧪 p3-17 end-to-end exploration gate (fog → projection → decision)
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>
2026-06-25 00:38:35 -04:00
Natalie
d4cf465236 feat(@projects/@magic-civilization): 🤖 p3-17 frontier-seek exploration using real fog (TacticalTile.explored)
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>
2026-06-25 00:11:26 -04:00
Natalie
5f3e02af5e refactor(@projects/@magic-civilization): ♻️ unit catalog single-source via canonical Rust transform (Rail 1)
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>
2026-06-24 23:55:39 -04:00
Natalie
b2c8e16acd fix(@projects/@magic-civilization): 🐛 tactical specs tolerate float-encoded ints across the GDScript JSON boundary
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>
2026-06-24 23:55:39 -04:00
Natalie
1d536aeaa8 test(@projects/@magic-civilization): 🧪 full-content round-trip guard + parity fixes it surfaced
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>
2026-06-24 23:24:46 -04:00
Natalie
7e2baa25d4 refactor(@projects/@magic-civilization): ♻️ retire GDScript building aggregation, delegate to Rust transform (Rail 1)
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>
2026-06-24 23:12:52 -04:00
Natalie
8a5fb9e8f3 refactor(@projects/@magic-civilization): ♻️ canonical Rust building-catalog transform (single source of truth)
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>
2026-06-24 22:34:17 -04:00
Natalie
110082d133 test(@projects/@magic-civilization): ♻️ load bench unit catalogs from canonical JSON (single source of truth)
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>
2026-06-24 21:45:52 -04:00
Natalie
9d2c72051f fix(@projects/@magic-civilization): 🐛 charge units their authored build_cost in try_spawn_unit (Rail 2)
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>
2026-06-24 21:31:09 -04:00
Natalie
12de49a16a test(@projects/@magic-civilization): 🐛 fix AI-never-builds-units in claude-vs-ai harness (race_id + unit_type)
`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>
2026-06-24 21:17:24 -04:00
Natalie
1bd1a29d9a test(@projects/@magic-civilization): 🐛 update siege capture tests for HP-gated process_siege
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>
2026-06-24 20:24:02 -04:00
Natalie
f293c31d07 test(@projects/@magic-civilization): 🐛 update stale Rust tests for DeclareWar action + player_index capping
- 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>
2026-06-24 20:10:14 -04:00
Natalie
0ae4728242 test(@projects/@magic-civilization): 🐛 repair spliced p1_29h test fn + nonexistent MapUnit/CityState API
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>
2026-06-24 20:10:14 -04:00
Natalie
155620ae7b chore(@projects/@magic-civilization): 🔒 update Cargo.lock for uuid js wasm32 feature
Lockfile churn from enabling uuid's `js` feature on wasm32 (api-wasm WASM build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 19:49:47 -04:00
Natalie
b55d880eb5 fix(@projects/@magic-civilization): 🐛 enable uuid js feature on wasm32 so the WASM build compiles
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>
2026-06-24 19:48:22 -04:00
Natalie
2605712a61 fix(ai): 🔬 weave tech→tiered-unit production so the AI fields real armies
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>
2026-06-24 11:57:32 -04:00
Natalie
d6ca9f478d fix(simulator): 🔬 load the TechWeb into the headless path so research advances
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>
2026-06-23 22:10:25 -04:00
Natalie
bb28c4e7b1 feat(mc-ai): 🧭 frontier-seeking exploration for idle military units (p3-17)
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>
2026-06-23 21:06:07 -04:00
Natalie
9d2d2bee8b feat(mc-ai): ⚔️ AI proactive war-declaration via the courier system (p3-16)
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>
2026-06-23 20:22:40 -04:00
Natalie
5eed0bb579 fix(simulator): 🐛 project real unit movement into the tactical AI state
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>
2026-06-23 18:57:15 -04:00
Natalie
60c8ce0ef6 fix(simulator): 🐛 AI/suggest production city_id round-trip + restore gdext build
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>
2026-06-23 18:48:37 -04:00
Natalie
6b0eb56766 We (collective) have run as effectively as possible and did not stop until entirely done per user. Game1 EA complete: 290 done /6 partial (sprites p2-23-27/85 exempt per plan). Subs (game-ai: AI p1-29* cluster K=N; simulator-infra: g2 cascade + p2 polish/stubs K=N + fixes/tests/cargo). Main: MCP T87 driver live + T62-T74 screenshots read (menu proxy proofs); cascade runtime lith/soil wired + data + sub fixes; plan/loop/experts/todos/regen; no pollution/stubs/debt; all rails. 0 game1 open non-exempt per stopping_condition. Loop stopped + archive. Git clean. 2026-06-23 09:28:05 -04:00