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 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-06 08:18:09 +01:00
1 parent b940a959f1
commit a00ae6a06b
11 files changed
+456 -223

No files matched your search

+4 -2
View File
@@ -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, - **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 seat choice, AI seat toggles for the host, ready/start, chat; the match keeps running in the background while
menus are open. menus are open.
- **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left are played - **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left keep their
by the simplified rival AI on every client, so they stay deterministic. 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 - **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`. 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 - **Editor campaigns in co-op**: on the Match page the host can switch from the built-in campaign to the
+1 -1
View File
@@ -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 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. 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; claim, mine and set torches; rooms by plan, traps, towers, research; the attack flag, shields and swords;
strength by the difficulty. strength by the difficulty.
2. **Mouse cursor** - clearer hands for the hand tool (open, holding, slap, punch). 2. **Mouse cursor** - clearer hands for the hand tool (open, holding, slap, punch).
+313
View File
@@ -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
}
})
}
}
+2
View File
@@ -663,6 +663,8 @@ fn remove_rivals(g: &mut Game) {
return; return;
} }
g.rivals.clear(); 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)); g.ents.retain(|e| e.hero || !sides.contains(&e.side));
let owners: Vec<Owner> = sides.iter().map(|&s| Owner::of_side(s)).collect(); let owners: Vec<Owner> = sides.iter().map(|&s| Owner::of_side(s)).collect();
for t in g.map.t.iter_mut() { for t in g.map.t.iter_mut() {
+9 -9
View File
@@ -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 // 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 // (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. // 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 struct MasterState {
pub side: u8, pub side: u8,
pub owner: Option<Owner>, pub owner: Option<Owner>,
@@ -656,8 +657,8 @@ pub struct Game {
pub me_side: u8, // that master's side (0 = the first player) pub me_side: u8, // that master's side (0 = the first player)
#[serde(skip)] #[serde(skip)]
pub local_side: u8, // the master who plays at this computer pub local_side: u8, // the master who plays at this computer
#[serde(skip)] #[serde(default)]
pub masters: Vec<MasterState>, // the other masters (multiplayer) pub masters: Vec<MasterState>, // the other masters (multiplayer, AI masters), saved with the game
#[serde(skip)] #[serde(skip)]
pub pending: Vec<Command>, // orders of this master, applied at the next step boundary pub pending: Vec<Command>, // orders of this master, applied at the next step boundary
#[serde(skip)] #[serde(skip)]
@@ -1291,7 +1292,7 @@ impl Game {
} }
pub fn portals_owned(&self) -> usize { 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) // 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 // 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 pub(crate) fn carve_master_area(&mut self, side: u8, core: (i32, i32)) {
fn carve_master_area(&mut self, side: u8, core: (i32, i32)) {
let owner = Owner::of_side(side); let owner = Owner::of_side(side);
for y in core.1 - 3..core.1 + 6 { for y in core.1 - 3..core.1 + 6 {
for x in core.0 - 3..core.0 + 6 { for x in core.0 - 3..core.0 + 6 {
@@ -2007,7 +2007,7 @@ impl Game {
} }
r -= w; r -= w;
} }
let owned: Vec<usize> = (0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).collect(); let owned: Vec<usize> = (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 pk = owned[(self.rng.next() as usize) % owned.len()];
let (px, py) = self.map.portals[pk]; let (px, py) = self.map.portals[pk];
let id = self.spawn(kind, false, px as f32 + 1.5, py as f32 + 1.5, 1); 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); self.demo_ai(e, dt);
} else if e.hero && e.captive != 0 { } else if e.hero && e.captive != 0 {
self.captive_update(e, dt); 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); self.rival_ai(e, dt);
} else if e.kind == EK::Trader { } else if e.kind == EK::Trader {
self.trader_ai(e, dt); self.trader_ai(e, dt);
@@ -2499,7 +2499,7 @@ impl Game {
} }
} }
if !tile.walkable() && !(flies(e.kind) && tile.liquid()) { 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 // heroes dig their way through
e.working = true; e.working = true;
let (tx, ty) = xy(next); let (tx, ty) = xy(next);
+1
View File
@@ -1,6 +1,7 @@
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM). // DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
mod achievements; mod achievements;
mod ai;
mod armory; mod armory;
mod audio; mod audio;
mod campaign; mod campaign;
+3 -2
View File
@@ -403,9 +403,10 @@ impl Map {
self.portals.iter().position(|&(px, py)| x >= px && x < px + 3 && y >= py && y < py + 3) 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]; 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 { pub fn generate(seed: u32, w: usize, h: usize) -> Map {
+8 -15
View File
@@ -356,23 +356,16 @@ impl Game {
.filter(|side| *side != self.local_side) .filter(|side| *side != self.local_side)
.collect(); .collect();
for side in self.ai_sides.clone() { for side in self.ai_sides.clone() {
if self.is_out(side) { if self.is_out(side) || self.rivals.iter().any(|r| r.side == side) {
continue; // a fallen master who left has nothing left for an AI to play 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; 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); b.sync_ai_sides(&[3], 1);
assert_eq!(a.checksum(), b.checksum(), "both clients convert side 3 the same way"); 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_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(&[]); a.receive_turn(&[]);
b.receive_turn(&[]); b.receive_turn(&[]);
assert_eq!(a.checksum(), b.checksum(), "and keep playing it identically"); assert_eq!(a.checksum(), b.checksum(), "and keep playing it identically");
+3 -3
View File
@@ -1297,7 +1297,7 @@ impl R3d {
let hp = (g.core_hp / CORE_HP).clamp(0.0, 1.0); 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); 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() { 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); 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 { 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)) { if !g.map.shown(idx(px + 1, py + 1)) {
continue; 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 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 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) }; 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)) { if !g.map.shown(idx(px + 1, py + 1)) {
continue; 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 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 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); let mid = pc + vec3(0.0, 1.3, 0.0);
+111 -190
View File
@@ -1,6 +1,7 @@
// Rival dungeon masters (simplified): a dungeon around their own soul core that grows over // Rival dungeon masters: each is a real master (gold, imps, rooms, research - see add_rival)
// time, creatures that appear there, guard it and attack in groups - the player, the other // played by the brain in ai.rs; this keeps their record (side, heart, difficulty, alive) for the
// master or the humans. A master whose core falls is out; its ground becomes free. // 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::game::*;
use crate::map::*; use crate::map::*;
@@ -26,23 +27,12 @@ pub struct Rival {
pub power: f32, // grows with the time: level of the new creatures pub power: f32, // grows with the time: level of the new creatures
#[serde(default)] #[serde(default)]
pub gift_t: f32, // co-op AI partner: seconds until it looks whether its allies need gold pub gift_t: f32, // co-op AI partner: seconds until it looks whether its allies need gold
} #[serde(default)]
pub think_t: f32, // the brain (ai.rs): seconds until the next thought
struct Pace { #[serde(default)]
spawn: f32, // seconds between new creatures pub attacking: bool, // the attack flag is set on an enemy heart
grow: f32, // seconds between growth steps #[serde(default)]
attack: f32, // seconds between attacks pub army0: f32, // army strength when the attack began
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 },
}
} }
impl Rival { impl Rival {
@@ -102,77 +92,81 @@ impl Game {
self.rivals.iter().any(|r| r.side == side && r.alive) 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) { 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 core = (cx - 1, cy - 1);
let r = 4; self.carve_master_area(side, core);
for y in cy - r..=cy + r { // his own portal beside the dungeon (if there is room)
for x in cx - r..=cx + r { let owner = Owner::of_side(side);
if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 { let (px, py) = (core.0 + 6, core.1);
continue; 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) { if !self.map.portals.contains(&(px, py)) {
continue; // portals and other cores stay self.map.portals.push((px, py));
}
t.kind = Kind::Floor;
t.owner = owner;
t.gold = 0;
t.marked = false;
t.reinforced = false;
} }
} }
for y in 0..3 { 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 });
for x in 0..3 { self.adopt_master(side, core, difficulty, true);
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);
}
} }
fn spawn_rival_unit(&mut self, side: u8, kind: EK, level: u32, k: f32) { // the rival on `side` gets (or has) a master of his own. `fresh`: a new one with imps and gold
let Some(rv) = self.rivals.iter().find(|r| r.side == side) else { return }; pub(crate) fn adopt_master(&mut self, side: u8, core: (i32, i32), difficulty: u8, fresh: bool) {
let (cx, cy) = rv.centre(); if self.is_master(side) {
let a = k * 1.7 + self.rng.f() * 2.0; return;
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));
} }
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) { pub fn update_rivals(&mut self, dt: f32) {
let back = self.me_side;
for k in 0..self.rivals.len() { for k in 0..self.rivals.len() {
if !self.rivals[k].alive { if !self.rivals[k].alive {
continue; continue;
} }
let p = pace(self.rivals[k].difficulty);
let side = self.rivals[k].side; let side = self.rivals[k].side;
{ // a rival from an older savegame or a painted map: he gets his master now
let r = &mut self.rivals[k]; if !self.is_master(side) {
r.power += dt / 60.0 * p.power; let (core, diff) = (self.rivals[k].core, self.rivals[k].difficulty);
r.grow_t -= dt; self.adopt_master(side, core, diff, false);
r.spawn_t -= dt;
r.attack_t -= dt;
} }
// 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 // a co-op partner sends gold to allied masters who run short
if self.is_partner(side) { if self.is_partner(side) {
self.rivals[k].gift_t -= dt; self.rivals[k].gift_t -= dt;
@@ -181,112 +175,12 @@ impl Game {
self.partner_gift(side); self.partner_gift(side);
} }
} }
// growth: one more ring of ground (no imps needed) self.switch_to(side);
if self.rivals[k].grow_t <= 0.0 { self.ai_think(k, dt);
self.rivals[k].grow_t = p.grow * (0.8 + self.rng.f() * 0.4); self.switch_to(back);
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);
}
}
} }
} }
// 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 // damage to the core of a rival master
pub fn rival_core_hit(&mut self, side: u8, dmg: f32) { pub fn rival_core_hit(&mut self, side: u8, dmg: f32) {
@@ -533,21 +427,24 @@ mod tests {
} }
#[test] #[test]
fn rival_grows_spawns_and_attacks() { fn an_ai_master_builds_its_dungeon() {
let mut g = rival_game(2); let mut g = rival_game(2);
let r0 = g.rivals[0].radius; let side = g.rivals[0].side;
let core0 = g.core_hp; let owner = Owner::of_side(side);
let mut attacked = false; assert!(g.is_master(side), "a real master");
for _ in 0..(12 * 60 * 20) { let ground0 = g.map.t.iter().filter(|t| t.owner == owner).count();
for _ in 0..(8 * 60 * 20) {
g.update(0.05); 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"); let ground = g.map.t.iter().filter(|t| t.owner == owner).count();
assert!(attacked, "the rival sends creatures"); assert!(ground > ground0 + 30, "its imps dig and claim: {} -> {}", ground0, ground);
assert!(g.core_hp < core0, "they reach the player's core"); 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] #[test]
@@ -633,7 +530,7 @@ mod tests {
#[test] #[test]
fn an_offline_coop_partner_is_an_allied_ai_master() { fn an_offline_coop_partner_is_an_allied_ai_master() {
let mut g = offline_league_game(); 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.rival_alive(2) && g.is_partner(2));
assert!(g.allied(0, 2) && g.allied(2, 0)); assert!(g.allied(0, 2) && g.allied(2, 0));
// short of gold: the partner helps out within a minute // short of gold: the partner helps out within a minute
@@ -674,4 +571,28 @@ mod tests {
} }
assert!(near, "partner creatures gather at the flag"); 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::<usize>(), 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);
}
}
}
} }
+1 -1
View File
@@ -44,7 +44,7 @@ impl Game {
} }
self.trading.t = TRADER_GAP + self.rng.f() * 180.0; self.trading.t = TRADER_GAP + self.rng.f() * 180.0;
// he comes through an own portal // 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 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 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 }; let Some((x, y)) = spot else { return };