Silver, ruby and diamond veins; treasury shows its treasures; announced payday

- New veins (appended kinds): silver (common), ruby (rare), diamond (very rare, far from the core);
  treasure caves contain gems; each treasure is worth a different amount per strike and per vein
- Imps carry one kind of treasure at a time
- Treasury tiles hold up to 500 each and show gold coins, silver bars, rubies or diamonds;
  spending empties random treasury tiles, the soul core keeps what does not fit
- Training no longer drains gold continuously: it is billed on the next payday
- Payday is announced 20 s before (message with the cost); the HUD shows a payday countdown and
  the cost of the next payday in red next to the gold counter
- Tests for the treasury and the payday bill

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 10:43:33 +01:00
1 parent 75dc445d3b
commit 7e84cae910
7 files changed
+362 -31

No files matched your search

+178 -6
View File
@@ -6,6 +6,10 @@ use serde::{Deserialize, Serialize};
pub const CORE_HP: f32 = 2000.0;
pub const GATE_HP: f32 = 3000.0;
pub const PAYDAY: f32 = 150.0;
pub const PAYDAY_WARN: f32 = 20.0; // the payday is announced this many seconds before
pub const TREASURY_TILE: f32 = 500.0; // value one treasury tile holds
pub const CORE_STORE: f32 = 2000.0; // value the soul core holds without any treasury
pub const TRAIN_COST: f32 = 1.5; // gold per second of training (billed on payday)
pub const FIRST_WAVE: f32 = 300.0;
pub const WAVE_GAP: f32 = 180.0;
pub const MAX_SQUAD: usize = 18;
@@ -169,6 +173,8 @@ pub struct Ent {
pub think: f32,
pub repath: f32,
pub carry: i32,
#[serde(default)]
pub carry_res: u8, // treasure the imp carries (see map::RES_GOLD)
pub hunger: f32,
pub energy: f32,
pub happy: f32,
@@ -220,6 +226,7 @@ impl Ent {
think: 0.0,
repath: 0.0,
carry: 0,
carry_res: 0,
hunger: 0.0,
energy: 1.0,
happy: 1.0,
@@ -311,6 +318,14 @@ pub struct Game {
pub entry_history: Vec<(i32, i32)>, // entry points of recent waves
#[serde(default)]
pub planned_entries: Vec<(i32, i32)>, // entry points of the coming/current wave (minimap)
#[serde(default)]
pub reserve: f32, // value stored in the soul core (the rest lies in the treasury tiles)
#[serde(skip)]
pub incoming: Vec<(u8, f32)>, // treasures added since the last treasury update
#[serde(default)]
pub training_bill: f32, // training costs, paid on the next payday
#[serde(default)]
pub payday_warned: bool,
#[serde(skip)]
pub hints: bool, // show hint messages (setting)
#[serde(skip, default = "hint_times")]
@@ -407,6 +422,10 @@ impl Game {
planned_entries: Vec::new(),
hints: true,
hint_t: hint_times(),
reserve: 0.0,
incoming: Vec::new(),
training_bill: 0.0,
payday_warned: false,
};
let (cx, cy) = g.map.core;
// the soul core starts surrounded by a one tile wide ring of treasury
@@ -533,16 +552,97 @@ impl Game {
}
pub fn gold_cap(&self) -> f32 {
2000.0 + 500.0 * self.counts[0] as f32
CORE_STORE + TREASURY_TILE * self.counts[0] as f32
}
pub fn add_gold(&mut self, amount: f32) -> f32 {
self.add_res(amount, RES_GOLD)
}
// adds treasure of one kind (as far as there is room); returns the value accepted
pub fn add_res(&mut self, amount: f32, res: u8) -> f32 {
let cap = self.gold_cap();
let acc = amount.min((cap - self.gold).max(0.0));
self.gold += acc;
if acc > 0.0 {
self.incoming.push((res, acc));
}
acc
}
// what the next payday costs: wages of all creatures plus the training bill
pub fn next_payday_cost(&self) -> f32 {
let wages: f32 = self.ents.iter().filter(|e| e.alive && e.creature()).map(|e| st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)).sum();
wages + self.training_bill
}
// The gold counter is the truth; the treasury tiles show where it lies. Added treasure fills
// tiles of the same kind first, then empty ones, the rest goes into the core. Spending takes
// from random treasury tiles first, then from the core.
pub fn sync_treasury(&mut self) {
let mut tiles = Vec::new();
let mut stored = 0.0;
for i in 0..self.map.t.len() {
let t = &mut self.map.t[i];
if t.kind == Kind::Treasury && t.owner == Owner::Player {
tiles.push(i);
stored += t.gold as f32;
} else if t.gold != 0 && t.vein().is_none() && t.kind != Kind::Treasury {
t.gold = 0; // a sold treasury tile loses its content
t.res = 0;
}
}
let diff = self.gold - (self.reserve + stored);
let incoming = std::mem::take(&mut self.incoming);
if diff < -0.5 {
let mut need = -diff;
while need > 0.5 {
let full: Vec<usize> = tiles.iter().copied().filter(|&i| self.map.t[i].gold > 0).collect();
if full.is_empty() {
break;
}
let i = full[(self.rng.next() as usize) % full.len()];
let t = &mut self.map.t[i];
let take = (t.gold as f32).min(need.ceil());
t.gold -= take as i32;
need -= take;
}
self.reserve = (self.reserve - need.max(0.0)).max(0.0);
} else if diff > 0.5 {
let mut left = diff;
let mut parts: Vec<(u8, f32)> = incoming;
let typed: f32 = parts.iter().map(|p| p.1).sum();
if typed < left {
parts.push((RES_GOLD, left - typed));
}
for (res, amount) in parts {
let mut a = amount.min(left);
left -= a;
// tiles holding this treasure first, then empty tiles
for pass in 0..2 {
for &i in &tiles {
if a <= 0.5 {
break;
}
let t = &mut self.map.t[i];
let fits = if pass == 0 { t.gold > 0 && t.res == res } else { t.gold <= 0 };
if !fits {
continue;
}
let room = TREASURY_TILE - t.gold as f32;
let put = a.min(room).floor();
if put >= 1.0 {
t.gold += put as i32;
t.res = res;
a -= put;
}
}
}
self.reserve += a.max(0.0);
}
}
}
pub fn portals_owned(&self) -> usize {
(0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).count()
}
@@ -624,6 +724,7 @@ impl Game {
if self.state == 0 && !self.paused {
self.step(dt);
}
self.sync_treasury();
for f in self.fx.iter_mut() {
f.t += dt;
}
@@ -651,8 +752,20 @@ impl Game {
}
self.payday -= dt;
if !self.payday_warned && self.payday <= PAYDAY_WARN {
self.payday_warned = true;
let cost = self.next_payday_cost();
if cost >= 1.0 {
self.msg("msg.payday_soon", vec![(self.payday.ceil() as i32).to_string(), (cost.ceil() as i32).to_string()]);
self.sfx_ui(Sfx::FlagSet);
if cost > self.gold {
self.hint(22, "hint.payday_short");
}
}
}
if self.payday <= 0.0 {
self.payday = PAYDAY;
self.payday_warned = false;
self.do_payday();
}
@@ -790,6 +903,11 @@ impl Game {
self.ents[i].happy -= 0.45;
}
}
// training costs collected since the last payday
let bill = self.training_bill.min(self.gold).max(0.0);
self.gold -= bill;
total += bill;
self.training_bill = 0.0;
if paid + unpaid > 0 {
self.sfx_ui(Sfx::Payday);
if unpaid > 0 {
@@ -1532,6 +1650,13 @@ impl Game {
e.job = Job::None;
return;
}
// an imp carries one kind of treasure: other loads are delivered first
if tile.vein().map_or(false, |r| e.carry > 0 && r != e.carry_res) {
self.unreserve(me);
e.job = Job::None;
self.start_deliver(e);
return;
}
let (tx, ty) = xy(t);
e.face = sgn(tx as f32 + 0.5 - e.x);
e.working = true;
@@ -1542,10 +1667,11 @@ impl Game {
let fxy = (e.y + ty as f32 + 0.5) * 0.5;
let tl = &mut self.map.t[t];
let done;
if tl.kind == Kind::Gold {
let take = tl.gold.min(25);
if let Some(res) = tl.vein() {
let take = tl.gold.min(RES_STRIKE[res as usize]);
tl.gold -= take;
e.carry += take;
e.carry_res = res;
done = tl.gold <= 0;
self.fx(5, fxx, fxy, 0.0, 0.0, 0.5, 0xff30d0ff);
self.sfx(Sfx::GoldDig, fxx, fxy);
@@ -1644,7 +1770,7 @@ impl Game {
Job::Deliver => {
if self.is_deposit(cur) {
let amount = e.carry as f32;
let got = self.add_gold(amount);
let got = self.add_res(amount, e.carry_res);
if got < amount {
self.hint(3, "hint.treasury_full");
}
@@ -1859,13 +1985,14 @@ impl Game {
}
}
Job::Train(t) => {
if cur != t || self.map.t[t].kind != Kind::Training || self.gold < 1.0 {
// training is billed on payday; it stops when the bill would exceed the gold
if cur != t || self.map.t[t].kind != Kind::Training || self.training_bill + 1.0 > self.gold {
self.unreserve(me);
e.job = Job::None;
return;
}
e.working = true;
self.gold -= 1.5 * dt;
self.training_bill += TRAIN_COST * dt;
e.xp += 7.0 * dt;
e.timer -= dt;
if e.timer <= 0.0 {
@@ -3359,6 +3486,51 @@ mod tests {
assert_eq!(g.map.hero_cost()(idx(rx + 1, cy)), None);
}
fn treasury_total(g: &Game) -> f32 {
g.reserve + g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player).map(|t| t.gold as f32).sum::<f32>()
}
#[test]
fn treasury_shows_its_treasures() {
let mut g = Game::new(3, 64, 64);
g.update(0.01);
// the starting gold lies in the treasury ring
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
assert_eq!(g.reserve, 0.0);
// rubies fill their own tiles
g.add_res(1200.0, 2);
g.update(0.01);
let rubies: i32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.res == 2).map(|t| t.gold).sum();
assert_eq!(rubies, 1200);
assert!(g.map.t.iter().all(|t| t.gold <= TREASURY_TILE as i32 || t.vein().is_some()));
// spending empties tiles (randomly), the total always matches the gold counter
g.gold -= 1700.0;
g.update(0.01);
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
// more than the treasury holds: the core keeps the rest
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);
}
#[test]
fn training_is_billed_on_payday() {
let mut g = Game::new(3, 64, 64);
g.spawn(EK::Kobold, false, 10.0, 10.0, 1);
g.training_bill = 50.0;
let cost = g.next_payday_cost();
assert_eq!(cost, 50.0 + st(EK::Kobold).wage as f32);
let gold = g.gold;
g.payday = PAYDAY_WARN + 0.05;
g.update(0.1);
assert!(g.payday_warned);
g.payday = 0.01;
g.update(0.1);
assert_eq!(g.training_bill, 0.0);
assert!((gold - g.gold - cost).abs() < 1.0);
}
#[test]
fn some_imps_always_claim() {
// lots of digging work: still at least 10% (min. one) of the imps claim the dug-out floor