From a00ae6a06b9f8dd8429d38a225d82a341a780f06 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Fri, 6 Nov 2026 08:18:09 +0100 Subject: [PATCH] Real AI masters: rivals, AI seats and the co-op partner play by the player's rules (imps dig, mine and claim, rooms, towers, tunnels and the attack flag); other masters are saved; portals belong to whoever claims them Co-Authored-By: Claude Opus 5.5 --- docs/MULTIPLAYER.md | 6 +- docs/ROADMAP.md | 2 +- src/ai.rs | 313 ++++++++++++++++++++++++++++++++++++++++++++ src/campaign.rs | 2 + src/game.rs | 18 +-- src/main.rs | 1 + src/map.rs | 5 +- src/netplay.rs | 23 ++-- src/render3d.rs | 6 +- src/rival.rs | 301 ++++++++++++++++-------------------------- src/traders.rs | 2 +- 11 files changed, 456 insertions(+), 223 deletions(-) create mode 100644 src/ai.rs diff --git a/docs/MULTIPLAYER.md b/docs/MULTIPLAYER.md index 7ee3bff..752280e 100644 --- a/docs/MULTIPLAYER.md +++ b/docs/MULTIPLAYER.md @@ -67,8 +67,10 @@ per turn. Fog of war, particles and the camera are local and not part of the sha - **Menu**: *Multiplayer* in the title menu with the match list, create (versus or co-op mission), join by code, seat choice, AI seat toggles for the host, ready/start, chat; the match keeps running in the background while menus are open. -- **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left are played - by the simplified rival AI on every client, so they stay deterministic. +- **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left keep their + master (dungeon, gold, creatures) and are played by the AI brain (`src/ai.rs`) on every client - the same + orders a player gives (dig, rooms, imps, towers, the attack flag, shields), from the shared state and the + shared random numbers, so they stay deterministic. Free-play and campaign rivals are such AI masters too. - **Co-op** (`Game::coop`): the master sides are allies against the heroes; allied creatures never target each other, allied ground does not block claims. Co-op campaign matches use `campaign::start_coop_mission`. - **Editor campaigns in co-op**: on the Match page the host can switch from the built-in campaign to the diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c6ef173..efad2f1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -121,7 +121,7 @@ painted mission whose triggers switch each other on. **Block 4 is done.** Done: the pixel look (the world 360 rows on every device, 270/480 selectable; the interface on the same grid), guard and attack markers (shields and swords), savegames with the game state only. -1. **Real AI masters** - rivals, AI seats and the co-op partner play by the player's rules: imps dig, +1. **Real AI masters** (done, v202611-00052) - rivals, AI seats and the co-op partner play by the player's rules: imps dig, claim, mine and set torches; rooms by plan, traps, towers, research; the attack flag, shields and swords; strength by the difficulty. 2. **Mouse cursor** - clearer hands for the hand tool (open, holding, slap, punch). diff --git a/src/ai.rs b/src/ai.rs new file mode 100644 index 0000000..9559be4 --- /dev/null +++ b/src/ai.rs @@ -0,0 +1,313 @@ +// AI dungeon masters: a rival (free play, campaign), an AI seat of a match or the co-op partner is +// a real master with gold, mana, imps, rooms and research - the same rules as the player. This +// brain gives him the player's orders every few seconds: summon imps, mark earth and veins to dig +// in growing rings, build rooms in a sensible order, put towers at the border, set the attack +// flag on an enemy heart once the army is strong enough (and take it back after heavy losses), +// guard its own heart with a shield. The difficulty sets how often it thinks and how bold it is. +// Everything runs in the shared step with the shared random numbers (the same on every client). + +use crate::game::*; +use crate::map::*; + +// per difficulty (easy, medium, hard) +const THINK: [f32; 3] = [4.0, 2.5, 1.5]; // seconds between two thoughts +const MAX_RADIUS: [i32; 3] = [11, 15, 19]; // how far the dungeon grows around the heart +const MAX_IMPS: [usize; 3] = [6, 9, 12]; +const FIRST_ATTACK: [f32; 3] = [720.0, 480.0, 330.0]; // no attack before (seconds) +const BOLDNESS: [f32; 3] = [1.5, 1.15, 0.9]; // own army / enemy army needed to attack +const MAX_TOWERS: [usize; 3] = [1, 2, 4]; +const START_GOLD: [f32; 3] = [1500.0, 2500.0, 3500.0]; + +// the rooms a master builds, in this order: (room index, width, height, how many of them) +const BUILD: [(usize, i32, i32, usize); 10] = [ + (0, 3, 3, 1), // treasury + (1, 4, 3, 1), // lair + (2, 4, 3, 1), // mushroom garden + (3, 3, 3, 1), // training hall + (4, 3, 3, 1), // library + (1, 4, 3, 2), // a second lair + (2, 4, 3, 2), // a second garden + (8, 3, 3, 1), // forge + (6, 3, 3, 1), // temple + (9, 3, 2, 1), // prison +]; + +pub fn start_gold(difficulty: u8) -> f32 { + START_GOLD[difficulty.min(2) as usize] +} + +impl Game { + // the brain of rival k (its master is the one being processed) + pub(crate) fn ai_think(&mut self, k: usize, dt: f32) { + let d = self.rivals[k].difficulty.min(2) as usize; + self.rivals[k].think_t -= dt; + if self.rivals[k].think_t > 0.0 { + return; + } + self.rivals[k].think_t = THINK[d] * (0.85 + self.rng.f() * 0.3); + let side = self.rivals[k].side; + let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1); + self.ai_imps(d, cx, cy); + self.ai_dig(k, d, cx, cy); + self.ai_gold(cx, cy); + self.ai_rooms(cx, cy); + self.ai_towers(d, cx, cy); + self.ai_fight(k, d, side, cx, cy); + } + + // enough imps for the dungeon's size + fn ai_imps(&mut self, d: usize, cx: i32, cy: i32) { + let claimed = self.map.t.iter().filter(|t| t.owner == self.me).count(); + let want = (4 + claimed / 30).min(MAX_IMPS[d]); + if self.worker_count() < want && self.mana >= self.summon_cost() + 20.0 { + self.summon(cx as f32 + 0.5, cy as f32 + 2.5); + } + } + + // dig: rings of earth around the heart, veins first; the ring grows when it is dug out + fn ai_dig(&mut self, k: usize, d: usize, cx: i32, cy: i32) { + let me = self.me; + let r = self.rivals[k].radius.max(5); + let marked_mine = |g: &Game| { + let mut n = 0; + for y in cy - r - 4..=cy + r + 4 { + for x in cx - r - 4..=cx + r + 4 { + if g.map.get(x, y).map_or(false, |t| t.marked) { + n += 1; + } + } + } + n + }; + let open = marked_mine(self); + if open > self.worker_count() * 4 { + return; // the imps have enough to do + } + let mut added = 0; + let diggable = |t: &Tile| t.diggable() && !t.plant() && !t.reinforced && t.kind != Kind::Steel && (t.owner == Owner::None || t.owner == me); + // veins a little beyond the ring, then the ring itself + for pass in 0..2 { + let reach = if pass == 0 { r + 3 } else { r }; + for y in cy - reach..=cy + reach { + for x in cx - reach..=cx + reach { + if added >= 16 || !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 { + continue; + } + let i = idx(x, y); + let t = self.map.t[i]; + if t.marked || !diggable(&t) || (pass == 0 && t.vein().is_none()) { + continue; + } + self.map.t[i].marked = true; + added += 1; + } + } + } + // the ring is done: grow + if added == 0 && open == 0 && r < MAX_RADIUS[d] { + self.rivals[k].radius = r + 2; + } + } + + // gold running low: a tunnel to the nearest vein that nobody works on yet + fn ai_gold(&mut self, cx: i32, cy: i32) { + if self.gold > 1500.0 { + return; + } + let me = self.me; + // a marked vein the imps can reach (next to own ground) is being worked already + let busy = (0..self.map.t.len()).any(|i| { + let t = &self.map.t[i]; + let (x, y) = xy(i); + t.marked && t.vein().is_some() && DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == me && n.walkable())) + }); + if busy { + return; // a vein is being worked already + } + self.ai_mark_tunnel(cx, cy, |g: &Game, i: usize| { + let t = &g.map.t[i]; + t.vein().is_some() && !t.marked && (t.owner == Owner::None || t.owner == me) + }, 40); + } + + // marks the diggable tiles on the cheapest way from the heart to a goal tile (and the goal + // with the veins next to it); false if there is none within `reach` tiles + fn ai_mark_tunnel(&mut self, cx: i32, cy: i32, goal: impl Fn(&Game, usize) -> bool, reach: i32) -> bool { + let start = [(cx, cy + 2), (cx, cy - 2), (cx + 2, cy), (cx - 2, cy)].into_iter().find(|&(x, y)| self.map.walk(x, y)); + let Some((sx, sy)) = start else { return false }; + let found = { + let cost = self.map.hero_cost_as(false); + let near = |i: usize| { + let (x, y) = xy(i); + (x - cx).abs().max((y - cy).abs()) <= reach + }; + self.map.search(idx(sx, sy), |i| if near(i) { cost(i) } else { None }, |i| goal(self, i), false) + }; + let Some(path) = found else { return false }; + let me = self.me; + let mut cluster = Vec::new(); + for &i in &path { + let t = self.map.t[i]; + if t.diggable() && !t.plant() && t.kind != Kind::Steel && (t.owner == Owner::None || t.owner == me || t.reinforced) { + self.map.t[i].marked = true; + if t.vein().is_some() { + cluster.push(i); + } + } + } + // the rest of the vein + for i in cluster { + let (x, y) = xy(i); + for &(dx, dy) in DIR8.iter() { + if let Some(t) = self.map.get(x + dx, y + dy) { + if t.vein().is_some() && t.owner == Owner::None { + self.map.t[idx(x + dx, y + dy)].marked = true; + } + } + } + } + true + } + + // the next room of the build order, where there is free own floor for it + fn ai_rooms(&mut self, cx: i32, cy: i32) { + for &(room, w, h, n) in BUILD.iter() { + let tiles = self.counts[room]; + if tiles >= (w * h) as usize * n { + continue; + } + let cost = ROOM_COST[room] as f32 * (w * h) as f32; + if self.gold < cost + 200.0 { + return; // saving for it + } + if let Some((x0, y0)) = self.ai_free_rect(cx, cy, w, h) { + self.build_rect(room, x0, y0, x0 + w - 1, y0 + h - 1); + } + return; + } + } + + // a rectangle of own plain floor, as near the heart as possible (not right next to it) + fn ai_free_rect(&self, cx: i32, cy: i32, w: i32, h: i32) -> Option<(i32, i32)> { + let me = self.me; + let free = |x: i32, y: i32| self.map.get(x, y).map_or(false, |t| t.kind == Kind::Floor && t.owner == me) && !self.ents.iter().any(|e| e.alive && e.tile() == idx(x, y) && e.kind == EK::Worker && false); + let mut best: Option<((i32, i32), i32)> = None; + for y0 in cy - 22..=cy + 22 { + for x0 in cx - 22..=cx + 22 { + let (mx, my) = (x0 + w / 2, y0 + h / 2); + let dist = (mx - cx).abs() + (my - cy).abs(); + if dist < 4 || best.map_or(false, |b| b.1 <= dist) { + continue; + } + if (y0..y0 + h).all(|y| (x0..x0 + w).all(|x| free(x, y))) { + best = Some(((x0, y0), dist)); + } + } + } + best.map(|b| b.0) + } + + // a tower or two at the border of the dungeon (towards the open ground) + fn ai_towers(&mut self, d: usize, cx: i32, cy: i32) { + let me = self.me; + let mine = self.towers.iter().filter(|t| !t.hero && self.map.t[t.tile].owner == me).count(); + if mine >= MAX_TOWERS[d] || self.gold < 1800.0 || self.counts[0] == 0 { + return; + } + let spot = (0..self.map.t.len()).filter(|&i| { + let (x, y) = xy(i); + let t = &self.map.t[i]; + t.kind == Kind::Floor && t.owner == me && (x - cx).abs().max((y - cy).abs()) >= 4 + && DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner != me && n.walkable())) + }).min_by_key(|&i| { + let (x, y) = xy(i); + (x - cx).abs() + (y - cy).abs() + }); + if let Some(i) = spot { + let (x, y) = xy(i); + self.build_tower(TowerKind::Arrow, x, y); + } + } + + // fight: a shield at the heart; the attack flag on the nearest enemy heart once strong enough, + // taken back after heavy losses. A co-op partner follows the attack flag of his allies. + fn ai_fight(&mut self, k: usize, d: usize, side: u8, cx: i32, cy: i32) { + if self.guard_marks.is_empty() && self.creature_count() >= 3 && d >= 1 { + self.place_mark(cx + 1, cy + 3, false); + } + let army = self.side_army(side); + // co-op: go where the allies go + if self.is_partner(side) { + let lead = self.master_sides().into_iter().find(|&s| s != side && !self.is_out(s) && self.allied(side, s)); + let flag = lead.and_then(|s| self.masters.iter().find(|m| m.side == s).and_then(|m| m.flag)); + if flag != self.flag { + self.flag = flag; + } + return; + } + if self.rivals[k].attacking { + // too many lost, or the target is gone: back home + let target_gone = self.flag.map_or(true, |f| !self.ai_enemy_heart_near(side, f)); + if army < self.rivals[k].army0 * 0.45 || target_gone { + self.rivals[k].attacking = false; + self.flag = None; + } + return; + } + if self.time < FIRST_ATTACK[d] || self.creature_count() < 4 { + return; + } + // the nearest enemy heart (players, other AI masters) + let target = self + .master_sides() + .into_iter() + .filter(|&s| s != side && !self.is_out(s) && !self.allied(side, s) && self.core_hp_of(s) > 0.0) + .min_by_key(|&s| { + let (x, y) = self.core_of_side(s); + (x - cx).abs() + (y - cy).abs() + }); + let Some(t) = target else { return }; + if army < self.side_army(t) * BOLDNESS[d] { + return; + } + let (tx, ty) = self.core_of_side(t); + let spot = DIR8.iter().map(|&(dx, dy)| (tx + 1 + dx * 2, ty + 1 + dy * 2)).find(|&(x, y)| self.map.walk(x, y)); + // creatures only walk: first a tunnel to the enemy (the imps dig it) + let from = [(cx, cy + 2), (cx, cy - 2), (cx + 2, cy), (cx - 2, cy)].into_iter().find(|&(x, y)| self.map.walk(x, y)); + let open = match (from, spot) { + (Some((fx, fy)), Some((x, y))) => self.map.creature_path(false, idx(fx, fy), idx(x, y)).is_some(), + _ => false, + }; + if !open { + let target_owner = Owner::of_side(t); + if !self.map.t.iter().any(|tl| tl.marked && tl.reinforced) { + self.ai_mark_tunnel(cx, cy, |g: &Game, i: usize| { + let (x, y) = xy(i); + g.map.t[i].owner == target_owner && g.map.t[i].walkable() || ((x - tx - 1).abs() <= 2 && (y - ty - 1).abs() <= 2 && g.map.t[i].walkable()) + }, 200); + } + return; + } + if let Some((x, y)) = spot { + self.flag = Some(idx(x, y)); + self.rivals[k].attacking = true; + self.rivals[k].army0 = army; + if t == self.local_side { + let key = if side == 2 { "msg.rival1_attack" } else { "msg.rival2_attack" }; + let n = self.creature_count().to_string(); + self.tell_local(key, vec![n], Some(Sfx::WaveAlarm)); + } + } + } + + // is an enemy heart near tile f? + fn ai_enemy_heart_near(&self, side: u8, f: usize) -> bool { + let (fx, fy) = xy(f); + self.master_sides().into_iter().any(|s| { + s != side && !self.is_out(s) && self.core_hp_of(s) > 0.0 && { + let (x, y) = self.core_of_side(s); + (x + 1 - fx).abs() <= 4 && (y + 1 - fy).abs() <= 4 + } + }) + } +} diff --git a/src/campaign.rs b/src/campaign.rs index f8776de..df8f1ad 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -663,6 +663,8 @@ fn remove_rivals(g: &mut Game) { return; } g.rivals.clear(); + // their masters go too (the seats belong to the players) + g.masters.retain(|m| !sides.contains(&m.side)); g.ents.retain(|e| e.hero || !sides.contains(&e.side)); let owners: Vec = sides.iter().map(|&s| Owner::of_side(s)).collect(); for t in g.map.t.iter_mut() { diff --git a/src/game.rs b/src/game.rs index 9d9694a..90232f7 100644 --- a/src/game.rs +++ b/src/game.rs @@ -480,7 +480,8 @@ fn player_owner() -> Owner { // The state of a dungeon master that is not being processed right now. The Game's own fields // (gold, mana, hand, ...) always belong to the master whose side is `me_side`; switching swaps // them with the stored ones. A single player game has no stored masters at all. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] pub struct MasterState { pub side: u8, pub owner: Option, @@ -656,8 +657,8 @@ pub struct Game { pub me_side: u8, // that master's side (0 = the first player) #[serde(skip)] pub local_side: u8, // the master who plays at this computer - #[serde(skip)] - pub masters: Vec, // the other masters (multiplayer) + #[serde(default)] + pub masters: Vec, // the other masters (multiplayer, AI masters), saved with the game #[serde(skip)] pub pending: Vec, // orders of this master, applied at the next step boundary #[serde(skip)] @@ -1291,7 +1292,7 @@ impl Game { } pub fn portals_owned(&self) -> usize { - (0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).count() + (0..self.map.portals.len()).filter(|&k| self.map.portal_owner(k) == self.me).count() } // gate defenders alive (not the treasure guards) @@ -1610,8 +1611,7 @@ impl Game { } // 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)) { + pub(crate) 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 { @@ -2007,7 +2007,7 @@ impl Game { } r -= w; } - let owned: Vec = (0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).collect(); + let owned: Vec = (0..self.map.portals.len()).filter(|&k| self.map.portal_owner(k) == self.me).collect(); let pk = owned[(self.rng.next() as usize) % owned.len()]; let (px, py) = self.map.portals[pk]; let id = self.spawn(kind, false, px as f32 + 1.5, py as f32 + 1.5, 1); @@ -2436,7 +2436,7 @@ impl Game { self.demo_ai(e, dt); } else if e.hero && e.captive != 0 { self.captive_update(e, dt); - } else if self.is_rival_side(e.side) || (e.rival() && !self.is_master(e.side)) { + } else if !self.is_master(e.side) && (self.is_rival_side(e.side) || e.rival()) { self.rival_ai(e, dt); } else if e.kind == EK::Trader { self.trader_ai(e, dt); @@ -2499,7 +2499,7 @@ impl Game { } } if !tile.walkable() && !(flies(e.kind) && tile.liquid()) { - if (e.hero || self.is_rival_side(e.side) || (e.rival() && !self.is_master(e.side))) && tile.diggable() { + if (e.hero || (!self.is_master(e.side) && (self.is_rival_side(e.side) || e.rival()))) && tile.diggable() { // heroes dig their way through e.working = true; let (tx, ty) = xy(next); diff --git a/src/main.rs b/src/main.rs index 664d546..13af002 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ // DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM). mod achievements; +mod ai; mod armory; mod audio; mod campaign; diff --git a/src/map.rs b/src/map.rs index bded888..a061e7a 100644 --- a/src/map.rs +++ b/src/map.rs @@ -403,9 +403,10 @@ impl Map { self.portals.iter().position(|&(px, py)| x >= px && x < px + 3 && y >= py && y < py + 3) } - pub fn portal_owned(&self, k: usize) -> bool { + // whose portal k is (any master can claim one) + pub fn portal_owner(&self, k: usize) -> Owner { let (px, py) = self.portals[k]; - self.t[idx(px, py)].owner == Owner::Player + self.t[idx(px, py)].owner } pub fn generate(seed: u32, w: usize, h: usize) -> Map { diff --git a/src/netplay.rs b/src/netplay.rs index e13a1d2..3bfc656 100644 --- a/src/netplay.rs +++ b/src/netplay.rs @@ -356,23 +356,16 @@ 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 + if self.is_out(side) || self.rivals.iter().any(|r| r.side == side) { + continue; // a fallen master has nothing left to play; a brain is there already } - let core = if side == self.me_side { + if !self.is_master(side) { continue; - } else if let Some(m) = self.masters.iter().find(|m| m.side == side) { - Some(m.core) - } else { - None - }; - let Some(core) = core else { continue }; - if let Some(k) = self.masters.iter().position(|m| m.side == side) { - self.masters.remove(k); - } - if !self.is_rival_side(side) { - self.add_rival(side, core.0 + 1, core.1 + 1, self.ai_difficulty); } + // the seat keeps its master (dungeon, gold, creatures): the brain (ai.rs) plays it + let core = self.core_of_side(side); + let difficulty = self.ai_difficulty; + self.rivals.push(crate::rival::Rival { side, core, hp: crate::rival::RIVAL_CORE_HP, alive: true, radius: 6, grow_t: 0.0, spawn_t: 0.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: crate::rival::PARTNER_GIFT_EVERY, think_t: 1.0, attacking: false, army0: 0.0 }); } } @@ -629,7 +622,7 @@ mod tests { b.sync_ai_sides(&[3], 1); assert_eq!(a.checksum(), b.checksum(), "both clients convert side 3 the same way"); assert!(a.is_rival_side(3) && b.is_rival_side(3)); - assert!(!a.is_master(3) && !b.is_master(3)); + assert!(a.is_master(3) && b.is_master(3), "the seat keeps its master; a brain plays it"); a.receive_turn(&[]); b.receive_turn(&[]); assert_eq!(a.checksum(), b.checksum(), "and keep playing it identically"); diff --git a/src/render3d.rs b/src/render3d.rs index 834290c..9ab9bd6 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1297,7 +1297,7 @@ impl R3d { let hp = (g.core_hp / CORE_HP).clamp(0.0, 1.0); push(vec3(cx as f32 + 1.5, 1.6, cy as f32 + 1.5), 7.0, vec3(1.0, 0.25, 0.2), 1.1 + 0.2 * (t * 3.0).sin() * (1.5 - hp), 100.0); for (k, &(px, py)) in g.map.portals.iter().enumerate() { - let owned = g.map.portal_owned(k); + let owned = g.map.portal_owner(k) == g.me; push(vec3(px as f32 + 1.5, 0.8, py as f32 + 1.5), 6.0, vec3(0.7, 0.3, 1.0), if owned { 1.0 } else { 0.35 }, 20.0); } if g.gate_hp > 0.0 { @@ -2338,7 +2338,7 @@ fn draw_landmarks_solid(g: &Game, t: f32) { if !g.map.shown(idx(px + 1, py + 1)) { continue; } - let owned = g.map.portal_owned(pk); + let owned = g.map.portal_owner(pk) == g.me; let pc = vec3(px as f32 + 1.5, 0.0, py as f32 + 1.5); let stone = |k: u8| if owned { Color::from_rgba(46 + k, 30 + k / 2, 58 + k, 255) } else { Color::from_rgba(56 + k, 54 + k, 60 + k, 255) }; let bone = if owned { Color::from_rgba(212, 200, 176, 255) } else { Color::from_rgba(150, 146, 140, 255) }; @@ -2498,7 +2498,7 @@ fn draw_landmarks_glow(g: &Game, t: f32, cam_r: Vec3) { if !g.map.shown(idx(px + 1, py + 1)) { continue; } - let owned = g.map.portal_owned(pk); + let owned = g.map.portal_owner(pk) == g.me; let pc = vec3(px as f32 + 1.5, 0.0, py as f32 + 1.5); let arm = if owned { Color::new(0.8, 0.35, 1.0, 1.0) } else { Color::new(0.45, 0.42, 0.5, 1.0) }; let mid = pc + vec3(0.0, 1.3, 0.0); diff --git a/src/rival.rs b/src/rival.rs index c8c7af1..111c577 100644 --- a/src/rival.rs +++ b/src/rival.rs @@ -1,6 +1,7 @@ -// Rival dungeon masters (simplified): a dungeon around their own soul core that grows over -// time, creatures that appear there, guard it and attack in groups - the player, the other -// master or the humans. A master whose core falls is out; its ground becomes free. +// Rival dungeon masters: each is a real master (gold, imps, rooms, research - see add_rival) +// played by the brain in ai.rs; this keeps their record (side, heart, difficulty, alive) for the +// interface, the goals and the victory. A master whose heart falls is out; his ground becomes +// free. Units of a rival without a master (old savegames) still use rival_ai until he gets one. use crate::game::*; use crate::map::*; @@ -26,23 +27,12 @@ pub struct Rival { pub power: f32, // grows with the time: level of the new creatures #[serde(default)] pub gift_t: f32, // co-op AI partner: seconds until it looks whether its allies need gold -} - -struct Pace { - spawn: f32, // seconds between new creatures - grow: f32, // seconds between growth steps - attack: f32, // seconds between attacks - cap: usize, // most creatures at a time - max_radius: i32, // largest dungeon - power: f32, // levels per minute -} - -fn pace(d: u8) -> Pace { - match d { - 0 => Pace { spawn: 55.0, grow: 45.0, attack: 330.0, cap: 6, max_radius: 8, power: 0.25 }, - 2 => Pace { spawn: 26.0, grow: 22.0, attack: 170.0, cap: 13, max_radius: 13, power: 0.6 }, - _ => Pace { spawn: 38.0, grow: 30.0, attack: 240.0, cap: 9, max_radius: 11, power: 0.4 }, - } + #[serde(default)] + pub think_t: f32, // the brain (ai.rs): seconds until the next thought + #[serde(default)] + pub attacking: bool, // the attack flag is set on an enemy heart + #[serde(default)] + pub army0: f32, // army strength when the attack began } impl Rival { @@ -102,77 +92,81 @@ impl Game { self.rivals.iter().any(|r| r.side == side && r.alive) } - // sets up a rival dungeon around (cx, cy) + // sets up a rival master around (cx, cy): a real master (ai.rs) with his heart, a little + // ground, a portal of his own, four imps and gold by the difficulty pub fn add_rival(&mut self, side: u8, cx: i32, cy: i32, difficulty: u8) { - let owner = Owner::of_side(side); let core = (cx - 1, cy - 1); - let r = 4; - for y in cy - r..=cy + r { - for x in cx - r..=cx + r { - if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 { - continue; + self.carve_master_area(side, core); + // his own portal beside the dungeon (if there is room) + let owner = Owner::of_side(side); + let (px, py) = (core.0 + 6, core.1); + if px + 3 < mw() as i32 - 2 && py >= 2 && py + 3 < mh() as i32 - 2 { + for y in py..py + 3 { + for x in px..px + 3 { + let t = &mut self.map.t[idx(x, y)]; + if !matches!(t.kind, Kind::Core | Kind::Gate) { + t.kind = Kind::Portal; + t.owner = owner; + t.gold = 0; + t.reinforced = false; + } } - let t = &mut self.map.t[idx(x, y)]; - if matches!(t.kind, Kind::Portal | Kind::Gate | Kind::Core) { - continue; // portals and other cores stay - } - t.kind = Kind::Floor; - t.owner = owner; - t.gold = 0; - t.marked = false; - t.reinforced = false; + } + if !self.map.portals.contains(&(px, py)) { + self.map.portals.push((px, py)); } } - 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; - } - } - // a lair and a treasury (the player can take it) - for y in cy - 3..=cy + 3 { - let t = &mut self.map.t[idx(cx - 3, y)]; - t.kind = Kind::Lair; - } - for y in cy - 1..=cy + 1 { - let t = &mut self.map.t[idx(cx + 3, y)]; - t.kind = Kind::Treasury; - t.gold = 300; - t.res = 0; - } - self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: r, grow_t: 20.0, spawn_t: 5.0, attack_t: pace(difficulty).attack, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY }); - for k in 0..3 { - self.spawn_rival_unit(side, EK::Kobold, 1, k as f32); - } + self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: 5, grow_t: 20.0, spawn_t: 5.0, attack_t: 0.0, difficulty, power: 0.0, gift_t: PARTNER_GIFT_EVERY, think_t: 2.0, attacking: false, army0: 0.0 }); + self.adopt_master(side, core, difficulty, true); } - fn spawn_rival_unit(&mut self, side: u8, kind: EK, level: u32, k: f32) { - let Some(rv) = self.rivals.iter().find(|r| r.side == side) else { return }; - let (cx, cy) = rv.centre(); - let a = k * 1.7 + self.rng.f() * 2.0; - let (x, y) = (cx + a.cos() * 2.4, cy + a.sin() * 2.4); - let id = self.spawn(kind, false, x, y, level); - if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { - e.side = side; - e.home = Some((cx, cy)); + // the rival on `side` gets (or has) a master of his own. `fresh`: a new one with imps and gold + pub(crate) fn adopt_master(&mut self, side: u8, core: (i32, i32), difficulty: u8, fresh: bool) { + if self.is_master(side) { + return; } + let me = self.me_side; + self.add_master(side, core); + self.switch_to(side); + self.gold = crate::ai::start_gold(difficulty); + self.mana = 150.0; + self.recount(); + if fresh { + let (cx, cy) = core; + for j in 0..4 { + self.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + j as f32 * 0.7 + 0.3, 1); + } + } + // the creatures of that side are his + for e in self.ents.iter_mut().filter(|e| e.alive && !e.hero && e.side == side) { + e.home = None; + e.target = None; + } + self.switch_to(me); } + pub fn update_rivals(&mut self, dt: f32) { + let back = self.me_side; for k in 0..self.rivals.len() { if !self.rivals[k].alive { continue; } - let p = pace(self.rivals[k].difficulty); let side = self.rivals[k].side; - { - let r = &mut self.rivals[k]; - r.power += dt / 60.0 * p.power; - r.grow_t -= dt; - r.spawn_t -= dt; - r.attack_t -= dt; + // a rival from an older savegame or a painted map: he gets his master now + if !self.is_master(side) { + let (core, diff) = (self.rivals[k].core, self.rivals[k].difficulty); + self.adopt_master(side, core, diff, false); } + // his heart fell: he is out + if self.core_hp_of(side) <= 0.0 { + if !self.out_sides.contains(&side) { + self.out_sides.push(side); + } + self.eliminate_rival(k); + continue; + } + self.rivals[k].hp = self.core_hp_of(side) / CORE_HP * RIVAL_CORE_HP; // a co-op partner sends gold to allied masters who run short if self.is_partner(side) { self.rivals[k].gift_t -= dt; @@ -181,112 +175,12 @@ impl Game { self.partner_gift(side); } } - // growth: one more ring of ground (no imps needed) - if self.rivals[k].grow_t <= 0.0 { - self.rivals[k].grow_t = p.grow * (0.8 + self.rng.f() * 0.4); - if self.rivals[k].radius < p.max_radius { - self.rivals[k].radius += 1; - self.rival_grow(k); - } - } - // new creatures - let units = self.ents.iter().filter(|e| e.alive && e.side == side && !e.hero).count(); - if self.rivals[k].spawn_t <= 0.0 { - self.rivals[k].spawn_t = p.spawn * (0.8 + self.rng.f() * 0.4); - if units < p.cap { - let power = self.rivals[k].power; - let mut pool = vec![EK::Kobold, EK::Spider]; - if power >= 1.5 { - pool.extend([EK::Mage, EK::Ogre]); - } - if power >= 3.0 { - pool.push(EK::Gargoyle); - } - let kind = pool[(self.rng.next() as usize) % pool.len()]; - let level = (1.0 + power) as u32; - self.spawn_rival_unit(side, kind, level.clamp(1, 10), units as f32); - } - } - // attack: about two thirds of the creatures march on an enemy - if self.rivals[k].attack_t <= 0.0 { - self.rivals[k].attack_t = p.attack * (0.8 + self.rng.f() * 0.4); - let mut targets: Vec<(u8, u32)> = Vec::new(); - if self.coop { - if self.gate_on || self.ents.iter().any(|e| e.alive && e.hero) { - targets.push((1, 3)); - } - } else { - // the masters (the player, or the human masters of a match) that are not allies - for s in self.master_sides() { - if !self.is_out(s) && !self.allied(side, s) { - targets.push((s, 3)); - } - } - for o in &self.rivals { - if o.alive && o.side != side && !self.allied(side, o.side) { - targets.push((o.side, 1)); - } - } - if self.has_gate() && self.gate_hp > 0.0 { - targets.push((1, 1)); - } - } - let total: u32 = targets.iter().map(|t| t.1).sum(); - if total == 0 { - continue; - } - let mut r = self.rng.next() % total; - let mut target = 0; - for (s, w) in targets { - if r < w { - target = s; - break; - } - r -= w; - } - let mut n = 0; - for e in self.ents.iter_mut() { - if e.alive && !e.hero && e.side == side && e.target.is_none() && n * 3 < units * 2 { - e.target = Some(target); - e.path.clear(); - n += 1; - } - } - if n > 0 && target == 0 { - self.msg(if side == 2 { "msg.rival1_attack" } else { "msg.rival2_attack" }, vec![n.to_string()]); - self.sfx_ui(Sfx::WaveAlarm); - } - } + self.switch_to(side); + self.ai_think(k, dt); + self.switch_to(back); } } - // a ring of earth around the rival dungeon becomes its ground - fn rival_grow(&mut self, k: usize) { - let rv = self.rivals[k].clone(); - let owner = Owner::of_side(rv.side); - let (cx, cy) = (rv.core.0 + 1, rv.core.1 + 1); - let r = rv.radius; - for y in cy - r..=cy + r { - for x in cx - r..=cx + r { - if (x - cx).abs() != r && (y - cy).abs() != r { - continue; - } - if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 { - continue; - } - let t = self.map.t[idx(x, y)]; - let free = matches!(t.kind, Kind::Earth | Kind::Floor) && !t.reinforced && t.owner == Owner::None; - let touches = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == owner && n.walkable())); - if free && touches { - let tl = &mut self.map.t[idx(x, y)]; - tl.kind = Kind::Floor; - tl.owner = owner; - tl.gold = 0; - tl.marked = false; - } - } - } - } // damage to the core of a rival master pub fn rival_core_hit(&mut self, side: u8, dmg: f32) { @@ -533,21 +427,24 @@ mod tests { } #[test] - fn rival_grows_spawns_and_attacks() { + fn an_ai_master_builds_its_dungeon() { let mut g = rival_game(2); - let r0 = g.rivals[0].radius; - let core0 = g.core_hp; - let mut attacked = false; - for _ in 0..(12 * 60 * 20) { + let side = g.rivals[0].side; + let owner = Owner::of_side(side); + assert!(g.is_master(side), "a real master"); + let ground0 = g.map.t.iter().filter(|t| t.owner == owner).count(); + for _ in 0..(8 * 60 * 20) { g.update(0.05); - attacked |= g.ents.iter().any(|e| e.rival() && e.target == Some(0)); - if g.core_hp < core0 - 50.0 { - break; - } } - assert!(g.rivals[0].radius > r0, "the rival dungeon grows"); - assert!(attacked, "the rival sends creatures"); - assert!(g.core_hp < core0, "they reach the player's core"); + let ground = g.map.t.iter().filter(|t| t.owner == owner).count(); + assert!(ground > ground0 + 30, "its imps dig and claim: {} -> {}", ground0, ground); + g.switch_to(side); + let rooms: usize = g.counts.iter().sum(); + let imps = g.worker_count(); + g.switch_to(0); + assert!(rooms >= 9, "it builds rooms ({} tiles)", rooms); + assert!(imps >= 4, "and keeps its imps"); + assert!(g.torches.iter().any(|t| g.map.t[t.wall].owner == owner || g.map.t[t.wall].solid()), "torches"); } #[test] @@ -633,7 +530,7 @@ mod tests { #[test] fn an_offline_coop_partner_is_an_allied_ai_master() { let mut g = offline_league_game(); - assert!(g.netplay.is_none() && g.masters.is_empty()); + assert!(g.netplay.is_none() && g.is_master(2), "the partner is a real master without the server"); assert!(g.rival_alive(2) && g.is_partner(2)); assert!(g.allied(0, 2) && g.allied(2, 0)); // short of gold: the partner helps out within a minute @@ -674,4 +571,28 @@ mod tests { } assert!(near, "partner creatures gather at the flag"); } + + #[test] + #[ignore] + fn ai_master_report() { + for d in 0..3u8 { + let mut g = (1..200u32).map(|s| Game::new_free(s, 96, 96, d)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap(); + let side = g.rivals[0].side; + for m in 1..=15 { + for _ in 0..(60 * 20) { + g.update(0.05); + } + g.switch_to(side); + let (rooms, imps, cr, gold) = (g.counts.iter().sum::(), g.worker_count(), g.creature_count(), g.gold); + let flag = g.flag.is_some(); + let earned = g.stats.get(crate::stats::GOLD); + let me = g.me; + let veins = (0..g.map.t.len()).filter(|&i| g.map.t[i].marked && g.map.t[i].vein().is_some()).count(); + let marked = g.map.t.iter().filter(|t| t.marked).count(); + let near = (0..g.map.t.len()).filter(|&i| { let (x, y) = xy(i); g.map.t[i].marked && DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |n| n.owner == me && n.walkable())) }).count(); + g.switch_to(0); + println!("diff {} min {:2}: rooms {:3} imps {:2} creatures {:2} gold {:5.0} attacking {} | marked {} veins {} reachable {} earned {:.0} | player core {:.0}", d, m, rooms, imps, cr, gold, flag, marked, veins, near, earned, g.core_hp); + } + } + } } diff --git a/src/traders.rs b/src/traders.rs index 8a1919c..879d489 100644 --- a/src/traders.rs +++ b/src/traders.rs @@ -44,7 +44,7 @@ impl Game { } self.trading.t = TRADER_GAP + self.rng.f() * 180.0; // he comes through an own portal - let portals: Vec<(i32, i32)> = (0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).map(|k| self.map.portals[k]).collect(); + let portals: Vec<(i32, i32)> = (0..self.map.portals.len()).filter(|&k| self.map.portal_owner(k) == self.me).map(|k| self.map.portals[k]).collect(); let Some(&(px, py)) = portals.get((self.rng.next() as usize) % portals.len().max(1)) else { return }; let spot = DIR8.iter().map(|&(dx, dy)| (px + 1 + dx * 2, py + 1 + dy * 2)).find(|&(x, y)| self.map.walk(x, y)); let Some((x, y)) = spot else { return };