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

+138 -17
View File
@@ -120,6 +120,8 @@ pub struct St {
pub bounty: i32,
}
pub const RUNE_TOOL: usize = 20; // index of the rune forge tower in ui::TOOLS
pub fn st(k: EK) -> St {
let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty };
match k {
@@ -322,7 +324,11 @@ impl Ent {
}
// a unit of a rival master
pub fn rival(&self) -> bool {
!self.hero && self.side >= 2
!self.hero && (self.side == 2 || self.side == 3)
}
// a dwarf of a dwarf cave (neutral, never an opponent for victory)
pub fn dwarf(&self) -> bool {
!self.hero && self.side == crate::dwarves::DWARF_SIDE
}
// a hero that is still fighting (not a prisoner)
pub fn active_hero(&self) -> bool {
@@ -436,6 +442,16 @@ pub struct Game {
#[serde(skip)]
pub springs: usize, // claimed mana springs (recounted)
#[serde(default)]
pub dwarf_caves: Vec<crate::dwarves::DwarfCave>,
#[serde(default)]
pub dwarf_t: f32,
#[serde(default)]
pub rune_free: u32, // rune forge towers that can be built for free (from chests)
#[serde(default)]
pub rune_final: u32, // the same, but given back for a sold tower: no second refund
#[serde(default)]
pub rune_next: u32, // sold rune forge towers: back as a chest in the next mission
#[serde(default)]
pub freed: u32, // chained creatures freed // tiles next to fighting heroes: knight ground there cannot be claimed
#[serde(skip)]
pub hints: bool, // show hint messages (setting)
@@ -511,6 +527,7 @@ impl Game {
let dist = ((gx - cx).abs() + (gy - cy).abs()) as f32;
g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0);
g.guard_treasures();
g.place_dwarf_caves(seed);
g.msg("msg.welcome1", vec![]);
g.msg("msg.welcome2", vec![]);
g.msg("msg.welcome3", vec![]);
@@ -582,6 +599,11 @@ impl Game {
gate_on: true,
springs: 0,
freed: 0,
dwarf_caves: Vec::new(),
dwarf_t: 30.0,
rune_free: 0,
rune_final: 0,
rune_next: 0,
};
let (cx, cy) = g.map.core;
// the soul core starts surrounded by a one tile wide ring of treasury
@@ -615,6 +637,11 @@ impl Game {
// ---------------------------------------------------------------- campaign missions
// the rune forge tower only shows up in the build menu once there is one to build or upgrade
pub fn tool_visible(&self, tool: usize) -> bool {
tool != RUNE_TOOL || self.rune_available() > 0 || self.towers.iter().any(|t| !t.hero && t.kind == TowerKind::Rune)
}
pub fn tool_allowed(&self, tool: usize) -> bool {
self.mission.as_ref().map_or(true, |m| m.tools.get(tool).copied().unwrap_or(true))
}
@@ -989,6 +1016,7 @@ impl Game {
if !self.rivals.is_empty() {
self.update_rivals(dt);
}
self.update_dwarves(dt);
self.check_end();
}
@@ -1357,7 +1385,7 @@ impl Game {
}
// strength of a squad (same formula as the army value)
fn squad_value(list: &[(EK, u32)]) -> f32 {
pub(crate) fn squad_value(list: &[(EK, u32)]) -> f32 {
let (mut h, mut d) = (0.0, 0.0);
for &(k, l) in list {
let s = st(k);
@@ -1574,6 +1602,8 @@ impl Game {
self.captive_update(e, dt);
} else if e.rival() {
self.rival_ai(e, dt);
} else if e.dwarf() {
self.dwarf_ai(e, dt);
} else if e.hero {
self.hero_ai(e, dt);
} else if e.kind == EK::Worker {
@@ -1900,6 +1930,10 @@ impl Game {
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
self.sfx(Sfx::HeroDeath, x, y);
self.msg("msg.hero_slain", vec![format!("@{}", s.name), (got as i32).to_string()]);
} else if side == crate::dwarves::DWARF_SIDE {
let got = self.add_gold(s.bounty as f32 * 0.5);
self.sfx(Sfx::HeroDeath, x, y);
self.msg("msg.dwarf_slain", vec![(got as i32).to_string()]);
} else if side >= 2 {
// a creature of a rival master: half its wage as booty
let got = self.add_gold(s.wage as f32 * 0.5);
@@ -2161,6 +2195,9 @@ impl Game {
self.sfx(Sfx::Magic, x, y);
self.msg("msg.spring_taken", vec![]);
}
if t.kind == Kind::RuneChest {
self.open_rune_chest(i);
}
if t.kind == Kind::Shard {
self.shards_taken += 1;
self.fx(7, x, y, 0.0, 0.0, 1.2, 0xff6060ff);
@@ -2169,6 +2206,16 @@ impl Game {
}
}
// a dwarven chest: the plans of a rune forge tower (one free build)
pub fn open_rune_chest(&mut self, i: usize) {
let (x, y) = (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5);
self.map.t[i].kind = Kind::Floor;
self.rune_free += 1;
self.fx(7, x, y, 0.0, 0.0, 1.4, 0xff40c0ff);
self.sfx(Sfx::LevelUp, x, y);
self.msg("msg.rune_chest", vec![]);
}
// a fortress unit spotted an enemy: guards and patrols nearby join the fight
fn alarm(&mut self, x: f32, y: f32, target: u32) {
for o in self.ents.iter_mut() {
@@ -2294,7 +2341,7 @@ impl Game {
fn claimable(&self, i: usize, me: u32) -> bool {
let t = &self.map.t[i];
let ok = match t.owner {
Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Treasury | Kind::ManaSpring),
Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest),
// knight ground (also vaults and shard chambers), but not while heroes fight there
// knight and rival ground (also vaults and shard chambers), but not while enemies fight there
Owner::Hero | Owner::Rival1 | Owner::Rival2 => {
@@ -2604,13 +2651,7 @@ impl Game {
if e.carry > 0 && self.start_deliver(e) {
return;
}
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.claimable(i, me), true) {
let end = p.last().copied().unwrap_or(cur);
self.map.t[end].reserved = me;
e.path = p;
e.job = Job::Claim(end);
return;
}
// damaged towers are repaired (free of charge) before more ground is claimed
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.damaged_tower_near(i).is_some(), true) {
let end = p.last().copied().unwrap_or(cur);
if let Some(id) = self.damaged_tower_near(end) {
@@ -2619,6 +2660,13 @@ impl Game {
return;
}
}
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.claimable(i, me), true) {
let end = p.last().copied().unwrap_or(cur);
self.map.t[end].reserved = me;
e.path = p;
e.job = Job::Claim(end);
return;
}
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.reinforce_neighbor(i, me).is_some(), true) {
let end = p.last().copied().unwrap_or(cur);
if let Some(w) = self.reinforce_neighbor(end, me) {
@@ -3662,6 +3710,16 @@ impl Game {
let i = idx(x, y);
if let Some(k) = self.towers.iter().position(|t| t.tile == i && !t.hero) {
refund += self.towers[k].invested() as f32 * 0.5;
if self.towers[k].kind == TowerKind::Rune && !self.towers[k].rune_final {
// the base tower comes back once: in the next mission (free play: at once)
if self.mission.is_some() {
self.rune_next += 1;
self.msg("msg.rune_next", vec![]);
} else {
self.rune_final += 1;
self.msg("msg.rune_back", vec![]);
}
}
self.towers.remove(k);
self.map.t[i].kind = Kind::Floor;
continue;
@@ -3758,11 +3816,25 @@ impl Game {
self.sfx_ui(Sfx::Error);
return;
}
// rune forge towers: one build per opened chest (fresh plans first)
let mut rune_final = false;
if kind == TowerKind::Rune {
if self.rune_free > 0 {
self.rune_free -= 1;
} else if self.rune_final > 0 {
self.rune_final -= 1;
rune_final = true;
} else {
self.hint(24, "hint.rune_none");
self.sfx_ui(Sfx::Error);
return;
}
}
self.gold -= cost;
self.map.t[i].kind = Kind::Tower;
let id = self.next_id;
self.next_id += 1;
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false };
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false, rune_final };
let (px, py) = tower.pos();
self.towers.push(tower);
// units standing on the tile step aside
@@ -3826,8 +3898,7 @@ impl Game {
for (j, e) in self.ents.iter().enumerate() {
// knight towers shoot the player's units, the player's towers shoot heroes
// knight towers shoot everybody but the humans, the player's towers everybody else
let enemy = e.captive == 0 && !e.chained && if t.hero { e.side() != 1 } else { e.side() != 0 };
if !e.alive || e.held || !enemy {
if !e.alive || e.held || !self.tower_enemy(t, e) {
continue;
}
let d = (e.x - px).powi(2) + (e.y - py).powi(2);
@@ -3910,18 +3981,20 @@ impl Game {
TowerKind::Arrow => (px, py, t.top() + 0.25),
TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2),
TowerKind::Magic => (px, py, t.top() + 0.45),
TowerKind::Rune => (px, py, t.top() + 0.55),
};
let col = match t.kind {
TowerKind::Arrow => 0xffd0e0f0,
TowerKind::Cannon => 0xff303030,
TowerKind::Magic => 0xffff60d0,
TowerKind::Rune => 0xff40c0ff,
};
self.shot(mx, my, mh, ex, ey, eh, 0.3, col);
self.fx_at(2, ex, ey, eh, 0.3, 0xffffffff);
self.sfx(match t.kind {
TowerKind::Arrow => Sfx::Shoot,
TowerKind::Cannon => Sfx::Cannon,
TowerKind::Magic => Sfx::Magic,
TowerKind::Magic | TowerKind::Rune => Sfx::Magic,
}, px, py);
let dmg = t.dmg();
match target {
@@ -3983,6 +4056,7 @@ impl Game {
TowerKind::Arrow => (px, py, t.top() + 0.25),
TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2),
TowerKind::Magic => (px, py, t.top() + 0.45),
TowerKind::Rune => (px, py, t.top() + 0.55),
};
let dmg = t.dmg();
match t.kind {
@@ -4065,10 +4139,46 @@ impl Game {
}
}
}
TowerKind::Rune => {
// a rune bolt that jumps on to up to three more enemies nearby
self.shot(mx, my, mh, ex, ey, 0.45, 0.25, 0xff40c0ff);
self.fx(2, ex, ey, 0.0, 0.0, 0.35, 0xffffc040);
self.sfx(Sfx::Magic, px, py);
let mut hit = vec![j];
let (mut lx, mut ly) = (ex, ey);
self.tower_damage(j, dmg);
for n in 0..3 {
let next = self
.ents
.iter()
.enumerate()
.filter(|(o, e)| !hit.contains(o) && e.alive && !e.held && self.tower_enemy(&t, e) && (e.x - lx).powi(2) + (e.y - ly).powi(2) < 3.5 * 3.5)
.min_by(|a, b| {
let da = (a.1.x - lx).powi(2) + (a.1.y - ly).powi(2);
let db = (b.1.x - lx).powi(2) + (b.1.y - ly).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(o, _)| o);
let Some(o) = next else { break };
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
self.fx(0, lx, ly, ox, oy, 0.25, 0xff40c0ff);
hit.push(o);
if self.ents[o].alive {
self.tower_damage(o, dmg * (0.75 - 0.1 * n as f32));
}
lx = ox;
ly = oy;
}
}
}
}
}
// a unit the tower shoots at
fn tower_enemy(&self, t: &Tower, e: &Ent) -> bool {
e.captive == 0 && !e.chained && if t.hero { e.side() != 1 } else { e.side() != 0 }
}
// a hero attacks a tower; false = tower gone or unreachable
fn attack_tower(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
let Some(k) = self.towers.iter().position(|t| t.id == id) else { return false };
@@ -4127,8 +4237,8 @@ impl Game {
}
let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held) else { return false };
let e = &self.ents[j];
if e.rival() {
return false; // the rivals' creatures cannot be picked up
if e.rival() || e.dwarf() {
return false; // the rivals' creatures and the dwarves cannot be picked up
}
if e.hero {
if e.captive != 1 {
@@ -4209,6 +4319,9 @@ impl Game {
pub fn slap(&mut self, target: Option<usize>) -> u8 {
let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held) else { return 0 };
let (x, y) = (self.ents[j].x, self.ents[j].y);
if self.ents[j].dwarf() {
return 0;
}
if !self.ents[j].hero && !self.ents[j].rival() {
let e = &mut self.ents[j];
e.boost = 15.0;
@@ -4323,6 +4436,7 @@ pub enum TowerKind {
Arrow,
Cannon,
Magic,
Rune, // dwarven rune forge tower: from a chest of a dwarf cave, built for free
}
pub struct TowerStats {
@@ -4340,6 +4454,7 @@ pub fn tower_stats(k: TowerKind) -> TowerStats {
TowerKind::Arrow => TowerStats { cost: [250, 200, 300], hp: 300.0, dmg: 10.0, range: 6.0, min_range: 0.0, interval: 0.8, mana: 0.0 },
TowerKind::Cannon => TowerStats { cost: [450, 350, 500], hp: 450.0, dmg: 32.0, range: 8.5, min_range: 2.0, interval: 2.6, mana: 0.0 },
TowerKind::Magic => TowerStats { cost: [350, 300, 450], hp: 300.0, dmg: 24.0, range: 7.0, min_range: 0.0, interval: 1.5, mana: 8.0 },
TowerKind::Rune => TowerStats { cost: [0, 800, 1200], hp: 1400.0, dmg: 55.0, range: 10.0, min_range: 0.0, interval: 1.3, mana: 0.0 },
}
}
@@ -4348,6 +4463,7 @@ pub fn tower_name(k: TowerKind) -> &'static str {
TowerKind::Arrow => "tower.arrow",
TowerKind::Cannon => "tower.cannon",
TowerKind::Magic => "tower.magic",
TowerKind::Rune => "tower.rune",
}
}
@@ -4363,6 +4479,8 @@ pub struct Tower {
pub flash: f32,
#[serde(default)]
pub hero: bool, // tower of a knight fortress: shoots the player's units
#[serde(default)]
pub rune_final: bool, // rune forge tower built from a refund: selling it gives nothing back
}
impl Tower {
@@ -4386,6 +4504,7 @@ impl Tower {
TowerKind::Arrow => 1.0 + 0.3 * (self.level - 1) as f32,
TowerKind::Cannon => 0.6 + 0.15 * (self.level - 1) as f32,
TowerKind::Magic => 1.2 + 0.3 * (self.level - 1) as f32,
TowerKind::Rune => 1.4 + 0.25 * (self.level - 1) as f32,
}
}
// gold spent on this tower so far
@@ -4615,7 +4734,9 @@ mod tests {
g.gold = g.gold_cap();
g.update(0.01);
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
assert!(g.reserve > 0.0 && g.reserve <= CORE_STORE + 1.0);
// (tiles partly filled with rubies take no gold, so the core may hold a little more)
let gem_room: f32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res != 0).map(|t| TREASURY_TILE - t.gold as f32).sum();
assert!(g.reserve > 0.0 && g.reserve <= CORE_STORE + gem_room + 1.0, "reserve {} gold {} cap {}", g.reserve, g.gold, g.gold_cap());
}
#[test]