Versus teams, gold for allies, fallen hearts as spectators, multi-mission editor co-op

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 19:59:43 +01:00
1 parent 811c955bad
commit fc9c49d4ca
16 files changed
+1896 -49

No files matched your search

+446 -1
View File
@@ -6,6 +6,7 @@
use crate::commands::Command;
use crate::game::Game;
use crate::map::{Kind, Owner};
// one server turn of 100 ms: five fixed simulation steps
pub const STEPS_PER_TURN: usize = 5;
@@ -46,7 +47,281 @@ fn mixdbg(h: &mut u64, v: impl std::fmt::Debug) {
}
}
// the side whose ground a tile of `owner` is (unowned ground counts as the first player's)
pub fn side_of_owner(owner: Owner) -> u8 {
match owner {
Owner::Hero => 1,
Owner::Rival1 => 2,
Owner::Rival2 => 3,
Owner::Rival3 => 4,
Owner::Player | Owner::None => 0,
}
}
// gold amounts a master can send to an ally; `GOLD_ALL` sends everything he has
pub const GOLD_ALL: u32 = u32::MAX;
pub const GOLD_AMOUNTS: [u32; 4] = [100, 500, 1000, GOLD_ALL];
impl Game {
// ------------------------------------------------------------ teams and allies
// the team of a master seat in a versus match (sides without an entry are a team of their own)
pub fn team_of(&self, side: u8) -> u8 {
self.teams.iter().find(|t| t.0 == side).map_or(10 + side, |t| t.1)
}
// a master seat of the match: a human master or an AI seat
pub fn is_seat_side(&self, side: u8) -> bool {
Owner::MASTER_SIDES.contains(&side) && (self.is_master(side) || self.ai_sides.contains(&side))
}
// sides that never fight each other: the same side, co-op partners and team mates. Only shared
// state goes in, so every client of a match decides the same.
pub fn allied(&self, a: u8, b: u8) -> bool {
if a == b {
return true;
}
if !self.is_seat_side(a) || !self.is_seat_side(b) {
return false;
}
self.coop || self.team_of(a) == self.team_of(b)
}
// the teams of a versus match: (side, team) for the master seats
pub fn set_teams(&mut self, teams: &[(u8, u8)]) {
self.teams = teams.iter().copied().filter(|(s, _)| Owner::MASTER_SIDES.contains(s)).collect();
}
// ------------------------------------------------------------ fallen hearts
pub fn is_out(&self, side: u8) -> bool {
self.out_sides.contains(&side)
}
// the master at this computer lost his heart: he watches the rest of the match
pub fn spectating(&self) -> bool {
self.netplay.is_some() && self.is_out(self.local_side)
}
fn master_state(&self, side: u8) -> Option<&crate::game::MasterState> {
self.masters.iter().find(|m| m.side == side)
}
pub fn core_hp_of(&self, side: u8) -> f32 {
if side == self.me_side {
self.core_hp
} else {
self.master_state(side).map_or(0.0, |m| m.core_hp)
}
}
pub fn core_of_side(&self, side: u8) -> (i32, i32) {
if side == self.me_side {
self.map.core
} else {
self.master_state(side).map_or(self.map.core, |m| m.core)
}
}
pub fn gold_of(&self, side: u8) -> f32 {
if side == self.me_side {
self.gold
} else {
self.master_state(side).map_or(0.0, |m| m.gold)
}
}
// damage to the soul core of a master (whichever master is being processed)
pub fn damage_master_core(&mut self, side: u8, dmg: f32) {
if side == self.me_side {
self.core_hp -= dmg;
} else if let Some(m) = self.masters.iter_mut().find(|m| m.side == side) {
m.core_hp -= dmg;
}
}
// the heart of a master of the match that is no ally of `side`, near (x, y)
pub(crate) fn enemy_master_near(&self, side: u8, x: f32, y: f32) -> Option<u8> {
if self.masters.is_empty() {
return None;
}
self.master_sides().into_iter().filter(|&s| s != side && !self.is_out(s) && !self.allied(side, s)).find(|&s| {
let (cx, cy) = self.core_of_side(s);
(x - cx as f32 - 1.5).powi(2) + (y - cy as f32 - 1.5).powi(2) <= 8.0 * 8.0
})
}
// a message and a sound for the player at this computer, whichever master is being processed
fn tell_local(&mut self, key: &'static str, args: Vec<String>, sfx: Option<crate::game::Sfx>) {
let me = self.me_side;
let local = self.local_side;
self.switch_to(local);
self.msg(key, args);
if let Some(sfx) = sfx {
self.sfx_ui(sfx);
}
self.switch_to(me);
}
// a master of a match whose core has no strength left is out. His creatures fight on without a
// heart until they fall; he keeps watching. Runs in the shared part of the step.
pub(crate) fn update_fallen_masters(&mut self) {
if self.netplay.is_none() {
return;
}
for side in self.master_sides() {
if self.is_out(side) || self.core_hp_of(side) > 0.0 {
continue;
}
self.out_sides.push(side);
self.master_fell(side);
}
}
fn master_fell(&mut self, side: u8) {
let me = self.me_side;
self.switch_to(side);
self.core_hp = 0.0;
self.flag = None;
// what he held in his hand is put down beside the broken heart
let (cx, cy) = self.map.core;
let hand = std::mem::take(&mut self.hand);
for (k, id) in hand.iter().enumerate() {
if let Some(e) = self.ents.iter_mut().find(|e| e.id == *id && e.held) {
e.held = false;
e.x = cx as f32 + 1.5 + (k % 3) as f32 * 0.6 - 0.6;
e.y = cy as f32 + 3.5;
e.disp_x = e.x;
e.disp_y = e.y;
e.job = crate::game::Job::None;
e.path.clear();
}
}
// the heart is gone: its tiles become plain ground of that master
let owner = Owner::of_side(side);
for t in self.map.t.iter_mut() {
if t.kind == Kind::Core && t.owner == owner {
t.kind = Kind::Floor;
}
}
self.switch_to(me);
if side == self.local_side {
self.tell_local("msg.master_out_you", vec![], Some(crate::game::Sfx::Defeat));
} else {
self.tell_local("msg.master_out", vec![format!("@net.side{side}")], Some(crate::game::Sfx::CoreHit));
}
}
// goals of a mission played in a match: what one master holds (mana springs) counts for any of
// the masters still in the game, everything else is shared
pub fn match_goal_done(&self, goal: crate::game::Goal) -> bool {
match goal {
crate::game::Goal::Springs(n) => self.master_sides().into_iter().filter(|s| !self.is_out(*s)).any(|s| {
let springs = if s == self.me_side { self.springs } else { self.master_state(s).map_or(0, |m| m.springs) };
springs >= n as usize
}),
g => self.goal_done(g),
}
}
// The end of a match, decided from shared state only so that all clients stop in the same step:
// a mission (co-op) is lost when every master's heart fell; a versus match is over when only one
// team is left - the masters of that team win, the others lose.
pub(crate) fn check_match_end(&mut self) {
if self.state != 0 {
return;
}
let alive: Vec<u8> = self.master_sides().into_iter().filter(|s| !self.is_out(*s)).collect();
let lost = |g: &mut Game, key: &'static str| {
g.state = 2;
g.msg(key, vec![]);
g.sfx_ui(crate::game::Sfx::Defeat);
};
if let Some(m) = &self.mission {
let (limit, goals) = (m.time_limit, m.goals.clone());
if alive.is_empty() {
return lost(self, "msg.defeat");
}
if limit > 0.0 && self.time > limit {
return lost(self, "msg.time_up");
}
if !goals.is_empty() && goals.iter().all(|&g| self.match_goal_done(g)) {
self.state = 1;
self.msg("msg.mission_won", vec![]);
self.sfx_ui(crate::game::Sfx::Victory);
}
return;
}
// the teams that started (masters and AI seats) and the ones still standing
let mut sides: Vec<u8> = self.master_sides();
sides.extend(self.rivals.iter().map(|r| r.side));
let mut teams: Vec<u8> = sides.iter().map(|&s| self.team_of(s)).collect();
teams.sort_unstable();
teams.dedup();
let mut standing: Vec<u8> = alive.iter().copied().chain(self.rivals.iter().filter(|r| r.alive).map(|r| r.side)).map(|s| self.team_of(s)).collect();
standing.sort_unstable();
standing.dedup();
if alive.is_empty() {
return lost(self, "msg.defeat");
}
let over = if teams.len() >= 2 {
standing.len() <= 1
} else {
// everybody on one team: the humans' gate is the enemy
self.gate_on && self.gate_hp <= 0.0
};
if !over {
return;
}
if standing.contains(&self.team_of(self.local_side)) {
self.state = 1;
self.msg("msg.match_won", vec![]);
self.sfx_ui(crate::game::Sfx::Victory);
} else {
lost(self, "msg.match_lost");
}
}
// ------------------------------------------------------------ gold for allies
// the masters the local master can send gold to: human allies whose heart still beats
pub fn gold_allies(&self) -> Vec<u8> {
if self.netplay.is_none() || self.is_out(self.local_side) {
return Vec::new();
}
self.master_sides().into_iter().filter(|&s| s != self.local_side && !self.is_out(s) && self.allied(self.local_side, s)).collect()
}
// the master being processed sends gold to an ally (never more than he has, never more than
// the ally's treasury takes)
pub(crate) fn send_gold(&mut self, to: u8, amount: u32) {
let from = self.me_side;
if to == from || !self.is_master(to) || !self.allied(from, to) || self.is_out(to) || self.is_out(from) {
return;
}
let want = (amount as f32).min(self.gold.floor());
if want < 1.0 {
self.msg("msg.gold_none", vec![]);
self.sfx_ui(crate::game::Sfx::Error);
return;
}
self.switch_to(to);
let got = self.add_gold(want).floor();
if got >= 1.0 {
self.msg("msg.gold_received", vec![(got as i32).to_string(), format!("@net.side{from}")]);
self.sfx_ui(crate::game::Sfx::Payday);
}
self.switch_to(from);
if got < 1.0 {
self.msg("msg.gold_full", vec![format!("@net.side{to}")]);
self.sfx_ui(crate::game::Sfx::Error);
return;
}
self.gold -= got;
self.msg("msg.gold_sent", vec![(got as i32).to_string(), format!("@net.side{to}")]);
self.sfx_ui(crate::game::Sfx::Deposit);
}
// a match with masters on `sides` (of 0, 2, 3, 4), `local` is the master who plays at this computer
pub fn start_match(&mut self, sides: &[u8], local: u8) -> bool {
if !self.start_masters(sides, local) {
@@ -81,6 +356,9 @@ impl Game {
.filter(|side| *side != self.local_side)
.collect();
for side in self.ai_sides.clone() {
if self.is_out(side) {
continue; // a fallen master who left has nothing left for an AI to play
}
let core = if side == self.me_side {
continue;
} else if let Some(m) = self.masters.iter().find(|m| m.side == side) {
@@ -169,7 +447,8 @@ impl Game {
pub fn checksum(&self) -> u64 {
let mut h = FNV_OFFSET;
mix(&mut h, self.sim_steps);
mix(&mut h, self.state as u64);
// whether the world is still running: the end itself is told per team (won or lost)
mix(&mut h, (self.state != 0) as u64);
mix(&mut h, self.next_id as u64);
mix(&mut h, self.rng.0 as u64);
mix(&mut h, self.wave as u64);
@@ -269,6 +548,9 @@ impl Game {
for side in self.ai_sides.iter() {
mix(&mut h, *side as u64);
}
for side in self.out_sides.iter() {
mix(&mut h, 100 + *side as u64);
}
h
}
}
@@ -371,6 +653,169 @@ mod tests {
assert_eq!(g.netplay.as_ref().unwrap().turn, 1);
}
// every client of one match: the same world, seen from another seat
fn clients(seed: u32, locals: &[u8], teams: &[(u8, u8)]) -> Vec<Game> {
locals
.iter()
.map(|&local| {
let mut g = client(seed, local);
g.set_teams(teams);
g
})
.collect()
}
fn turn_all(games: &mut [Game], cmds: &[Command]) {
for g in games.iter_mut() {
g.receive_turn(cmds);
}
}
fn same_world(games: &[Game], why: &str) {
let sum = games[0].checksum();
for g in games.iter().skip(1) {
assert_eq!(g.checksum(), sum, "{why}");
}
}
#[test]
fn a_fallen_master_watches_while_the_match_goes_on() {
let mut games = clients(41, &[0, 2, 3], &[]);
for g in games.iter_mut() {
g.damage_master_core(2, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
same_world(&games, "the heart fell in the same step everywhere");
for g in games.iter() {
assert!(g.is_out(2) && !g.is_out(0) && !g.is_out(3));
assert_eq!(g.state, 0, "the match goes on for the others");
}
let green = &games[1];
assert!(green.spectating() && !games[0].spectating());
assert!(green.msgs.iter().any(|m| m.key == "msg.master_out_you"));
assert!(games[0].msgs.iter().any(|m| m.key == "msg.master_out"));
assert!(!green.map.t.iter().any(|t| t.kind == Kind::Core && t.owner == Owner::Rival1), "the heart is broken");
let workers = green.ents.iter().filter(|e| e.alive && e.side == 2).count();
assert!(workers > 0, "his creatures go on without a heart");
// the spectator sees everything and cannot order anything
let mut games = games;
for _ in 0..30 {
turn_all(&mut games, &[]);
}
assert!(games[1].map.visible.iter().all(|v| *v), "no fog for the spectator");
assert!(!games[0].map.visible.iter().all(|v| *v), "the others still have fog");
let (gx, gy) = games[1].core_of_side(2);
games[1].queue(Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)));
assert!(games[1].take_orders().is_empty(), "a spectator gives no orders");
// an order that still comes in for the fallen side changes nothing
let stray = vec![Command { side: 2, seq: 0, act: Act::Dig(Rect::new(gx + 7, gy, gx + 8, gy + 1)) }];
turn_all(&mut games, &stray);
assert!(!games[0].map.t[crate::map::idx(gx + 7, gy)].marked);
for _ in 0..50 {
turn_all(&mut games, &[]);
}
same_world(&games, "the spectator stays in step");
assert!(games.iter().all(|g| g.state == 0));
// the last enemy falls: red wins, blue (and the spectator) lose, in the same step
for g in games.iter_mut() {
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
same_world(&games, "the end came in the same step everywhere");
assert_eq!(games[0].state, 1, "red is the last master standing");
assert_eq!(games[1].state, 2);
assert_eq!(games[2].state, 2);
assert!(games[0].msgs.iter().any(|m| m.key == "msg.match_won"));
}
#[test]
fn team_mates_do_not_fight_and_win_together() {
// red and green are team A, blue is team B
let teams = [(0u8, 0u8), (2, 0), (3, 1)];
let mut games = clients(43, &[0, 2, 3], &teams);
let g = &games[0];
assert!(g.allied(0, 2) && g.allied(2, 0) && !g.allied(0, 3) && !g.allied(2, 3));
assert_eq!(games[1].gold_allies(), vec![0u8], "green can send gold to red");
assert!(games[2].gold_allies().is_empty(), "blue has no team mate");
// a creature of red next to one of green: no target; one of blue is
for g in games.iter_mut() {
let (cx, cy) = g.core_of_side(2);
let (x, y) = (cx as f32 + 4.5, cy as f32 + 4.5);
let red = g.spawn(crate::game::EK::Kobold, false, x, y, 3);
let green = g.spawn(crate::game::EK::Kobold, false, x + 1.0, y, 3);
for (id, side) in [(red, 0u8), (green, 2u8)] {
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.side = side;
}
}
let red_e = g.ents.iter().find(|e| e.id == red).unwrap().clone();
assert_eq!(g.nearest_enemy(&red_e, 3.0), None, "team mates are no targets");
let blue = g.spawn(crate::game::EK::Kobold, false, x, y + 1.0, 3);
if let Some(e) = g.ents.iter_mut().find(|e| e.id == blue) {
e.side = 3;
}
assert_eq!(g.nearest_enemy(&red_e, 3.0), Some(blue), "the other team is");
}
for _ in 0..40 {
turn_all(&mut games, &[]);
}
same_world(&games, "teams play the same on every client");
// the fog shows the team mate's ground
let (gx, gy) = games[0].core_of_side(2);
assert!(games[0].map.visible[crate::map::idx(gx + 1, gy + 1)], "red sees green's heart");
assert!(!games[2].map.visible[crate::map::idx(gx + 1, gy + 1)], "blue does not");
// one of team A falls: the match goes on; team B falls: team A wins together
for g in games.iter_mut() {
g.damage_master_core(0, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
assert!(games.iter().all(|g| g.state == 0), "green still stands for team A");
for g in games.iter_mut() {
g.damage_master_core(3, crate::game::CORE_HP * 2.0);
}
turn_all(&mut games, &[]);
same_world(&games, "the end came in the same step everywhere");
assert_eq!(games[0].state, 1, "red was out, but his team won");
assert_eq!(games[1].state, 1);
assert_eq!(games[2].state, 2);
}
#[test]
fn gold_goes_to_an_ally_on_every_client() {
let mut games = clients(45, &[0, 2, 3], &[(0, 0), (2, 0), (3, 1)]);
let red0 = games[0].gold_of(0);
let green0 = games[0].gold_of(2);
games[0].queue(Act::SendGold { to: 2, amount: 500 });
let orders = games[0].take_orders();
assert_eq!(orders.len(), 1);
turn_all(&mut games, &orders);
for g in games.iter() {
assert_eq!(g.gold_of(0), red0 - 500.0, "red paid");
assert_eq!(g.gold_of(2), green0 + 500.0, "green got it");
}
assert!(games[1].msgs.iter().any(|m| m.key == "msg.gold_received"), "the receiver hears of it");
assert!(games[1].events.iter().any(|e| e.sfx == crate::game::Sfx::Payday));
assert!(games[0].msgs.iter().any(|m| m.key == "msg.gold_sent"));
same_world(&games, "both clients have the same gold");
// not more than he has (and not more than the ally's treasury takes); never to an enemy
let all = vec![
Command { side: 2, seq: 0, act: Act::SendGold { to: 0, amount: crate::netplay::GOLD_ALL } },
Command { side: 0, seq: 1, act: Act::SendGold { to: 3, amount: 100 } },
Command { side: 3, seq: 0, act: Act::SendGold { to: 0, amount: 100 } },
];
let blue = games[0].gold_of(3);
let (red1, green1) = (games[0].gold_of(0), games[0].gold_of(2));
turn_all(&mut games, &all);
for g in games.iter() {
let sent = green1 - g.gold_of(2);
assert!(sent > 0.0 && sent <= green1, "green sends what he has: {sent}");
assert_eq!(g.gold_of(0), red1 + sent, "red gets all of it");
assert!(g.gold_of(0) <= g.gold_cap() + 0.5 || g.local_side != 0);
assert!((g.gold_of(3) - blue).abs() < 1.0, "blue got nothing from red and gave nothing");
}
same_world(&games, "still the same gold everywhere");
}
#[test]
fn a_world_that_develops_differently_is_caught_by_the_checksum() {
let (mut a, mut b) = (client(23, 0), client(23, 0));