From bbf56c7ab70cd884dc28e89a84ac705eab90d3d6 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 30 Jun 2026 11:21:45 -0400 Subject: [PATCH] feat(ai): project clan_index into PlayerView (clan-conditioning prerequisite) The clan-conditioned learned policy needs the bound player's clan in its observation, but PlayerView exposed none. Add PlayerView.clan_index: the canonical 0..5 clan index (ai_personalities.json key order: ironhold, goldvein, blackhammer, deepforge, tinkersmith, runesmith; -1 = generalist), projected from PlayerState.clan_id via clan_to_index(). CLAN_ORDER is the shared contract the Python encoder (encoders.py::CLAN_ORDER) must match for the clan one-hot. serde default = -1 so old fixtures/saves deserialize as generalist. Encoder unchanged (doesn't read it yet), so learned_parity stays green. Verified on the DO fleet: mc-player-api 188/188 passed (new clan mapping test + learned_parity + full_game_transcript determinism). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../crates/mc-player-api/src/projection.rs | 34 +++++++++++++++++++ .../crates/mc-player-api/src/view.rs | 12 +++++++ 2 files changed, 46 insertions(+) diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index c9f58d78..351e4670 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -142,6 +142,40 @@ pub fn project_view_with_vision( pending_events: PendingEventsView::default(), legal_actions, score, + clan_index: own.map(|p| clan_to_index(&p.clan_id)).unwrap_or(-1), + } +} + +/// Canonical clan order — the key order in `ai_personalities.json`. The +/// clan-conditioned learned policy one-hots a clan by this index; the Python +/// encoder (`encoders.py::CLAN_ORDER`) MUST share this exact order. +pub const CLAN_ORDER: [&str; 6] = [ + "ironhold", "goldvein", "blackhammer", "deepforge", "tinkersmith", "runesmith", +]; + +/// Map a `PlayerState.clan_id` string to its canonical `0..5` index, or `-1` +/// for empty/unknown (generalist). +pub fn clan_to_index(clan_id: &str) -> i32 { + CLAN_ORDER + .iter() + .position(|&c| c == clan_id) + .map_or(-1, |i| i as i32) +} + +#[cfg(test)] +mod clan_index_tests { + use super::clan_to_index; + + #[test] + fn maps_clan_ids_to_canonical_indices() { + assert_eq!(clan_to_index("ironhold"), 0); + assert_eq!(clan_to_index("goldvein"), 1); + assert_eq!(clan_to_index("blackhammer"), 2); + assert_eq!(clan_to_index("deepforge"), 3); + assert_eq!(clan_to_index("tinkersmith"), 4); + assert_eq!(clan_to_index("runesmith"), 5); + assert_eq!(clan_to_index(""), -1, "unset clan = generalist"); + assert_eq!(clan_to_index("nonexistent"), -1); } } diff --git a/src/simulator/crates/mc-player-api/src/view.rs b/src/simulator/crates/mc-player-api/src/view.rs index b61bad0d..0ca6dea5 100644 --- a/src/simulator/crates/mc-player-api/src/view.rs +++ b/src/simulator/crates/mc-player-api/src/view.rs @@ -431,6 +431,17 @@ pub struct PlayerView { pub legal_actions: Vec, /// Score / standings. pub score: ScoreView, + /// Bound player's clan as a canonical `0..5` index — the key order in + /// `ai_personalities.json` (ironhold, goldvein, blackhammer, deepforge, + /// tinkersmith, runesmith). `-1` = unset/unknown (generalist). The + /// clan-conditioned learned policy one-hots this; the Python and Rust + /// observation encoders share the same order. + #[serde(default = "default_clan_index")] + pub clan_index: i32, +} + +fn default_clan_index() -> i32 { + -1 } impl PlayerView { @@ -455,6 +466,7 @@ impl PlayerView { pending_events: PendingEventsView::default(), legal_actions: Vec::new(), score: ScoreView::default(), + clan_index: -1, } } }