fix(@projects/@magic-civilization): 🐛 fix empty params stringify bug

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-15 07:26:23 -07:00
parent fea7584cc6
commit 313c2c1fb3
2 changed files with 140 additions and 6 deletions

View file

@ -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):
```
<pending batch in flight; see scripts/apricot-run.sh status 20260515_072145>
```
## References
- Batch log: `apricot:~/.local/var/p1_29c/run.log`

View file

@ -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);
}
}