Five new rooms: prison, graveyard, guard post, arena, sacrifice altar; new creature: the revenant

- prison: captured heroes sit in cells apart from the torture chamber, do not defect and starve in time; the hand can move prisoners between cells and torture
- graveyard: the fallen are buried there; after a while some rise as revenants (undead: no food, no sleep, life drain from level 5)
- guard post: creatures hold the position and attack whatever comes into view
- arena: creatures spar in pairs for fast, free experience, at the cost of bruises
- sacrifice altar: creatures dropped on it are sacrificed; certain pairs bring a rare creature, otherwise a blessing (mood and mana)
- researched in the library, campaign unlocks, textures, furniture, texts EN/DE, bestiary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-04 07:35:12 +01:00
1 parent 2c8db33a69
commit a58497e3a9
14 files changed
+606 -28

No files matched your search

+132 -20
View File
@@ -108,6 +108,7 @@ pub enum EK {
Owl, // aerie + library: arcane bolts from afar
Wyrm, // big aerie + treasury: rare, very strong, fire breath
Cogling, // forge: a sooty little tinkerer, builds traps and doors fastest
Revenant, // rises from the graveyard: undead, needs no food and no bed
}
pub struct St {
@@ -145,6 +146,7 @@ pub fn st(k: EK) -> St {
EK::Owl => s("unit.owl", 70.0, 12.0, 4.5, 2.8, 1.3, 100, 0),
EK::Wyrm => s("unit.wyrm", 650.0, 30.0, 3.0, 2.5, 1.4, 380, 0),
EK::Cogling => s("unit.cogling", 90.0, 9.0, 1.0, 2.4, 0.9, 70, 0),
EK::Revenant => s("unit.revenant", 160.0, 14.0, 1.0, 1.9, 1.2, 30, 0),
}
}
@@ -184,6 +186,8 @@ pub enum Job {
Forge(usize), // creature works in the forge (traps and doors)
Install(u32), // imp carries a finished trap or door (id) to its spot and installs it
Brawl(u32), // two creatures that cannot stand each other scuffle (never to the death)
Guard(usize), // holds a guard post
Spar(usize), // sparring in the arena
}
#[derive(Clone, Serialize, Deserialize)]
@@ -489,6 +493,10 @@ pub struct Game {
pub research: f32, // research points towards the current item
#[serde(default)]
pub reveals: Vec<(f32, f32, f32)>, // seer's eye: x, y, seconds left
#[serde(default)]
pub graves: Vec<crate::rooms::Grave>,
#[serde(default)]
pub altar_last: Option<EK>, // the last sacrifice that waits for its pair
#[serde(skip)]
pub fog_t: f32, // fog of war: seconds until the next update
#[serde(skip)]
@@ -533,11 +541,41 @@ impl Torch {
}
}
pub const N_ROOMS: usize = 9;
pub const N_ROOMS: usize = 14;
pub const ROOM_FORGE: usize = 8;
pub const ROOM_KINDS: [Kind; N_ROOMS] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library, Kind::Torture, Kind::Temple, Kind::Aerie, Kind::Forge];
pub const ROOM_COST: [i32; N_ROOMS] = [60, 80, 100, 140, 160, 200, 180, 120, 150];
pub const ROOM_NAMES: [&str; N_ROOMS] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library", "room.torture", "room.temple", "room.aerie", "room.forge"];
pub const ROOM_KINDS: [Kind; N_ROOMS] = [
Kind::Treasury,
Kind::Lair,
Kind::Farm,
Kind::Training,
Kind::Library,
Kind::Torture,
Kind::Temple,
Kind::Aerie,
Kind::Forge,
Kind::Prison,
Kind::Graveyard,
Kind::GuardPost,
Kind::Arena,
Kind::Altar,
];
pub const ROOM_COST: [i32; N_ROOMS] = [60, 80, 100, 140, 160, 200, 180, 120, 150, 160, 140, 90, 180, 250];
pub const ROOM_NAMES: [&str; N_ROOMS] = [
"room.treasury",
"room.lair",
"room.farm",
"room.training",
"room.library",
"room.torture",
"room.temple",
"room.aerie",
"room.forge",
"room.prison",
"room.graveyard",
"room.guardpost",
"room.arena",
"room.altar",
];
// bridge cost per tile: over water, over lava
pub const BRIDGE_COST: [i32; 2] = [30, 75];
@@ -663,6 +701,8 @@ impl Game {
clouds: Vec::new(),
forge_work: 0.0,
fog_t: 0.0,
graves: Vec::new(),
altar_last: None,
reveals: Vec::new(),
known: Vec::new(),
researchable: Vec::new(),
@@ -1116,6 +1156,7 @@ impl Game {
self.update_forge(dt);
self.update_research();
self.update_reveals(dt);
self.update_graves(dt);
self.update_fog(dt);
if !self.rivals.is_empty() {
self.update_rivals(dt);
@@ -1850,7 +1891,7 @@ impl Game {
}
}
fn release_all(&mut self, id: u32) {
pub(crate) fn release_all(&mut self, id: u32) {
let me = id + 1;
for t in self.map.t.iter_mut() {
if t.reserved == me {
@@ -2034,6 +2075,9 @@ impl Game {
}
}
// learned abilities that come with a blow
if crate::personality::knows(e, "drain") {
e.hp = (e.hp + dmg * 0.3).min(e.max_hp);
}
if crate::personality::knows(e, "web") && self.rng.f() < 0.3 {
self.ents[j].slow = self.ents[j].slow.max(3.0);
}
@@ -2070,7 +2114,7 @@ impl Game {
// free places in the torture chamber (one prisoner per 2 tiles)
pub fn prison_space(&self) -> usize {
let cap = self.counts[5] / 2;
let cap = self.counts[5] / 2 + self.counts[crate::rooms::ROOM_PRISON];
let used = self.ents.iter().filter(|e| e.alive && e.hero && e.captive != 0).count();
cap.saturating_sub(used)
}
@@ -2111,6 +2155,9 @@ impl Game {
}
self.release_all(id);
self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070);
if kind != EK::Worker && kind != EK::Revenant {
self.bury(x, y);
}
let s = st(kind);
if hero {
let b = s.bounty as f32;
@@ -2906,9 +2953,15 @@ impl Game {
fn creature_ai(&mut self, e: &mut Ent, dt: f32) {
let me = e.id + 1;
e.hunger += dt / 75.0;
if !matches!(e.job, Job::Sleep(_)) {
e.energy -= dt / 110.0;
if e.kind == EK::Revenant {
// the undead neither eat nor sleep
e.hunger = 0.0;
e.energy = 1.0;
} else {
e.hunger += dt / 75.0;
if !matches!(e.job, Job::Sleep(_)) {
e.energy -= dt / 110.0;
}
}
if e.hunger > 1.8 {
e.happy -= dt * 0.004;
@@ -2962,8 +3015,8 @@ impl Game {
return;
}
}
if !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rest(_)) {
let radius = if e.job == Job::Rally { 8.5 } else if flies(e.kind) { 9.0 } else { 7.0 };
if !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rest(_) | Job::Brawl(_)) {
let radius = if e.job == Job::Rally { 8.5 } else if matches!(e.job, Job::Guard(_)) { 10.0 } else if flies(e.kind) { 9.0 } else { 7.0 };
if let Some(id) = self.nearest_enemy(e, radius) {
self.unreserve(me);
e.job = Job::Attack(id);
@@ -3120,6 +3173,19 @@ impl Game {
e.job = Job::None;
}
}
Job::Guard(t) => {
e.timer -= dt;
if cur != t || self.map.t[t].kind != Kind::GuardPost || e.timer <= 0.0 {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Spar(t) => {
if !self.spar(e, t, dt) {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Forge(t) => {
if cur != t || self.map.t[t].kind != Kind::Forge || !self.forge_orders() {
self.unreserve(me);
@@ -3432,6 +3498,27 @@ impl Game {
}
}
}
// guard posts and the arena
if self.counts[crate::rooms::ROOM_GUARD] > 0 && self.rng.f() < 0.2 {
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.free_post(i, me), true) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Guard(t);
e.timer = 35.0 + self.rng.f() * 20.0;
return;
}
}
if self.counts[crate::rooms::ROOM_ARENA] > 0 && e.hp > e.max_hp * 0.8 && self.rng.f() < 0.25 {
if let Some(p) = self.find_room_tile(cur, Kind::Arena, me, false) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Spar(t);
e.timer = 14.0 + self.rng.f() * 6.0;
return;
}
}
// the forge: orders waiting (coglings love it, others lend a hand now and then)
if self.forge_orders() && self.counts[ROOM_FORGE] > 0 {
let r = self.rng.f();
@@ -3501,6 +3588,17 @@ impl Game {
}
}
}
_ if self.map.t[e.tile()].kind == Kind::Prison => {
// in prison: no torture, but hunger wears the prisoner down
e.hp -= e.max_hp / 180.0 * dt;
if e.hp <= 0.0 {
e.alive = false;
self.release_all(e.id);
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
self.msg("msg.prisoner_starved", vec![format!("@{}", st(e.kind).name)]);
self.bury(e.x, e.y);
}
}
_ => {
let t = e.tile();
if self.map.t[t].kind != Kind::Torture {
@@ -3533,6 +3631,7 @@ impl Game {
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
self.sfx(Sfx::HeroDeath, e.x, e.y);
self.msg("msg.prisoner_died", vec![format!("@{}", st(e.kind).name)]);
self.bury(e.x, e.y);
}
}
}
@@ -3566,11 +3665,6 @@ impl Game {
}).map(|o| o.id)
}
fn free_torture_tile(&self, i: usize) -> bool {
let t = &self.map.t[i];
t.kind == Kind::Torture && t.owner == Owner::Player && t.bed == 0
}
fn carry_job(&mut self, e: &mut Ent, id: u32, dt: f32) {
let Some(k) = self.find(id) else {
e.job = Job::None;
@@ -3582,7 +3676,7 @@ impl Game {
let (cx, cy) = (self.ents[k].x, self.ents[k].y);
if (cx - e.x).abs() < 1.2 && (cy - e.y).abs() < 1.2 {
// pick up and look for a free place in the torture chamber
match self.map.search(cur, self.map.walk_cost(), |i| self.free_torture_tile(i), true) {
match self.map.search(cur, self.map.walk_cost(), |i| self.free_cell(i), true) {
Some(p) => {
let target = p.last().copied().unwrap_or(cur);
self.map.t[target].bed = id + 1;
@@ -4497,7 +4591,7 @@ impl Game {
return false; // the rivals' creatures and the dwarves cannot be picked up
}
if e.hero {
if e.captive != 1 {
if e.captive != 1 && e.captive != 3 {
return false;
}
if !self.on_own_ground(e.x, e.y) {
@@ -4509,6 +4603,12 @@ impl Game {
let id = self.ents[j].id;
let me = id + 1;
self.unreserve(me);
if self.ents[j].captive == 3 {
// taken out of its cell
for t in self.map.t.iter_mut().filter(|t| t.bed == me && matches!(t.kind, Kind::Torture | Kind::Prison)) {
t.bed = 0;
}
}
for o in self.ents.iter_mut() {
if o.carrier == id && o.captive != 0 {
o.captive = 1;
@@ -4541,7 +4641,17 @@ impl Game {
return false;
}
self.hand.pop();
let imprison = t.kind == Kind::Torture && t.bed == 0;
let imprison = matches!(t.kind, Kind::Torture | Kind::Prison) && t.bed == 0;
// the sacrifice altar takes creatures
if t.kind == Kind::Altar {
if let Some(k) = self.ents.iter().position(|e| e.id == id && !e.hero) {
self.ents[k].held = false;
self.ents[k].x = wx;
self.ents[k].y = wy;
self.sacrifice(k);
return true;
}
}
if let Some(k) = self.ents.iter().position(|e| e.id == id) {
let e = &mut self.ents[k];
e.held = false;
@@ -4790,6 +4900,8 @@ pub fn job_name(e: &Ent) -> &'static str {
Job::Train(_) => "job.train",
Job::Forge(_) => "job.forge",
Job::Brawl(_) => "job.brawl",
Job::Guard(_) => "job.guard_post",
Job::Spar(_) => "job.spar",
Job::Install(_) => "job.install",
Job::Study(_) => "job.study",
Job::Attack(_) => "job.fight",
@@ -5362,7 +5474,7 @@ mod tests {
#[test]
fn aerie_attracts_flyers() {
let mut g = Game::new(3, 64, 64);
g.counts = [16, 0, 9, 0, 6, 4, 0, 16, 0];
g.counts = [16, 0, 9, 0, 6, 4, 0, 16, 0, 0, 0, 0, 0, 0];
let mut seen = std::collections::HashSet::new();
for _ in 0..200 {
g.try_portal_for_test();