feat(@projects/@magic-civilization): ✨ add deterministic ai personality slot mapping
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
425af8377d
commit
c0a62b08f5
2 changed files with 109 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue