The live GDScript turn emitted `culture_researched` inline; the headless
Rust turn dropped tradition completions. Emit a `TurnEvent::CultureResearched`
at the completion site in `process_culture_research` (single-source per Rail-1),
translate it to the existing `wire::Event::CultureResearched` in dispatch (not
dropped), and surface it as a kind-tagged dict in `event_to_dict` so the live
turn_manager will receive it at the Rail-1 swap. Threaded the events sink into
the phase + both call sites.
Verified headless: mc-replay 18/0 (culture_researched_serde), mc-turn 287/0
(event_collector_wiring), mc-player-api 138/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
turn_result_to_dict now includes an "events" array (each TurnEvent mapped via the reused
replay::event_to_dict, now pub(crate)) — CityGrew, CityBordersExpanded, FloraSuccession,
CityBuildingCompleted, UnitCreated, CityCaptured, etc. So when turn_manager adopts
GdTurnProcessor.step (the Rail-1 swap), it can translate result["events"] → EventBus signals
and the GDScript turn orchestration can be deleted. gdext compiles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Regression fix: the B2 healing phase ran end-of-turn BEFORE siege and healed cities
unconditionally — so a besieged city healed (e.g. 30→50 hp) before the same turn's siege
resolved, defeating captures (last_survivor_via_capture got empty events: a 30-hp city +
3 attackers @15 = 45 dmg no longer captured after healing to 50). Bisected to this session
(passed at e926345ad; the healing phase introduced the bug).
Fix: process_healing_phase now snapshots all units' tiles and SKIPS healing any city with an
enemy unit on its tile (under siege) — real-time siege-suppress matching the live game's intent
(its `last_attacked_turn` window). Un-besieged cities still heal. Tests:
besieged_city_does_not_heal (new) + last_survivor_via_capture (restored) + healing 11/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Third + final p3-29 step-1 event. The ecology phase (uniform fn(&mut GameState) registry
signature, no event sink) buffers its flora-succession transitions into a transient
GameState.pending_flora_events; step() drains them into the TurnResult as
TurnEvent::FloraSuccession — single-source replacement for the GDScript turn's flora_succession
signal, avoiding a 40-call-site registry-signature cascade. Surfaced through all four TurnEvent
consumers + tested (step_drains_flora_buffer_into_flora_succession_events).
p3-29 step 1 DONE: the Rust turn now emits CityGrew + CityBordersExpanded + FloraSuccession —
the granular UI events the live game's GDScript turn emitted inline. Replay value now;
UI-parity ready for the swap (steps 3-5). Events-only → golden/combat unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second p3-29 step-1 event: the Rust turn emits border expansion (process_culture now takes
&mut events, collects claimed tiles + flushes TurnEvent::CityBordersExpanded with clan/city/hex)
— single-source replacement for the GDScript turn's inline `city_border_expanded` signal.
Surfaced through all four TurnEvent consumers + tested (culture_expansion_claims_frontier_tiles
now asserts the event). Events-only → no state change → golden/combat unaffected. 2/3 step-1
events done (CityGrew, CityBordersExpanded); FloraSuccession next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First step of the Rail-1 turn-unification: the Rust turn now EMITS city growth as
`TurnEvent::CityGrew` (process_city_production collects grown cities + flushes the event with
clan/city/population/hex), instead of growth being a silent state mutation only the GDScript
turn announced (`city_grew` signal). Surfaced through all four TurnEvent consumers (replay
turn(), mc-player-api wire drop, api-gdext replay dict, test labels).
Value now: richer replay. Value next: when turn_manager switches to GdTurnProcessor.step, it
translates this event to the EventBus.city_grew signal — letting us delete the GDScript growth
logic (Rail-1 DRY). Doesn't change turn state (events only) → golden + combat tests unaffected.
mc-turn city_growth_emits_city_grew_event green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two climate disaster categories via the existing magic_heat_delta forcing field (which the
climate physics adds to temperature each step, then decays — so the effect is persistent +
transient like the GDScript duration, no new grid field/physics change):
- solar: global warming — every tile's magic_heat_delta += global_heat.
- glacial (cold): cools a hex disk (magic_heat_delta += negative temp_delta) + dries it
(moisture_loss), skipping open water. (warm-T5 runaway + river-freeze deferred.)
apply_solar/apply_glacial + dispatch + match arms + tests. mc-climate 65/0.
Events: 9/12 live (wildfire/drought/volcanic/seismic/impact/tsunami/plague/solar/glacial);
pandemic/ecological fauna handled by the disease applier; marine via process_step; magical→G3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes the equipment loop — units can now obtain gear in headless play:
- ItemCombatBonus extended to a full item def (+ category + materials from production.materials);
the loader stores ALL items (craftable), not just combat-bonus ones.
- PlayerAction::CraftEquipment { unit_id, item_id } + dispatch::craft_equipment — looks up the
item def, verifies + consumes its raw materials from strategic_ledger (the refined outputs
B6a produces — recipe→craft chain), then pushes an EquippedItem onto the unit. Rejects on
unknown item / insufficient materials.
End-to-end: B6a refines raw→processed → CraftEquipment consumes materials → equipped gear →
B6b combat-read adds attack/defense. Tests: craft consumes+equips, rejects insufficient.
mc-player-api 138/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GdPlayerApi::set_item_combat_json — loads item combat bonuses onto GameState.
- player_api_main._apply_item_combat — stamps DataLoader.get_all_items() via the FFI at boot,
emitting item_combat_api_loaded.
Equipped gear now boosts units in real headless combat (table booted → combat-read live).
gdext compiles. Remaining for full B6b: a Craft/Equip action to populate units' gear + loot
drop-on-death/decay (B5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MapUnit += equipped: Vec<EquippedItem> (#[serde(default)]) — items a unit has equipped, to
drive combat bonuses + charge consumption + loot-on-death. mc-state now deps mc-items
(standalone crate, no cycle). Foundation for the combat-read (B6b/2) + Craft action (B6b/3).
mc-state builds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- GdPlayerApi::set_recipes_json — stores the recipe bundle JSON on GameState. Mirrors
set_improvement_defs_json.
- player_api_main._apply_recipes — reads public/resources/recipes/recipes.json (the
{recipes:[…]} bundle, 12 recipes) at boot and stamps it via the FFI, emitting
recipes_api_loaded.
Resource refinement now runs end-to-end in real headless play: boot recipes → recipe phase
consumes raw + produces refined into strategic_ledger each turn. B6a (recipe refinement)
complete. gdext compiles; dylib rebuild in progress.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the previously-unwired mc_city::recipes system into the turn:
- GameState += recipes_json (#[serde(skip)] boot) + load_recipes_json (mc-state holds the raw
bundle JSON; no mc-city dep).
- recipe_phase::process_recipe_phase — per player, loads strategic_ledger into a
ResourceStockpile, runs tick_recipes over the player's processing buildings (consume raw →
produce refined), writes the transformed ledger back. Registered in END_OF_TURN_PHASES
(ecology, healing, improvement_build, recipe_refine).
Refined resources land in strategic_ledger (which trade/economy already use), so it's a real
economic transform, not inert. Tests: refines, idle-on-shortage, no-op-without-recipes.
mc-turn recipe_phase 3/3. Remaining (2/2): FFI + harness to boot recipes.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Disease applies the full tier spec now (fauna/canopy/tier/o2/lair). Ocean-collapse remains
unwired with precise prereqs recorded (global_fish_stock aggregation + registry/has_tag
reconciliation) — a scoped real task, code+tests exist in isolation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverses the two deferrals in apply_disease_events — every EventTierData field now applies:
- o2_delta: atmospheric (global) depletion of grid.o2_fraction, once per fired event
(negative drives toward an anoxic ocean). o2_fraction is grid-level, so it's applied
after the radius loop, not per-tile.
- lair_kill_chance: an active lair (lair_tier > 0) on an affected tile is wiped
(lair_tier=0, lair_population=0 — same clear as evolution's lair removal) with the
configured probability via the deterministic rng.
Disease now applies fauna_loss + canopy_loss + tier_loss + o2_delta + lair_kill_chance —
the full tier spec. mc-ecology events 11/0 (+1 test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runtime wiring so improvements actually build + yield in a real headless game:
- GdPlayerApi::set_improvement_defs_json — loads the improvement defs (JSON array of
{id, build_turns, yields}) onto GameState. Mirrors set_ecology_species_json.
- player_api_main._apply_improvement_defs — stamps DataLoader.get_all_improvements() via the
FFI at boot (after load_state_json), emitting improvement_defs_api_loaded.
With this, the full B3 chain is live: BuildImprovement → pending (build_turns) → build-tick
completes → city_improvements → process_improvement_yields folds food/production in. gdext
compiles; dylib rebuild in progress.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
De-stubs the headless improvement pipeline:
- improvement_phase::process_improvement_build_phase — ticks pending_improvements down,
completes at 0 → appends the improvement id to the owning city's city_improvements (so it
yields). Registered in END_OF_TURN_PHASES (ecology, healing, improvement_build).
- dispatch::build_improvement — replaces the stub: validates via the action gate, finds the
worker's owning city (CityState.owned_tiles, else first city), queues a PendingImprovement
with turns_remaining from improvement_defs[id].build_turns. (improvement_id, previously
dropped by the dispatcher, is now plumbed through.)
- apply_end_turn repopulates the fresh processor's improvement_yield_table from
state.improvement_defs each turn, so process_improvement_yields actually folds food/
production into cities (was a no-op — table never loaded in real play).
Tests: build-tick (3), dispatch queue (1), registry order. mc-turn 279/0, mc-player-api 136/0.
Remaining (3/4): FFI + harness to boot improvement_defs from public/resources/improvements.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full_turn_golden values were frozen 2026-05-13, before the p3-26 B2 healing phase joined
step()'s end-of-turn sequence. Healing now restores units damaged in lair/ambient encounters
before the next turn, shifting encounter survival → exactly two values moved: p1.gold 320→319
and p0 unit count 13→14 (p0.gold, culture, pop, city counts all unchanged). Deterministic
(twenty_turn_determinism passes); a clean rebuild surfaced the staleness that incremental
builds had masked. Sanctioned golden update per the test's own "sequencing changes must update
these" contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the dependency cycle broken (5ee312e45), mc-turn can finally depend on mc-ecology, so
the ecology tick moves out of its mc-player-api::apply_end_turn special-case and into the turn
itself — all phases now live in one crate.
Introduces `mc-turn::sim_phases` — the end-of-turn world-sim phases declared as an ordered DATA
registry (`END_OF_TURN_PHASES = [ecology, healing]`, uniform `fn(&mut GameState)`), run after
climate inside step(). Extending the living world (marine, fauna disease, …) is now one line in
the registry + a phase module — no edit to step()'s body, sequence visible in one place.
- Moved ecology_phase.rs → mc-turn; added mc-turn → mc-ecology dep.
- step(): replaced the direct healing call with `run_end_of_turn_phases` (ecology → healing).
- Removed from mc-player-api: the apply_end_turn ecology call, module, file, and mc-ecology dep.
mc-turn 276/0 (incl. ecology + sim_phases tests), mc-player-api 135/0. Behavior identical
(ecology still ticks once per turn, now inside step). Dylib rebuild pending (functionally
equivalent; FFI surface unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mc-ecology depended on the entire mc-mapgen crate solely to reach `mc_mapgen::seed::*` —
which is itself just a 13-line re-export of `mc_core::seed`. So a low-level ecology crate
pulled in the high-level map generator to use a foundational RNG utility that already lives
in mc-core (the WORLDGEN_RNG PCG64/SeedDomain/derive contract).
Repoint fauna_select.rs + flora_select.rs to `mc_core::seed` directly and drop the mc-mapgen
dependency. This cuts the mc-ecology → mc-mapgen edge, breaking the
mc-turn → mc-ecology → mc-mapgen → mc-turn cycle that forced the ecology tick out of
mc-turn::step into mc-player-api::apply_end_turn.
Proven: `cargo check -p mc-turn` with an mc-ecology dep added now compiles (no cyclic error);
reverted the probe pending the TurnPhase-registry step that will move ecology back into the
unified phase list. mc-ecology 338/0 — determinism intact (seed code is byte-identical, was
already mc-core via the re-export).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seventh event category, the terrain side of plague (GDScript process_plague): on
target_terrain tiles in a hex disk, drop quality by tier_loss (min 1) + downgrade the biome
per terrain_downgrade (e.g. enchanted_forest → forest). apply_plague + dispatch_plague +
match arm + tests. mc-climate 63/0. 7/12 categories live.
Note: the FAUNA side of plague/pandemic (population mortality) is NOT this — that belongs to
mc_ecology's disease system, which is currently config-only (load_event_categories + structs,
no apply fn, only caller is disease_validate bin). Recorded as a real gap in p3-27.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runtime wiring so the ecology phase actually ticks in a real headless game (was a no-op
until the species library was supplied):
- GdPlayerApi::set_ecology_species_json (api-gdext) — loads the fauna species library (JSON
array of per-species file contents) onto GameState. Mirrors set_events_config_json; call
after load_state_json (the field is #[serde(skip)]).
- player_api_main._apply_ecology_species — reads public/resources/ecology/fauna/species/*.json
into an array + stamps it via the FFI at boot (right after _apply_events_config), emitting
ecology_species_api_loaded. Mirrors the live EcologyState species load.
gdext compiles clean. Dylib rebuild in progress.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ecology engine ticked only in the live game (GdFaunaEcology); the headless turn had no
living fauna. Now apply_end_turn drives mc_ecology::EcologyEngine every turn:
- process_ecology_phase (mc-player-api/ecology_phase.rs): build engine + species library from
boot-supplied fauna JSONs, restore populations from the persisted continuation state (or
seed_initial genesis on the first tick), process_step (mutates grid: emergence, population
dynamics, flora succession), then re-serialize the continuation state. Deterministic from
map_seed.
- Lives in mc-player-api, NOT mc-turn::step — mc-turn → mc-ecology → mc-mapgen → mc-turn is a
dependency cycle; the orchestration layer (apply_end_turn, right after step) avoids it.
- GameState += ecology_species_json (#[serde(skip)] boot-loaded fauna JSONs) + worldsim_state_json
(#[serde(default)] opaque continuation state — persists across the fresh-per-turn processor
AND save/load, matching the live worldsim_state save payload) + load_ecology_species_json.
Reuses the existing engine save/restore (continuation_state / restore_continuation_state) — no
new ecology logic, just headless wiring. No-op until the species library is boot-loaded (the
FFI + harness wiring is the next slice). mc-player-api 138/0 (+3 ecology tests), mc-state green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
project_resources hardcoded happiness_pool: 0 (a stub from before the happiness phase
existed). Now that process_happiness_phase computes player.happiness each turn, surface it
in the PlayerView so the headless view exposes it end-to-end (compute → projection → view).
mc-player-api 135/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates two live-game-only per-turn subsystems into mc-turn (parallel agent port,
integrated by file-extraction onto current main — worktree forked from a stale base, so a
merge was unsafe; new files extracted clean, shared-file edits re-applied manually, API
drift fixed):
B1 happiness + golden age (happiness_phase.rs):
- process_happiness_phase(state) — per player, assembles HappinessInput (city count,
citizens, luxuries, building bonus, growth tier) → mc-happiness → writes happiness pool +
status, advances the golden-age meter (progress/active/turns/count). Mirrors the live
_process_golden_age. Wired into step() after the per-player economy loop.
- Drift fix: p3-24 added building_happiness_effects/happiness_per_city_effects (Vec<i32>) to
HappinessInput; bench leaves them empty (scalar building_happiness carries the bridge sum).
B2 unit + city healing (healing.rs):
- process_healing_phase(state) — units regen by territory class (garrison 20 / fortified-
friendly 15 / neutral 10), cities heal toward max_hp. Mirrors _process_healing +
_process_city_healing. Wired into step() after climate.
State: 9 new #[serde(default)] PlayerState fields (happiness, happiness_status, growth_tier,
owned_luxuries, golden_age_active/turns/progress/count, building_happiness) — all
backward-compat. mc-happiness promoted dev-dep → dep.
mc-turn 271/0 (incl. 16 new tests); mc-state green. Documented bench limitations: war-
weariness + enemy-territory healing + city siege-suppress need a tile-owner index (live
bridge writes them).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three geological natural-event categories (parallel agent port, extracted file-only onto
current main — the agent worktree forked from a stale base, so a branch merge was unsafe;
events.rs is a clean superset verified to compile + test against current HEAD):
- seismic — shifts tile elevation over a radius (with falloff).
- impact (asteroid) — T1-4 crater (biome/elevation/moisture/quality + local heat + aerosol);
T5 extinction (global aerosol injection). Magic/anchor/resource-spawn deferred (Game-3).
- tsunami — floods coastal land (moisture/quality/reef_health), skips open water.
Each: apply_X + dispatch_X + match arm in process_events + tests. Configs already present
(seismic/impact/tsunami.json) + auto-loaded via the headless harness, so they dispatch in
process_climate_phase with no extra wiring. mc-climate 61/0; mc-turn builds.
6/12 categories live (wildfire/drought/volcanic/seismic/impact/tsunami).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Third (and flagship) natural-event category, ported from GDScript process_volcanic:
apply_volcanic — center → volcano (quality 1), scorch disk turns non-water tiles to
scorched_terrain (desert, drier, quality 1), sulfate_aerosol injected in aerosol_radius
(climate physics converts to cooling next tick). dispatch_volcanic resolves the tier
config + picks a non-water center. Added to process_events dispatch. (Anchor/resource
spawns are magic → Game-3 deferred.)
Test: apply_volcanic_erupts_scorches_and_injects_aerosol (volcano center, desert scorch,
water spared, aerosol injected). mc-climate events 9/9. 3/12 categories live
(wildfire, drought, volcanic).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Activates natural events in real headless games:
- GdPlayerApi.set_events_config_json FFI → state.load_events_config_json.
- player_api_main.gd::_apply_events_config stamps DataLoader.get_ecological_events()
(the merged {category: config} map) onto GdPlayerApi after load_state_json (same
#[serde(skip)] re-stamp pattern as resource_categories / catalogs).
So a headless self-play game now loads the 12-category event configs → mc-turn's climate
phase fires wildfire (other handlers to follow). The firing mechanism is already proven
deterministically (climate_phase_fires_natural_events). FFI compiles; GDScript additions
gdlint-clean. Dylib rebuild needed for the live boot to call the new FFI (next).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Events now fire in headless self-play (when configs are loaded):
- mc-state: GameState.events_config (BTreeMap<String, serde_json::Value>, #[serde(skip)],
raw category→config so mc-state needs no mc-climate dep) + load_events_config_json loader
(no-clobber). Tested.
- mc-climate::events: EventCategoryConfig::from_raw + configs_from_raw (build typed configs
from the in-memory state map); load_event_configs refactored to share from_raw.
- mc-turn::process_climate_phase: after the climate tick + before weather (live-game order),
runs events::process_events on the grid when events_config is non-empty (max_tier 10;
era-cap is a follow-up). No-op when configs absent.
Test: climate_phase_fires_natural_events — an always-fire wildfire config on a forest grid
transforms forest→grassland during the climate phase. mc-turn 338/0, mc-state 13/0,
workspace cargo check clean (new GameState field broke no literals).
Next (makes it LIVE in real games): GdPlayerApi.set_events_config_json FFI + player_api_main
+ bench wiring to load public/resources/events/*.json; then the remaining category handlers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-turn dispatch ties the events core together, matching GDScript
ecological_events.process_events:
- turn_seed = seed*1000 + turn; per category in CATEGORY_ORDER (channel = index*10+10),
gate on category_fires(base_frequency), roll_severity (era-capped), apply the effect.
- Wildfire implemented end-to-end: dispatch_wildfire resolves the tier config (radius/
moisture_loss/becomes from raw JSON), picks a deterministic forest center, calls
apply_wildfire. Returns FiredEvent{category,tier,center,affected}. Other 11 categories
are recognised (gate+severity) with effect handlers to follow.
Test: an always-fire wildfire config on a forest grid → fires once, burns forest →
grassland, deterministic for (turn,seed). mc-climate events 7/7.
Tile-pick is Rust-deterministic (internal determinism; the dispatch GATE matches GDScript
bit-for-bit). Next: wire process_events into the mc-turn climate phase (config carried on
GameState) so wildfires fire in headless self-play, then the remaining category handlers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Next brick of the events port: load per-category configs from the canonical JSON
(Rail-2, nothing hardcoded).
- EventCategoryConfig { base_frequency, severity_weights, raw } — typed dispatch inputs +
the full JSON kept in `raw` so each per-category handler reads its own fields (tiers,
target_terrain, becomes, aerosol_strength) without modeling all 12 shapes up front.
- load_event_configs(dir) reads public/resources/events/<category>.json (category =
filename stem; skips *.schema / cross_triggers / events). Test parses the real
wildfire.json (base_frequency 0.04, severity_weights, target_terrain ∋ "forest").
mc-climate events 5/5. Next: dispatch (process_events using category_fires + roll_severity)
+ per-category handlers (wildfire first — burn forest in radius, transform biome) + wire
into the mc-turn climate phase. Tile-picking will use a Rust-deterministic RNG (the
headless sim needs internal determinism, not byte-match with the live game's Godot RNG;
the dispatch GATE already matches GDScript bit-for-bit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First slice of the natural/"apocalyptic" events port (M3). The deterministic primitives
every category depends on, ported from GDScript ecological_event_utils:
- hash_noise(x,y,seed) = frac(sin(x*127.1+y*311.7+seed*74.3)*43758.5453), f64 — verified
to match the LIVE GDScript game bit-for-bit (ran it: hash_noise(10,0,1000) =
0.67791910066535). The headless sim must match the game, NOT the TS web guide (whose
Math.sin diverges on these large arguments — a pre-existing game-vs-guide gap, not a
port bug; the old comment's "0.1270 from TS" golden was misleading).
- roll_severity(weights, turn_seed, channel, max_tier) — weighted tier roll with era cap.
- category_fires(base_frequency, channel, turn_seed) — the per-category dispatch gate.
4 cargo tests (GDScript-golden determinism, channel separation, severity bounds + cap,
fire gate). Source corrected: .messy is gone — the port source is the live
ecological_events.gd + handlers_a/b + public/resources/events/*.json. Next: event-config
structs/loading + dispatch + per-category handlers (wildfire first) + turn wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the headless climate phase from physics-only to the full per-turn chain mirroring
the live game's _process_climate (climate → weather → effects):
- process_climate_phase now: ClimatePhysics::process_step → weather::derive_events
(storms/heat-waves/blizzards, default thresholds = live GdWeatherPhysics) →
apply_climate_effects.
- apply_climate_effects (extracted, testable): runs climate_effects::apply (tile effects +
per-unit hp_loss) then fans hp_loss onto MapUnit.hp as max(0, hp - hp_loss) — exactly
climate_effects.gd. movement_penalty surfaced but not applied to units (matches live).
Tests: apply_climate_effects_fans_hp_loss_onto_units (deterministic — unit in heat-wave
radius loses HP, unit outside unharmed) + the determinism test; mc-turn 337/0, no
regression. Gap 1 remaining: marine_harvest (ocean_dead_fraction → climate).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The headless mc-turn ran no climate (live-game GDScript only). Now it does:
- mc-turn deps mc-climate (no cycle; mc-climate is lower-level).
- TurnProcessor::process_climate_phase ticks mc_climate::physics::ClimatePhysics once per
round on state.grid (process_step(grid, turn, map_seed, dt=1.0)). Default config
("{}"/"[]"/"{}") matches the ecology bench; the grid carries climate state across turns;
fresh-processor-per-turn is safe (physics is the operator, grid is the state). No-op
without a grid.
- Called in step() right after fauna (world-level end-of-round phase).
First slice of gap 1 — temperature/aerosol/precipitation now evolve on the live grid in
self-play. Still to come: the weather + climate_effects (unit HP) + marine_harvest chain.
Verified: climate_phase_ticks_grid_deterministically (determinism + no-grid no-op);
mc-turn 336/0 (no regression — climate phase runs in every step() with a grid).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner chose headless-only hardening over the live-game refactor (step 6 deferred).
- mc_sim::load_deposit_categories(deposits_dir) reads public/resources/deposits/*.json →
id→category map (handles single-object or array files; skips bad files).
- dominion_bench + tournament_bench now set state.resource_categories from it after
building GameState. These benches run the Rust TurnProcessor (process_trade_phase) but
never loaded categories, so step-4's real sourcing had left their inter-player trades
inert (sourcing from empty categories → no luxuries/strategics → no trades). Now bench
trade dynamics (trade_willingness axis, gold-from-sales) form again.
Also recorded: real-game confirmation that the headless pipeline is live — the magic-civ
MCP view_json returns cities[].owned_tiles populated (step-2 territory projection running
in a real headless game on the rebuilt dylib).
Verified: mc-sim load_deposit_categories_reads_real_deposits passes; dominion_bench +
tournament_bench compile. solo_dominion (single-player, no trade partners) intentionally
not wired.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The headless trade pipeline was unit-proven but inert in real runs: nothing called
set_resource_categories_json, so process_trade_phase saw empty categories and sourced
nothing. Wire it in.
- scenes/headless/player_api_main.gd::_apply_resource_categories builds the resource
id→category map from DataLoader.get_all_resources() and stamps it onto GdPlayerApi via
set_resource_categories_json, AFTER load_state_json (same #[serde(skip)] re-stamp
pattern as units_runtime_catalog + tech_web). Now a real headless game classifies
owned-tile collectibles → sources luxury/strategic surpluses → forms trades → view_json
carries them. End-to-end LIVE.
Verified: unit+integration GUT 750 (737 pass / 13 pending / 0 fail); the headless
projection-roundtrip boot path (which exercises _apply_resource_categories) is green.
GDScript-only change calling an existing FFI — no dylib rebuild needed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
process_trade_phase_forms_and_persists_strategic_swap: a crafted 2-player state with
complementary owned-tile strategics (p0 rainforest→hardwood, p1 mountains→iron_ore,
biomes guarantee neither has the other's) → process_trade_phase forms a StrategicSwap,
persists it to state.trade_ledger, and fans it onto both players' traded_strategics.
Proves the full chain end-to-end: owned-tile territory (step 2) → resource-category
classification (step 3) → real sourcing + evaluate_trades + persistence (step 4) →
which DiplomacyView.trade_deals then projects (step 5, separately tested). mc-turn green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
view_json now carries real inter-player trades — the headless "simulator provides
everything" goal is met. A player-like the headless adapter sees territory (step 2) AND
trades (this step) from the projected view, no GDScript re-derivation.
- view.rs: DiplomacyView gains trade_deals: Vec<TradeDealView> ({kind, you_receive,
you_give, gold_per_turn}, described from the viewer's perspective; serde skip-if-empty
for wire stability).
- projection.rs build_diplomacy: populates trade_deals from the persisted
state.trade_ledger swap/sale agreements (LuxurySwap/StrategicSwap/ResourceSale) for the
viewer↔counterpart pair, via swap_deal_view/sale_deal_view helpers (correct give/receive
direction; sale gold signed + for seller, − for buyer).
Verified: projection_surfaces_trade_deals_from_ledger (luxury swap direction + sale
buyer/gold); mc-player-api 171/0. (Disk filled mid-step from cargo target — cargo clean
reclaimed 9.5GiB; tests re-run from a clean build.)
p3-25 steps 1-5 DONE: view_json now carries territory + real trades, sourced fully in
Rust. Step 6 (live game adopts the unified PlayerView) reframed as a large separate
follow-on — the headless view-completeness this objective targets is achieved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The core of the rail-1 trade port: inter-player trades now form in the Rust headless
sim from REAL owned-tile resources (no proxy), persist to state, and apply.
- mc-turn::process_trade_phase: source_tradeable_resources sources each player's tradeable
luxuries + strategics from its cities' owned tiles → deterministic tile_collectibles
rolls (seed = map_seed ^ coord, stable across turns) → classified via
GameState.resource_categories (dups kept for MIN_COPIES_TO_TRADE). Replaces the old
proxy (tile_strategics: Vec::new(), tile_luxuries from traded_luxuries).
- Persists the re-derived swap/sale agreements into state.trade_ledger (retaining the
persistent OpenBorders/SharedMap), so the projection/view can carry real trades.
- Writes PlayerState.traded_strategics (new serde-default field) + applies net per-turn
gold flow (gold_flow_for: seller +, buyer −).
Verified: mc-turn source_tradeable_resources_classifies_owned_tile_collectibles
(determinism + classification purity + uncategorized-filtered + empty-categories no-op);
mc-turn+mc-state+mc-player-api 517/0; workspace cargo check clean (new PlayerState field
broke no literals). p3-25 steps 1-4 done; 5-6 remain (project trade deals into the view,
then GDScript view-only).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rail-1 city-model unification, step 3: give the headless sim the luxury/strategic
categories it needs to classify owned-tile resources for trade sourcing — currently
GDScript-only (DataLoader). No content hardcoded in Rust (Rail-2): loaded from JSON.
- GameState.resource_categories: BTreeMap<String,String> (id → "luxury"/"strategic"/
"bonus"), #[serde(skip)] boot-loaded exactly like units_catalog/civic_catalog (not
save-persisted; empty Default → nothing tradeable, a safe no-op).
- GameState::load_resource_categories_json parses the flat {id:category} object GDScript's
DataLoader emits; no-clobber on malformed input.
- GdPlayerApi.set_resource_categories_json FFI loads it onto the held state (call after
load_state_json, since the field is serde-skip).
Verified: mc-state load_resource_categories_parses_flat_map + suite 13/0; workspace
cargo check clean (GameState field addition broke no literals — all use ..Default).
Rust-only; live game unaffected. Unblocks step 4 (process_trade_phase classification).
p3-25 steps 1-3 done; 4-6 remain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rail-1 city-model unification, step 2: give the headless/bench simulation real,
growing territory so view_json carries it (toward "simulator provides everything").
- mc_city::CityState gains owned_tiles: Vec<(i32,i32)> (serde default; backward-compat).
- mc-turn::process_culture: the culture-ready list from CulturePool::tick_all was
previously DROPPED (let _ready = ...). Now each ready city claims one contiguous,
in-bounds frontier tile per turn into owned_tiles — real border expansion in Rust.
Deterministic pick (lowest col,row among the unclaimed frontier); city centre owned
implicitly via city_positions, materialised on first expansion; consume_expansion
advances the threshold. Grid dims read before the &mut player borrow.
- mc-player-api projection: CityView.owned_tiles (schema field that existed but was
stubbed Vec::new()) now projects CityState.owned_tiles, with a centre fallback so
every city reports at least the tile it sits on.
- Fixed a pre-existing broken test (serde_roundtrip HappinessInput literal missing the
building_happiness_effects/happiness_per_city_effects fields p3-24 added).
Verified: cargo test mc-city + mc-turn + mc-player-api 725/0, incl. new
culture_expansion_claims_frontier_tiles + projection_surfaces_city_owned_tiles. Rust-only
headless-path change; live game (presentation_cities) unaffected. Unblocks step 4
(trade sourcing from owned-tile resources). p3-25 steps 1-2 done; 3-6 remain.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Owner directive: "gd should only be UI view of simulation / simulator provides
everything / no stubs". Root cause (verified): the sim holds two parallel city models —
authoritative mc_city::City (presentation_cities, has owned_tiles) vs bench
mc_state::CityState (GameState.players[].cities, no territory) — and project_view reads
the bench one, so view_json is structurally blind to territory + trades. New objective
p3-25 captures the full sequenced unification plan.
Step 1 (this commit) de-stubs what real bench state already carries, no fabrication:
- projection.rs build_diplomacy: DiplomacyView.{open_borders,shared_map,agreements_active}
now read the real OpenBorders/SharedMap entries from state.trade_ledger (the entries
dispatch writes on signing), replacing hardcoded false/false/empty stubs.
- CityView.owned_tiles left honestly TODO (center-only would mislead; fills in step 2 when
bench territory + border expansion are ported).
Verified: cargo test -p mc-player-api 169/0 (incl. new projection_surfaces_open_borders_
from_ledger). Rust-only headless-view change; no GDScript touched, live game path
unaffected. Swap/sale trade deals NOT yet in the view — they need the sourcing+persistence
port (p3-25 steps 2-5); not faked here per "no stubs".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase-gate proof for the deal UI (revival step 5 verification): diplomacy_deal_proof.tscn
instantiates the REAL diplomacy_panel.tscn with a crafted GameState (human + 2 AI rivals,
ledger holding one LuxurySwap + one StrategicSwap + one ResourceSale) and self-captures.
Screenshot reviewed in-conversation — the panel renders, in the correct per-rival rows:
- AI 1 (Ironhold): "Luxury Trade: Receiving Silk for Furs" + "Resource Sale: Buying
Horses (−2 gold/turn)"
- AI 2 (Goldvein): "Strategic Trade: Receiving Coal Seam for Iron Ore"
All three deal types render with correct direction, resources, and gold flow.
p3-23 status partial → done. Every acceptance bullet now met with evidence: gold↔resource
+ strategic swaps + luxury swaps (mc-trade) · AI evaluation · in-game pipeline revived
end-to-end (steps 1-4, GUT 750/0 + 25-turn arena exit 0) · deal UI (step 5, screenshot).
tribute.rs stays Game-2 deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Active inter-player trade deals now surface in the diplomacy panel's per-rival agreement
section, alongside open-borders / shared-map rows.
- diplomacy.gd get_active_agreements parses the serde-tagged LuxurySwap / StrategicSwap /
ResourceSale entries straight out of GameState.trade_ledger_json (_append_trade_deals +
_swap_entry + _sale_entry — pure GDScript, no new FFI). Each deal becomes a display dict
{type, partner, you_receive/you_give | role/resource/gold_per_turn}.
- diplomacy_panel._make_agreement_section renders luxury_swap/strategic_swap (receiving X
for Y) + resource_sale (buying/selling X, ±gold/turn). 6 diplomacy_* vocab keys added.
- GUT test_get_active_agreements_surfaces_trade_deals: all three deal types + partner/
direction/resource fields. Panel script compiles + its tests pass. Full suite 750/0.
p3-23 implementation + logic now COMPLETE and GUT-proven across steps 1-5. The only item
left before status:done is a phase-gate proof screenshot of the trade rows (needs a
crafted live state with a human-held ledger deal; not reproducible in the all-AI arena).
Stays partial per objective-integrity.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Added the last missing link test: _collect_tradeable_resources against a real GameMap.
- test_collect_tradeable_resources_classifies_owned_tiles: builds a GameMap with
iron_ore×2 (strategic) + silk (luxury) deposit tiles owned by a player, asserts
_collect_tradeable_resources returns strategics=[iron_ore,iron_ore] (dups kept for
the MIN_COPIES_TO_TRADE surplus rule) + luxuries=[silk]. Proves _serialize_players'
real DataLoader-category tile sourcing. NB: DataLoader maps the "resources" category
to the deposits/ dir — served strategic ids are iron_ore/horses, not "iron".
- before_all loads the theme (category lookups need DataLoader). GUT 749/0.
Full inter-player trade pipeline now GUT-proven link-by-link AND headless-proven live:
real tiles → _collect_tradeable_resources (step 4) → process_trades → ledger →
traded_luxuries/strategics (step 1) → strategic build access (step 3); gold sale →
gold_flow_for → net gold (pre-existing); integration runs every round in a live 25-turn
arena without aborting the loop (step 2). Gold flow no longer inert — process_turn now
populates GameState.trade_ledger_json each round.
Only remaining for done: the deal UI (diplomacy panel + wire trade_agreed). Stays partial.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A strategic resource gained via an active inter-player trade now grants unit-build
access exactly like a tile-owned copy (Civ-style "access", not stockpile).
- turn_processor._player_owns_resource (the live production-COMPLETION gate behind
EventBus.strategic_gate_rejected) now short-circuits true when resource_id is in
player.traded_strategics, before the game_map tile scan. The Rust
GdCitySlot.enqueue_item gate has no live GDScript caller (unused FFI surface), so
this completion gate is the only live unit-gating path.
- GUT: test_player_owns_resource_via_traded_strategic (traded → access) +
_false_without_tile_or_trade (neither → no access). Full suite 748/0.
Acceptance chain now GUT-proven link-by-link: process_trades→ledger→traded_strategics
(step 1) · traded_strategics→build access (step 3) · gold_flow_for→net gold
(test_trade_gold_flows_into_net_gold, pre-existing).
Next (step 4): end-to-end in-game proof (a trade demonstrably forms in a played game,
gold flows >0, a gated unit becomes buildable) + deal UI. p3-23 stays partial.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>