Verify-first while scoping part B (strategic gating) surfaced that p3-23's premise
was wrong. The inter-player trade evaluation does NOT run in the played game:
- turn_manager.gd:287 has Diplomacy.process_turn() commented out under a stale
"empty stub module" note (diplomacy.gd was rebuilt but never re-enabled).
- The only writer of GameState.trade_ledger_json is diplomacy.gd:32 inside that
disabled call → the ledger is never populated → NO inter-player trades run
(luxury, strategic, or gold).
- The diplomacy.gd <-> GdTrade.process_trades contract has drifted in 3 places:
input shape ({index,traded_luxuries,personality} vs Vec<PlayerTradeInput>),
return keys ({ledger} vs trade_ledger_json/relation_changes/new_trades), and
relations advancement. So enabling is not a one-line uncomment.
Consequence: the part-A gold-flow wiring (last pass) is correct + GUT-tested but
INERT in-game until the integration is revived (it reads an always-empty ledger).
The mc-trade simulation logic remains complete + cargo-tested (66/0). p3-23's real
remaining work is now scoped: revive the diplomacy trade turn-integration
(reconcile the 3 contract drifts, re-enable carefully, add PlayerState.traded_strategics
+ unit-gating, verify headless+GUT) then the deal UI. Status stays partial.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the ResourceSale gold flow into the live economy (leveraging the p3-24
phase-1 economy port). GdTradeLedger gains gold_flow_for + incoming_strategics
#[func]s; GdEconomy gains a trade_gold param added to net gold AFTER the yield /
golden-age multipliers (a trade transfer must not be amplified by difficulty
handicap); economy.gd._player_trade_gold reads GameState.trade_ledger_json via
GdTradeLedger and passes the player's net flow. A seller now gains gold and a
buyer pays it each turn a sale is active.
Verified: GUT test_trade_gold_flows_into_net_gold (seller +3 → net 8, buyer −2 →
net 3, trade_gold echoed); dylib rebuilt + canonical GUT 748/0.
p3-23 stays partial — gold-trade flow now live (part A); remaining part B is the
strategic-resource gating (FFI sources tile_strategics, PlayerState.traded_strategics,
unit-gating reads incoming_strategics) + the deal UI.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verify-first (per the never-infer rule): the objective flagged climate_effects.gd:125
(unit.hp -= hp_loss) as a GDScript simulation-logic violation, but verification shows
the hp_loss COMPUTATION already lives in mc-climate::climate_effects::apply
(hp_loss = unit_damage × severity_scale; climate_effects.rs:113, 6 cargo tests).
GdClimateEffectsPhysics.apply delegates to it; climate_effects.gd is a thin marshaler
that fans the Rust-computed value onto GDScript Unit entities — the same sanctioned
pattern as economy.gd's disbanded_units fan-out (the file's own doc says so). No
GDScript simulation arithmetic remained, so bullet 3 is marked done with evidence
rather than churning already-compliant code.
All three named GDScript violations (gold, happiness, climate) now resolved. p3-24
stays partial only on the explicit (Stretch) bullet 4 (per-turn orchestration → Rust
turn driver), deferred to the broader pathfinder/turn port. cargo + GUT 747/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
happiness.gd summed building happiness effects and applied the
happiness_per_city × city_count multiply IN GDScript before the GdHappiness
call. Moved both into mc-happiness:
- HappinessInput gains building_happiness_effects + happiness_per_city_effects
(#[serde(default)]); building_happiness_total() does the sum + per-city multiply.
calculate_happiness uses it. Legacy building_happiness kept as a back-compat
default field.
- happiness.gd passes the raw effect lists (no arithmetic); turn_processor_helpers
sum_building_effects → collect_building_effects (pure per-building extraction,
its only caller was happiness.gd). The luxury-map assembly stays GDScript (tile/
DataLoader extraction; mc-happiness is pure).
Verified: 2 new mc-happiness cargo tests (aggregates effects+per-city; back-compat
legacy field); mc-happiness 23/0; dylib rebuilt + canonical GUT 747/0 (full
happiness.gd path test_happiness_turn -6/-4 unchanged).
p3-24 bullet 2 done; stays partial — remaining: climate HP-loss→Rust, orchestration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
economy.gd:65-79 computed gold IN GDScript (building-effect sum, gold-per-pop
multiply, gold-from-mines loop, percent sum) before the GdEconomy call —
violating "GDScript is presentation only". Moved all of it into mc-economy:
- New CityGoldRaw (per-building effect VALUES + population + mine_count) and
aggregate_city_gold() that does the building-sum + per-pop×pop + per-mine×mines
+ percent composition. Pure arithmetic, cargo-tested.
- GdEconomy FFI now deserializes the raw shape and aggregates before process_gold.
- economy.gd reduced to data extraction: _collect_effect_ints/_floats (no summing)
+ mine count; zero gold arithmetic. gdlint clean.
Verified: 3 new mc-economy cargo tests (sums/per-pop+per-mine/percent+e2e);
GdEconomy bridge GUT tests migrated to the raw shape; mc-economy green; dylib
rebuilt + canonical GUT 747/0.
p3-24 bullet 1 done; stays partial — remaining phases: happiness assembly→
mc-happiness, climate HP-loss→Rust, orchestration (stretch).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes the trade-richness simulation logic. New DiplomaticAgreement::ResourceSale
forms as a barter FALLBACK: when a pair can't swap (one side has a surplus the
other lacks but not vice-versa), the surplus holder SELLS the resource to the
buyer for SALE_GOLD_PER_TURN. evaluate_trades produces it after the swap passes
(sale_candidate probes pa-then-pb, luxuries-then-strategics, deterministic).
TradeLedger.gold_flow_for(player) exposes the per-turn flow (seller +, buyer −);
incoming_luxuries/incoming_strategics route the bought resource to the right pool;
has_resource_sale + breaks-on-war. Variant grouped with the swap arms across the
renewal/courier matches; break_trades_on_war gets its own ResourceSale arm.
Verified: 4 new cargo tests (forms-as-fallback, no-sale-when-swap, strategic-
routing, breaks-on-war); existing no-surplus test updated for the new fallback;
mc-trade 66/0; api-gdext + mc-turn compile.
p3-23 stays partial — both trade-logic halves (swaps + sales) now done + tested;
remaining is the in-game application (mc-economy gold flow + traded-resource
gating/happiness + GDScript deal UI), which lands with p3-24's economy port.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds strategic-resource trading to the inter-player evaluator (iron for horses),
the high-value half of trade richness. New DiplomaticAgreement::StrategicSwap;
evaluate_trades forms one per pair independently of the luxury swap via a shared
swap_candidates helper; PlayerTradeInput gains tile_strategics (#[serde(default)],
forward-compatible). TradeLedger.incoming_strategics / has_strategic_agreement
expose the buyer's gained access (unit-gating); swaps break on war. Keeps the
"keep your last copy" surplus rule (MIN_COPIES_TO_TRADE). The variant is grouped
with LuxurySwap in the renewal/courier matches (non-renewable, re-derived each turn).
Verified: 4 new cargo tests (forms-from-complementary-surplus, needs-surplus-and-
complementarity, luxury+strategic coexist, breaks-on-war); mc-trade 62/0;
api-gdext compiles.
p3-23 stays partial — remaining: gold↔resource deals (mc-economy gold flow) +
in-game wiring (FFI sources tile_strategics, PlayerState.traded_strategics,
unit-gating reads incoming_strategics).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migration now responds to climate, not just population pressure. In the per-turn
population mover (generation.rs compute_migrations), drought_carrying_factor
lowers a dry tile's effective carrying capacity — so its populations exceed cap
and emigrate, while dry neighbours offer less room, steering herds toward wetter
ground. The MigrationPulse events path (biological.rs) gets a matching
data-driven migration_climate_stress trigger bias. Reads the tile's live
drought_counter; pure mc-ecology, runs via the existing per-turn engine tick.
Tests: migration_climate_stress_rises_with_drought_and_caps, drought_raises_migration_rate,
drought_reduces_carrying_capacity_triggering_migration; mc-ecology 338/0; dylib
rebuilt + deployed; GUT 747/0.
p3-21 → done. Next: p3-23 (trade richness).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes weather→scouting. weather.gd gains vision_penalty_at(col,row) (worst
penalty from active events covering the tile, hex-distance footprint);
world_map_vision.recalculate_vision cuts each unit's sight radius by it (floored
at 1) before computing visible_hexes — so a unit standing in a storm/blizzard/dust
storm reveals fewer tiles. The penalty VALUE is Rust-derived (WeatherEvent.vision_penalty,
data); GDScript only reads + applies it. Headless path has compute_vision_with_penalties.
Verified: mc-climate 45/0, mc-vision 30/0, GUT vision_penalty_at (within/outside/
worst-overlap); dylib rebuilt + deployed; canonical GUT 747/0.
p3-20 → done. Next: p3-21 (weather-driven migration).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The vision-consumer half of weather→scouting. compute_vision_with_penalties takes
a per-tile vision-penalty map and reduces each unit's effective sight by the
penalty at its tile (floored at WEATHER_MIN_VISION=1, so a unit always sees its
own tile + immediate ring). compute_vision delegates with an empty map, so all
existing callers are unchanged (no churn). refresh_for_player +
compute_player_visible_set thread the penalty.
Test: weather_vision_penalty_shrinks_unit_sight (radius-2 disk 19 → radius-1 disk
7 under penalty 1; floored under penalty 99); mc-vision 30/0.
p3-20 stays partial — bridge/GDScript wiring (weather events → penalty map →
compute_vision_with_penalties) + dylib/GUT next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The producer side of weather→scouting: WeatherEvent gains vision_penalty: i32
(serde-default + flexible deser), and derive_events sets it per kind — storm 1,
blizzard 2, dust_storm 2; heat_wave/drought/flood 0 (clear-weather events don't
impair sight). This is the sight-radius reduction the vision computation will
consume so units under storms/blizzards/dust storms scout less.
Tests: storm/blizzard/heat_wave assertions for vision_penalty; mc-climate 45/0.
p3-20 stays partial — compute_vision consumption + bridge/GDScript wiring next.
(Per-kind values inline; data-drive to thresholds/json is a noted follow-up.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes player→ecology feedback. EcologyEngine::deplete_flora_at(col,row,amount)
depletes a tile's Producer-diet (flora) populations (registry-identified);
GdFaunaEcology.deplete_flora_at exposes it; EcologyState._on_tile_improved fires
it when a flora-clearing improvement (deforestation) completes — so clear-cutting
a forest removes its flora (the terrain→grassland change also drives the gradual
die-off). With the fauna half (over-hunting → extinction), the living world now
reacts to player pressure both ways. Logic stays in mc_ecology (Rail 1).
Test: deplete_flora_at_targets_producer_species_only (mc-ecology green); dylib
rebuilt + deployed; canonical GUT 745/0 (wiring loads, no regression).
p3-19 → done. Next: p3-20 (weather→scouting).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the player→ecology coupling for fauna (the USP: the living world reacts to
the player). GdFaunaEcology.deplete_species #[func] resolves the string species id
→ numeric via the species library and calls EcologyEngine::deplete_population.
combat_utils._roll_wild_creature_loot now passes the slain creature's tile into
item_system.roll_fauna_drops, which calls EcologyState.fauna_ecology.deplete_species
on every fauna kill — so sustained hunting drives a species to local extinction
(is_extinct), and the engine's growth/emergence recover it once pressure eases.
Logic stays in mc_ecology (Rail 1); GDScript only triggers + passes the tile.
Verified: mc-ecology cargo green; dylib rebuilt + deployed; canonical GUT 745/0
(new roll_fauna_drops signature + caller load cleanly, deplete_species callable).
p3-19 stays partial — flora-harvest half (chop/intensive → flora population) next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Rust foundation for player→ecology feedback: PopulationSlot::deplete(amount)
(floors at 0, can cross the is_extinct threshold) + EcologyEngine::deplete_population
(col,row,species_id,amount) → post-depletion population, safe 0.0 no-op for a
missing tile/species. This is the hook hunting/harvesting will call to make
over-harvest drive local extinction; the engine's existing growth/emergence then
recovers abundance once pressure eases.
Tests: deplete_reduces_floors_at_zero_and_can_extinct (PopulationSlot) +
deplete_population_reduces_tile_species_and_can_extinct (engine); mc-ecology green.
p3-19 → partial. Remaining: GdEcologyEngine #[func] + GDScript kill/harvest wiring
+ dylib/GUT (loop continues).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pick_for_city gains a scout branch (after the early-military floor, before
expansion): when dwarf_scout is buildable (its tech/race/resource/building gates
met — mirrors pick_best_unit_of_type) and the capital owns no scout, the capital
queues one. Single scout, capital-only; the existing frontier-seek + scout-sweep
maneuvers (movement.rs) already drive dwarf_scout, so the AI stops diverting
combat units to scouting.
Tests: ai_builds_scout_when_buildable_and_none_owned +
ai_does_not_build_scout_without_its_tech; mc-ai 289/0 green. p3-22 → done.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Game 1 was NOT actually complete; the green dashboard overstated it. Created
objectives for the verified, untracked gaps (all confirmed 2026-06-25 against the
code, both Rust + GDScript paths):
- p3-19 player→ecology feedback (over-harvest/hunt deplete live populations; extinction)
- p3-20 weather affects scouting (vision penalty, not just movement)
- p3-21 weather-driven migration (migration ignores weather today)
- p3-22 AI builds dedicated scouts (only frontier-seeks with idle military)
- p3-23 trade richness (luxury swaps only; no gold/strategic trades)
- p3-24 Rail-1 port of economy/happiness/climate per-turn glue logic from GDScript
Dashboard regenerated. (Systems I'd wrongly doubted — ecology engine ticking, AI
worker improvements, naval harbor-gating — are confirmed working; not reopened.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tools/objectives-report.py after marking p3-18 (water crossing) done. Keeps the
dashboard fresh for verify step 2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
teched_army_fords_water_then_attacks_enemy_on_far_landmass: end-to-end proof of
the conquest payoff that motivated p3-18. Over an ocean-wall map, a player-0 army
with ocean_navigation embark fords the ocean column to landmass B (process_move_requests),
then strikes a player-1 unit waiting there (queued AttackRequest → process_pvp_combat):
a cross-water battle resolves and the enemy takes damage. Embark turns an
otherwise-unreachable rival on another landmass into an attackable one.
Deterministic, cargo-verifiable; complements P6-core (the ford proof). The full
headless 1v1-to-game_over demo is the only remaining (confirmatory) P6 item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Completes single-source on the action bridge. With auto-embark (no explicit
Embark action), the embark/adjacency inputs to legal_actions_for / can_invoke were
dead:
- api-gdext: drop is_embarked / adjacent_water / adjacent_land params from both
#[func]s (+ the consume lines + doc).
- GDScript callers updated to the new arity: unit.gd (get_legal_actions +
can_invoke_action), unit_panel.gd (legal_actions_for + its dead arg vars and the
three dead _get_* helpers), test_unit_actions.gd (4 calls).
- unit.gd: remove the now-dead has_adjacent_water/has_adjacent_land fields (never
set — their _refresh_unit_terrain_context setter is long gone) + their serialize/
deserialize; test_unit_serialize.gd updated. is_embarked stays (real state).
Verified: cargo check green; dylib rebuilt + deployed; canonical GUT suite 745
tests / 732 passing / 0 failing (13 pending). No arg-mismatch errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Embark + transport now work in both the headless sim (authoritative) and the
rendered UI, verified at unit / integration / GUT levels (732 GUT passing).
Remaining: P6-full headless 1v1-to-game_over demo (confirmatory over P6-core),
vestigial-FFI trim (cosmetic), transport refinements (deferred, documented).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
test_pathfinder_embark.gd: over an ocean-column map, a land unit finds no path
without embark (embark_level 0), fords the column with embark (level > 0) and
reaches the far landmass; naval movement is unaffected by the embark arg.
Verified headless against the rebuilt dylib: GUT 732 passing / 0 failed (+3 from
729 — these tests; no regression). UI embark now matches the authoritative
headless sim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the rendered game's GDScript movement to the data-driven embark capability,
so a human playing the UI can cross water when teched (previously embark worked
headless only):
- pathfinder.gd: find_path / movement_range / find_path_with_fog gain a defaulted
embark_level param (non-breaking for all existing callers); _is_passable lets a
land unit enter water when embark_level > 0. Single-tier UI gate — the precise
coast-vs-ocean tier stays authoritative in the Rust sim; the gate avoids
hardcoding the biome→tier map in GDScript (Rail 1).
- KnowledgeWeb + TechWeb: embark_level(researched) passthroughs to the Rust
GdTechWeb.embark_level (the tech→level mapping stays in Rust, data-driven).
- world_map.gd: _current_embark_level() (defensive) feeds the active player's
level into all four pathfinder call sites (move, range overlay, fog previews).
gdlint clean. Verifies via dylib rebuild + GUT (next).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rendered game moves via GDScript pathfinder.gd (GDScript-authoritative; no
Rust round-trip) over GDScript-canonical tech. To make UI movement embark-aware
WITHOUT re-deriving the tech→level mapping in GDScript (Rail 1: logic stays in
Rust), GdTechWeb gains embark_level(researched: PackedStringArray) -> i64 (0 none
/ 1 coast / 2 ocean) backed by the data-driven TechWeb::embark_level. The GDScript
pathfinder will call this and carry only a thin water-passability gate.
cargo check -p magic-civ-physics-gdext green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P5a (dead GDScript embark UI) and P6-core (ford integration proof) landed. P5b
(rendered UI embark) scoped: the rendered state is hybrid (GDScript GameState/Player
+ Rust GdGameState bridge); the single-source path is a GdGameState #[func]
exposing the Rust-computed EmbarkLevel, threaded into pathfinder.gd — dylib-gated
(build-gdext.sh works) + GUT. P6-full demo is confirmatory over the integration proof.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P3b deleted the Civ-V explicit Embark/Disembark actions on the Rust side, leaving
the GDScript UI plumbing dangling (it dispatched the now-removed "embark"/
"disembark" actions; gated on the amphibious keyword no unit carries, so it never
rendered). Removed it: unit_panel.gd embark_pressed/disembark_pressed signals,
their ACTION_SIGNAL_MAP entries + emit cases; world_map.gd signal connections +
_on_embark/_on_disembark handlers. No dangling refs remain; no GUT test referenced
them. Auto-embark (move onto water) remains the only embark model, now consistent
across Rust and the GDScript UI.
Remaining P5: the rendered game's movement (pathfinder.gd, GDScript logic) is not
yet embark-aware — entangled with the broader Rail-1 debt that rendered movement
runs GDScript rather than the Rust pathfinder. Tracked in the objective.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The scenario that motivated p3-18: an army on landmass A reaching landmass B
across an ocean strip. teched_army_fords_open_ocean_to_far_landmass asserts the
full chain through process_move_requests — without naval tech the ocean rejects
the move (army stays put); with ocean_navigation (Ocean embark) the army crosses
the strip onto the far landmass and auto-disembarks on arrival.
Deterministic, cargo-verifiable evidence (the project's preferred proof form), no
dylib/Godot needed. The full headless 1v1-to-game_over demo (P6-full) is the
heavier dylib follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The earlier "cargo resolver panic blocks the gdext dylib" claim was wrong: I was
running `cargo -p gdextension-api`, which is a crates.io godot dependency, not our
crate. The local crate is `magic-civ-physics-gdext`; `cargo check -p
magic-civ-physics-gdext` is green in ~38s. P5 (UI embark) and P6 (demo) are
feasible locally via build-gdext.sh + GUT — not build-host-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Records transport P4a/b/c complete + the finding that the rendered game moves via
GDScript pathfinder.gd (not the Rust pathfinder), so embark works headless only
until P5 plumbs the embark gate into the GDScript path (or routes movement through
Rust). Transport refinements (carried-unit target-protection, AI-use) deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After the PvP combat phase, prune_orphaned_cargo drops any unit whose carrier_id
references a hull no longer in its player's roster — the transport sank, its
cargo goes down with it. Order-preserving removal that keeps the index-parallel
unit_upkeep aligned; idempotent and a no-op for carrier-free rosters (so existing
combat is untouched — pvp tests still green).
Test: destroyed_transport_loses_cargo (orphaned cargo pruned, uncarried unit
survives, unit_upkeep stays aligned).
Known follow-up: carried units ride the hull's hex (stacked); fully shielding
them from being individually targeted needs combat target-selection changes that
touch 1UPT assumptions — tracked, not in this commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the carry mechanic in process_one_move, modelled as automatic moves
(consistent with auto-embark — no new explicit actions):
- BOARD: a land unit stepping onto an adjacent friendly transport hull (on water)
boards it (carrier_id = hull.id) instead of being blocked — no embark tech
needed, capacity-gated at TRANSPORT_CAPACITY. Handled before find_path (a land
unit can't path onto water otherwise).
- CARRY: when a transport moves, its loaded units are dragged to the new hex
(they ride the hull's hex, stacked).
- DISEMBARK: a carried unit steps off onto an adjacent empty land hex (carrier_id
cleared); rejected otherwise (carried units can't move independently on water).
Boarding/disembarking units are aboard, not embarked (is_embarked stays false).
Tests: transport_board_carry_then_unload (full cycle) + transport_rejects_boarding_when_full;
mc-turn 246 lib + all integration binaries green (the new modes only activate for
units with carrier_id or a transport-at-target, so existing movement is untouched).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for the transport mechanic (owner: build it):
- catalog UnitStats gains `keywords: Vec<String>` (authored on units/<id>.json),
+ `is_transport()` (keywords.contains("transport")) and a TRANSPORT_CAPACITY=2
constant mirroring the combat.json transport keyword ("carry up to 2 land
units"). Data-driven — no unit id hardcoded.
- MapUnit gains `carrier_id: Option<u32>` — the hull carrying this land unit
(None = on map normally). A carried unit rides the carrier's hex, isn't
independently attackable, and is lost with the carrier.
Test: transport_keyword_detected_data_driven; mc-units lib green. Mechanic
(board/carry/unload + combat-loss) lands in P4b/P4c.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auto-embark is now the sole embark model. Remaining: transport (P4, large fresh
Rust mechanic), GDScript mirror + vestigial-FFI trim (P5, needs build host),
end-to-end conquest demo (P6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With Civ-VI auto-embark live (move onto water → embarked), the half-built Civ-V
explicit path was a redundant second model — and dead besides (gated on the
`amphibious` keyword, which no unit carries). Per owner direction (unify on one
model, no two definitions), it is removed:
- mc-core: drop ActionKind::Embark/Disembark (+ idx/as_str/from_str), the four
embark DisabledReasons, the amphibious Embark/Disembark action-gen block, and
the now-dead UnitCapability is_embarked/adjacent_water/adjacent_land fields
(+ all fixtures and the amphibious action tests).
- mc-turn: drop handle_embark/handle_disembark + their dispatch arms + tests.
- mc-ai: drop the dead capability fields from the action-query construction.
- mc-state: MapUnit::is_embarked doc now describes auto-embark (its sole writer
is process_one_move).
- api-gdext: the legal_actions_for/can_invoke FFI embark inputs are now vestigial
(no longer affect legality); kept for ABI stability + flagged for the P5
GDScript-mirror trim.
The only embark model is now the data-driven auto-embark (P1b/P2/P3).
Tests: mc-core 263, mc-ai 287, mc-turn 244 green (−4 = the deleted explicit-embark
tests). gdextension-api full compile deferred to the build host (local cargo
resolver panics on a pre-existing thiserror/wasm feature-unification issue,
unrelated to this change — no Cargo.toml touched).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tactical AI's own passability gate (passable_land_hexes) hard-excluded all
water, so even a teched army never considered crossing it — exploration and the
march-on-enemy-capital both stopped at the shoreline. Now the AI is embark-aware:
- TacticalState carries the bound player's embark_level (data-driven; projected
from PlayerState::embark_level by project_tactical_with_vision).
- passable_land_hexes → passable_hexes(map, embark): water tiles open per the
embark level (coast water needs Coast, open/deep ocean needs Ocean), mirroring
mc_pathfinding::is_passable. Non-water impassables (mountains/volcano/ice) stay
blocked regardless of embark.
So a frontier-seeking or capital-marching army with the naval tech will path
across water to reach a rival on another landmass — the discovery/conquest gap
that motivated p3-18.
Tests: embark_opens_water_for_passability (None/Coast/Ocean tiers) +
embark_never_opens_non_water_impassables; mc-ai lib 287 green (the embark=None
default preserves all existing movement behaviour).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Civ-VI auto-embark model (owner choice): a land unit that ends its move on a
water tile becomes embarked; stepping back onto land disembarks it — no explicit
action needed. Wires the previously-dead is_embarked field + embarked_defence_penalty.
- mc-pathfinding: pub is_water_at(grid, pos) helper.
- mc-turn process_one_move: after a land unit moves, set MapUnit::is_embarked =
(destination is water). The dragged escort protectee's embarked state is kept
consistent with the tile it lands on too. Naval units never toggle (they belong
on water).
- mc-combat: CombatParams gains defender_is_embarked; resolve() halves the
defender's defence via the canonical embarked_defence_penalty when set. The
apply_attack caller passes the defender unit's is_embarked.
So an army can cross water (P1 gate + tech) and is vulnerable while doing so
(the Civ embarked rule), exactly as the half-built scaffolding intended.
Tests: mc-combat embarked_defender_takes_more_damage (high-defence case so the
50% penalty clears damage-rounding); full mc-combat (146) + mc-turn (248) green,
no regression (penalty only activates when embarked).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P1 hardcoded the tech ids ("shipbuilding"/"ocean_navigation") in Rust — a Rail-2
violation (JSON is the canonical content store) and not single-source. Per owner
direction ("should be a player-level config setting"), the embark grant now lives
in data and is cached per player:
- mc_core::EmbarkLevel moves to the shared base crate (was mc-pathfinding) so
PlayerState can hold it; mc-pathfinding re-exports it. Adds from_mechanic_key
(the ONLY place the embark_* mechanic-key strings live).
- The naval techs carry the grant in JSON via unlocks.mechanics: shipbuilding →
embark_coast, ocean_navigation → embark_ocean. Which tech grants embark is now
authored data, not Rust.
- TechWeb::embark_level(researched) derives the strongest grant across a player's
researched techs (None < Coast < Ocean).
- PlayerState gains a cached embark_level field; process_science recomputes it
each turn from the researched set (idempotent → save-load / tech injection
covered). The move handler reads the cache (no per-move tech parsing).
Tests: mc-core EmbarkLevel ordering + mapping; mc-tech embark_level method
(inline web) + a real-data guard that authored naval.json carries the mechanics;
mc-pathfinding 9/9 unchanged. All green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Land units were hard-confined to their landmass: is_passable("ocean", Land) was
always false, with no tech path across water (Civ "embarkation" gap). The
scaffolding existed but was dead (embarked_defence_penalty, the transport keyword).
P1 wires the first layer — the pathfinding gate:
- mc-pathfinding gains EmbarkLevel {None, Coast, Ocean}; is_passable + find_path
take it. A Land unit may now enter water per level — coastal (IsCoast) water
needs Coast, open/deep ocean needs Ocean. None = legacy land-locked (default,
so existing behaviour is unchanged).
- The move handler (process_one_move) derives the level from the player's naval
tree via embark_level_for: ocean_navigation→Ocean, shipbuilding→Coast. So a
teched army can cross water; an un-teched one still cannot.
Maps onto the existing naval tech tree and the IsCoast/IsWater biome tags — no
new techs, no new biomes. Civ two-tier model (Optics/Astronomy → shipbuilding/
ocean_navigation).
Tests: mc-pathfinding 9/9 (incl. embark_gates_land_on_water_by_level +
ocean_embark_lets_a_land_unit_cross_an_ocean_strip); mc-turn suite green.
Objective p3-18 created (full design + phased acceptance P1–P6); P1 marked done.
Follow-ups: P2 embarked combat, P3 AI water-pathing, P4 transport, P5 GDScript
mirror, P6 end-to-end conquest demo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pump loop treated an empty stdin read as "EOF or no line yet" and yielded +
retried forever, so `cat reqs | player-api-server.sh` hung until CP_TIMEOUT_SEC
instead of the documented clean EOF exit. Godot 4.6's read_string_from_stdin
blocks, so an empty return is end-of-stream — break and quit(0). Also add a
`{"type":"stop"}` request (acked, sets the loop flag) so interactive clients that
hold stdin open (Popen drivers, the MCP adapter) can exit cleanly without relying
on EOF — the clean-exit request path the docs promised but never implemented.
Verified: 11-request batch pipe now exits 0 in ~1s (was 124/timeout); view+stop
exits 0 in ~4s with both responses acked.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the deterministic end-to-end proof p3-16's last bullet needed: in AI-vs-AI
self-play, once frontier-seek exploration brings a militarist into view of a
*weaker* rival, it dispatches a war-dec and the relation flips to War within 60
turns. Asymmetric armies (slot 2 weakened) so the aggressor clears its
superiority threshold on discovery. Pairs with the existing decide_diplomacy unit
cases (cautious-holds-at-parity / warmonger-strikes) to demonstrate
personality-driven war-decs manifest in actual play — the integration that was
blocked purely on discovery (p3-17). Shared self-play setup helper extracted.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes p3-17's "measurable improvement in self-play: earlier first-contact" as a
reproducible cargo test (the sim is deterministic, so this needs no apricot smoke
batch). Two AI militarists start fogged-apart on a 12x12 map; the frontier-seek
exploration must bring one into view of another within 40 turns. Asserts no
contact at game start (fog intact) and contact by the budget. This is the
discovery feed p3-16's decide_diplomacy (declares only on a *discovered* rival)
depends on — it never fired reliably before exploration landed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
movement.rs::is_at_war defaulted a missing relation slot to `true` (at war) —
the legacy p1-01 "missing → war" model that courier-diplomacy (p3-16) supersedes:
every pair starts at PEACE, war begins only on a war-dec envelope. An absent slot
now defaults to peace (`map_or(false, …)`), so the AI never treats a
not-yet-projected pair as a phantom war. project_tactical_relations fills the vec
from real courier state, so genuine wars are unaffected. mc-ai+mc-player-api 556/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guards the wiring the movement unit tests can't: compute_vision (fog) →
project_tactical_with_vision (sets TacticalTile.explored) → decide_tactical_actions.
An idle warrior with no visible enemy on a 20x20 foggy map must issue a MoveUnit
toward the fog rather than idling — the discovery feed p3-16's war-dec gate needs.
Asserts the far corner projects as unexplored and the unit steps off its start
tile. Reproducible (no MCP), runs under cargo test --workspace.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Idle military/scout units' explore move used a centroid-mirror heuristic (head
for the far reach) that ignored actual fog, so the AI rarely expanded its
explored footprint or made first contact — starving target acquisition and the
p3-16 war-dec discovery gate.
- Add `explored: bool` to TacticalTile (serde default true so un-flagged bridge
tiles read as seen, never spuriously sought). projection.rs::project_tactical_map
populates it from PlayerVision::is_explored (omniscient → all true).
- Upgrade movement.rs::score_explore_move to target the nearest genuinely
unexplored tile (nearest_unexplored_frontier), with a deterministic per-unit
tie-break so a stack fans across frontier tiles; the centroid-mirror is kept as
the fully-explored fallback. No rng/Instant — determinism contract preserved.
- 2 new tests (targets-nearest-unexplored, mirror-fallback-when-fully-explored).
The headless self-play path (dispatch → project_tactical_with_vision, rebuilt per
turn) now drives real exploration. mc-ai+mc-player-api 555/0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add tools/check-no-gdscript-sim-logic.py and wire it as verify step 18 (TOTAL
20→21). Fails if presentation GDScript (src/game/engine/src/**/*.gd) re-introduces
catalog yield aggregation (`yield_production += …`) or hand-built spec dicts
(`"yield_production": …`) — the exact drift class just moved to Rust. Verified to
flag the pre-7e2baa25d aggregation and pass clean on the current tree. Logic
belongs in the mc-* crates, reached via the GDExtension bridge (Rail 1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Twin of the building consolidation. The GDScript build_unit_catalog did the
buildable-unit filter (drop faction:"wild" monsters + "freepeople" NPCs) and
field mapping by hand. Add mc_ai::tactical::parse_unit_catalog as the one Rust
implementation (resilient: skips malformed, drops map-spawned factions, defaults
tier=1), exposed as GdItemSystem.parse_unit_catalog_json. GDScript
build_unit_catalog now marshals the raw unit docs to it; the bench routes through
the same transform. The wild/freepeople filter lives only in Rust now.
Validated: mc-core+mc-ai+mc-player-api 826/0 (3 new parse_unit_catalog tests);
rebuilt aarch64 dylib; headless GUT 729/0/13 incl. a unit round-trip + wild-filter
delegation test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Godot's JSON.parse_string decodes every JSON number as a float, so a tactical
catalog that round-trips through GDScript (build_*_catalog → JSON →
decide_actions / set_ai_*_catalog_json) presents tier/cost/yields as `1.0`, and
Rust's u32/i32 deserialize rejected the whole catalog
("invalid type: floating point 1.0, expected u32"). Add lenient_u32 / lenient_i32
deserializers and apply them to TacticalUnitSpec.tier and TacticalBuildingSpec
{tier, cost, yield_*×9, great_work_slots}. This was latent in the building
delegation and would have bitten as soon as buildings loaded; the unit-catalog
delegation surfaced it. Unit-tested (float + int forms).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a regression guard that loads the ENTIRE authored content store through the
Rust source-of-truth types — every public/resources/units/*.json into UnitStats
AND TacticalUnitSpec, every buildings/*.json through parse_building_catalog — not
just the 7-file bench subset. The `game data JSON schemas` step validates against
schemas; this validates against the structs the simulator actually runs on, so a
file can no longer satisfy a schema yet break the sim. Runs under
`cargo test --workspace`, so verify auto-enforces it; a drifting file fails the
gate with its filename.
The guard immediately caught two parser-parity bugs the bench never exercised:
- Building `effects[]` may carry a boolean value (`{"type":"enables_naval",
"value":true}`); `BuildingEffect.value: f64` rejected it, dropping the whole
building (harbor, stable, deep_*, …) from the catalog. Add a lenient_number
deserializer that coerces non-numbers to 0.0 — parity with the GDScript
`value is int or is float` guard. (NB: the dylib from 7e2baa25d had the strict
parser; rebuilt so the live game re-includes these buildings.)
- TacticalUnitSpec.tier had no serde default while the GDScript builder defaults
it to 1; a unit JSON omitting tier (founder.json) failed to deserialize. Add
#[serde(default = "default_tier")] for path parity.
Test excludes the *.schema.json / *_categories.json sidecars that live in the
content dirs. Validated: mc-core+mc-ai+mc-player-api 822/0; rebuilt aarch64 dylib;
headless GUT 728/0/13.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>