fix(@projects/@magic-civilization): 🐛 fix empty params stringify bug
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
fea7584cc6
commit
313c2c1fb3
2 changed files with 140 additions and 6 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue