p2-65 foundation. New data-shape crate `mc-state` (deps: data crates only —
no mc-ai, no mc-turn, no rayon/GPU) to decouple simulation state from the
turn-step mutation logic in mc-turn.
- New crate `crates/mc-state` added to workspace members; lib.rs declares the
module set (ransom now; game_state to follow).
- `RansomOffer` + `RansomQueue` (struct + self-contained queue mechanics +
`RANSOM_OFFER_DURATION_TURNS`) moved verbatim from mc-turn to
`mc_state::ransom`. `mc-turn/src/ransom.rs` is now a `pub use` shim so every
`mc_turn::ransom::*` import path resolves unchanged.
- mc-turn gains `mc-state` path dep. No cycle: mc-vision→mc-turn→mc-state, and
mc-state deliberately does NOT dep mc-vision (game_state uses no mc_vision
field type — only a doc-comment ref).
- Save-format invariant held: serde shapes byte-identical (serde never encoded
module paths). New round-trip + tick/take tests in mc-state.
Gates (apricot): cargo test --workspace --no-run exit 0; mc-state 2/2;
mc-turn ransom 13/13; api-gdext capture_bridge 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ran the deferred full-game validation as a controlled same-build before/after:
one GDExtension built once on apricot from pinned SHA 3d83f4781 (carries the
lever); combat_balance.json is runtime-loaded, so only cooldown_turns would
change between arms.
Pre-flight killed the batch before it ran — cd=5 is inert by construction on the
p1-29d autoplay gate surface, for two independent reasons:
1. Architectural: autoplay applies founding via GDScript dispatch_found_city,
never calling the Rust try_found_city/process_siege where the refound gate
lives (same class as process_science bypassed by GdTechWeb). Lever cannot fire.
2. Behavioral: autoplay produces terminal capital-capture eliminations, never
refound churn — no event for cooldown_turns to gate (4-seed cd=0 run shows
cities_lost 0–1 per game, all terminal; corroborated by the 10-seed
20260529_185955 table).
Arm B (cd=5) NOT run: byte-identical by logic (zero qualifying events) — a hollow
"no effect" confirmation, the inverse of the batch-attribution trap. The pre-flight
clause authorizes stopping.
Verdict: do NOT author cd=5. combat_balance.json left at default 0 (the gridded
5/9→8/9 lift is real on the gridded harness but does NOT transfer — recontextualized
as a surface mismatch, NOT retracted). p1-29h elim bullet scoped to the gridded
surface. p1-29d D1 re-pointed: no longer gated on the refound lever (it does not
unblock D1); real unblock is the autoplay→Rust action-application architecture gap
(out of fence).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runs a Godot proof .tscn under a weston --backend=headless virtual Wayland
compositor (a real display is required — --headless returns a null viewport
image, and the proof scenes read get_viewport().get_texture().get_image()).
Wipes the matching prior PNGs, runs the scene, lists the produced shots so the
caller can rsync them back. Parameterized by PROOF_SCENE + SHOT_GLOB; generalizes
the per-objective p2-60/p1-56 proof scripts. Used to re-capture the statistics +
end-game summary proofs for the Wave-A vocab polish.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Advances p3-10a. Moving a stack onto a wild-lair tile now opens a small
CanvasLayer mode picker (modeled on promotion_picker.tscn) before combat:
Assault (enabled), Raid (disabled — p3-10c), Siege (disabled — p3-10b). The
picker emits mode_chosen(mode) / cancelled(); world_map_combat.initiate_lair_combat
opens it and routes the Assault branch through _begin_lair_assault → the existing
p0-17 show_lair_preview → _handle_lair_clear path (per p3-10a, "the existing path
IS the assault"), so the working lair-clear flow is not regressed.
Scope note: Assault routes through the live p0-17 flow, NOT GdLair.assault()
(api-gdext/src/lair.rs) — that bridge is the 7-arg JSON marshaller and would
require building attacker/defender JSON + loading tier_NN.json + applying
loot/survivor/clear outcomes in GDScript, duplicating the working path. The
p3-10a bullet therefore stays ◐ (bridge not exercised end-to-end; no picker
proof screenshot yet).
GUT: tests/unit/test_lair_mode_picker.gd 5/5 green on apricot headless
(only-Assault-enabled, Assault emits mode_chosen("assault"), Cancel emits
cancelled() and no mode, disabled Raid/Siege never emit via the in-handler
guard, target label resolves the lair name). All-Dwarf vocab keys
(lair_picker_*, lair_mode_*) authored in vocabulary.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stats modal (statistics.gd) and end-game summary (end_game_summary.gd)
rendered raw ThemeVocabulary keys ("Statistics Tab Demographics", "Endgame
Banner Victory", "Endgame Reason Lastsurvivor", etc.) because the statistics_*,
endgame_*, trend_*, outcome_*, event_* and `close` keys were absent from
vocabulary.json — lookup() falls back to title-case on a miss.
Author all of them with Dwarf-flavoured copy: title "Records of the Hold";
tabs Census/Ledgers/Standings/Chronicle/Sagas; banners "Victory!" / "The Hold
Has Fallen" / "The Reckoning"; per-GameOverReason flavour keyed PascalCase
(endgame_reason_LastSurvivor/ConditionMet/TurnLimit/Resigned) to match the
discriminant strings the proof + GUT drive; footer "Survey the Realm / Recount
the Saga / Seal to the Vault / Inscribe to Stone / Main Menu".
statistics_proof.gd: rebuild StatsTracker.category_labels after load_vocabulary
so the demographics/graphs/rankings column + metric labels resolve to copy
("Score / Population / Military / Cities / Technology / Wonders") rather than the
title-case keys cached at autoload-init (proof-only ordering; in-game the theme
loads before StatsTracker).
Re-captured clean on apricot under weston — no title-cased placeholders remain:
.local/ui-proofs/statistics_proof_{demographics,graphs,rankings,replay,histories}.png
.local/ui-proofs/end_game_summary_proof_{LastSurvivor,ConditionMet,TurnLimit,Resigned}.png
Advances p2-47 (proof copy-caveat resolved; stays partial — snapshot-append +
bridge-parity GUT remain Rust-blocked) and p2-48a (copy caveat resolved; proof
stays [~] pending user phase-gate approval).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A single-seed sweep is uninformative (scripted path barely responds to rng), so
add a geometry ensemble over INITIAL CONDITIONS: start-distance {4,5,6} ×
attacker-warriors {3,4,5} = 9 surfaces, at cooldown {0,3,5,8}.
Result (elimination-rate X/9):
cd=0 (baseline): 5/9 (attacker-wins 5 / def 4)
cd=3: 7/9 (att 7 / def 2)
cd=5: 8/9 (att 7 / def 2) <- peak
cd=8: 6/9 (att 5 / def 4)
The refound cooldown produces a ROBUST lift (soft hump peaking cd 3-5, every
value >= baseline; cd=5 weakly dominates cd=0 cell-by-cell — better in 3/9,
worse in 0/9), shifting outcomes toward the heavier attacker. This overturns the
single-seed pessimism in the prior commit.
CORRECTED PREMISE: p1-29h's cited "20 captures / 0 eliminations" was a
single-GEOMETRY artifact (the dist=5/w=4 cell). Eliminations already occur in
5/9 baseline geometries — the lever RAISES an existing rate (5/9 -> 8/9), it
does not unlock elimination. p1-29h elimination bullet updated to reflect this.
DELIBERATELY NOT DONE (honest, evidence-bounded):
- No cd value authored into combat_balance.json — the lift is gridded-micro-
surface only (9 geometries, 1 seed each); a live balance value needs the
full-game 10-seed batch (tools/p1-survival-score.py, the multi-seed-tournament
rule). The cd response is also an unexplained hump (cd=8 -> 6/9). Lever stays
defaulted off.
- p1-29d NOT re-scored as converged — its gate is the full-game multi-gate
scorecard (D1: P1 elim<=T100 OR stalled, 10/10), a different + heavier surface
not run this pass. The brief's "re-score p1-29d" is gated on that measurement.
Tests (apricot): gridded harness non-ignored 1/1; ensemble + sweep run via
--ignored; cargo check --workspace 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
p1-29h Phase 2 isolated the elimination wall. A per-player diagnostic added to
the gridded harness shows per_player_min_cities=[1,1] — both empires ARE pressed
to one city but neither is eliminated; the loser instantly refounds. That
[1,1]-but-no-kill signal makes refound-suppression the correctly-targeted lever.
Lever (data-driven, Rail 2, DEFAULTED OFF — zero live-balance change):
- mc_core::CombatBalance::refound_suppression { cooldown_turns: u32 } (default 0).
- mc_turn::PlayerState::last_city_lost_turn stamped at the capture site.
- processor::try_found_city refuses a replacement while
turn - last_city_lost_turn < cooldown_turns (0 short-circuits).
Measurement (single-seed sweep, 160t): cooldown 0->0 elim, 5->1, 10->0, 20->0,
40->0. The lone cd=5 elimination is NOT credited as convergence — it is
single-seed noise: non-monotone (more suppression -> fewer eliminations), the
WINNER flips slot-to-slot across the sweep (chaotic-snowball perturbation, not a
lever response), and it fails p1-29h's literal gate. The lever demonstrably
suppresses refounding (founds trend down) but suppression ALONE does not robustly
convert captures into eliminations. Honest measured-negative per the brief; NO
non-zero combat_balance.json value authored, p1-29d NOT re-scored as converged.
Tests (apricot): mc-core 262 lib; gridded harness non-ignored 1/1; cargo check
--workspace 0; cargo test --workspace --no-run 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mc_units::UnitStats `combat: CombatStats` field added in cd339ff7d broke
four test-only catalog constructors that cargo check --workspace does not
compile (test targets are skipped by check): mc-player-api common/mod.rs and
the three capture_* integration tests. Adds the field (warrior gets its real
60/12/1 JSON line; the rest default to zero). cargo test --workspace --no-run
now exit 0 on apricot.
Gate lesson: after a catalog/struct field change, run
`cargo test --workspace --no-run`, not just `cargo check`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tick_siege/decay_siege + SiegeState (persisted on GameState::siege_pressure)
shipped earlier but were dead code — never called from the turn loop. This
adds the per-turn lair-siege phase + a data-driven config loader.
- mc-turn/src/lair_siege.rs: LairSiegeConfig loader from
lair_combat_modes.json::siege.siege_pressure (per-tier resistance + tuning,
Rail 2 — consumes authored data, no fabricated per-lair siege_resistance);
tick_one_lair pure helper; LairSiegeEvent.
- processor::process_lair_sieges (Phase 5d in step): iterates siege_pressure,
detects besieger adjacency, ticks/decays, surrenders + rolls loot + drops
the entry, logs to TurnResult::lair_siege_log. authored_lair_siege_config()
embeds the JSON build-time + OnceLock (same pattern as encounter_rates).
- resolve_lair_combat Siege arm stays single-shot-NotImplemented BY DESIGN
(multi-turn state can't flow through LairAssaultParams -> AssaultOutcome;
same precedent as resolve_raid's separate entry) — doc'd to point at the
live per-turn phase.
Honest boundary: nothing in the live loop INITIATES a siege yet (a player
picks Siege via the GdLair bridge / UI mode picker — godot-ui follow-up that
owns GdLair::begin_siege). The phase is exercised by tests + the future
bridge; mechanic is live, initiation is the remaining dependency. GDExt
begin_siege/siege_pressure bullet stays open (K=4/5).
Tests (apricot): lair_siege lib 5/5 (incl. named pressure_accumulates_then_
surrenders + pressure_decays_when_unattended) + config loaders 3; integration
lair_siege_phase 4/4 (through processor.step + save/load round-trip); mc-turn
240 lib, mc-combat 143 lib, serde_roundtrip 6/6; cargo check --workspace 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the proof drives _on_tab_changed directly, the TabBar current_tab
does not update (in-game the player clicks the TabBar, which does). Set
_tab_bar.current_tab in the proof loop so the captured screenshots show
the correct tab highlighted. Cosmetic; product behaviour unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The EndGameSummary script existed but its .tscn did not, and nothing
instantiated it — the summary scene was orphaned, so no screen showed
at game-over for any GameOverReason. Author end_game_summary.tscn with
all 14 @onready %-named nodes the script requires, and instantiate it
in main.gd under a persistent CanvasLayer (layer 25): the control
self-connects to EventBus.game_over and unhides itself for every reason
(LastSurvivor, ConditionMet, TurnLimit, Resigned).
Add the three feature-local GameState members the footer handlers
dereference (read_only_mode, pending_replay_game_id, clear()) — without
them 2/5 footer buttons null-crashed; referenced only by this feature
(+ replay_viewer reads pending_replay_game_id). read_only_mode is a
flag with no enforcement consumer yet (honest partial).
Rewrite end_game_summary_proof from the old _draw() mockup to BOOT the
real scene for all four GameOverReason variants; add GUT tests
test_award_computation (3/3) + test_end_game_footer_actions (5/5).
GUT full default suite on apricot: 645/693 pass, only deltas vs the
pre-change baseline are +8 (these new tests) and -1 (a pre-existing
detached-node bug fixed in test_statistics_modal) — no regressions.
All four end-game variants proof-rendered on apricot.lan and reviewed.
Production game_over emission (mc-turn) and a GdReplayPlayer::get_awards
bridge stay Rust-lane; the live awards path shows the pending notice.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 709-line self-building StatisticsModal script existed but its scene
asset did not — world_map.gd and ingame_menu.gd both push_overlay
"statistics.tscn", so F9 / info-button / Stats-menu dead-ended at a
missing resource. Author the thin one-node Control wrapper with the
script attached; both entry points now open the modal.
Also add statistics_proof.{gd,tscn} (boots the real wrapper, seeds a
4-clan 12-turn StatsTracker fixture, captures one screenshot per tab)
and fix a pre-existing detached-node bug in test_statistics_modal's
close-fallback test (called _ready()+_on_close() on a node never added
to the tree). GUT: test_statistics_modal 8/8 green on apricot. All 5
tabs proof-rendered on apricot.lan and reviewed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New api-gdext/src/lair.rs exposing GdLair::assault(...) #[func], registered
in lib.rs. Mirrors the GdLootRoller JSON-string pattern (Rail 3 — file IO
stays in GDScript, the bridge only marshals):
assault(attackers_json, defenders_json, loot_tier_json, lair_id,
lair_tier, turn_seed, defender_terrain_bonus) -> Dictionary
GDScript reads public/resources/lairs/loot/tier_NN.json + builds the
stack/defender JSON; the bridge parses (build_params — Godot-free,
unit-tested) into LairAssaultParams, calls mc_combat::resolve_assault, and
returns a cleared/repulsed/withdrawn outcome Dictionary (error, never panic,
on malformed JSON).
The Assault/Siege/Raid UI mode picker is a godot-ui follow-up, noted in the
objective — NOT built in this Rust lane.
Tests (apricot): GdLair 6/6 (cleared+guaranteed-loot, repulsed via real
wild_combat_stats(8,huge,carnivore), withdrawn, params-assembly, 2 parse-error
paths); api-gdext lib 29/29; cargo check --workspace exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Closes p2-57c bullet-2's apply half and p2-57b's pipeline live-loop gap.
- mc_units::UnitStats gains a flattened CombatStats { hp, max_hp?, attack,
defense, ranged_attack, range } (serde-default; the unit JSON already
authors these — the catalog was dropping them on load).
- mc-turn processor::resolve_spawn_combat resolves a spawning unit's base
combat line from the units catalog BY unit_id, then applies the stamped
QualityTier delta from combat_balance.quality_deltas (Rail 2). Both
try_spawn_unit and spawn_unit_typed call it.
- Fixes a latent live bug: try_spawn_unit hardcoded 60/12/1 on EVERY unit
type, so queued non-warriors spawned with warrior stats.
Honest scope: the apply half is now live; the stockpile->tier STAMP source
(per-city typed ResourceStockpile p2-57a + per-unit gating p2-57b) is not
wired into process_city_production, so live units carry quality:None today.
Bullet 2 stays partial (apply proven; stamp-source half gated on infra).
Tests (apricot): mc-turn 235 lib, mc-units 12, mc-city 262, mc-combat 217,
mc-core 143; new quality_spawn_live_processor 3/3; cargo check --workspace 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- true-state/path-forward/blockers section appended to every open objective
- status corrections: p2-48 done→partial (end_game_summary.tscn orphaned, over-claim); p2-72b blocked_by cleared (Path 2 chosen)
- .project/FINISH_GAME1_PLAN.md: 5 waves (wiring/lairs/AI-convergence/architecture/CI), AI refound-suppression flagged as the at-risk long pole, audio+paid-sprites+guide as accepted-open
- objectives.json regenerated
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
game_state.rs references 6 mc-turn sibling DATA types in field decls (not
turn-logic calls): RansomQueue, 6 combat_event structs + TurnResult,
CapturePosture, PatrolOrder, CombatBalance (already mc-core). Phase 3 is a
2-step — relocate these to mc-core first (Phase 0c pattern), then move
game_state.rs to mc-state behind a pub-use shim. Save-format round-trip is the
gate. p2-72 gated on full p2-65, 2+ sessions out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-flight for the mc-state extraction found Phase 0 under-scoped: GameState/
PlayerState carry 4 mc_ai::tactical typed fields (TacticalMemory, BuildingPriors,
TacticalUnitSpec, TacticalBuildingSpec), so mc-state (forbids mc-ai dep) could
not hold GameState until they moved. All verified data-only.
- New mc_core::tactical_types holds the 4 structs verbatim + is_buildable +
default_tier + 3 unit tests (serde round-trip, memory state-machine, gates).
- mc-ai memory.rs/state.rs re-export from mc-core (pub use) — every existing
mc_ai::tactical::* path still resolves; duplicate defs/tests removed.
- mc-turn game_state.rs 5 type refs → mc_core::* (incl ScoringWeights),
forward-looking for the game_state→mc-state move. Save format byte-identical.
Independently kills the irregular mc-turn→mc-ai data dependency the spec Context
flags. Gates green on apricot: check --workspace --tests clean; mc-core
tactical_types 3/3; mc-ai all green; mc-turn 235 lib; mc-player-api 126+integ.
Pre-existing five_players_overflow failure (MAX_PLAYERS 4→12 stale; touches no
moved type; fails on HEAD) is not a regression. p2-65 stays stub (mc-state crate
not yet created — Phase 1+).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 (bullets 5+6). Builds option (b): a gridded GameState Rust harness so
compute_vision populates and both combatant slots run the scripted-default
controller through the persistent drive_ai_slot mem::take seam (fair two-
scripted:default — a passive ender drives every other slot via apply_end_turn).
Findings on the fair surface (160t): lock ENGAGES (ever_committed=true,
committed_with_target=true), 20 CityCaptured fire, but 0 eliminations and both
empires grow (15 vs 22 cities). min_total_cities never dips below start despite
20 captures (38 refounds offset every loss). Verdict: targeting lock works;
bottleneck is capture-stickiness / refound-suppression — confirms p1-29d's
indecisive-war root cause + the spec's weight-insensitivity risk, empirically.
- gridded_fair_surface_engages_army_lock: green always-on guard (vision>0 +
lock engages) — the Phase-1-on-production-seam coverage the gridless
p1_29h_persistent_memory_seam guard could not provide.
- fair_scripted_duel_elimination_measurement (#[ignore]): records the 160t
signals; elimination NOT asserted (measured-negative, bullet 3 stays open).
p1-29h stays partial K=5/6; resume note redirects next wave at refound-
suppression, not the targeting lock. mc-player-api 126 + integration green;
cargo check --workspace --tests clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 of p1-29h. Adds the only regression coverage for the persistent
memory seam: `apply_end_turn → drive_ai_slot` (the mem::take + write-back
that survives the per-turn TacticalState snapshot). The mc-ai movement test
calls decide_movement directly and never touches this seam, so this guards
the dispatch threading itself — drives 60 turns without panic, reads the
memory field back, asserts coherence (committed ⇒ has a target).
Phase 2 measurement (bullets 3/5/6) NOT met — objective flips stub → partial
(K=3/6, bullets 1/2/4 cited from Phase 1's green mc-ai tests). Verified
finding recorded in the objective + test: the persistent army-lock lives ONLY
on the drive_ai_slot/apply_end_turn path; both existing full-game drivers
bypass it (p1-clean-baseline.py clones memory via suggest(); full_game_transcript
passes a transient default). The seam test's gridless GameState::default()
fixture yields an EMPTY vision set (sees_own_city=false, visible_tiles=0), so
no enemy city is ever visible and want_attack never fires — engagement needs a
real grid+vision surface (Godot autoplay / AUTO_PLAY_ALL_AI driving end_turn).
Lock engagement on full visibility is already proven by the mc-ai movement test.
cargo check --workspace --tests green on apricot; seam test 1/1 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ports the decisive-war mechanic from the juiced harness auto_play.gd:1109-1189
into the shipping Rust AI so the scripted controller fields it natively
(removing the dependency on the GDScript harness for decisiveness).
Capability (acceptance bullets 1, 2, 4):
- New `mc_ai::tactical::TacticalMemory { locked_target, commitment_turns }` —
the cross-turn persistence channel. Lives on `mc_turn::PlayerState`
(`#[serde(skip)]`, transient), NOT on the per-turn TacticalState snapshot
(which round-trips FFI as JSON — a field there would mean GDScript shadow
state, violating Rail 1) and NOT on the controller (a stateless shared
singleton). Borrowed `&mut` by `dispatch::drive_ai_slot` via mem::take and
threaded through `decide_turn → run_ai_turn → decide_tactical_actions →
decide_movement` — the pure-Rust path p1-29d measures, zero GDScript.
- `TacticalMemory::resolve` folds the auto_play state machine: acquire nearest
target when attacking, HOLD through tactical-score wobble for the commitment
window (hysteresis), PRESS ON to the next objective when the locked target
falls (capture → don't disperse), clear when all enemy cities are gone.
- `decide_movement` computes the army-wide lock once per turn (step 5b) and
every non-adjacent military unit drives on it instead of per-unit greedy
re-targeting — so captures get finished into eliminations.
- Commitment length is personality-derived (`thresholds::commitment_turns`,
aggression 0.6 / grudge 0.4; axis=5 reproduces auto_play's =5) — Rail 2
(ai_personalities.json axes), not a hardcoded constant.
- AiController trait gains `&mut TacticalMemory`; scripted threads it, mod
(wasm/native) + learned + FFI-shim controllers take a documented transient.
Tests (cargo test -p mc-ai green: 278 lib, all integration; workspace --tests
checks clean on apricot):
- 10 `memory::` unit tests: acquire/hold/expire/press-on/clear/determinism.
- 2 `thresholds::commitment_turns` tests (auto_play baseline + personality).
- 1 `movement::army_lock_concentrates_and_persists_across_turns` integration
test: a PERSISTED memory across two real decide_movement calls concentrates
3 units on ONE locked city and holds it through a want_attack=false turn.
- No regression: mc-turn 235/235, mc-player-api 126+, mc-mod-host all green.
Phase 1 is the clean green commit boundary. Objective stays 🟡 partial:
bullets 3 (≥1 elimination measured), 5 (AUTO_PLAY_ALL_AI / asymmetric harness),
6 (p1-29d re-score) are Phase 2 (batch validation on apricot), not yet done.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the read-side civic query the civics UI needs, completing the bridge
split out of p3-05a. `GdGameState::civic(pi, axis) -> Dictionary` returns
`{ choice, anarchy_turns_remaining, in_anarchy }`, riding on GdGameState
next to request_civic_switch/get_anarchy_turns_remaining (the canonical
per-player accessor surface — there is no per-GdPlayer shim in the tree).
`choice` is serialised via serde with quotes stripped (symmetric with the
request_civic_switch parse path): named AxisChoice variants → snake_case,
Anarchy → "anarchy", untagged Custom(id) → id verbatim. Unknown axis or
out-of-range pi → empty Dictionary.
Tests (green headless on apricot.lan):
- engine/tests/unit/civics/test_civic_query_bridge.gd — 7/7
- engine/tests/integration/test_gdextension_contract.gd — civic in the
GdGameState method contract; test_gd_game_state_has_expected_methods passes
- cargo check --workspace green; build-gdext.sh release build green
Objective p3-05a-gdext-bridge: stub → done (4/4 acceptance, cited).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-verified p1-05 and p1-38 follow-up batch acceptance against the fresh
committed-build smoke batch on apricot (20260604_011524/smoke, built
7d77fe728 from origin/main, 10 seeds T300, completed 2026-06-04 08:28).
p1-05: bullet 2 (personality_win_balance) satisfied upstream via p0-02
(done); bullet 1 (luxury variance >=3 distinct/seed) still fails -- no
p0-08 tempo in main (median end-turn is stochastic same-SHA variance, not
a build property), no distinct-luxury-variance extraction tool, scalar
read fails on seeds 1/5/9; bullet 3 out of fence. Stays stub.
p1-38: coupled mode still gated static_terrain in main (7d77fe728 and
5c97ce3f9), run-headless-batch.sh absent, no forest-tile metric emitted,
flip is a forbidden source edit. Un-runnable under no-edit committed-build
fence, not a balance miss. Stays stub.
Per feedback_batch_attribution_discipline + feedback_balance_philosophy.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>