Dwarf caves in all modes: neutral dwarves guard a hoard and sometimes a rune chest; the rune forge tower; imps repair towers before claiming

- 1-4 cave slots by map size, 50% each (at least one); knight treasure caves only where a mission places them
- dwarves fight intruders near the cave, never chase far, heal at home, grow with distance and the player's army, never count for victory
- rune chest: one free rune forge tower (chain bolt to three more enemies), upgradeable for gold, sold once it comes back (next mission / free play at once), unbuilt ones carry over
- new tool (key N) shown in the tower menu while a rune tower can be built or upgraded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 15:14:36 +01:00
1 parent d92b45a0b8
commit 9a9f04ae1f
17 files changed
+658 -32

No files matched your search

+355
View File
@@ -0,0 +1,355 @@
// Dwarf caves: a neutral people that guards a treasure in a cave surrounded by rich veins.
// The dwarves defend themselves but never stray far from their cave, and they never count
// as opponents for victory. Some caves hold a chest with the plans of a rune forge tower:
// the player may build it once for free.
use crate::game::*;
use crate::map::*;
use serde::{Deserialize, Serialize};
pub const DWARF_SIDE: u8 = 4;
const LEASH: f32 = 7.0; // dwarves do not chase enemies further from the cave centre
const CHEST_CHANCE: f32 = 0.3;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DwarfCave {
pub at: (i32, i32), // centre
pub base: u32, // base level of its dwarves (grows with the distance to the player's core)
}
impl Game {
// 1 to 4 cave slots depending on the map size, each filled with a 50% chance (at least one)
pub fn place_dwarf_caves(&mut self, seed: u32) {
let mut rng = Rng(seed.wrapping_mul(0x9e37_79b9) ^ 0x5bd1_e995 | 1);
for _ in 0..8 {
rng.next();
}
let area = (mw() * mh()) as f32 / 4096.0;
let slots = (area.sqrt().ceil() as usize).clamp(1, 4);
let mut n = (0..slots).filter(|_| rng.f() < 0.5).count();
if n == 0 {
n = 1;
}
for _ in 0..n {
if let Some(at) = self.dwarf_spot(&mut rng) {
self.carve_dwarf_cave(at, &mut rng);
}
}
}
// a spot far from everything else whose surroundings are untouched neutral rock and earth
fn dwarf_spot(&self, rng: &mut Rng) -> Option<(i32, i32)> {
let (w, h) = (mw() as i32, mh() as i32);
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
let mut others: Vec<(i32, i32)> = vec![(cx, cy), (self.map.gate.0 + 1, self.map.gate.1 + 1)];
others.extend(self.map.portals.iter().map(|&(x, y)| (x + 1, y + 1)));
others.extend(self.map.treasures.iter().copied());
others.extend(self.rivals.iter().map(|r| (r.core.0 + 1, r.core.1 + 1)));
others.extend(self.dwarf_caves.iter().map(|c| c.at));
let mut best = None;
let mut bd = -1;
for _ in 0..200 {
let p = (rng.range(9, w - 10), rng.range(9, h - 10));
if (p.0 - cx).pow(2) + (p.1 - cy).pow(2) < 20 * 20 {
continue;
}
let clear = (p.1 - 6..=p.1 + 6).all(|y| {
(p.0 - 6..=p.0 + 6).all(|x| {
let t = &self.map.t[idx(x, y)];
t.owner == Owner::None && !t.liquid() && !matches!(t.kind, Kind::Core | Kind::Portal | Kind::Gate | Kind::Tower | Kind::Shard | Kind::Bridge | Kind::LavaBridge | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest)
})
});
if !clear {
continue;
}
let d = others.iter().map(|q| (p.0 - q.0).pow(2) + (p.1 - q.1).pow(2)).min().unwrap_or(i32::MAX);
if d < 14 * 14 {
continue;
}
// not hidden among the knights: keep off fortress ground and units
if self.ents.iter().any(|e| e.alive && (e.x - p.0 as f32).abs() < 8.0 && (e.y - p.1 as f32).abs() < 8.0) {
continue;
}
if d > bd {
bd = d;
best = Some(p);
}
}
best
}
fn carve_dwarf_cave(&mut self, (cx, cy): (i32, i32), rng: &mut Rng) {
let (px, py) = (self.map.core.0 + 1, self.map.core.1 + 1);
let dist = (((cx - px).pow(2) + (cy - py).pow(2)) as f32).sqrt();
// rich veins around the cave, with gems
for y in cy - 5..=cy + 5 {
for x in cx - 5..=cx + 5 {
let d = (((x - cx).pow(2) + (y - cy).pow(2)) as f32).sqrt();
let t = &mut self.map.t[idx(x, y)];
t.marked = false;
t.reinforced = false;
t.hp = 6.0;
if (x - cx).abs() <= 2 && (y - cy).abs() <= 2 {
t.kind = Kind::Floor;
t.gold = 0;
} else if d <= 5.2 && rng.f() < 0.7 {
let r = rng.f();
let (k, g) = if r < 0.08 { (Kind::Diamond, rng.range(1200, 1800)) } else if r < 0.28 { (Kind::Ruby, rng.range(800, 1200)) } else if r < 0.45 { (Kind::Silver, rng.range(500, 800)) } else { (Kind::Gold, rng.range(800, 1400)) };
t.kind = k;
t.gold = g;
} else {
t.kind = Kind::Earth;
t.gold = 0;
}
}
}
// the hoard: treasury piles in the corners and along the walls, more the further away
let hoard = (1500.0 + dist * 20.0).min(4000.0) as i32;
let spots = [(-2, -2), (2, -2), (-2, 2), (2, 2), (0, -2), (0, 2), (-2, 0), (2, 0)];
let piles = ((hoard + TREASURY_TILE as i32 - 1) / TREASURY_TILE as i32).clamp(1, spots.len() as i32) as usize;
for (k, &(dx, dy)) in spots.iter().take(piles).enumerate() {
let t = &mut self.map.t[idx(cx + dx, cy + dy)];
t.kind = Kind::Treasury;
t.gold = (hoard - k as i32 * TREASURY_TILE as i32).clamp(0, TREASURY_TILE as i32);
let r = rng.f();
t.res = if r < 0.12 { 3 } else if r < 0.35 { 2 } else if r < 0.55 { 1 } else { 0 };
}
if rng.f() < CHEST_CHANCE {
self.map.t[idx(cx, cy)].kind = Kind::RuneChest;
}
let base = (2 + (dist / 30.0) as u32).min(7);
let count = 4 + (dist > 60.0) as usize + (dist > 100.0) as usize;
for k in 0..count {
let a = k as f32 / count as f32 * std::f32::consts::TAU;
let id = self.spawn(EK::Dwarf, false, cx as f32 + 0.5 + a.cos() * 1.2, cy as f32 + 0.5 + a.sin() * 1.2, base);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.side = DWARF_SIDE;
e.home = Some((cx as f32 + 0.5, cy as f32 + 0.5));
e.happy = 1.0;
}
}
self.dwarf_caves.push(DwarfCave { at: (cx, cy), base });
}
// Every 30 s the dwarves of a cave grow stronger until they match about half the player's
// army (or their base level): one of the weakest idle dwarves gains a level.
pub fn update_dwarves(&mut self, dt: f32) {
self.dwarf_t -= dt;
if self.dwarf_t > 0.0 || self.dwarf_caves.is_empty() {
return;
}
self.dwarf_t = 30.0;
let army = self.army_value() * 0.5;
for c in 0..self.dwarf_caves.len() {
let home = (self.dwarf_caves[c].at.0 as f32 + 0.5, self.dwarf_caves[c].at.1 as f32 + 0.5);
let mine: Vec<usize> = (0..self.ents.len()).filter(|&j| {
let e = &self.ents[j];
e.alive && e.dwarf() && e.home == Some(home)
}).collect();
if mine.is_empty() {
continue;
}
let list: Vec<(EK, u32)> = mine.iter().map(|&j| (self.ents[j].kind, self.ents[j].level)).collect();
if Game::squad_value(&list) >= army {
continue;
}
if let Some(&j) = mine.iter().filter(|&&j| self.ents[j].level < 10 && !matches!(self.ents[j].job, Job::Attack(_))).min_by_key(|&&j| self.ents[j].level) {
let e = &mut self.ents[j];
e.level += 1;
let old = e.max_hp;
e.max_hp = st(e.kind).hp * (1.0 + 0.15 * (e.level - 1) as f32);
e.hp += e.max_hp - old;
}
}
}
// a dwarf: fights what comes close to the cave, otherwise potters about at home and heals
pub fn dwarf_ai(&mut self, e: &mut Ent, dt: f32) {
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
let from_home = |x: f32, y: f32| ((x - hx).powi(2) + (y - hy).powi(2)).sqrt();
if e.think <= 0.0 {
e.think = 0.4;
if !matches!(e.job, Job::Attack(_)) {
if let Some(id) = self.nearest_enemy(e, 6.5) {
if let Some(j) = self.find(id) {
if from_home(self.ents[j].x, self.ents[j].y) < LEASH {
e.job = Job::Attack(id);
e.path.clear();
e.repath = 0.0;
}
}
}
}
}
if let Job::Attack(id) = e.job {
let near = self.find(id).map_or(false, |j| from_home(self.ents[j].x, self.ents[j].y) < LEASH + 1.0);
if near && from_home(e.x, e.y) < LEASH + 1.0 && self.attack_job(e, id, dt) {
return;
}
e.job = Job::None;
e.path.clear();
e.repath = 0.0;
}
e.hp = (e.hp + e.max_hp * 0.01 * dt).min(e.max_hp);
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 3.0 + self.rng.f() * 4.0;
let (tx, ty) = if from_home(e.x, e.y) > 3.0 { (hx, hy) } else { (hx + self.rng.f() * 4.0 - 2.0, hy + self.rng.f() * 4.0 - 2.0) };
if self.map.walk(tx as i32, ty as i32) {
if let Some(p) = self.map.path_to(e.tile(), idx(tx as i32, ty as i32)) {
e.path = p;
}
}
}
self.step_path(e, dt);
}
// rune forge towers that can be built now
pub fn rune_available(&self) -> u32 {
self.rune_free + self.rune_final
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dwarves(g: &Game) -> usize {
g.ents.iter().filter(|e| e.alive && e.dwarf()).count()
}
#[test]
fn caves_by_map_size() {
for seed in 1..=10u32 {
for &(w, h, max) in &[(64usize, 64usize, 1usize), (128, 128, 2), (256, 256, 4)] {
let g = Game::new_free(seed, w, h, 1);
let n = g.dwarf_caves.len();
assert!(n >= 1 && n <= max, "seed {} {}x{}: {} caves", seed, w, h, n);
assert!(g.map.treasures.is_empty(), "knight caves replaced");
assert!(dwarves(&g) >= 4 * n);
for c in &g.dwarf_caves {
let t = &g.map.t[idx(c.at.0, c.at.1)];
assert!(matches!(t.kind, Kind::Floor | Kind::RuneChest), "cave centre {:?}", t.kind);
}
}
}
}
#[test]
fn dwarves_are_no_opponents_and_stay_home() {
let mut g = (1..100u32).map(|s| Game::new_free(s, 64, 64, 0)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap();
g.eliminate_rival(0);
for _ in 0..40 {
g.update(0.05);
}
assert_eq!(g.state, 1, "dwarves alive, but the game is won");
assert!(dwarves(&g) > 0);
}
#[test]
fn dwarves_fight_intruders_but_do_not_chase() {
let mut g = Game::new_free(3, 64, 64, 0);
let c = g.dwarf_caves[0].clone();
// an ogre of the player walks into the cave
let id = g.spawn(EK::Ogre, false, c.at.0 as f32 + 0.5, c.at.1 as f32 + 0.5, 3);
let mut hit = false;
for _ in 0..(20 * 20) {
g.update(0.05);
if let Some(j) = g.find(id) {
hit |= g.ents[j].hp < g.ents[j].max_hp;
}
}
assert!(hit, "the dwarves attack");
let home = (c.at.0 as f32 + 0.5, c.at.1 as f32 + 0.5);
for e in g.ents.iter().filter(|e| e.alive && e.dwarf()) {
let d = ((e.x - home.0).powi(2) + (e.y - home.1).powi(2)).sqrt();
assert!(d < LEASH + 2.5, "dwarf strayed {:.1} tiles", d);
}
}
#[test]
fn dwarves_grow_with_the_army() {
let mut g = Game::new_free(5, 64, 64, 0);
let lvl = |g: &Game| g.ents.iter().filter(|e| e.alive && e.dwarf()).map(|e| e.level).sum::<u32>();
let before = lvl(&g);
let (cx, cy) = g.map.core;
for k in 0..8 {
g.spawn(EK::Ogre, false, cx as f32 + 1.0 + k as f32 * 0.2, cy as f32 - 1.0, 8);
}
g.dwarf_t = 0.0;
g.update_dwarves(0.1);
assert!(lvl(&g) > before);
}
#[test]
fn rune_chest_gives_a_free_tower_once() {
let mut g = Game::new_free(5, 64, 64, 0);
let (cx, cy) = g.map.core;
let (tx, ty) = (cx + 4, cy + 1);
g.map.t[idx(tx, ty)].kind = Kind::RuneChest;
g.map.t[idx(tx, ty)].owner = Owner::None;
g.open_rune_chest(idx(tx, ty));
assert_eq!(g.rune_available(), 1);
g.map.t[idx(tx, ty)].owner = Owner::Player;
let gold = g.gold;
g.build_tower(TowerKind::Rune, tx, ty);
assert_eq!(g.towers.len(), 1);
assert_eq!(g.gold, gold, "free");
assert_eq!(g.rune_available(), 0);
g.build_tower(TowerKind::Rune, tx + 1, ty);
assert_eq!(g.towers.len(), 1, "only one per chest");
// free play: selling gives the base tower back at once - but only once
g.sell_rect(tx, ty, tx, ty);
assert_eq!(g.rune_available(), 1);
g.build_tower(TowerKind::Rune, tx, ty);
assert!(g.towers[0].rune_final);
g.sell_rect(tx, ty, tx, ty);
assert_eq!(g.rune_available(), 0, "no second refund");
// in a mission the tower comes back in the next one
let mut m = crate::campaign::start_mission(0, None);
let (cx, cy) = m.map.core;
m.rune_free = 1;
m.build_tower(TowerKind::Rune, cx + 4, cy + 1);
assert_eq!(m.towers.len(), 1, "built in the mission");
m.sell_rect(cx + 4, cy + 1, cx + 4, cy + 1);
assert_eq!((m.rune_available(), m.rune_next), (0, 1));
let c = crate::campaign::carry_from(&m);
let next = crate::campaign::start_mission(1, Some(&c));
assert_eq!(next.rune_final, 1);
}
#[test]
fn imps_repair_towers_before_claiming() {
let mut g = Game::new(4, 64, 64);
let (cx, cy) = g.map.core;
// plenty of unclaimed floor next to the dungeon
for y in cy - 3..=cy + 5 {
for x in cx + 6..=cx + 12 {
g.map.t[idx(x, y)].kind = Kind::Floor;
g.map.t[idx(x, y)].owner = Owner::None;
}
}
let (tx, ty) = (cx + 4, cy - 2);
g.map.t[idx(tx, ty)].kind = Kind::Floor;
g.map.t[idx(tx, ty)].owner = Owner::Player;
g.build_tower(TowerKind::Arrow, tx, ty);
assert_eq!(g.towers.len(), 1);
g.towers[0].hp = 50.0;
let gold = g.gold;
for _ in 0..(20 * 20) {
g.update(0.05);
}
assert!(g.towers[0].hp >= g.towers[0].max_hp() - 1.0, "repaired: {}", g.towers[0].hp);
assert!(g.gold >= gold - 1.0, "repairs are free");
}
#[test]
fn missions_have_dwarf_caves() {
let mut total = 0;
for i in 0..crate::campaign::missions().len() {
let g = crate::campaign::start_mission(i, None);
total += g.dwarf_caves.len();
}
println!("{} dwarf caves in the campaign", total);
assert!(total >= 20);
}
}