From 97d00f247dfad7f4bf6575dd2f56ae253b46bff1 Mon Sep 17 00:00:00 2001 From: rebelzion Date: Sun, 3 May 2026 01:03:41 -0700 Subject: [PATCH] feat(#114): ClusterManager party_assignments + set_party_assignments() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add party_assignments: HashMap field (entity_id → party_id) defaulting to empty — no behaviour change for existing callers. Add set_party_assignments() to register group memberships from outside (e.g. arcane-clustering-sim at startup). Populate party_id from party_assignments when building PlayerInfo in run_evaluation_cycle(), wiring group signals into AffinityEngine Phase 1b. Test D: verify two entities sharing a party UUID appear with matching party_id in WorldStateView.players; unregistered entity has party_id = None. Closes #114. Part of epic #112. Co-Authored-By: Claude Sonnet 4.6 --- crates/arcane-infra/src/cluster_manager.rs | 13 ++- .../tests/cluster_manager_tests.rs | 89 ++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/crates/arcane-infra/src/cluster_manager.rs b/crates/arcane-infra/src/cluster_manager.rs index e4d088e..089fe37 100644 --- a/crates/arcane-infra/src/cluster_manager.rs +++ b/crates/arcane-infra/src/cluster_manager.rs @@ -19,6 +19,10 @@ pub struct ClusterManager { spatial_index: SpatialIndex, /// Allocated cluster servers. active_count = allocated_servers.len(). allocated_servers: Vec, + /// entity_id → party_id. Empty by default; set via set_party_assignments(). + /// Populated into PlayerInfo.party_id each evaluation cycle so AffinityEngine + /// Phase 1b receives party signals. + party_assignments: HashMap, } impl ClusterManager { @@ -32,6 +36,7 @@ impl ClusterManager { pool, spatial_index, allocated_servers: Vec::new(), + party_assignments: HashMap::new(), } } @@ -44,6 +49,12 @@ impl ClusterManager { ) } + /// Register entity → party_id assignments. Called once at simulation setup. + /// Entities absent from this map continue to have party_id = None in PlayerInfo. + pub fn set_party_assignments(&mut self, assignments: HashMap) { + self.party_assignments = assignments; + } + /// Create with a named clustering model. Supported values: "rules" (default), "affinity". /// The "affinity" variant requires the `affinity-clustering` feature flag. pub fn with_model(model_type: &str) -> Self { @@ -116,7 +127,7 @@ impl ClusterManager { position: Vec2::new(pos.x, pos.z), velocity: Vec2::new(0.0, 0.0), guild_id: None, - party_id: None, + party_id: self.party_assignments.get(&entity_id).copied(), }) .collect(); diff --git a/crates/arcane-infra/tests/cluster_manager_tests.rs b/crates/arcane-infra/tests/cluster_manager_tests.rs index 1d39dd8..73148f7 100644 --- a/crates/arcane-infra/tests/cluster_manager_tests.rs +++ b/crates/arcane-infra/tests/cluster_manager_tests.rs @@ -1,13 +1,58 @@ //! Tests for ClusterManager (IN-01). Define expected behavior; implementation must satisfy these. -use arcane_core::Vec3; +use arcane_core::{ + clustering_model::{ModelInfo, ValidationResult, WorldStateView}, + IClusteringModel, Vec3, +}; use arcane_infra::ClusterManager; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use uuid::Uuid; fn uuid(i: u8) -> Uuid { Uuid::from_bytes([i, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) } +struct CapturingModel { + last_view: Mutex>, +} + +impl CapturingModel { + fn new() -> Arc { + Arc::new(Self { + last_view: Mutex::new(None), + }) + } + fn last_view(&self) -> Option { + self.last_view.lock().unwrap().clone() + } +} + +impl IClusteringModel for CapturingModel { + fn evaluate( + &self, + view: &WorldStateView, + ) -> Vec { + *self.last_view.lock().unwrap() = Some(view.clone()); + vec![] + } + fn get_model_info(&self) -> ModelInfo { + ModelInfo { + model_type: "capturing".into(), + version: "0".into(), + trained_at: None, + feature_count: None, + } + } + fn validate_view(&self, _view: &WorldStateView) -> ValidationResult { + ValidationResult { + valid: true, + warnings: vec![], + errors: vec![], + } + } +} + #[test] fn with_defaults_creates_manager() { let _manager = ClusterManager::with_defaults(); @@ -41,3 +86,45 @@ fn get_neighbors_for_cluster_returns_neighbors_from_spatial_index() { "B's neighbors should include A" ); } + +/// Test D: party_assignments populate PlayerInfo.party_id in WorldStateView. +#[test] +fn party_assignments_populate_player_info() { + use arcane_pool::LocalPool; + use arcane_spatial::SpatialIndex; + + let model = CapturingModel::new(); + let pool = Arc::new(LocalPool::default()); + let mut mgr = ClusterManager::new(model.clone(), pool, SpatialIndex::new()); + + let (e1, e2, e3) = (uuid(1), uuid(2), uuid(3)); + let party = uuid(99); + + // e1 and e2 are in the same party; e3 is ungrouped + let mut assignments = HashMap::new(); + assignments.insert(e1, party); + assignments.insert(e2, party); + mgr.set_party_assignments(assignments); + + let cluster = uuid(10); + mgr.update_entity(e1, cluster, Vec3::new(0.0, 0.0, 0.0)); + mgr.update_entity(e2, cluster, Vec3::new(1.0, 0.0, 0.0)); + mgr.update_entity(e3, cluster, Vec3::new(2.0, 0.0, 0.0)); + + mgr.run_evaluation_cycle().unwrap(); + + let view = model + .last_view() + .expect("model should have received a view"); + let p1 = view.players.iter().find(|p| p.player_id == e1).unwrap(); + let p2 = view.players.iter().find(|p| p.player_id == e2).unwrap(); + let p3 = view.players.iter().find(|p| p.player_id == e3).unwrap(); + + assert_eq!(p1.party_id, Some(party), "e1 should have party_id set"); + assert_eq!(p2.party_id, Some(party), "e2 should have party_id set"); + assert_eq!(p3.party_id, None, "e3 should have no party_id"); + assert_eq!( + p1.party_id, p2.party_id, + "e1 and e2 must share the same party_id" + ); +}