Phase-2b live swap (default OFF). When RUST_TURN=1, the proven
GdTurnProcessor.step advances the WHOLE round on live state in one call
(sync presentation->inner, step, sync inner->presentation), and the
per-player _process_* loop + round-end ecology/climate/wild/diplomacy
GDScript passes are gated off to avoid double-processing. step's events[]
are translated to EventBus signals (tech/culture/golden-age now; entity-
payload kinds deferred). Default path is byte-for-byte the existing turn.
Render-proof of the ON path (live game plays a turn through the Rust step)
remains the render-gated acceptance item.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rail-1 spine rewrite Phase 2 foundation. GdTurnProcessor::step mutates
GdGameState.inner only, but the live game holds authoritative cities/units
in the rich presentation_* slots. Add state_sync module + two #[func]s
(sync_presentation_to_inner / sync_inner_to_presentation) implementing
Option C batch sync around the step:
- Units: whole-vec clone both ways (presentation_units and
inner.players[].units are the identical mc_state::MapUnit type).
- Cities: rich City <-> lean CityState scalar projection (population,
food_stored<->food, production_progress<->production_stored, owned_tiles,
hp/max_hp). Down-sync updates lean in place, preserving lean-only fields
(queue/queue_cost/queue_tier/food_yield/prod_yield/worker_expertise);
up-sync merges only the bridged scalars back, leaving rich-only fields
(queues, buildings, building_yields, culture_*, focus, name) untouched.
city_positions/capital_position kept aligned for process_culture/siege.
- Player scalars (gold/science/culture_pool/tech/relations) are inner-only;
no parallel rich slot, so no sync needed.
Sync gap (documented, not fabricated): lean single queue vs rich per-building
queues map has no clean 1:1 mapping and is deliberately not bridged.
8 cargo tests incl. a real mc_turn::TurnProcessor::step driven through the
down/up loop (city grows, rich queues survive). Not yet wired into the live
turn (GDScript Phase-2b). 8/0 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GUT coverage for Rail-1 Phase-1 increment 1. Each test reads the authoritative
Rust slot directly (unit_dict / unit_index_by_id / unit_locate_by_id) and
asserts the proxy stays in lockstep:
- spawn → slot reflects position + hp; proxy reads the same
- move → slot position updates
- take_damage → slot hp drops
- fortify → slot is_fortified set
- death → remove_unit deletes the entry and clears rust_id
- index-shift safety → survivors stay addressable by stable id
- wild creatures land in the wilds row, never colliding with player 0
- transfer_to_owner moves the slot entry between rows
- save → load round-trips a unit through presentation_units
Tests early-return with a pending() note when the GDExtension dylib is absent
(headless CI that never built it) rather than asserting the local-mirror
fallback, which the existing test_unit_actions.gd no-extension suite covers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rail-1 Phase-1 increment 1 (wiring) — every site that brings a Unit into or out
of the world now flips the authoritative Rust slot alongside the player/layer
lists, so the proxy resolves a live MapUnit.
Spawn sites → `spawn_into_slot()`:
- world_map_units.register_unit (the central chokepoint: starting units via
spawn_starting_units, the prologue tribe via _on_prologue_tribe_converged, and
any future caller)
- turn_processor._spawn_unit (city-built unit)
- wild_creature_ai (lair spawns → wilds row; now constructs via the populating
ctor so stats come from JSON)
Death / consumption sites → `remove_from_slot()` (index-shift-safe; snapshots
final pos/hp into the local mirror first so unit_destroyed subscribers — loot,
chronicle — still read the unit as it died):
- world_map_units.remove_unit
- combat_utils.handle_unit_death + _destroy_high_archon
- economy upkeep disband
- ai_turn_bridge_dispatch settler-consumed-on-found
- prologue_driver tribe-consumed-into-capital
No live unit-CAPTURE path exists in Game 1 (units die, they are not captured);
`transfer_to_owner` is wired on the proxy for parity but no site converts to it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rail-1 Phase-1 increment 1 — the units analogue of City's proxy over
presentation_cities. Unit's authoritative gameplay surface (position, hp,
max_hp, attack, defense, movement_remaining, xp/experience, the posture flags,
promotions) now lives in the Rust `presentation_units` slot, reached via
`GameState.get_gd_state().unit_*(_pi, _resolve_ui())`. The slot is positional,
so the view keys on a stable u32 `rust_id` and re-resolves its row index on
every access — a death (remove_unit shifts indices) or capture (transfer_unit
changes both _pi and _ui) never leaves a getter reading the wrong unit.
- Slot-backed fields become property getter/setter pairs that route to the slot
when spawned, falling back to `_local_*` mirrors when unspawned / no dylib, so
bare `Unit.new(...)` (arena tests, early construction) keeps working.
- DUAL ID: `id: String` stays the renderer/debug key; `rust_id: int` is the
Rust-backing key. unit_slot::spawn trusts the JSON-borne id (unlike
City::found which derives it), so GameState owns id assignment via a new
monotonic `next_unit_id()` counter (serialized; restored above loaded ids).
- Wild creatures (owner -1) land in a dedicated wilds row at `players.size()`
(`wilds_pi`/`unit_slot_pi`) so they never collide with player 0.
- spawn_into_slot / remove_from_slot / transfer_to_owner are the slot lifecycle;
from_save_dict reattaches a restored unit to a fresh slot entry keyed on its
saved rust_id (rust_id 0 = never-slotted synthetic unit, left on its mirror so
the save dict round-trips byte-identically).
- deserialize / reset drain the unit slot (incl. the wilds row), mirroring the
city-slot drain, so units do not accumulate across loads.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the proven `presentation_cities` city store for units: a parallel
`presentation_units: Vec<Vec<mc_state::MapUnit>>` slot on GdGameState, owned
by a new `unit_slot` ops module that is the exact unit-side analogue of
`city_slot`.
- `api-gdext/src/unit_slot.rs`: bounds-safe `at/at_mut`, stable-u32-id
`index_by_id/locate_by_id`, `spawn`, `move_unit`, `transfer_owner` (capture),
`remove` (index-shift-safe), `take_damage/heal/set_hp`, posture setters
(fortify/sentry/movement), `to_dict` projection, `to_json/load_from_json`
serde round-trip. Pure holding + projection; turn/action logic stays in
mc-turn / mc-player-api dispatch (no duplication of MapUnit mutation).
- GdGameState gains `presentation_units`, initialised parallel to
`presentation_cities` (empty rows grow on demand) and folded into the save
envelope at every site (init, serialize_full, load_from_json).
- SaveEnvelope gains `presentation_units` (#[serde(default)]); CURRENT_VERSION
bumped v3 → v4. save_envelope.rs literals + version-lock test updated.
- `#[func]` delegators on GdGameState mirror the city_* surface (spawn_unit,
move_unit_slot, transfer_unit, remove_unit, unit_take_damage/heal/set_hp,
posture setters, unit_dict, unit_index_by_id/locate_by_id, unit_to_json/
load_from_json).
- `GdUnit` per-instance wrapper for parity with GdCity (owned MapUnit,
to_dict/to_json/field reads).
Tests: 7 unit_slot ops tests (spawn→locate, move, remove-shift-id-stable,
damage/heal clamp, posture, transfer between rows, json round-trip) + the
updated save_envelope suite, all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add consume_pending_promotions phase to the end-turn step (after combat XP):
validates each unit's pending_promotion against its applied promotions
(requires-chain, no-dupes, existence), records it, folds hp_bonus into max_hp,
applies heal_on_promote (clamped), clears the pick. Illegal picks are dropped.
Inject per-unit promotion combat modifiers at both PvP combat sites, mirroring
equip_combat_bonus: attacker offence keys off its tile biome + defender flags,
defender defence off its own tile + whether the attack is ranged. Percentages
fold into flat atk/def/ranged against base stats. Projection now gauges
promotion_available against the real level (promotions.len()).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add MapUnit.promotions (applied promo ids; len = promotion level) and a
mc-combat registry parsed from the embedded promotions.json trees. Replace
the stale PromotionEffect {stat,value} model with {type,value,condition}
matching the JSON, and add promotion_combat_modifiers(applied, ctx) — a pure
aggregator that evaluates effect conditions (open/rough terrain, vs_ranged,
vs_city, vs_fortified, in_city) against a combat context and sums atk/def/
ranged/range/hp/movement/wall/xp modifiers. validate_promotion_pick enforces
existence, no-dupes, and requires-chains.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The live city_screen reads a per-city culture meter off the GDScript CityScript;
view_json had no equivalent, so the UI-pure-view migration couldn't render it from
getState(). project_cities now surfaces culture_stored from
PlayerState.culture_pool.city(c_idx) (the same accumulator mc-turn::process_culture
ticks for border expansion); 0.0 for a city with no pool entry.
Closes the last genuine Phase-0 projection gap (UnitView equipped/experience/movement/
posture + ResourceView golden_age were already projected — design-doc table was stale).
Test: projection_surfaces_city_culture_stored. mc-player-api lib 142/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four E0063 compile errors broke `cargo test --workspace --no-run`, blocking
`./run dist:test` on the DO fleet. Each is a stale struct literal in test/test-cfg
code that drifted from its current definition:
- mc-worldsim event_dispatch low_bio_thresholds: BiologicalThresholds missing
migration_drought_factor / migration_drought_max (p3-21 drought coupling) —
set to 0.0 / 1.0 to keep the helper's migration-suppression intent.
- mc-mod-host wasm_controller_{noop,limits}: TacticalState missing embark_level —
Default::default() (EmbarkLevel::None) to match the empty-state intent.
- api-gdext ai.rs tile_with + ai_controller test: TacticalTile missing explored /
TacticalState missing embark_level — explored:true (pre-field default = seen),
embark_level default.
Mirrors the sibling fix 04fabbc1c. `cargo test --workspace --no-run` now compiles
clean; full suite passes except 3 pre-existing GPU-parity tests (Metal fp drift,
unrelated to these changes).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The RangedAttack dispatch work (e8dd4a85b) added `is_ranged` to
`AttackRequest`, but the mc-golden-tests pvp_combat_determinism test still
constructed the struct without it, breaking `cargo test --workspace` (and
the cloud fleet) with E0063. Set `false` for this melee-combat test,
matching the mc-turn PvP tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Proves the spawn → command → view contract the GdGameState bridge exposes for
the render-gated live flip, at the mc_player_api layer its shims call: a MapUnit
pushed onto inner (as spawn_unit_into_inner produces) appears in project_view;
a Fortify via apply_action is reflected in the next view; a command on a stale
unit id is a typed error, not a panic. Existing integration tests load pre-built
states — none exercised the spawn-then-act-then-view triple a freshly-spawned
live unit goes through. De-risks the foundation before the GDScript flip depends
on it. mc-player-api 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Offload heavy compute from plum (M2 Air) to on-demand DO workers:
- dist:test — cargo test --workspace (nextest) on a worker (the main DX win)
- dist:build — cargo build + WASM on a worker; rsync the platform-independent
WASM back (native .so is linux-only, stays on the worker)
- dist:sync — git pull <ref> + rebuild gdext on live workers (no image rebuild)
- forge:down/up — snapshot+destroy / restore-from-snapshot (DO bills powered-off
droplets; only destroy stops it). ~$6/mo -> ~$0.30/mo idle; refreshes the
forge IP in ~/.vault/mc_forge_creds on restore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live unit store (GdGameState.apply_action_json → inner) handled melee but
RangedAttack was NotYetImplemented. Wire it by reusing the melee resolver:
split resolve_single_pvp_attack into resolve_single_pvp_attack_typed(.., is_ranged);
ranged sets CombatType::Ranged → sources ranged_attack/range from units_catalog
and the resolver's prevents_retaliation(combat_is_ranged=true) suppresses the
counter-attack. Did NOT reuse the crude pending_volley AoE (separate Volley
action); verified live parity is immediate-resolve (combat_resolver.gd:87-104),
so a direct resolve mirroring melee is correct.
- AttackRequest gains is_ranged (serde-default); process_pvp_combat threads it.
- dispatch apply_ranged_attack: owner + enemy + within-range gate, then resolve.
- tests: ranged_pvp_no_retaliation (resolver: damage, attacker untouched, 0
retaliation), ranged_attack_no_retaliation (dispatch: range gate + rejections).
Deferred (parity, cited): no movement-spend on attack — melee doesn't spend it
either; a "ranged is in-scope; verify gate mc-combat+mc-turn+mc-player-api 0 failed.
Dispatched combat-dev; verify gate green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gives the live GdGameState the same Rust-driven surface the headless GdPlayerApi
has, on its own inner GameState, so inner.players[].units (rich MapUnit) can
become the live unit store:
- apply_action_json(player, action_json) → mc_player_api::apply_action(&mut inner)
- inner_view_json(player) → mc_player_api::project_view(&inner)
- spawn_unit_into_inner(player, unit_type_id, col, row) → MapUnit::new + push,
monotonic next_unit_id (same idiom as the AI-faction spawn).
Thin shims over the SAME mc_player_api fns GdPlayerApi calls (no dispatch/
projection duplication; 4 envelope helpers made pub(crate) for reuse). No
GDScript touched; GdPlayerApi + bench path untouched.
Contract for the later (render-gated) live caller: stamp inner.units_catalog
(+ action configs) via the existing set_*_catalog_json setters before relying on
the view — documented inline (lib.rs:4297). cdylib links with all 3 #[func]s
registered (distinct symbols from GdPlayerApi); mc-player-api 0 failed.
Dispatched simulator-infra; verify gate green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Units gained no XP in the headless/bench turn (only GDScript UnitScript tracked
it). The XP amounts were already Rust-authoritative (mc-combat: BASE_COMBAT_XP=5
× xp_from_combat strength scaling; resolver zeroes dead-defender XP / suppresses
capture XP). This wires the award into the bench turn so the unified game has
veterancy:
- MapUnit.experience: i32 (#[serde(default)]; all 110 literals use ..default()).
- resolve_single_pvp_attack accumulates attacker_xp/defender_xp onto survivors,
survival-gated exactly like combat_resolver.gd:215-223.
- project_units surfaces UnitView.experience + promotion_available from XP
threshold eligibility (mc_combat::check_promotion), replacing the 0 stub.
- new test pvp_combat_awards_xp_to_survivors (queued-attack path, no kills →
both survivors gain XP).
Deferred (cited, out of scope): the veteran_level/promotion stat-growth pick
subsystem (bench uses flat UnitStats, not the D20 path) and the pre-existing
Rust↔JSON promotion-threshold divergence (promotions.json [15,30,45,60] vs Rust
[10,30,60,100]) — a Rail-2 content/code gap tracked separately.
Dispatched combat-dev; verify gate: mc-combat+mc-turn+mc-player-api 0 failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
EquippedItemView (item_id, category, charges_remaining, triggers_in_combat —
the exact mc_items::EquippedItem fields, cited) + UnitView.equipped, projected
from MapUnit.equipped for OWN units only, omitted from the wire when empty.
Surfaces the unit_panel.gd:789 entity read via view_json.
happiness_breakdown DEFERRED (verified, not fabricated): the per-contributor
breakdown is a transient calculate_happiness return (mc-happiness/pool.rs:170),
not persisted PlayerState — only the scalar happiness pool is stored
(game_state.rs:1295), already surfaced as ResourceView.happiness_pool. A
Phase-1 SOT-flip widening, like XP/culture_stored.
Dispatched simulator-infra; verify gate: mc-player-api green incl. new equipped
round-trip/omit test. Additive (serde defaults).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ResourceView gains golden_age_active + golden_age_turns, projected from the
PlayerState GA fields. These are the top_bar.gd:162/169 entity reads now
available via view_json (HUD badge driven by Rust, not the Player entity).
Omitted from the wire when inactive. Per-city culture_stored (city_screen.gd:287)
is DEFERRED: it has no bench CityState backing (culture is a player-level pool in
the reduced model) — a Phase-1 SOT-flip widening, not fabricated here.
Additive (serde defaults). mc-player-api 140/0 incl. a GA round-trip/omission test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First projection-completeness increment toward UI-driven-by-Rust. project_units
stubbed movement_left/max=0, sentry=false, promotion_available=false despite the
bench MapUnit carrying the real data — fix to read movement_remaining/base_moves/
is_sentrying/pending_promotion (same fields the move dispatch + legal-move gate
use). Add UnitPostureView (embarked/deployed/stealthed/ambushing/field_aura/
fire_arrows/pursuing/shield_wall/braced/rage/war_cry) + formation_id, projected
for OWN units only (no stealth/ambush leak); omitted from the wire when resting.
These are the unit_panel.gd entity reads (audit Group B) now available via
view_json. XP stays 0 (not yet on MapUnit — Phase-1 SOT-flip gap, noted).
Additive (serde defaults; no other UnitView constructors in the workspace).
mc-player-api 139/0 incl. 2 new posture round-trip/omission tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Owner chose the bridge over a headless wild-unit substrate. Adds the
JSON contract + a pure mc_ai::wild::decide_wild_actions_json(json, seed)
helper (parses a WildContextDto — wilds, player_units, lairs, cities, config,
passable set — runs the decision core, returns per-action JSON strings, the
GdAiController envelope), and a thin GdWildAiController GDExtension shim
(set_rng_seed + decide_actions → PackedStringArray) over it.
The live game keeps its roaming owner==-1 units; GDScript projects them into
the DTO and dispatches the returned move/attack Actions via the existing
AI-action path — so the wild DECISION logic is fully Rust (Rail-1), no
duplicated headless model. 16 wild tests (4 new JSON-bridge: chase/attack/
passable-roam/malformed), mc-ai lib 305/0; gdext cdylib links with the class
registered. Remaining (render-gated): GDScript rewire of _process_wild_creatures
+ wild_creature_ai.gd deletion + render-proof.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port wild_creature_ai.gd's decision logic to a pure, deterministic Rust
module (mc-ai::wild). decide_wild_actions(ctx, rng) -> Vec<Action> mirrors
process_wild_turn → _act: chase+attack a player unit in detection range,
drive home when leashed out, drift toward the nearest city, else roam a
leashed neighbour. One action per creature (the player-tactical convention:
attack iff adjacent, else move). Reuses mc_core hex helpers + XorShift64 +
the existing Action taxonomy; combat resolution stays in mc_combat::wilds.
Fork-neutral: WildContext is a flat projection, identical whether the
integration drives it inside mc_turn::step or via a GdWildAiController bridge
(p3-30 leaves that drive-site to infra). 12 unit tests: target-select,
chase, attack-iff-adjacent, leash return, leashed roam, city drift, passable
gating, no-movement skip, determinism, wilds.json config parse. mc-ai lib
301/0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live GDScript turn emitted `golden_age_started`/`golden_age_ended` inline;
the headless happiness phase flipped `golden_age_active` silently. Detect the
false→true / true→false edge in `process_happiness_phase`, buffer it into a new
transient `GameState.pending_golden_age_events` (registry-has-no-event-sink
pattern), and drain it in `step()` into `GoldenAgeStarted`/`GoldenAgeEnded`.
Both edges emitted since the live UI consumes both signals (event_bus.gd). No
wire surface — dispatch drops them; live UI reads the kind-tagged event_to_dict.
Verified headless: mc-replay 20/0 (golden_age_events_serde), mc-turn 291/0
(golden_age_start_edge_buffers_started_event +
golden_age_end_edge_buffers_ended_event + event_collector_wiring).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live GDScript turn emitted `unit_healed` inline; the headless healing
phase recovered HP silently. The healing phase runs in the end-of-turn
`fn(&mut GameState)` registry (no event sink), so follow the FloraSuccession
buffer pattern: stash `(player, unit_id, applied_amount, col, row)` into a new
transient `GameState.pending_heal_events`, drain it in `step()` into
`TurnEvent::UnitHealed`. The buffered amount is the CLAMPED delta actually
applied (not the nominal heal rate). No wire surface — dispatch drops it; the
live UI consumes it via the kind-tagged `event_to_dict` dict.
Verified headless: mc-replay 19/0 (unit_healed_serde), mc-turn 289/0
(healing_buffers_unit_heal_event_with_applied_amount +
healing_buffers_clamped_amount_near_full_hp + event_collector_wiring).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>