feat(@projects/@magic-civilization): add deterministic ai personality slot mapping

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-11 09:16:31 -07:00
parent 425af8377d
commit c0a62b08f5
2 changed files with 109 additions and 0 deletions

View file

@ -114,6 +114,19 @@ func _hydrate_player_api(num_players: int) -> void:
var capitals: Array[Vector2i] = _pick_spaced_capitals(land_tiles, num_players)
for cap: Vector2i in capitals:
gs.add_player_militarist(cap.x, cap.y)
# p2-68 final wave — load `ai_personalities.json` once and stamp real
# personality-shaped `ScoringWeights` onto every non-Claude slot's
# `PlayerState`. Without this, `mc_player_api::dispatch::drive_ai_slot`
# pulls `ScoringWeights::default()` and produces a flat AI action chain
# regardless of slot. Single source of truth: the file is parsed inside
# Rust via `mc_core::scoring_weights::ScoringWeights::from_personality_json`
# (called from `GdGameState::set_player_personality_json`). GDScript
# only reads the file bytes and picks the clan id per slot — no JSON
# parsing here. Deterministic slot→clan mapping: slot index modulo
# the clan list (sorted by id for stable ordering across runs).
_apply_ai_personalities(gs, num_players)
var json: String = String(gs.to_json())
if json.is_empty() or json == "{}":
_emit_protocol_error("GdGameState.to_json returned empty payload")
@ -122,6 +135,53 @@ func _hydrate_player_api(num_players: int) -> void:
_emit_protocol_error("GdPlayerApi.load_state_json rejected bootstrap state")
func _apply_ai_personalities(gs: RefCounted, num_players: int) -> void:
## p2-68 final wave. Read `ai_personalities.json` once and hand the
## raw bytes plus a per-slot clan id to `GdGameState::set_player_personality_json`.
## Slot 0 = Claude — skipped. Slots 1..N-1 cycle through the sorted
## clan id list so a 2-player game always pairs Claude vs `blackhammer`
## (alphabetically first), a 6-player game uses every clan exactly once.
const PERSONALITIES_PATH: String = "res://public/games/age-of-dwarves/data/ai_personalities.json"
# Canonical project-resource path — same constant `how_to_play.gd` and
# `loading_screen.gd` use. Works under editor + flatpak headless.
var json_text: String = ""
if FileAccess.file_exists(PERSONALITIES_PATH):
var f: FileAccess = FileAccess.open(PERSONALITIES_PATH, FileAccess.READ)
if f != null:
json_text = f.get_as_text()
f.close()
if json_text.is_empty():
_emit_protocol_error("could not read ai_personalities.json — AI slots will use default weights")
return
# Parse just for the key list (so slot→clan mapping is deterministic).
# We do NOT hand the parsed dict to Rust — the raw JSON bytes go down
# so the Rust parser stays the single source of truth for the data shape.
var personalities: Dictionary = JSON.parse_string(json_text) as Dictionary
if personalities.is_empty():
_emit_protocol_error("ai_personalities.json parsed empty")
return
var clan_ids: Array[String] = []
for k: String in personalities.keys():
clan_ids.append(k)
clan_ids.sort()
if clan_ids.is_empty():
_emit_protocol_error("ai_personalities.json has no clans")
return
# Deterministic slot→clan mapping: count AI slots in slot order and
# assign clan_ids[ai_index % clan_count]. Stable across runs.
var ai_index: int = 0
for slot: int in range(num_players):
if slot == _claude_slot:
continue
var clan_id: String = clan_ids[ai_index % clan_ids.size()]
ai_index += 1
var ok: bool = bool(gs.set_player_personality_json(slot, clan_id, json_text))
if not ok:
_emit_protocol_error("set_player_personality_json failed slot=%d clan=%s" % [slot, clan_id])
else:
_emit_event("ai_personality_assigned", {"slot": slot, "clan_id": clan_id})
func _scan_land_tiles(grid: RefCounted, w: int, h: int) -> Array[Vector2i]:
## Walk the grid and collect every land hex. Mirrors
## `mc_mapgen::spawn_box::FORBIDDEN_BIOMES` pathfinder LAND_IMPASSABLE_FLAGS.

View file

@ -3037,6 +3037,55 @@ impl GdGameState {
pi as i64
}
/// p2-68 final-wave harness loader. Replace player `slot`'s
/// `scoring_weights` with the personality identified by `personality_id`
/// loaded from `personalities_json` (the raw contents of
/// `public/games/age-of-dwarves/data/ai_personalities.json`).
///
/// The harness reads the JSON file once at boot (`FileAccess` in
/// GDScript) and calls this setter per AI slot so the headless AI
/// driver — `mc_player_api::dispatch::drive_ai_slot` — pulls real
/// personality-shaped weights off `PlayerState.scoring_weights`
/// instead of the default zeros.
///
/// Returns `false` if the slot is out of range, the JSON is malformed,
/// or `personality_id` is not a key in the personalities table.
/// Errors are logged via `godot_error!` for headless transcript review.
///
/// Rationale (CLAUDE.md Rail 1): the JSON parser stays in `mc-core`
/// via `ScoringWeights::from_personality_json`. GDScript only reads
/// the file and hands the string in. No parallel parser anywhere.
#[func]
fn set_player_personality_json(
&mut self,
slot: i64,
personality_id: GString,
personalities_json: GString,
) -> bool {
let pi = slot as usize;
if pi >= self.inner.players.len() {
godot_error!(
"GdGameState::set_player_personality_json: slot {slot} out of range ({} players)",
self.inner.players.len()
);
return false;
}
let id_str = personality_id.to_string();
let json_str = personalities_json.to_string();
match mc_core::scoring_weights::ScoringWeights::from_personality_json(&id_str, &json_str) {
Ok(weights) => {
self.inner.players[pi].scoring_weights = weights;
true
}
Err(e) => {
godot_error!(
"GdGameState::set_player_personality_json('{id_str}'): {e:?}"
);
false
}
}
}
/// Current turn number.
#[func]
fn turn(&self) -> i64 {