From c0a62b08f515b9d7c2107fed618cc3383ce139db Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 11 May 2026 09:16:31 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20deterministic=20ai=20personality=20slot=20map?= =?UTF-8?q?ping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../scenes/headless/claude_player_main.gd | 60 +++++++++++++++++++ src/simulator/api-gdext/src/lib.rs | 49 +++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/game/engine/scenes/headless/claude_player_main.gd b/src/game/engine/scenes/headless/claude_player_main.gd index 6d37a317..6e93cba3 100644 --- a/src/game/engine/scenes/headless/claude_player_main.gd +++ b/src/game/engine/scenes/headless/claude_player_main.gd @@ -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. diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index f49449d4..85228595 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -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 {