feat(ai): project clan_index into PlayerView (clan-conditioning prerequisite)
Some checks are pending
ci / regression gate (push) Waiting to run
deploy-next / deploy dev guide to mc.next.black.lan (push) Waiting to run

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-30 11:21:45 -04:00
parent 0554ae7389
commit bbf56c7ab7
2 changed files with 46 additions and 0 deletions

View file

@ -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);
}
}

View file

@ -431,6 +431,17 @@ pub struct PlayerView {
pub legal_actions: Vec<LegalActionEntry>,
/// 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,
}
}
}