Debug demo map: four allied masters with every room and creature against an endless hero gate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-07 23:04:34 +01:00
1 parent 5f70992cfb
commit 210f17773a
23 files changed
+536 -3

No files matched your search

+1 -1
View File
@@ -153,7 +153,7 @@ impl Tracker {
// called every frame; returns achievements unlocked just now
pub fn update(&mut self, g: &Game, ctx: &Context, dt: f32, now: u64) -> Vec<String> {
if g.demo.is_some() || ctx.editor_test {
if g.demo.is_some() || g.demo_map.is_some() || ctx.editor_test {
self.last_time = 0.0;
return Vec::new();
}
+471
View File
@@ -0,0 +1,471 @@
// The demo map of the debug menu: all four dungeon masters (the player and three AI partners, all
// allied) each have a walled dungeon with every room kind and every dungeon creature kind. Their
// dungeons lie in the four corners around the humans' gate in the middle; the attack flag goes up
// on the gate now and then and all four armies march on it together, in between the creatures do
// their work in the rooms. The gate never falls and keeps sending heroes of every kind, the soul
// cores never fall, the treasuries never run dry. Meant to watch every animation; nothing of it
// counts for achievements or overwrites the autosave (see main.rs, achievements.rs).
use crate::game::*;
use crate::map::*;
use crate::render3d::Cam;
use serde::{Deserialize, Serialize};
const W: usize = 74;
const H: usize = 74;
const PLAZA: (i32, i32) = (37, 37); // middle of the humans' plaza (the gate stands on it)
const PLAZA_R: f32 = 8.5;
const HALF: i32 = 10; // a dungeon's floor reaches this far from the middle of its soul core
// the masters and the middle of their dungeons: player north-west, then clockwise
pub const MASTERS: [(u8, (i32, i32)); 4] = [(0, (13, 13)), (2, (60, 13)), (3, (60, 60)), (4, (13, 60))];
// every creature kind a dungeon can have (imps come with every master)
pub const CREATURES: [EK; 17] = [
EK::Kobold,
EK::Spider,
EK::Mage,
EK::Ogre,
EK::Jailer,
EK::Priestess,
EK::Chainbreaker,
EK::Bat,
EK::Gargoyle,
EK::Owl,
EK::Wyrm,
EK::Cogling,
EK::Revenant,
EK::CaveBear,
EK::Beetle,
EK::Ram,
EK::Catapult,
];
// the gate sends them in this order, over and over
pub const HEROES: [EK; 14] = [
EK::Knight,
EK::Archer,
EK::Priest,
EK::Dwarf,
EK::Fairy,
EK::Thief,
EK::BattleMage,
EK::Monk,
EK::Paladin,
EK::Ranger,
EK::Giant,
EK::Inquisitor,
EK::Ram,
EK::Catapult,
];
const HERO_EVERY: f32 = 1.2; // seconds between two heroes at the gate
const MAX_HEROES: usize = 30; // fighting heroes on the map at most
const RESTOCK_EVERY: f32 = 15.0; // seconds between two looks for creature kinds that died out
const ATTACK_FOR: f32 = 75.0; // the flag stays on the gate this long ...
const HOME_FOR: f32 = 40.0; // ... then the creatures are free for their rooms this long
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DemoMap {
pub hero_t: f32,
pub next_hero: u32,
pub heroes_sent: u32,
pub restock_t: f32,
pub phase_t: f32, // seconds until the flag goes up (or down)
pub attacking: bool,
}
// the 3x3 room slots of a dungeon around its core, one tile of corridor between them (5 x 5
// slots, the middle one is the core): top-left corners
fn slots(c: (i32, i32)) -> Vec<(i32, i32)> {
let mut v = Vec::new();
for ky in -2..=2 {
for kx in -2..=2 {
if kx != 0 || ky != 0 {
v.push((c.0 - 1 + 4 * kx, c.1 - 1 + 4 * ky));
}
}
}
v
}
fn in_square(c: (i32, i32), x: i32, y: i32, r: i32) -> bool {
(x - c.0).abs() <= r && (y - c.1).abs() <= r
}
// the spot of the attack flag: on the plaza right before the gate
fn rally_tile() -> usize {
idx(PLAZA.0, PLAZA.1 + 3)
}
impl Game {
pub fn new_demo_map(seed: u32) -> (Game, Cam) {
let opts = GenOpts { portals: Some(0), treasures: Some(0), gate: false, core_y: None };
let mut map = Map::generate_with(seed, W, H, &opts);
let (wi, hi) = (W as i32, H as i32);
// solid earth everywhere, with a few gold veins between the dungeons
for y in 2..hi - 2 {
for x in 2..wi - 2 {
let t = &mut map.t[idx(x, y)];
let var = t.var;
*t = Tile::new(Kind::Earth);
t.var = var;
t.hp = 6.0;
let h = ((x * 73 + y * 151) as u32).wrapping_mul(2654435761) >> 26;
let between = MASTERS.iter().all(|&(_, c)| !in_square(c, x, y, HALF + 1)) && ((x - PLAZA.0).pow(2) + (y - PLAZA.1).pow(2)) as f32 > (PLAZA_R + 2.0).powi(2);
if between && h % 17 == 0 {
t.kind = Kind::Gold;
t.gold = 400;
}
}
}
// the humans' plaza with the gate in the middle
for y in 2..hi - 2 {
for x in 2..wi - 2 {
if (((x - PLAZA.0).pow(2) + (y - PLAZA.1).pow(2)) as f32) <= PLAZA_R * PLAZA_R {
let t = &mut map.t[idx(x, y)];
t.kind = Kind::Floor;
t.owner = Owner::Hero;
t.gold = 0;
}
}
}
let gate = (PLAZA.0 - 1, PLAZA.1 - 1);
for y in gate.1..gate.1 + 3 {
for x in gate.0..gate.0 + 3 {
let t = &mut map.t[idx(x, y)];
t.kind = Kind::Gate;
t.owner = Owner::Hero;
}
}
map.gate = gate;
// the dungeons: a square of own floor in a reinforced wall, the core in the middle, the
// rooms around it
map.portals.clear();
map.treasures.clear();
for &(side, c) in MASTERS.iter() {
let owner = Owner::of_side(side);
for y in c.1 - HALF - 1..=c.1 + HALF + 1 {
for x in c.0 - HALF - 1..=c.0 + HALF + 1 {
let t = &mut map.t[idx(x, y)];
t.owner = owner;
t.gold = 0;
if in_square(c, x, y, HALF) {
t.kind = Kind::Floor;
} else {
t.kind = Kind::Earth;
t.reinforced = true;
}
}
}
for y in c.1 - 1..=c.1 + 1 {
for x in c.0 - 1..=c.0 + 1 {
map.t[idx(x, y)].kind = Kind::Core;
}
}
// every room kind, more beds and food, the portal on the far side; slots left over
// stay plain floor for the master's own plans
let mut rooms: Vec<Kind> = ROOM_KINDS.to_vec();
rooms.extend([Kind::Lair, Kind::Lair, Kind::Farm, Kind::Aerie]);
let mut list = slots(c);
// the slots nearest the plaza first: the busy rooms are what the camera sees
list.sort_by_key(|&(x, y)| (x + 1 - PLAZA.0).pow(2) + (y + 1 - PLAZA.1).pow(2));
let portal = list.pop().unwrap();
for y in portal.1..portal.1 + 3 {
for x in portal.0..portal.0 + 3 {
map.t[idx(x, y)].kind = Kind::Portal;
}
}
map.portals.push(portal);
for (&(sx, sy), &kind) in list.iter().zip(rooms.iter()) {
for y in sy..sy + 3 {
for x in sx..sx + 3 {
map.t[idx(x, y)].kind = kind;
}
}
}
}
// corridors from every dungeon to the plaza (through the wall)
for &(side, c) in MASTERS.iter() {
let owner = Owner::of_side(side);
let (dx, dy) = ((PLAZA.0 - c.0) as f32, (PLAZA.1 - c.1) as f32);
let n = (dx.abs().max(dy.abs()) * 2.0) as i32;
for k in 0..=n {
let (px, py) = (c.0 as f32 + 0.5 + dx * k as f32 / n as f32, c.1 as f32 + 0.5 + dy * k as f32 / n as f32);
for y in (py - 1.5) as i32..=(py + 1.5) as i32 {
for x in (px - 1.5) as i32..=(px + 1.5) as i32 {
if (x as f32 + 0.5 - px).powi(2) + (y as f32 + 0.5 - py).powi(2) > 1.6 * 1.6 || x < 2 || y < 2 || x >= wi - 2 || y >= hi - 2 {
continue;
}
let t = &mut map.t[idx(x, y)];
if in_square(c, x, y, HALF) || t.owner == Owner::Hero {
continue; // the dungeon's rooms and the plaza stay as they are
}
let wall = in_square(c, x, y, HALF + 1);
t.kind = Kind::Floor;
t.reinforced = false;
t.gold = 0;
t.owner = if wall { owner } else { Owner::None };
}
}
}
}
let player = MASTERS[0].1;
map.core = (player.0 - 1, player.1 - 1);
let mut g = Game::from_map(map, seed);
// from_map put a treasury ring around the player's core, on the corridors between the
// rooms: back to floor (his treasury is one of the rooms, like everybody's)
for y in player.1 - 2..=player.1 + 2 {
for x in player.0 - 2..=player.0 + 2 {
let t = &mut g.map.t[idx(x, y)];
if t.kind == Kind::Treasury && ((x - player.0).abs() == 2 || (y - player.1).abs() == 2) {
t.kind = Kind::Floor;
}
}
}
g.demo_map = Some(DemoMap { hero_t: 3.0, next_hero: 0, heroes_sent: 0, restock_t: RESTOCK_EVERY, phase_t: 15.0, attacking: false });
g.gate_on = true;
g.gate_hp = GATE_HP;
g.wave_timer = f32::MAX;
g.coop = true;
g.ai_sides = vec![2, 3, 4];
// the three AI partners: real masters with the brain of ai.rs (hard: they think fast)
for &(side, c) in MASTERS.iter().skip(1) {
let core = (c.0 - 1, c.1 - 1);
g.rivals.push(crate::rival::Rival {
side,
core,
hp: crate::rival::RIVAL_CORE_HP,
alive: true,
radius: HALF + 1,
grow_t: 20.0,
spawn_t: 5.0,
attack_t: 0.0,
difficulty: 2,
power: 0.0,
gift_t: crate::rival::PARTNER_GIFT_EVERY,
think_t: 2.0,
attacking: false,
army0: 0.0,
focus: None,
defending: false,
});
g.adopt_master(side, core, 2, true);
}
// every creature kind for every master, gold in the treasuries
for &(side, _) in MASTERS.iter() {
g.switch_to(side);
g.recount();
for &kind in CREATURES.iter() {
g.demo_map_creature(side, kind);
}
let fill = g.gold_cap() * 0.7 - g.gold;
g.add_gold(fill.max(0.0));
g.mana = g.mana_max();
g.wood = g.wood.max(200.0);
}
g.switch_to(0);
g.place_all_torches();
g.msgs.clear();
// the camera on the plaza, where the four armies meet
let mut cam = Cam::new(PLAZA.0 as f32 + 0.5, PLAZA.1 as f32 + 0.5);
cam.zoom = 30.0;
(g, cam)
}
// a creature of `kind` for the master on `side` (the master being processed), somewhere on
// the floor of his dungeon
fn demo_map_creature(&mut self, side: u8, kind: EK) {
let Some(&(_, c)) = MASTERS.iter().find(|m| m.0 == side) else { return };
let owner = Owner::of_side(side);
let spots: Vec<usize> = (0..self.map.t.len())
.filter(|&i| {
let (x, y) = xy(i);
let t = &self.map.t[i];
in_square(c, x, y, HALF) && t.owner == owner && t.walkable() && t.kind != Kind::Portal
})
.collect();
if spots.is_empty() {
return;
}
let i = spots[self.rng.next() as usize % spots.len()];
let (x, y) = xy(i);
let level = 2 + self.rng.next() % 5;
let id = self.spawn(kind, false, x as f32 + 0.5, y as f32 + 0.5, level);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.side = side;
e.settled = true;
e.happy = 1.0;
if crate::siege::siege(kind) {
e.home = Some((x as f32 + 0.5, y as f32 + 0.5));
}
}
}
// the demo map's rules, once per step from the first master's side: the gate and the cores
// never fall, gold never runs out, heroes keep coming, the flag goes up and down
pub(crate) fn demo_map_tick(&mut self, dt: f32) {
let Some(mut d) = self.demo_map.take() else { return };
let back = self.me_side;
self.gate_hp = GATE_HP;
self.gate_stage = 0;
self.wave_timer = f32::MAX;
self.warned = true;
self.out_sides.clear();
// the attack flag of the player (the partners follow it, see ai.rs)
d.phase_t -= dt;
if d.phase_t <= 0.0 {
d.attacking = !d.attacking;
d.phase_t = if d.attacking { ATTACK_FOR } else { HOME_FOR };
}
d.restock_t -= dt;
let restock = d.restock_t <= 0.0;
if restock {
d.restock_t = RESTOCK_EVERY;
}
for &(side, _) in MASTERS.iter() {
self.switch_to(side);
self.core_hp = CORE_HP;
if self.gold < self.gold_cap() * 0.4 {
let fill = self.gold_cap() * 0.7 - self.gold;
self.add_gold(fill.max(0.0));
}
if side == 0 {
self.flag = if d.attacking { Some(rally_tile()) } else { None };
}
// kinds that died out (or left) come back
if restock {
for &kind in CREATURES.iter() {
if !self.ents.iter().any(|e| e.alive && !e.hero && e.side == side && e.kind == kind) {
self.demo_map_creature(side, kind);
}
}
}
}
for r in self.rivals.iter_mut() {
r.alive = true;
r.hp = crate::rival::RIVAL_CORE_HP;
}
self.switch_to(back);
// heroes of every kind from the gate: half of them guard the plaza, the others march on
// the dungeons
d.hero_t -= dt;
let fighting = self.ents.iter().filter(|e| e.alive && e.active_hero()).count();
if d.hero_t <= 0.0 && fighting < MAX_HEROES {
d.hero_t = HERO_EVERY;
let n = d.next_hero;
d.next_hero += 1;
d.heroes_sent += 1;
let kind = HEROES[n as usize % HEROES.len()];
let level = 3 + (n / HEROES.len() as u32) % 6;
let (gx, gy) = self.map.gate;
let x = gx as f32 + 0.5 + self.rng.f() * 2.0;
let y = gy as f32 + 3.2 + self.rng.f() * 0.6;
let id = self.spawn(kind, true, x, y, level);
let guard = n % 2 == 0 && !crate::siege::siege(kind);
let a = self.rng.f() * std::f32::consts::TAU;
let r = 2.5 + self.rng.f() * 3.0;
let home = (PLAZA.0 as f32 + 0.5 + a.cos() * r, PLAZA.1 as f32 + 0.5 + a.sin() * r);
let target = [0u8, 2, 3, 4][(n / 2) as usize % 4];
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
if guard {
e.guard = true;
e.home = Some(home);
} else if target != 0 {
e.target = Some(target);
}
}
self.fx(3, gx as f32 + 1.5, gy as f32 + 1.5, 0.0, 0.0, 0.6, 0xffffeecc);
}
self.demo_map = Some(d);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds_of(g: &Game, side: u8) -> Vec<EK> {
g.ents.iter().filter(|e| e.alive && !e.hero && e.side == side).map(|e| e.kind).collect()
}
#[test]
fn four_allied_masters_with_every_room_and_creature() {
let (mut g, _) = Game::new_demo_map(5);
for y in 0..H as i32 {
let row: String = (0..W as i32)
.map(|x| {
let t = g.map.t[idx(x, y)];
match t.kind {
Kind::Gate => 'G',
Kind::Core => 'C',
Kind::Portal => 'P',
Kind::Gold => '$',
Kind::Floor => if t.owner == Owner::None { '.' } else { ',' },
k if room_index(k).is_some() => (b'a' + room_index(k).unwrap() as u8) as char,
_ if t.solid() => if t.reinforced { '#' } else { ' ' },
_ => '?',
}
})
.collect();
println!("{}", row);
}
assert_eq!(g.master_sides(), vec![0, 2, 3, 4]);
for &(a, _) in MASTERS.iter() {
for &(b, _) in MASTERS.iter() {
assert!(g.allied(a, b), "{a} and {b} are allies");
}
assert!(!g.allied(a, 1), "the heroes are the enemy");
}
for &(side, _) in MASTERS.iter() {
g.switch_to(side);
g.recount();
for (r, &k) in ROOM_KINDS.iter().enumerate() {
assert!(g.counts[r] >= 9, "side {side} has a {:?}", k);
}
assert!(g.portals_owned() > 0, "side {side} has a portal");
assert!(g.gold > 1000.0, "side {side} has gold");
let have = kinds_of(&g, side);
for k in CREATURES.iter().chain([EK::Worker].iter()) {
assert!(have.contains(k), "side {side} has a {:?}", k);
}
}
g.switch_to(0);
assert!(g.has_gate() && g.gate_hp > 0.0);
assert!(g.is_partner(2) && g.is_partner(3) && g.is_partner(4));
// every dungeon reaches the gate
let gate = rally_tile();
for &(_, c) in MASTERS.iter() {
assert!(g.map.path_to(idx(c.0 + 2, c.1 + 2), gate).is_some(), "the way from {:?} to the gate", c);
}
}
#[test]
fn the_gate_never_falls_and_keeps_sending_heroes() {
let (mut g, _) = Game::new_demo_map(5);
let mut kinds = Vec::new();
let mut rallied = false;
for k in 0..(20 * 120) {
g.update(0.05);
if k % (20 * 20) == 0 {
let c: Vec<usize> = MASTERS.iter().map(|m| g.ents.iter().filter(|e| e.alive && !e.hero && e.side == m.0).count()).collect();
let h = g.ents.iter().filter(|e| e.alive && e.active_hero()).count();
let rally = g.ents.iter().filter(|e| e.alive && e.job == Job::Rally).count();
println!("{:>3} s: creatures {:?}, heroes {}, rallying {}, gate {}", k / 20, c, h, rally, g.gate_hp);
}
for e in g.ents.iter().filter(|e| e.alive && e.hero) {
if !kinds.contains(&e.kind) {
kinds.push(e.kind);
}
}
rallied |= g.ents.iter().any(|e| e.alive && !e.hero && e.side != 0 && e.job == Job::Rally);
assert_eq!(g.state, 0, "the demo map never ends");
}
let d = g.demo_map.as_ref().unwrap();
assert!(d.heroes_sent >= 20, "heroes keep coming: {}", d.heroes_sent);
assert!(kinds.len() >= 12, "every kind of hero: {:?}", kinds);
assert!(g.gate_hp > 0.0 && g.has_gate());
assert_eq!(g.master_sides(), vec![0, 2, 3, 4]);
assert!(g.rivals.iter().all(|r| r.alive), "no master falls");
assert!(rallied, "the partners' creatures follow the flag to the gate");
for &(side, _) in MASTERS.iter() {
assert!(g.core_hp_of(side) > 0.0);
}
}
}
+6
View File
@@ -652,6 +652,8 @@ pub struct Game {
#[serde(skip)]
pub demo: Option<crate::demo::Demo>, // the endless bridge battle behind the title menu
#[serde(default)]
pub demo_map: Option<crate::demo_map::DemoMap>, // the debug demo map: four allied masters against an endless gate
#[serde(default)]
pub dwarf_caves: Vec<crate::dwarves::DwarfCave>,
#[serde(default)]
pub bridgework: crate::bridges::BridgeWork, // burning and worn bridges
@@ -1001,6 +1003,7 @@ impl Game {
springs: 0,
freed: 0,
demo: None,
demo_map: None,
traps: Vec::new(),
rollers: Vec::new(),
clouds: Vec::new(),
@@ -1843,6 +1846,9 @@ impl Game {
self.update_wood(dt);
self.update_events(dt);
self.update_traders(dt);
if self.demo_map.is_some() {
self.demo_map_tick(dt);
}
// fallen hearts of a match are shared state: every client marks them in the same step
self.update_fallen_masters();
// the end of the game and the fog are about the master at this computer
+26 -2
View File
@@ -12,6 +12,7 @@ mod campaign_doc;
mod cloud;
mod commands;
mod demo;
mod demo_map;
mod editor;
mod events;
mod dwarves;
@@ -1660,6 +1661,28 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
ui.show_help = false;
autosave_t = 0.0;
}
MenuAction::DemoMap => {
// debug: four allied masters with every room and creature against an
// endless gate (never autosaved, counts for no achievement)
if app.match_mode || app.game.in_match() {
app.leave_match();
}
app.seed = next_seed(app.seed);
(app.game, app.cam) = Game::new_demo_map(app.seed);
apply_game_settings(&mut app.game, &app.settings);
app.coop = false;
app.recorded = false;
app.tutorial = false;
app.custom_campaign = None;
app.custom_progress = None;
app.custom_mission_index = None;
app.custom_next_index = None;
menu.page = None;
r3d.invalidate();
ui.log.clear();
ui.show_help = false;
autosave_t = 0.0;
}
MenuAction::OpenRepo => platform::open_url(menu::REPO_URL),
MenuAction::OpenClaudeCode => platform::open_url(menu::CLAUDE_CODE_URL),
MenuAction::OpenOpenCode => platform::open_url(menu::OPENCODE_URL),
@@ -1700,7 +1723,7 @@ app.pending_briefing = briefing_of(&menu.campaign_doc, i);
app.tutorial = false;
if app.match_mode || app.game.in_match() {
app.leave_match();
} else if app.game.state == 0 {
} else if app.game.state == 0 && app.game.demo_map.is_none() {
platform::storage_set(save::AUTOSAVE_KEY, &app.autosave_json());
menu.has_autosave = true;
}
@@ -1980,7 +2003,8 @@ app.pending_briefing = briefing_of(&campaign, i);
// the world runs in fixed steps: the speed setting only feeds more time into them
app.game.update(dt * app.settings.game_speed.max(1.0));
// autosave: snapshot for leaving the tab + regular autosave
if app.game.state == 0 && !app.match_mode {
// (the debug demo map never replaces the autosave)
if app.game.state == 0 && !app.match_mode && app.game.demo_map.is_none() {
cache_t += dt;
autosave_t += dt;
if cache_t >= AUTOSAVE_CACHE_EVERY {
+14
View File
@@ -252,6 +252,7 @@ enum Btn {
Start,
Key(usize),
ResetKeys,
DemoMap, // debug tab: start the demo map
SaveNew,
Slot(usize), // load / overwrite
Delete(usize),
@@ -512,6 +513,7 @@ pub enum MenuAction {
OpenRepo,
OpenClaudeCode,
Tutorial, // start the tutorial mission
DemoMap, // debug: the demo map with all four masters against an endless gate
BackToEditor,
OpenOpenCode,
Login(String, String),
@@ -854,6 +856,10 @@ impl Menu {
self.page = Some(Page::KeyCapture { action: a, from_title });
MenuAction::Consumed
}
Some(Btn::DemoMap) => {
self.page = None;
MenuAction::DemoMap
}
Some(Btn::ResetKeys) => {
settings.reset_keys();
MenuAction::SettingsChanged
@@ -1523,6 +1529,14 @@ impl Menu {
self.buttons.push((right, Btn::Change(i, 1)));
self.buttons.push((mid, Btn::Change(i, 1)));
}
if tab == 4 {
// the demo map: all four masters with every room and creature against an endless gate
let label = tr("set.debug_demo_map");
let rw = gfx.measure(&label, S()) + CW() * 4.0;
let rr = Rect::new(x + cols as f32 * CW() - rw - CW() * 2.0, y + (rows - 4) as f32 * CH(), rw, CH() * 2.0);
button(gfx, rr, &label, rr.contains(vec2(mx, my)), FRAME);
self.buttons.push((rr, Btn::DemoMap));
}
}
let bw = gfx.measure(&tr("menu.back"), S()) + CW() * 6.0;
let br = Rect::new(x + CW() * 2.0, y + (rows - 4) as f32 * CH(), bw, CH() * 2.0);