From 773be21455c4d87f003cfa3e50e040b857392e99 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Fri, 6 Nov 2026 09:21:42 +0100 Subject: [PATCH] Marks and dig selection in each master's colour; imps dig only their master's marks; AI marks near its ground, acts calmer, re-summons imps; allies never claim each other's ground Co-Authored-By: Claude Opus 5.5 --- src/ai.rs | 74 ++++++++++++++++++++++++++++++++++++++--------- src/game.rs | 23 +++++++++------ src/main.rs | 6 +++- src/render3d.rs | 18 ++++-------- src/rival.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 37 deletions(-) diff --git a/src/ai.rs b/src/ai.rs index 99d2938..65c00ad 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -10,11 +10,12 @@ 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 THINK: [f32; 3] = [5.2, 3.25, 1.95]; // seconds between two thoughts (a calm pace) 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 MAX_IMPS: [usize; 3] = [5, 7, 9]; +const FIRST_ATTACK: [f32; 3] = [940.0, 625.0, 430.0]; // no attack before (seconds) const BOLDNESS: [f32; 3] = [1.5, 1.15, 0.9]; // own army / enemy army needed to attack +const MARK_REACH: i32 = 5; // an AI master marks only this close to its own ground const MAX_TOWERS: [usize; 3] = [1, 2, 4]; const START_GOLD: [f32; 3] = [1500.0, 2500.0, 3500.0]; @@ -55,11 +56,56 @@ impl Game { self.ai_fight(k, d, side, cx, cy); } + // the tiles an AI master may mark: within MARK_REACH tiles of its own ground + fn ai_reach_mask(&self) -> Vec { + let me = self.me; + let mut dist = vec![u8::MAX; self.map.t.len()]; + let mut queue = std::collections::VecDeque::new(); + for (i, t) in self.map.t.iter().enumerate() { + if t.owner == me { + dist[i] = 0; + queue.push_back(i); + } + } + while let Some(i) = queue.pop_front() { + let d = dist[i]; + if d as i32 >= MARK_REACH { + continue; + } + let (x, y) = xy(i); + for &(dx, dy) in DIR8.iter() { + if inb(x + dx, y + dy) { + let j = idx(x + dx, y + dy); + if dist[j] > d + 1 { + dist[j] = d + 1; + queue.push_back(j); + } + } + } + } + dist.into_iter().map(|d| d as i32 <= MARK_REACH).collect() + } + + // marks a tile for this master's imps (only near its own ground) + fn ai_mark(&mut self, i: usize, mask: &[bool]) -> bool { + if !mask.get(i).copied().unwrap_or(false) { + return false; + } + let side = self.me.side().min(7); + let t = &mut self.map.t[i]; + if t.marked && t.mark_by != side { + return false; // another master's plan + } + t.marked = true; + t.mark_by = side; + true + } + // 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 { + if self.worker_count() < want && self.mana >= self.summon_cost() { self.summon(cx as f32 + 0.5, cy as f32 + 2.5); } } @@ -67,12 +113,13 @@ impl Game { // 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 side = me.side().min(7); 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) { + if g.map.get(x, y).map_or(false, |t| t.marked && t.mark_by == side) { n += 1; } } @@ -84,13 +131,14 @@ impl Game { return; // the imps have enough to do } let mut added = 0; + let mask = self.ai_reach_mask(); 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 { + if added >= 11 || !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 { continue; } let i = idx(x, y); @@ -98,9 +146,9 @@ impl Game { if t.marked || !diggable(&t) || (pass == 0 && t.vein().is_none()) { continue; } - self.map.t[i].marked = true; - self.map.t[i].mark_by = self.me.side().min(7); - added += 1; + if self.ai_mark(i, &mask) { + added += 1; + } } } } @@ -146,12 +194,11 @@ impl Game { }; let Some(path) = found else { return false }; let me = self.me; + let mask = self.ai_reach_mask(); 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; - self.map.t[i].mark_by = self.me.side().min(7); + if t.diggable() && !t.plant() && t.kind != Kind::Steel && (t.owner == Owner::None || t.owner == me || t.reinforced) && self.ai_mark(i, &mask) { if t.vein().is_some() { cluster.push(i); } @@ -163,8 +210,7 @@ impl Game { 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; - self.map.t[idx(x + dx, y + dy)].mark_by = self.me.side().min(7); + self.ai_mark(idx(x + dx, y + dy), &mask); } } } diff --git a/src/game.rs b/src/game.rs index 0062127..786bb92 100644 --- a/src/game.rs +++ b/src/game.rs @@ -3269,12 +3269,13 @@ impl Game { // ---------------------------------------------------------------- pit imps (workers) - fn dig_neighbor(&self, i: usize, me: u32) -> Option { + // a neighbour of tile i that this imp may dig: marked by its own master (side) + fn dig_neighbor(&self, i: usize, me: u32, side: u8) -> Option { let (x, y) = xy(i); let mut found = None; for &(dx, dy) in &DIR4 { if let Some(t) = self.map.get(x + dx, y + dy) { - if t.marked && t.diggable() { + if t.marked && t.mark_by == side && t.diggable() { if t.reserved == me { return Some(idx(x + dx, y + dy)); } @@ -3312,14 +3313,16 @@ impl Game { None } - fn claimable(&self, i: usize, me: u32) -> bool { + pub(crate) fn claimable(&self, i: usize, me: u32) -> bool { let t = &self.map.t[i]; let ok = if t.owner == self.me { false } else if t.owner == Owner::None { matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest) + } else if t.owner != Owner::Hero && self.allied(self.me_side, t.owner.side()) { + false // an ally's ground is never taken } else { - // knight and other masters' ground (also vaults and shard chambers), but not while enemies fight there + // knight and enemy masters' ground (also vaults and shard chambers), but not while enemies fight there matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Lair | Kind::Training) && !self.contested.get(i).copied().unwrap_or(false) }; if !ok { @@ -3412,7 +3415,7 @@ impl Game { match e.job { Job::Dig(t) => { let tile = self.map.t[t]; - if !tile.marked || !tile.diggable() || !adj4(cur, t) { + if !tile.marked || tile.mark_by != e.side || !tile.diggable() || !adj4(cur, t) { self.unreserve(me); e.job = Job::None; return; @@ -3647,9 +3650,9 @@ impl Game { return; } } - if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.dig_neighbor(i, me).is_some(), true) { + if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.dig_neighbor(i, me, e.side).is_some(), true) { let end = p.last().copied().unwrap_or(cur); - if let Some(t) = self.dig_neighbor(end, me) { + if let Some(t) = self.dig_neighbor(end, me, e.side) { self.map.t[t].reserved = me; e.path = p; e.job = Job::Dig(t); @@ -4851,9 +4854,11 @@ impl Game { for x in x0.min(x1)..=x0.max(x1) { if inb(x, y) { let t = &mut self.map.t[idx(x, y)]; - if t.diggable() { + // a master sets and clears only his own marks + let side = self.me.side().min(7); + if t.diggable() && (!t.marked || t.mark_by == side) { t.marked = on; - t.mark_by = self.me.side().min(7); + t.mark_by = side; } } } diff --git a/src/main.rs b/src/main.rs index 9802667..02ec105 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2097,7 +2097,11 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M let ground = app.cam.ground_point(mx, my); clear_background(Color::from_rgba(4, 3, 5, 255)); let (color, radius) = match ui.tool { - Tool::Dig => (Color::new(1.0, 0.85, 0.3, 1.0), None), + Tool::Dig => { + // the dig selection in the master's colour (like the marks) + let c = render3d::master_rgb(app.game.local_side); + (Color::new(c.x, c.y, c.z, 1.0), None) + } Tool::Hand => (Color::new(0.9, 0.75, 0.6, 1.0), None), Tool::Room(_) => (Color::new(0.4, 0.85, 1.0, 1.0), None), Tool::Sell => (Color::new(1.0, 0.5, 0.3, 1.0), None), diff --git a/src/render3d.rs b/src/render3d.rs index 35f08b8..1a3ad3f 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -675,18 +675,10 @@ fn view_side() -> u8 { VIEW_SIDE.with(|v| v.get()) } -// colour of a dig mark: own marks yellow, other masters' marks in their colour +// colour of a dig mark: always the colour of the master who set it fn mark_rgb(by: u8) -> [u8; 3] { - if by == view_side() { - return [255, 205, 50]; - } - match by { - 0 => [235, 90, 125], // the first master, seen by another one - 2 => [80, 240, 110], // green rival - 3 => [100, 150, 255], // blue rival - 4 => [255, 150, 50], // the fourth master - _ => [255, 205, 50], - } + let c = master_rgb(by); + [(c.x * 255.0) as u8, (c.y * 255.0) as u8, (c.z * 255.0) as u8] } // Glowing box in the marker's colour around a block that is marked for digging @@ -768,9 +760,9 @@ fn build_chunk(map: &Map, amb: &[f32], glow: &[f32], cx: i32, cz: i32, atlas: &T let (xf, zf) = (x as f32, z as f32); if !map.shown(idx(x, z)) { // fog of war: a black block, with black sides towards known open ground; - // tiles marked for digging glow dark yellow so the plan stays visible + // own tiles marked for digging glow darkly in the master's colour so the plan stays visible let black = col(Vec3::splat(0.0), 1.0); - let top = if t.marked && t.mark_by == view_side() { col(vec3(0.42, 0.33, 0.06), 1.0) } else { black }; + let top = if t.marked && t.mark_by == view_side() { col(master_rgb(t.mark_by) * 0.42, 1.0) } else { black }; mb.quad([vec3(xf, WALL_H, zf), vec3(xf + 1.0, WALL_H, zf), vec3(xf + 1.0, WALL_H, zf + 1.0), vec3(xf, WALL_H, zf + 1.0)], [ua, ua, ua, ua], [top; 4], [Vec3::Y; 4]); for (d, &(dx, dz)) in DIR4.iter().enumerate() { let open = map.get(x + dx, z + dz).map_or(false, |o| !o.solid()) && map.shown(idx((x + dx).max(0), (z + dz).max(0))); diff --git a/src/rival.rs b/src/rival.rs index 4784a7b..cea4959 100644 --- a/src/rival.rs +++ b/src/rival.rs @@ -468,6 +468,60 @@ mod tests { assert!(ground > ground0 + 20, "the yellow master digs and claims: {} -> {}", ground0, ground); } + #[test] + fn ai_marks_stay_near_its_ground_and_imps_dig_only_their_masters_marks() { + let mut g = rival_game(2); + let side = g.rivals[0].side; + let owner = Owner::of_side(side); + for _ in 0..(10 * 60 * 20) { + g.update(0.05); + if g.time as i32 % 20 == 0 { + for (i, t) in g.map.t.iter().enumerate() { + if t.marked && t.mark_by == side { + let (x, y) = crate::map::xy(i); + let near = (-5..=5).any(|dy| (-5..=5).any(|dx| g.map.get(x + dx, y + dy).map_or(false, |o| o.owner == owner))); + assert!(near, "AI mark at {},{} far from its ground", x, y); + } + } + } + } + // a foreign mark next to the player's imps stays untouched + let (cx, cy) = g.map.core; + let spot = (0..g.map.t.len()).find(|&i| { + let (x, y) = crate::map::xy(i); + (x - cx).abs() < 10 && (y - cy).abs() < 10 && g.map.t[i].kind == Kind::Earth && !g.map.t[i].marked + && DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |o| o.owner == Owner::Player && o.walkable())) + }).expect("earth beside the player's floor"); + g.map.t[spot].reinforced = false; + g.map.t[spot].marked = true; + g.map.t[spot].mark_by = side; + for _ in 0..(60 * 20) { + g.update(0.05); + } + assert_eq!(g.map.t[spot].kind, Kind::Earth, "the player's imps leave the AI's mark alone"); + } + + #[test] + fn the_ai_summons_new_imps_when_its_imps_die() { + let mut g = rival_game(1); + let side = g.rivals[0].side; + for _ in 0..(60 * 20) { + g.update(0.05); + } + for e in g.ents.iter_mut().filter(|e| e.alive && e.side == side && e.kind == EK::Worker) { + e.hp = 0.0; + } + for _ in 0..20 { + g.update(0.05); + } + assert_eq!(g.ents.iter().filter(|e| e.alive && e.side == side && e.kind == EK::Worker).count(), 0, "all its imps are dead"); + for _ in 0..(3 * 60 * 20) { + g.update(0.05); + } + let imps = g.ents.iter().filter(|e| e.alive && e.side == side && e.kind == EK::Worker).count(); + assert!(imps >= 2, "new imps (one per 80 mana, like the player): {}", imps); + } + #[test] fn ai_dig_marks_carry_the_masters_side() { let mut g = rival_game(1); @@ -564,6 +618,29 @@ mod tests { g } + #[test] + fn imps_take_ground_from_enemies_but_never_from_allies() { + let mut g = offline_league_game(); + assert!(g.allied(0, 2)); + // a partner floor tile right beside the player's floor + let spot = (0..g.map.t.len()).find(|&i| { + let (x, y) = crate::map::xy(i); + g.map.t[i].kind == Kind::Floor && g.map.t[i].owner == Owner::Player + && DIR4.iter().any(|&(dx, dy)| g.map.get(x + dx, y + dy).map_or(false, |o| o.kind == Kind::Floor && o.owner == Owner::Player)) + && x > 2 && y > 2 + }).expect("player floor"); + let (x, y) = crate::map::xy(spot); + let n = DIR4.iter().map(|&(dx, dy)| crate::map::idx(x + dx, y + dy)).find(|&j| g.map.t[j].owner == Owner::Player).unwrap(); + g.map.t[n].owner = Owner::of_side(2); + g.map.t[n].reserved = 0; + assert!(!g.claimable(n, 999), "an ally's floor stays his"); + // the same tile of an enemy master may be taken + g.coop = false; + g.teams = vec![(0, 0), (2, 1)]; + assert!(!g.allied(0, 2)); + assert!(g.claimable(n, 999), "an enemy's floor can be claimed"); + } + #[test] fn an_offline_coop_partner_is_an_allied_ai_master() { let mut g = offline_league_game();