diff --git a/.project/objectives/p1-29c-followup-empty-params-json-regression.md b/.project/objectives/p1-29c-followup-empty-params-json-regression.md index 961e1cfc..456d4a29 100644 --- a/.project/objectives/p1-29c-followup-empty-params-json-regression.md +++ b/.project/objectives/p1-29c-followup-empty-params-json-regression.md @@ -2,12 +2,12 @@ id: p1-29c-followup-empty-params-json-regression title: "GdEconomy::process_turn fails — `_build_params_json` produces empty string for autoplay seeds" priority: p1 -status: stub +status: partial scope: game1 category: bug owner: shipwright created: 2026-05-14 -updated_at: 2026-05-14 +updated_at: 2026-05-15 blocked_by: [] follow_ups: [p1-29c] --- @@ -32,12 +32,24 @@ var result: Dictionary = gd_economy.process_turn(cities_json, units_json, params ## Acceptance -- ☐ Reproduce: pick one failing seed, run with verbose logging, capture the `_build_params_json` inputs that trigger the empty stringify. -- ☐ Identify the input that breaks stringify (likely `yield_mult` NaN). -- ☐ Fix at source: either guard `get_effective_yield_mult` to clamp NaN→1.0, or guard `_build_params_json` to substitute a sentinel. -- ☐ Re-run `autoplay-batch.sh 10 300` on apricot; E2E gate ≥10/12 PASS (allow 2 unrelated environmental failures). +- ✓ Reproduce: pick one failing seed, run with verbose logging, capture the `_build_params_json` inputs that trigger the empty stringify. — Inspection: `GameState.get_effective_yield_mult` was called from 5 sites (`economy.gd:112`, `turn_processor.gd:51/153/353/398`) but **never defined anywhere in the codebase** (`grep -rn "func get_effective_yield_mult" src/` → 0 hits). Calling a missing method on the `GameState` autoload pushes a runtime error and returns `null`; the surrounding GDScript evaluation poisons the Dictionary literal, and `JSON.stringify` emits `""`. Not NaN/Inf as the prior hypothesis suggested — **missing function entirely**. +- ✓ Identify the input that breaks stringify — `GameState.get_effective_yield_mult(player, "gold")` at `economy.gd:112` (and the four `turn_processor.gd` sites). Function symbol absent from `src/game/engine/src/autoloads/game_state.gd`. +- ✓ Fix at source — added `GameState.get_effective_yield_mult(player, yield_kind)` returning the documented per-yield multiplier: `"production"` → `ai_difficulty_modifier` (+ per-player override via `ai_per_player_production_mult[player.index]`), `"research"` → `ai_research_modifier` (+ override), all other kinds → `1.0` (Rust-side default at `api-gdext/src/lib.rs:6178`). NaN/Inf/negative results clamp to 1.0 with `push_warning`. Belt-and-suspenders defensive sanitizer added in `economy.gd::_build_params_json` (same NaN/Inf/negative clamp) so any future regression cannot reproduce the empty-stringify failure mode. +- ☐ Re-run `autoplay-batch.sh 10 300` on apricot; E2E gate ≥10/12 PASS — launched as stamp `20260515_072145` (smoke 10 300, async via `scripts/apricot-run.sh launch`). See verification block below. - ☐ Then unblock `p1-29c` final acceptance bullet (tier_peak ≥2 in ≥7/10 alive-aware seeds). +## Verification (2026-05-15) + +Apricot smoke batch `20260515_072145` launched via `scripts/apricot-run.sh launch smoke 10 300`. + +Behavioral side-effect note (recorded for follow-up triage): the four other call sites of `get_effective_yield_mult` in `turn_processor.gd` (production, research, two culture sites) were also hitting the missing function and silently degrading. Restoring the function returns `ai_difficulty_modifier` / `ai_research_modifier` / `1.0` to those paths, so per-yield AI scaling now applies as originally intended (warcouncil p1-29 H4 / p1-31). If this batch reveals a different-flavored regression (e.g. AI runaway / production stalls), it is downstream of restoring the intended multiplier path and belongs in a new follow-up rather than re-fixing here. + +PASS verdict (filled in on batch completion): + +``` + +``` + ## References - Batch log: `apricot:~/.local/var/p1_29c/run.log` diff --git a/src/simulator/crates/mc-turn/src/processor.rs b/src/simulator/crates/mc-turn/src/processor.rs index 13fe1f09..89b89c85 100644 --- a/src/simulator/crates/mc-turn/src/processor.rs +++ b/src/simulator/crates/mc-turn/src/processor.rs @@ -7209,4 +7209,126 @@ mod tests { by: 1, with: 0, gold: 30, .. })); } + + // ── p2-59: Pioneer escort dampening ───────────────────────────────────── + // + // Verifies the substitution rule in `process_fauna_encounters_inner` + // Step 1b: a civilian (`unit_kind_scale ≥ 1.5`, e.g. "civilian" or + // "pioneer") with a same-player military companion within 1 hex routes + // its ambient encounter roll through the companion's lower roll-rate + // scale instead. Solo civilian on the same tile fires materially more + // encounters than the escorted civilian. + #[test] + fn p2_59_escort_dampens_civilian_encounter_rate() { + use mc_core::grid::GridState; + use mc_core::encounter::EncounterRates; + + let project_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(4) + .expect("project root 4 levels above mc-turn manifest") + .to_path_buf(); + let rates_json = std::fs::read_to_string( + project_root.join("public/resources/ecology/encounter_rates.json"), + ) + .expect("encounter_rates.json"); + let rates = EncounterRates::from_json(&rates_json) + .expect("encounter_rates parse"); + + // Dense T7 wilderness on the civilian's tile only. The escort sits + // on an adjacent tile with fauna_density=0, so it never rolls its + // own encounter — the test isolates the civilian's roll rate. + let species = mc_core::ids::SpeciesId::new("grey_wolf"); + let civ_pos = (4_i32, 4_i32); + let escort_pos = (5_i32, 4_i32); // adjacent — offset_distance == 1 + let build_grid = || { + let mut g = GridState::new(8, 8); + let idx = (civ_pos.1 * g.width + civ_pos.0) as usize; + g.tiles[idx].fauna_density = 1.0; + g.tiles[idx].ecosystem_tier = 7; + g.tiles[idx].fauna_index = vec![species.clone()]; + g + }; + + let civilian_unit_id = "civilian"; // matches encounter_rates scale 2.0 + let warrior_unit_id = "infantry"; // matches encounter_rates scale 0.8 + + let processor = { + let mut p = TurnProcessor::new(200); + p.encounter_rates = Some(rates.clone()); + p + }; + + let run_scenario = |with_escort: bool| -> usize { + let mut units = vec![MapUnit { + id: 1, + col: civ_pos.0, + row: civ_pos.1, + hp: 60, max_hp: 60, attack: 1, defense: 1, + unit_id: civilian_unit_id.into(), + ..MapUnit::default() + }]; + if with_escort { + units.push(MapUnit { + id: 2, + col: escort_pos.0, // adjacent tile w/ fauna_density=0 + row: escort_pos.1, + hp: 60, max_hp: 60, attack: 1, defense: 1, + unit_id: warrior_unit_id.into(), + ..MapUnit::default() + }); + } + let mut state = GameState { + turn: 0, + players: vec![crate::game_state::PlayerState { + player_index: 0, + units, + ..Default::default() + }], + grid: Some(build_grid()), + ..Default::default() + }; + let mut count = 0_usize; + for _ in 0..200 { + let r = processor.step_encounters_only(&mut state); + for ev in &r.events_emitted { + if matches!(ev, mc_replay::TurnEvent::AmbientEncounterFired { .. }) { + count += 1; + } + } + // Restore any unit removed by the lair pipeline so the + // ambient hook keeps rolling — this scenario has no lairs, + // so deaths can only come from elsewhere; we just bump + // `turn` forward each iteration via step_encounters_only. + state.turn += 1; + } + count + }; + + let solo = run_scenario(false); + let escorted = run_scenario(true); + + assert!( + solo > 0, + "solo civilian on dense T7 must fire >0 ambient encounters (got {solo})", + ); + // Expected scale ratio: civilian 2.0 vs infantry 0.8 → escorted + // should fire roughly 0.4× as many. Allow generous slack for the + // 200-step sample. + assert!( + (escorted as f64) < (solo as f64) * 0.75, + "escort substitution must materially dampen encounter rate (solo={solo}, escorted={escorted})", + ); + } + + #[test] + fn p2_59_escort_link_construct_round_trip() { + let link = mc_core::encounter::EscortLink::new(7, 42); + assert_eq!(link.protected_unit_id, 7); + assert_eq!(link.escort_unit_id, 42); + let json = serde_json::to_string(&link).unwrap(); + let back: mc_core::encounter::EscortLink = + serde_json::from_str(&json).unwrap(); + assert_eq!(back, link); + } }