From 87890adc53eacadbcf86c14081d4b9ce4c535834 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 4 Nov 2026 10:55:17 +0100 Subject: [PATCH] Masters for a match: the four sides red, green, blue and yellow (side 4 gets its own owner), and start_masters gives every master his core and his starting area --- VERSION | 2 +- docs/MULTIPLAYER.md | 13 ++++++- lang/de.lang | 1 + lang/en.lang | 1 + src/commands.rs | 17 ++++++++- src/game.rs | 91 +++++++++++++++++++++++++++++++++++++++++++-- src/gfx.rs | 5 ++- src/map.rs | 8 +++- src/render3d.rs | 2 + src/ui.rs | 1 + 10 files changed, 132 insertions(+), 9 deletions(-) diff --git a/VERSION b/VERSION index 789af57..0c93da4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v202611-00003 +v202611-00004 diff --git a/docs/MULTIPLAYER.md b/docs/MULTIPLAYER.md index ece24fa..7c4cbbd 100644 --- a/docs/MULTIPLAYER.md +++ b/docs/MULTIPLAYER.md @@ -58,5 +58,14 @@ per turn. Fog of war, particles and the camera are local and not part of the sha | E | Alliances and coop rules, AI masters for free and abandoned slots | planned | | F | Coop campaign: 20 missions with story (EN/DE) | planned | -Still open on the way to C: player owners (today side 1 is the humans' and 2/3 the rivals' - four -player sides need their own `Owner`s), the turn/turn-boundary client loop, and the state checksum. +Still open on the way to C: the turn/turn-boundary client loop, the state checksum, and heroes and +alliances for the new sides (today the hero gate path always leads to side 0's core, and 2/3 are the +rivals' sides in free play). + +## Masters and sides + +A match plays on the four master sides `0, 2, 3, 4` (side 1 is the heroes'): red (side 0, `Owner::Player`), +green (`Owner::Rival1`), blue (`Owner::Rival2`) and yellow (`Owner::Rival3`). `Game::start_masters(sides, +local)` carves a 9x9 starting area and a 3x3 soul core for every master and remembers his economy in a +`MasterState`; the places are worked out from the map size alone, so all clients agree without passing them +over the network. diff --git a/lang/de.lang b/lang/de.lang index 8542a37..2807ab5 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -603,6 +603,7 @@ msg.payday_walk = Zahltag! {0} Kreaturen holen sich ihren Lohn aus der Schatzkam msg.wage_unpaid = Dein {0} hat kein Gold für seinen Lohn gefunden und ist wütend! owner.rival1 = gehört Mossgrim, dem grünen Meister owner.rival2 = gehört Lady Nightwell, der blauen Meisterin +owner.rival3 = gehört dem gelben Meister # rival masters (free game) newgame.difficulty = Gegner diff --git a/lang/en.lang b/lang/en.lang index 499436f..052df4a 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -604,6 +604,7 @@ msg.payday_walk = Payday! {0} creatures fetch their wages from the treasury ({1} msg.wage_unpaid = Your {0} found no gold for its wage and is furious! owner.rival1 = held by Mossgrim, the green master owner.rival2 = held by Lady Nightwell, the blue master +owner.rival3 = held by the yellow master # rival masters (free game) newgame.difficulty = Opponents diff --git a/src/commands.rs b/src/commands.rs index db8569d..50e04c9 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -169,7 +169,7 @@ impl Game { mod tests { use super::*; use crate::game::{EK, Job, STEP}; - use crate::map::idx; + use crate::map::{idx, Kind, Owner, mh, mw}; fn game(seed: u32) -> Game { let mut g = Game::new(seed, 64, 64); @@ -272,4 +272,19 @@ mod tests { let json = serde_json::to_string(&c).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), c); } + + #[test] + fn a_match_command_builds_on_the_ground_of_its_own_master() { + let mut g = Game::new(13, 96, 128); + g.init_fog(); + assert!(g.start_masters(&[0, 2], 0), "a match of two masters"); + let home = g.map.t.iter().position(|t| t.kind == Kind::Core && t.owner == Owner::Rival1).expect("the green master got his core"); + let (cx, cy) = ((home % mw() as usize) as i32, (home / mh() as usize) as i32); + let gold = g.gold; + g.apply_turn(&[Command { side: 2, seq: 0, act: Act::Room { room: 0, r: Rect::new(cx + 4, cy + 4, cx + 4, cy + 4) } }], 1); + let t = &g.map.t[idx(cx + 4, cy + 4)]; + assert_eq!(t.kind, Kind::Treasury, "he builds on his own ground"); + assert_eq!(t.owner, Owner::Rival1); + assert_eq!(g.gold, gold, "my gold is untouched"); + } } diff --git a/src/game.rs b/src/game.rs index ec36435..5a45564 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1287,9 +1287,9 @@ impl Game { self.put_active(target); } - // a new master on `side` with its core at (cx, cy) (top-left of the 3x3 core). Used by the - // multiplayer lobby (and its tests); single player games have no stored masters. - #[allow(dead_code)] + // a new master on `side` with his core at (cx, cy) (top-left of the 3x3 core); single player + // games have no stored masters + #[allow(dead_code)] // used by the tests; the *Multiplayer* menu (step D) will call it pub fn add_master(&mut self, side: u8, core: (i32, i32)) { let mut m = self.take_active(); let me = std::mem::replace(&mut m, MasterState::default()); @@ -1311,6 +1311,62 @@ impl Game { self.masters.push(fresh); } + // A match: `sides` (out of 0, 2, 3, 4 - side 1 is the heroes') play against each other and + // `local` is the master who plays at this computer. Side 0 keeps the core of the generated map + // (that is where the hero gate path leads); the other masters get their cores at fixed places + // worked out from the map size alone, so every client of a match puts them in the same spots. + #[allow(dead_code)] // used by the tests; the *Multiplayer* menu (step D) will call it + pub fn start_masters(&mut self, sides: &[u8], local: u8) -> bool { + let sides: Vec = sides.iter().copied().filter(|s| Owner::MASTER_SIDES.contains(s)).collect(); + if sides.len() < 2 || !sides.contains(&0) || !sides.contains(&local) { + return false; + } + let (w, h) = (mw() as i32, mh() as i32); + if w < 72 || h < 72 { + return false; // the master areas need room + } + let cores: Vec<(i32, i32)> = sides.iter().map(|&s| match s { 0 => self.map.core, 2 => (w - 30, 12), 3 => (w - 30, h - 16), _ => (14, h - 16) }).collect(); + for (k, side) in sides.iter().enumerate() { + if *side == self.me_side { + continue; + } + self.carve_master_area(*side, cores[k]); + self.add_master(*side, cores[k]); + } + self.local_side = local; + self.switch_to(local); + true + } + + // the starting area of a master: a 9x9 room of his own floor and his 3x3 soul core + #[allow(dead_code)] // called by start_masters only + fn carve_master_area(&mut self, side: u8, core: (i32, i32)) { + let owner = Owner::of_side(side); + for y in core.1 - 3..core.1 + 6 { + for x in core.0 - 3..core.0 + 6 { + if x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 { + continue; + } + let t = &mut self.map.t[idx(x, y)]; + if matches!(t.kind, Kind::Portal | Kind::Gate | Kind::Core) { + continue; + } + t.kind = Kind::Floor; + t.owner = owner; + t.gold = 0; + t.marked = false; + t.reinforced = false; + } + } + for y in 0..3 { + for x in 0..3 { + let t = &mut self.map.t[idx(core.0 + x, core.1 + y)]; + t.kind = Kind::Core; + t.owner = owner; + } + } + } + pub(crate) fn update_units(&mut self, dt: f32) { for i in 0..self.ents.len() { if !self.ents[i].alive || self.ents[i].held { @@ -5341,6 +5397,35 @@ mod tests { assert!(e.alive && g.map.walk(e.x as i32, e.y as i32)); } + #[test] + fn a_match_gives_every_master_his_core_and_his_ground() { + let mut g = Game::new(7, 96, 128); + g.init_fog(); + assert!(g.start_masters(&[0, 2, 3, 4], 2), "four masters, and I am the green one"); + assert_eq!(g.local_side, 2); + assert_eq!(g.masters.len(), 3); + for side in Owner::MASTER_SIDES { + let owner = Owner::of_side(side); + assert!(g.is_master(side)); + let cores = g.map.t.iter().filter(|t| t.kind == Kind::Core && t.owner == owner).count(); + assert_eq!(cores, 9, "master {side} has his soul core"); + let ground = g.map.t.iter().filter(|t| t.kind == Kind::Floor && t.owner == owner).count(); + assert!(ground >= 40, "master {side} has a starting area of his own"); + g.switch_to(side); + assert_eq!(g.core_hp, CORE_HP, "every core starts intact"); + assert_eq!(g.gold, 1500.0, "and every master with the same gold"); + } + g.switch_to(2); + let green = g.map.core; + g.switch_to(4); + assert_ne!(g.map.core, green, "the masters live apart"); + for _ in 0..50 { + g.update(STEP); + } + assert_eq!(g.sim_steps, 50, "every master is simulated"); + assert_eq!(g.me_side, 2, "the saddle goes back to the master who plays here"); + } + #[test] fn lava_burns_and_needs_stone_bridges() { let (mut g, rx) = river_game(true); diff --git a/src/gfx.rs b/src/gfx.rs index 25ced3b..a2f123f 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -79,6 +79,7 @@ pub const T_GUARDPOST: u32 = 174; pub const T_ARENA: u32 = 178; pub const T_ALTAR: u32 = 182; pub const I_ORDERS: u32 = 186; // attack, guard +pub const T_RIVAL3: u32 = 189; // 4 variants: floor of the yellow master pub struct Gfx { pub atlas: Texture2D, @@ -1035,7 +1036,7 @@ fn gen_atlas() -> Image { } // floors of the rival masters: like the player's claimed floor, in their colours for v in 0..4u32 { - for (id, base, grout, mark) in [(T_RIVAL1, (44, 74, 50), (20, 36, 24), (90, 170, 100)), (T_RIVAL2, (44, 54, 86), (20, 24, 44), (90, 120, 200))] { + for (id, base, grout, mark) in [(T_RIVAL1, (44, 74, 50), (20, 36, 24), (90, 170, 100)), (T_RIVAL2, (44, 54, 86), (20, 24, 44), (90, 120, 200)), (T_RIVAL3, (74, 62, 26), (36, 30, 12), (210, 185, 70))] { cv.cell(id + v, |x, y| { let col = stones(x, y, v + 250, base, grout, 10); if (x % 8 == 4 && y % 8 == 4) && hash(x / 8, y / 8, v + 251) % 2 == 0 { @@ -1815,6 +1816,8 @@ impl Gfx { T_RIVAL1 + v } else if owner == Owner::Rival2 { T_RIVAL2 + v + } else if owner == Owner::Rival3 { + T_RIVAL3 + v } else if hero { T_HEROFLOOR + v } else { diff --git a/src/map.rs b/src/map.rs index 8a8080a..62b6566 100644 --- a/src/map.rs +++ b/src/map.rs @@ -145,9 +145,10 @@ pub enum Owner { Hero, Rival1, // AI dungeon masters (free play, campaign) Rival2, + Rival3, // the fourth master side: a third rival, or a human master in a match } -const OWNERS: [Owner; 5] = [Owner::None, Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2]; +const OWNERS: [Owner; 6] = [Owner::None, Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2, Owner::Rival3]; impl Owner { pub fn of_side(side: u8) -> Owner { @@ -156,9 +157,14 @@ impl Owner { 1 => Owner::Hero, 2 => Owner::Rival1, 3 => Owner::Rival2, + 4 => Owner::Rival3, _ => Owner::None, } } + + // the masters of a match: the four sides a dungeon master can play (1 is the heroes' side) + #[allow(dead_code)] // the sides of a match; the lobby (step C/D) uses them + pub const MASTER_SIDES: [u8; 4] = [0, 2, 3, 4]; } // Short serde names (only used by version 1 savegames, newer ones use the packed codec) diff --git a/src/render3d.rs b/src/render3d.rs index abb6ba1..35eea79 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -2555,6 +2555,7 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] { Owner::Hero => [170, 175, 190, 255], Owner::Rival1 => [70, 150, 80, 255], Owner::Rival2 => [70, 100, 180, 255], + Owner::Rival3 => [150, 130, 50, 255], Owner::None => [70, 64, 60, 255], }, Kind::Treasury => [200, 160, 60, 255], @@ -2565,6 +2566,7 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] { Kind::Core => match tile.owner { Owner::Rival1 => [90, 240, 110, 255], Owner::Rival2 => [100, 150, 255, 255], + Owner::Rival3 => [250, 220, 90, 255], _ => [255, 60, 60, 255], }, Kind::Portal => [190, 90, 240, 255], diff --git a/src/ui.rs b/src/ui.rs index aeef908..185fc25 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1300,6 +1300,7 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec Owner::Hero => "owner.hero", Owner::Rival1 => "owner.rival1", Owner::Rival2 => "owner.rival2", + Owner::Rival3 => "owner.rival3", Owner::None => "owner.none", }; match t.kind {