use crate::multi_agent::joint::{JointEnv, JointStepResult};
#[derive(Debug, Clone)]
pub struct NPlayerMatchingPennies {
num_agents: usize,
step: usize,
}
impl NPlayerMatchingPennies {
pub const EPISODE_LEN: usize = 16;
pub const OBS_DIM: usize = 1;
pub const ACTION_DIM: usize = 2;
pub fn new(num_agents: usize) -> Self {
debug_assert!(num_agents >= 2, "NPlayerMatchingPennies requires num_agents >= 2");
Self { num_agents, step: 0 }
}
fn per_agent_obs(&self) -> Vec<Vec<f32>> {
let denom = (self.num_agents.saturating_sub(1)).max(1) as f32;
(0..self.num_agents).map(|i| vec![(i as f32) / denom]).collect()
}
pub fn num_agents(&self) -> usize {
self.num_agents
}
}
impl JointEnv for NPlayerMatchingPennies {
fn reset_joint(&mut self, _seed: Option<u64>) -> Vec<Vec<f32>> {
self.step = 0;
self.per_agent_obs()
}
fn step_joint(&mut self, actions: &[Vec<i64>]) -> JointStepResult {
debug_assert_eq!(
actions.len(),
self.num_agents,
"n-player matching pennies requires {} agents, got {}",
self.num_agents,
actions.len()
);
for (i, a) in actions.iter().enumerate() {
debug_assert_eq!(a.len(), 1, "agent {i} must supply a 1-d action");
debug_assert!(a[0] == 0 || a[0] == 1, "agent {i} action must be 0 or 1, got {}", a[0]);
}
let n = self.num_agents;
let mut rewards = vec![0.0_f32; n];
let total_ones: i64 = actions.iter().map(|a| a[0]).sum();
for i in 0..n {
let others_ones = total_ones - actions[i][0];
let n_others = (n - 1) as i64;
let n_zeros = n_others - others_ones;
let majority: Option<i64> = match others_ones.cmp(&n_zeros) {
std::cmp::Ordering::Greater => Some(1),
std::cmp::Ordering::Less => Some(0),
std::cmp::Ordering::Equal => None,
};
rewards[i] = match majority {
Some(m) if m == actions[i][0] => 1.0,
Some(_) => -1.0,
None => 0.0,
};
}
self.step += 1;
let done = self.step >= Self::EPISODE_LEN;
JointStepResult { rewards, done, observations: self.per_agent_obs() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_n2_coordination_reward_table() {
for a0 in 0..2_i64 {
for a1 in 0..2_i64 {
let mut env = NPlayerMatchingPennies::new(2);
env.reset_joint(None);
let res = env.step_joint(&[vec![a0], vec![a1]]);
let expected = if a0 == a1 { 1.0_f32 } else { -1.0_f32 };
assert_eq!(
res.rewards,
vec![expected, expected],
"N=2 coordination reward wrong for (a0={a0}, a1={a1}): {:?}",
res.rewards
);
}
}
}
#[test]
fn test_majority_reward_n4() {
for mask in 0..16_u32 {
let bits: Vec<i64> = (0..4).map(|i| ((mask >> i) & 1) as i64).collect();
let mut env = NPlayerMatchingPennies::new(4);
env.reset_joint(None);
let actions: Vec<Vec<i64>> = bits.iter().map(|b| vec![*b]).collect();
let res = env.step_joint(&actions);
let others_ones: i64 = bits[1] + bits[2] + bits[3];
let majority_other: i64 = if others_ones >= 2 { 1 } else { 0 };
let expected_a0: f32 = if bits[0] == majority_other { 1.0 } else { -1.0 };
assert_eq!(
res.rewards[0], expected_a0,
"N=4 agent-0 reward wrong for bits={bits:?}: got {} expected {}",
res.rewards[0], expected_a0
);
}
}
#[test]
fn test_tie_break_returns_zero_on_odd_n_3() {
for a0 in 0..2_i64 {
let mut env = NPlayerMatchingPennies::new(3);
env.reset_joint(None);
let res = env.step_joint(&[vec![a0], vec![0], vec![1]]);
assert_eq!(
res.rewards[0], 0.0,
"N=3 tie should give agent 0 reward 0 (a0={a0}), got {}",
res.rewards[0]
);
}
}
#[test]
fn test_tie_break_returns_zero_on_odd_n_5() {
for a0 in 0..2_i64 {
let mut env = NPlayerMatchingPennies::new(5);
env.reset_joint(None);
let res = env.step_joint(&[vec![a0], vec![0], vec![0], vec![1], vec![1]]);
assert_eq!(
res.rewards[0], 0.0,
"N=5 tie should give agent 0 reward 0 (a0={a0}), got {}",
res.rewards[0]
);
}
}
#[test]
fn test_per_agent_obs_differ_by_index() {
for n in [2_usize, 3, 4, 8] {
let mut env = NPlayerMatchingPennies::new(n);
let obs = env.reset_joint(None);
assert_eq!(obs.len(), n);
for i in 0..n {
for j in (i + 1)..n {
assert_ne!(
obs[i], obs[j],
"obs for N={n} agents {i} and {j} must differ: {:?} == {:?}",
obs[i], obs[j]
);
}
assert_eq!(obs[i].len(), NPlayerMatchingPennies::OBS_DIM);
}
}
}
#[test]
fn test_done_flag_at_episode_len() {
let mut env = NPlayerMatchingPennies::new(4);
env.reset_joint(None);
let actions: Vec<Vec<i64>> = (0..4).map(|_| vec![0]).collect();
for step in 0..NPlayerMatchingPennies::EPISODE_LEN {
let res = env.step_joint(&actions);
let expected_done = step + 1 >= NPlayerMatchingPennies::EPISODE_LEN;
assert_eq!(res.done, expected_done, "done flag wrong at step {step}");
}
}
#[test]
fn test_n4_all_ones_unanimous_reward() {
let mut env = NPlayerMatchingPennies::new(4);
env.reset_joint(None);
let actions: Vec<Vec<i64>> = (0..4).map(|_| vec![1]).collect();
let res = env.step_joint(&actions);
for (i, &r) in res.rewards.iter().enumerate() {
assert_eq!(r, 1.0, "agent {i} should get +1 in unanimous-1 case, got {r}");
}
}
#[test]
fn test_reset_restores_step_counter() {
let mut env = NPlayerMatchingPennies::new(3);
env.reset_joint(None);
let actions: Vec<Vec<i64>> = (0..3).map(|_| vec![0]).collect();
for _ in 0..NPlayerMatchingPennies::EPISODE_LEN {
env.step_joint(&actions);
}
env.reset_joint(None);
for step in 0..NPlayerMatchingPennies::EPISODE_LEN - 1 {
let res = env.step_joint(&actions);
assert!(!res.done, "should not be done at step {step} after reset");
}
let last = env.step_joint(&actions);
assert!(last.done, "should be done on final step after reset");
}
#[test]
fn test_observation_shape() {
let n = 5;
let mut env = NPlayerMatchingPennies::new(n);
let obs = env.reset_joint(None);
assert_eq!(obs.len(), n);
for o in &obs {
assert_eq!(o.len(), NPlayerMatchingPennies::OBS_DIM);
}
let actions: Vec<Vec<i64>> = (0..n).map(|_| vec![0]).collect();
let res = env.step_joint(&actions);
assert_eq!(res.observations.len(), n);
for o in &res.observations {
assert_eq!(o.len(), NPlayerMatchingPennies::OBS_DIM);
}
}
}