Commit graph

1822 commits

Author SHA1 Message Date
Natalie
e24c1a03d2 feat(turn): consume promotion picks + inject promotion modifiers into combat
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>
2026-06-27 15:46:54 -04:00
Natalie
66cf5b7e45 feat(combat): promotion effect registry + per-unit combat modifiers
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>
2026-06-27 15:40:26 -04:00
Natalie
04763a3870 feat(view): project CityView.culture_stored from the per-city CulturePool (Rail-1 Phase 0)
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>
2026-06-27 15:05:34 -04:00
Natalie
d4c4a5aa1b fix(test): add missing fields to stale struct literals across workspace tests
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>
2026-06-27 14:35:44 -04:00
Natalie
04fabbc1c2 fix(test): add is_ranged field to stale AttackRequest in pvp_combat_determinism
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>
2026-06-27 12:49:19 -04:00
Natalie
24c0e0c24c test(@projects/@magic-civilization): 🛤️ Rail-1 Phase-1 — end-to-end live-unit-store loop test
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>
2026-06-27 09:44:39 -04:00
Natalie
22f7fa1116 feat(infra): DO compute-offload verbs + forge on/off lifecycle
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>
2026-06-27 09:24:30 -04:00
Natalie
e8dd4a85b4 feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-1 — RangedAttack dispatch (completes unit input for the live store)
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>
2026-06-27 09:21:36 -04:00
Natalie
b689f52ccc feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-1 — GdGameState act/view/spawn bridge (live unit store foundation)
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>
2026-06-27 08:57:17 -04:00
Natalie
bd186b162a feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-1 — bench unit XP/veterancy in the Rust turn
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>
2026-06-27 08:48:35 -04:00
Natalie
8c3e7b8a27 feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-0 — project equipped items to UnitView
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>
2026-06-27 08:19:07 -04:00
Natalie
0d501a3d72 feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-0 — project Golden Age state to the HUD
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>
2026-06-27 08:03:07 -04:00
Natalie
568e43084b feat(@projects/@magic-civilization): 🛤️ Rail-1 Phase-0 — project real unit movement + tactical posture
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>
2026-06-27 08:00:41 -04:00
Natalie
8696a48aa0 feat(@projects/@magic-civilization): 🐺 p3-30 — GdWildAiController bridge (owner-chosen drive path)
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>
2026-06-27 07:34:02 -04:00
Natalie
95a2e580bc feat(@projects/@magic-civilization): 🐺 p3-30 — Rust wild-creature decision AI core
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>
2026-06-27 06:50:44 -04:00
Natalie
a87ea9f4d4 feat(@projects/@magic-civilization): 🌅 p3-29 T3 — Rust turn emits GoldenAgeStarted/Ended
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>
2026-06-27 06:18:26 -04:00
Natalie
158ef4d1bd feat(@projects/@magic-civilization): 🩹 p3-29 T2 — Rust turn emits UnitHealed
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>
2026-06-27 06:12:07 -04:00
Natalie
74844f74d3 feat(@projects/@magic-civilization): 🎭 p3-29 T1 — Rust turn emits CultureResearched
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>
2026-06-27 06:03:47 -04:00
Natalie
a9b92df51b feat(@projects/@magic-civilization): 📡 p3-29 (step 2) — surface turn events in GdTurnProcessor.step result dict
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>
2026-06-27 03:58:01 -04:00
Natalie
7f4b69eac1 fix(@projects/@magic-civilization): 🛡️ p3-26 B2 — siege-suppress city healing (besieged cities don't heal)
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>
2026-06-27 03:44:34 -04:00
Natalie
8e17594564 feat(@projects/@magic-civilization): 🌿 p3-29 (3) — surface FloraSuccession; step-1 event enrichment complete
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>
2026-06-27 03:26:11 -04:00
Natalie
841f741ed5 feat(@projects/@magic-civilization): 🗺️ p3-29 (2) — surface CityBordersExpanded as a turn event
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>
2026-06-27 02:43:38 -04:00
Natalie
f829d87e59 feat(@projects/@magic-civilization): 🌱 p3-29 (1) — surface CityGrew as a turn event (single-source UI prep)
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>
2026-06-27 02:32:57 -04:00
Natalie
8022722a33 feat(@projects/@magic-civilization): ☀️🧊 p3-26 B8 — solar + glacial event categories (9/12)
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>
2026-06-27 02:03:02 -04:00
Natalie
5288555bdb feat(@projects/@magic-civilization): ⚔️ p3-26 B6b (4/4) — CraftEquipment action (populate path; gear reachable in play)
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>
2026-06-26 23:44:33 -04:00
Natalie
a44ba951bd feat(@projects/@magic-civilization): ⚔️ p3-26 B6b (3/3 boot) — FFI + harness boot the item combat table
- 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>
2026-06-26 21:15:09 -04:00
Natalie
a3dac211e3 feat(@projects/@magic-civilization): ⚔️ p3-26 B6b (2/3) — equipment combat bonuses (units fight harder with gear)
The combat-read half of equipment:
- GameState += item_combat: BTreeMap<String, ItemCombatBonus{attack,defense}> (#[serde(skip)]
  boot) + load_item_combat_json (sums items.json stats: melee_bonus+ranged_bonus → attack,
  armor_bonus → defense).
- processor::equip_combat_bonus(unit, table) sums a unit's equipped-item bonuses; injected into
  BOTH combat paths (process_pvp_combat formation params + the click/MCTS resolve helper) —
  added to attacker/defender attack + defense.

Safe by construction: unequipped units (empty `equipped`) + empty table → (0,0) → combat
unchanged, so all existing combat tests + the 20-turn golden are unaffected (verified:
mc-turn 284/0). Tests: equip_combat_bonus sums stats / zero for unequipped/unknown.
Remaining (3/3): FFI+harness to boot the item table + a Craft/Equip action to populate gear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 21:13:16 -04:00
Natalie
4c41ecf66e feat(@projects/@magic-civilization): ⚔️ p3-26 B6b (1/3) — MapUnit.equipped (unit equipment state)
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>
2026-06-26 21:04:30 -04:00
Natalie
5edd20ced6 feat(@projects/@magic-civilization): ⚒️ p3-26 B6a (2/2) — FFI + harness boot the recipe bundle
- 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>
2026-06-26 20:51:11 -04:00
Natalie
dd1a537ab5 feat(@projects/@magic-civilization): ⚒️ p3-26 B6a (1/2) — resource-refinement (recipe) tick in the headless turn
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>
2026-06-26 20:49:04 -04:00
Natalie
14c4de8f85 docs(@projects/@magic-civilization): p3-27 — disease applier complete (o2+lair); ocean-collapse prereqs scoped
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>
2026-06-26 19:53:20 -04:00
Natalie
668ab7d152 feat(@projects/@magic-civilization): 🦠 p3-27 — complete the disease applier (o2 depletion + lair kill, no stubs)
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>
2026-06-26 19:46:35 -04:00
Natalie
5d12b4bbee feat(@projects/@magic-civilization): 🏗️ p3-26 B3 (3/4) — FFI + harness boot the improvement defs
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>
2026-06-26 19:42:08 -04:00
Natalie
f4e9d02115 feat(@projects/@magic-civilization): 🏗️ p3-26 B3 (2/4) — improvement subsystem logic (build-tick + dispatch + yields)
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>
2026-06-26 19:40:08 -04:00
Natalie
cb451832e0 feat(@projects/@magic-civilization): 🏗️ p3-26 B3 (1/4) — improvement subsystem state model
Groundwork for the headless improvement subsystem (wired in the next increments):
- PlayerState.pending_improvements: Vec<PendingImprovement> (#[serde(default)]) — tile
  improvements under construction.
- GameState.improvement_defs: BTreeMap<String, ImprovementDef> (#[serde(skip)] boot) +
  load_improvement_defs_json (parses public/resources/improvements/*.json → build_turns +
  food/production yields).
- ImprovementDef + PendingImprovement structs.

mc-state 14/0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:31:26 -04:00
Natalie
daf00bbee8 test(@projects/@magic-civilization): 🔧 re-pin 20-turn golden after the B2 healing phase
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>
2026-06-26 19:04:06 -04:00
Natalie
a3e26c6ed2 chore(@projects/@magic-civilization): sync Cargo.lock after mc-ecology dep moves
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:16:24 -04:00
Natalie
af41ea10a9 refactor(@projects/@magic-civilization): 🧩 unify turn phases — ecology into mc-turn + end-of-turn phase registry (OCP)
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>
2026-06-26 18:16:12 -04:00
Natalie
5ee312e452 refactor(@projects/@magic-civilization): 🔗 break the mc-turn↔mc-ecology dependency cycle at its root (DIP)
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>
2026-06-26 18:07:58 -04:00
Natalie
d14ba0b006 feat(@projects/@magic-civilization): 🦠 p3-26 B8 — plague (terrain blight) event category
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>
2026-06-26 17:50:36 -04:00
Natalie
2a0777e183 feat(@projects/@magic-civilization): 🦌 p3-27 — FFI + harness to boot the headless biosphere
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>
2026-06-26 17:17:38 -04:00
Natalie
b984143e60 feat(@projects/@magic-civilization): 🦌 p3-27 — living biosphere ticks in the headless turn
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>
2026-06-26 17:15:12 -04:00
Natalie
f8eadc9119 feat(@projects/@magic-civilization): 😊 p3-26 B1 — surface computed happiness_pool in view_json
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>
2026-06-26 15:50:30 -04:00
Natalie
d5729d67ce feat(@projects/@magic-civilization): 😊 p3-26 B1+B2 — happiness/golden-age + healing in the headless turn
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>
2026-06-26 15:46:42 -04:00
Natalie
afa7613fd8 feat(@projects/@magic-civilization): 🌍 p3-26 B8 — seismic + impact + tsunami event categories
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>
2026-06-26 15:31:19 -04:00
Natalie
8fd906241c feat(@projects/@magic-civilization): 🌋 p3-26 gap 2 — volcanic eruption category
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>
2026-06-26 14:55:18 -04:00
Natalie
60e941caa5 feat(@projects/@magic-civilization): 🏜️ p3-26 gap 2 — drought category + shared tile-picker
Second natural-event category, ported from GDScript process_drought:
- apply_drought: reduce moisture (min 0) in a hex disk, skipping water tiles
  (mc_core::grid::biome_registry::has_tag(.., IsWater)). Returns tiles affected.
- dispatch_drought: resolve tier (radius/moisture_loss) + pick a non-water center.
- Extracted pick_matching_tile (shared deterministic tile selection); refactored
  dispatch_wildfire to use it. process_events now dispatches wildfire + drought.

Test: apply_drought_dries_land_skips_water (land moisture drops, ocean untouched).
mc-climate events 8/8. Remaining categories (volcanic/seismic/tsunami/plague/marine/…)
follow the same pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:01:50 -04:00
Natalie
64ea08b7ce feat(@projects/@magic-civilization): 🔌 p3-26 gap 2 — load event configs in the headless harness (events now LIVE)
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>
2026-06-26 10:55:34 -04:00
Natalie
9bc2b2c34b feat(@projects/@magic-civilization): p3-26 gap 2 — wire natural events into the headless turn
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>
2026-06-26 10:52:09 -04:00
Natalie
b07e1dd367 feat(@projects/@magic-civilization): 🌋 p3-26 gap 2 — natural-event dispatch (process_events) + wildfire end-to-end
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>
2026-06-26 10:45:33 -04:00