From 82cdc8cc29dc3f944fa940ba842422879a8afb81 Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Mon, 1 Jun 2026 10:29:49 +1000 Subject: [PATCH 01/20] feat(rooms): add Lateania, a multiplayer D&D MUD room game Lateania is a swords-and-sorcery MUD built on the room-game trait surface. Players SSH in, enter a shared persistent world, and meet, move, talk, and fight together in real time. Unlike the seated games, one GameRoom is a whole world: the room graph, mobs, and players live inside a single MudService, and movement happens within the service rather than the rooms directory. Implements the standard module shape (svc/state/input/ui/manager/create_modal) plus world.rs for the static world data, and wires GameKind::Mud through the enum, registry, filter, activity feed, and main.rs. Engine highlights: - 2-second world tick resolves round-based combat and mob/player respawns, reusing the loop shape proven by Tron. - Per-session snapshots are filtered to the player's own room and character sheet, following the poker public/private split. - Warrior class, xp/leveling, three mobs, and a safe-haven town for the slice. - world.rs seeds Embergate plus the King's Road; the design targets 200 rooms across nine zones, loaded as data. Input note: the slice uses wasd/arrows to move, space/x to attack, z to flee, o to look, avoiding the chat-reserved keys (i/j/k and selection actions). A full typed command prompt needs an input-capture mode and is deferred. Inline unit tests cover world-graph integrity (every exit resolves, all rooms reachable, start room safe), movement, safe-zone combat blocking, a full mob kill awarding xp, and room-scoped say. CONTEXT.md updated per the contributor guide. World and design by Tasmania (Tony Hosaroygard, hardlygospel.github.io), with thanks to the creator of late.sh and its contributors. Signed-off-by: Tony Hosaroygard --- late-core/src/models/game_room.rs | 6 +- late-ssh/src/app/activity/event.rs | 4 + late-ssh/src/app/rooms/filter.rs | 1 + late-ssh/src/app/rooms/mod.rs | 1 + late-ssh/src/app/rooms/mud/create_modal.rs | 236 ++++++ late-ssh/src/app/rooms/mud/input.rs | 106 +++ late-ssh/src/app/rooms/mud/manager.rs | 196 +++++ late-ssh/src/app/rooms/mud/mod.rs | 12 + late-ssh/src/app/rooms/mud/state.rs | 88 ++ late-ssh/src/app/rooms/mud/svc.rs | 931 +++++++++++++++++++++ late-ssh/src/app/rooms/mud/ui.rs | 245 ++++++ late-ssh/src/app/rooms/mud/world.rs | 300 +++++++ late-ssh/src/app/rooms/registry.rs | 5 + late-ssh/src/main.rs | 3 + late-ssh/src/metrics.rs | 1 + 15 files changed, 2134 insertions(+), 1 deletion(-) create mode 100644 late-ssh/src/app/rooms/mud/create_modal.rs create mode 100644 late-ssh/src/app/rooms/mud/input.rs create mode 100644 late-ssh/src/app/rooms/mud/manager.rs create mode 100644 late-ssh/src/app/rooms/mud/mod.rs create mode 100644 late-ssh/src/app/rooms/mud/state.rs create mode 100644 late-ssh/src/app/rooms/mud/svc.rs create mode 100644 late-ssh/src/app/rooms/mud/ui.rs create mode 100644 late-ssh/src/app/rooms/mud/world.rs diff --git a/late-core/src/models/game_room.rs b/late-core/src/models/game_room.rs index 60c5a8eb..22086161 100644 --- a/late-core/src/models/game_room.rs +++ b/late-core/src/models/game_room.rs @@ -10,16 +10,18 @@ pub enum GameKind { Asterion, Blackjack, Chess, + Mud, Poker, TicTacToe, Tron, } impl GameKind { - pub const ALL: [Self; 6] = [ + pub const ALL: [Self; 7] = [ Self::Asterion, Self::Blackjack, Self::Chess, + Self::Mud, Self::Poker, Self::TicTacToe, Self::Tron, @@ -30,6 +32,7 @@ impl GameKind { Self::Asterion => "asterion", Self::Blackjack => "blackjack", Self::Chess => "chess", + Self::Mud => "mud", Self::Poker => "poker", Self::TicTacToe => "tictactoe", Self::Tron => "tron", @@ -51,6 +54,7 @@ impl TryFrom<&str> for GameKind { "asterion" => Ok(Self::Asterion), "blackjack" => Ok(Self::Blackjack), "chess" => Ok(Self::Chess), + "mud" => Ok(Self::Mud), "poker" => Ok(Self::Poker), "tictactoe" => Ok(Self::TicTacToe), "tron" => Ok(Self::Tron), diff --git a/late-ssh/src/app/activity/event.rs b/late-ssh/src/app/activity/event.rs index 307cf26b..62a814a6 100644 --- a/late-ssh/src/app/activity/event.rs +++ b/late-ssh/src/app/activity/event.rs @@ -58,6 +58,7 @@ pub enum ActivityGame { Blackjack, Chess, Minesweeper, + Mud, Nonogram, Poker, Solitaire, @@ -76,6 +77,7 @@ impl ActivityGame { Self::Blackjack => "blackjack", Self::Chess => "chess", Self::Minesweeper => "minesweeper", + Self::Mud => "mud", Self::Nonogram => "nonogram", Self::Poker => "poker", Self::Solitaire => "solitaire", @@ -94,6 +96,7 @@ impl ActivityGame { Self::Blackjack => "Blackjack", Self::Chess => "Chess", Self::Minesweeper => "Minesweeper", + Self::Mud => "Lateania", Self::Nonogram => "Nonogram", Self::Poker => "Poker", Self::Solitaire => "Solitaire", @@ -170,6 +173,7 @@ impl ActivityEvent { ActivityGame::Blackjack => "won Blackjack hand", ActivityGame::Chess => "won Chess game", ActivityGame::Minesweeper => "cleared Minesweeper", + ActivityGame::Mud => "triumphed in Lateania", ActivityGame::Nonogram => "solved Nonogram", ActivityGame::Poker => "won Poker hand", ActivityGame::Solitaire => "won Solitaire", diff --git a/late-ssh/src/app/rooms/filter.rs b/late-ssh/src/app/rooms/filter.rs index 6b8bf83f..1cae6655 100644 --- a/late-ssh/src/app/rooms/filter.rs +++ b/late-ssh/src/app/rooms/filter.rs @@ -14,6 +14,7 @@ impl RoomsFilter { Self::Kind(GameKind::Asterion) => "Asterion", Self::Kind(GameKind::Blackjack) => "Blackjack", Self::Kind(GameKind::Chess) => "Chess", + Self::Kind(GameKind::Mud) => "Lateania", Self::Kind(GameKind::Poker) => "Poker", Self::Kind(GameKind::TicTacToe) => "Tic-Tac-Toe", Self::Kind(GameKind::Tron) => "Tron", diff --git a/late-ssh/src/app/rooms/mod.rs b/late-ssh/src/app/rooms/mod.rs index f0a87049..43c90760 100644 --- a/late-ssh/src/app/rooms/mod.rs +++ b/late-ssh/src/app/rooms/mod.rs @@ -6,6 +6,7 @@ pub mod chess; pub mod filter; pub mod game_ui; pub mod input; +pub mod mud; pub mod poker; pub mod registry; pub mod state; diff --git a/late-ssh/src/app/rooms/mud/create_modal.rs b/late-ssh/src/app/rooms/mud/create_modal.rs new file mode 100644 index 00000000..7bd507b7 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/create_modal.rs @@ -0,0 +1,236 @@ +// Create-room modal for Lateania. The world has no configurable options in the +// slice, so this is a single name field, mirroring the Tic-Tac-Toe modal. + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, +}; + +use crate::app::{ + common::theme, + input::{ParsedInput, sanitize_paste_markers}, + rooms::backend::{CreateModalAction, CreateRoomModal}, +}; + +const DISPLAY_NAME_MAX_LEN: usize = 48; +const MODAL_WIDTH: u16 = 60; +const MODAL_HEIGHT: u16 = 12; +const LABEL_WIDTH: usize = 10; + +pub struct MudCreateModal { + display_name: String, + error: Option, +} + +impl MudCreateModal { + pub fn new(default_name: impl Into) -> Self { + Self { + display_name: default_name.into(), + error: None, + } + } + + fn push_name_char(&mut self, ch: char) { + if ch.is_control() || self.display_name.chars().count() >= DISPLAY_NAME_MAX_LEN { + return; + } + self.error = None; + self.display_name.push(ch); + } + + fn submit(&mut self) -> CreateModalAction { + let display_name = self.display_name.trim().to_string(); + if display_name.is_empty() { + self.error = Some("World name is required.".to_string()); + return CreateModalAction::Continue; + } + + CreateModalAction::Submit { + display_name, + settings: serde_json::json!({}), + } + } +} + +impl CreateRoomModal for MudCreateModal { + fn draw(&self, frame: &mut Frame, area: Rect) { + let modal_area = centered_rect( + area, + MODAL_WIDTH.min(area.width), + MODAL_HEIGHT.min(area.height), + ); + frame.render_widget(Clear, modal_area); + + let block = Block::default() + .title(" New Lateania World ") + .title_style( + Style::default() + .fg(theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + ) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme::BORDER_ACTIVE())); + let inner = block.inner(modal_area); + frame.render_widget(block, modal_area); + + let layout = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(inner); + + let width = inner.width as usize; + frame.render_widget(Paragraph::new(section_heading("World")), layout[1]); + frame.render_widget( + Paragraph::new(field_row( + true, + "Name", + name_value_span(&self.display_name), + width, + )), + layout[3], + ); + + let footer = self + .error + .as_ref() + .map(|message| { + Line::from(vec![ + Span::raw(" "), + Span::styled(message.clone(), Style::default().fg(theme::ERROR())), + ]) + }) + .unwrap_or_else(footer_line); + frame.render_widget(Paragraph::new(footer), layout[5]); + } + + fn handle_event(&mut self, event: &ParsedInput) -> CreateModalAction { + match event { + ParsedInput::Byte(0x1B) => CreateModalAction::Cancel, + ParsedInput::Byte(b'\r' | b'\n') => self.submit(), + ParsedInput::Byte(0x08 | 0x7F) => { + self.error = None; + self.display_name.pop(); + CreateModalAction::Continue + } + ParsedInput::Byte(0x17) => { + self.error = None; + self.display_name.clear(); + CreateModalAction::Continue + } + ParsedInput::Char(ch) => { + self.push_name_char(*ch); + CreateModalAction::Continue + } + ParsedInput::Byte(byte) => { + if byte.is_ascii_graphic() || *byte == b' ' { + self.push_name_char(*byte as char); + } + CreateModalAction::Continue + } + ParsedInput::Paste(bytes) => { + let pasted = String::from_utf8_lossy(bytes); + for ch in sanitize_paste_markers(&pasted).chars() { + self.push_name_char(ch); + } + CreateModalAction::Continue + } + _ => CreateModalAction::Continue, + } + } +} + +fn name_value_span(value: &str) -> ValueSpan { + ValueSpan { + text: format!("{value}\u{2588}"), + style: Style::default() + .fg(theme::AMBER()) + .add_modifier(Modifier::BOLD), + } +} + +fn footer_line() -> Line<'static> { + Line::from(vec![ + Span::raw(" "), + Span::styled("\u{21B5}", Style::default().fg(theme::AMBER_DIM())), + Span::styled(" create ", Style::default().fg(theme::TEXT_DIM())), + Span::styled("Esc", Style::default().fg(theme::AMBER_DIM())), + Span::styled(" cancel", Style::default().fg(theme::TEXT_DIM())), + ]) +} + +fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { + Rect { + x: area.x + area.width.saturating_sub(width) / 2, + y: area.y + area.height.saturating_sub(height) / 2, + width, + height, + } +} + +fn section_heading(title: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(" -- ", Style::default().fg(theme::BORDER())), + Span::styled( + title.to_string(), + Style::default() + .fg(theme::AMBER()) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" --", Style::default().fg(theme::BORDER())), + ]) +} + +struct ValueSpan { + text: String, + style: Style, +} + +fn field_row(focused: bool, label: &str, value: ValueSpan, width: usize) -> Line<'static> { + let marker = if focused { "\u{203A}" } else { " " }; + let prefix_style = if focused { + Style::default() + .fg(theme::AMBER_GLOW()) + .bg(theme::BG_SELECTION()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::TEXT_FAINT()) + }; + let label_style = if focused { + Style::default() + .fg(theme::TEXT_BRIGHT()) + .bg(theme::BG_SELECTION()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::TEXT_DIM()) + }; + let value_style = if focused { + value.style.bg(theme::BG_SELECTION()) + } else { + value.style + }; + let trailing_style = if focused { + Style::default().bg(theme::BG_SELECTION()) + } else { + Style::default() + }; + + let prefix = format!(" {marker} "); + let label_text = format!("{label: up / down +// space or x attack what's here +// z flee +// l look again +// Esc / q leave the world +// +// A full typed MUD prompt ("attack goblin", "look chest") needs an input-capture +// mode that suppresses chat routing; that is deferred to a later phase and noted +// in the design docs. + +use crate::app::rooms::{backend::InputAction, mud::state::State, mud::world::Dir}; + +pub fn handle_key(state: &mut State, byte: u8) -> InputAction { + match byte { + 0x1B | b'q' | b'Q' => { + state.leave_world(); + InputAction::Leave + } + b'w' | b'W' => { + state.go(Dir::North); + InputAction::Handled + } + b's' | b'S' => { + state.go(Dir::South); + InputAction::Handled + } + b'a' | b'A' | b'h' | b'H' => { + state.go(Dir::West); + InputAction::Handled + } + b'd' | b'D' | b'l' | b'L' => { + // `l` doubles as east here for convenience; `d` is east. (Both are + // safe: chat only claims `d`/`l` when a message is selected, in which + // case the player is interacting with chat anyway.) + state.go(Dir::East); + InputAction::Handled + } + b'<' | b',' => { + state.go(Dir::Up); + InputAction::Handled + } + b'>' | b'.' => { + state.go(Dir::Down); + InputAction::Handled + } + b' ' | b'x' | b'X' | b'\r' | b'\n' => { + state.attack(); + InputAction::Handled + } + b'z' | b'Z' => { + state.flee(); + InputAction::Handled + } + b'o' | b'O' => { + state.look(); + InputAction::Handled + } + _ => InputAction::Ignored, + } +} + +pub fn handle_arrow(state: &mut State, key: u8) -> bool { + match key { + b'A' => state.go(Dir::North), + b'B' => state.go(Dir::South), + b'C' => state.go(Dir::East), + b'D' => state.go(Dir::West), + _ => return false, + } + true +} + +/// The four CSI arrow finals this game consumes. Exposed for the input-routing +/// test so the mapping stays in one place. +pub fn arrow_maps_to_direction(key: u8) -> Option { + match key { + b'A' => Some(Dir::North), + b'B' => Some(Dir::South), + b'C' => Some(Dir::East), + b'D' => Some(Dir::West), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn arrows_map_to_the_four_compass_directions() { + assert_eq!(arrow_maps_to_direction(b'A'), Some(Dir::North)); + assert_eq!(arrow_maps_to_direction(b'B'), Some(Dir::South)); + assert_eq!(arrow_maps_to_direction(b'C'), Some(Dir::East)); + assert_eq!(arrow_maps_to_direction(b'D'), Some(Dir::West)); + assert_eq!(arrow_maps_to_direction(b'Z'), None); + } +} diff --git a/late-ssh/src/app/rooms/mud/manager.rs b/late-ssh/src/app/rooms/mud/manager.rs new file mode 100644 index 00000000..0c80aeb4 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/manager.rs @@ -0,0 +1,196 @@ +// Manager + per-session backend wiring for Lateania. +// +// The manager is the process-wide singleton registered with the +// RoomGameRegistry. It maps each game room id to one MudService (the world) and +// hands every entering session a State wrapper. Unlike the seated games, a +// Lateania "room" is a whole persistent world; "seats" map to adventurers +// present, with no fixed cap. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; + +use late_core::MutexRecover; +use tokio::sync::broadcast; +use uuid::Uuid; + +use crate::app::{ + activity::publisher::ActivityPublisher, + rooms::{ + backend::{ + ActiveRoomBackend, CreateRoomModal, DirectoryHints, DirectoryMeta, RoomGameEvent, + RoomGameManager, RoomTitleDetails, + }, + mud::{create_modal::MudCreateModal, state::State, svc::MudService}, + svc::{GameKind, RoomListItem}, + }, +}; + +/// Soft cap shown in directory hints (worlds are not really seat-limited). +const WORLD_CAPACITY_HINT: usize = 64; + +#[derive(Clone)] +pub struct MudTableManager { + activity: ActivityPublisher, + tables: Arc>>, + event_tx: broadcast::Sender, +} + +impl MudTableManager { + pub fn new(activity: ActivityPublisher) -> Self { + let (event_tx, _) = broadcast::channel::(256); + Self { + activity, + tables: Arc::new(Mutex::new(HashMap::new())), + event_tx, + } + } + + pub fn get_or_create(&self, room: &RoomListItem) -> MudService { + let mut tables = self.tables.lock_recover(); + tables + .entry(room.id) + .or_insert_with(|| { + MudService::new_with_events(room.id, self.activity.clone(), self.event_tx.clone()) + }) + .clone() + } +} + +impl RoomGameManager for MudTableManager { + fn kind(&self) -> GameKind { + GameKind::Mud + } + + fn label(&self) -> &'static str { + "Lateania" + } + + fn slug_prefix(&self) -> &'static str { + "mud" + } + + fn default_room_name(&self) -> &'static str { + "Lateania" + } + + fn default_settings(&self) -> serde_json::Value { + serde_json::json!({}) + } + + fn open_create_modal(&self) -> Box { + Box::new(MudCreateModal::new(self.default_room_name())) + } + + fn directory_meta(&self, _room: &RoomListItem) -> DirectoryMeta { + DirectoryMeta { + seats: WORLD_CAPACITY_HINT as u8, + pace: "real-time".to_string(), + stakes: "swords & sorcery".to_string(), + } + } + + fn directory_hints(&self, room_id: Uuid) -> Option { + let occupied = self.tables.lock_recover().get(&room_id)?.player_count(); + Some(DirectoryHints { + occupied, + total: WORLD_CAPACITY_HINT, + }) + } + + fn is_user_seated(&self, room_id: Uuid, user_id: Uuid) -> bool { + self.tables + .lock_recover() + .get(&room_id) + .is_some_and(|svc| svc.is_user_present(user_id)) + } + + fn subscribe_room_events(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + fn seat_join_ascii(&self) -> &'static [&'static str] { + &[ + r" /\ ", + r" |==| ", + r" | | ", + ] + } + + fn enter( + &self, + room: &RoomListItem, + user_id: Uuid, + _chip_balance: i64, + ) -> Box { + Box::new(State::new(self.get_or_create(room), user_id)) + } +} + +impl ActiveRoomBackend for State { + fn room_id(&self) -> Uuid { + self.room_id() + } + + fn tick(&mut self) { + State::tick(self); + } + + fn touch_activity(&self) { + State::touch_activity(self); + } + + fn handle_key(&mut self, byte: u8) -> crate::app::rooms::backend::InputAction { + crate::app::rooms::mud::input::handle_key(self, byte) + } + + fn handle_arrow(&mut self, key: u8) -> bool { + crate::app::rooms::mud::input::handle_arrow(self, key) + } + + fn preferred_game_height(&self, area: ratatui::layout::Rect) -> u16 { + // The adventure log wants vertical room; ask for most of the pane. + let scaled = area.height.saturating_mul(13) / 20; + scaled.clamp(8, 24) + } + + fn draw( + &self, + frame: &mut ratatui::Frame, + area: ratatui::layout::Rect, + ctx: crate::app::rooms::backend::GameDrawCtx<'_>, + ) { + crate::app::rooms::mud::ui::draw_game(frame, area, self, ctx.usernames); + } + + fn title_details(&self) -> Option { + let view = self.view(); + if !view.joined { + return Some(RoomTitleDetails { + seated: Some("entering".to_string()), + role: None, + balance: None, + }); + } + let here = format!("{} online", self.player_count()); + let role = if view.respawning { + "recovering".to_string() + } else if let Some(foe) = view.in_combat_with.as_ref() { + format!("fighting {foe}") + } else { + format!("lvl {} - {}", view.level, view.room_name) + }; + Some(RoomTitleDetails { + seated: Some(here), + role: Some(role), + balance: None, + }) + } + + fn drop_on_leave(&self) -> bool { + // The per-session wrapper owns the player's presence; dropping it should + // remove the adventurer from the world. + true + } +} diff --git a/late-ssh/src/app/rooms/mud/mod.rs b/late-ssh/src/app/rooms/mud/mod.rs new file mode 100644 index 00000000..237a5df6 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/mod.rs @@ -0,0 +1,12 @@ +// Lateania - a D&D MUD inside late.sh. +// +// World & design by Tasmania (Tony Hosaroygard) - hardlygospel.github.io +// With heartfelt thanks to the creator of late.sh and every developer who +// contributes to it. This world stands on the foundation you built. +pub mod create_modal; +pub mod input; +pub mod manager; +pub mod state; +pub mod svc; +pub mod ui; +pub mod world; diff --git a/late-ssh/src/app/rooms/mud/state.rs b/late-ssh/src/app/rooms/mud/state.rs new file mode 100644 index 00000000..5a24ab5e --- /dev/null +++ b/late-ssh/src/app/rooms/mud/state.rs @@ -0,0 +1,88 @@ +// Per-session client wrapper for a Lateania world. +// +// One State per session. Holds a cached snapshot drained from the service's +// watch channel each tick, plus local-only UI state (log scroll). All real +// actions delegate to the service's *_task methods; this struct never blocks +// and never mutates world truth. + +use tokio::sync::watch; +use uuid::Uuid; + +use super::svc::{MudService, MudSnapshot, PlayerView, empty_player_view}; +use super::world::Dir; + +pub struct State { + user_id: Uuid, + snapshot: MudSnapshot, + svc: MudService, + snapshot_rx: watch::Receiver, +} + +impl State { + pub fn new(svc: MudService, user_id: Uuid) -> Self { + let snapshot_rx = svc.subscribe_state(); + let snapshot = snapshot_rx.borrow().clone(); + let state = Self { + user_id, + snapshot, + svc, + snapshot_rx, + }; + // Auto-join the world on entry; the slice has no separate "sit" step. + state.svc.join_task(user_id); + state + } + + pub fn room_id(&self) -> Uuid { + self.svc.room_id() + } + + pub fn is_self(&self, user_id: Uuid) -> bool { + self.user_id == user_id + } + + pub fn tick(&mut self) { + if self.snapshot_rx.has_changed().unwrap_or(false) { + self.snapshot = self.snapshot_rx.borrow_and_update().clone(); + } + } + + pub fn touch_activity(&self) { + self.svc.touch_activity_task(self.user_id); + } + + /// This player's view, or an empty placeholder until the join lands. + pub fn view(&self) -> PlayerView { + self.snapshot + .players + .get(&self.user_id) + .cloned() + .unwrap_or_else(|| empty_player_view(self.snapshot.room_id)) + } + + pub fn player_count(&self) -> usize { + self.snapshot.players.values().filter(|p| p.joined).count() + } + + // ---- Actions (delegate to the service) ------------------------------ + + pub fn go(&self, dir: Dir) { + self.svc.move_task(self.user_id, dir); + } + + pub fn look(&self) { + self.svc.look_task(self.user_id); + } + + pub fn attack(&self) { + self.svc.attack_task(self.user_id); + } + + pub fn flee(&self) { + self.svc.flee_task(self.user_id); + } + + pub fn leave_world(&self) { + self.svc.leave_task(self.user_id); + } +} diff --git a/late-ssh/src/app/rooms/mud/svc.rs b/late-ssh/src/app/rooms/mud/svc.rs new file mode 100644 index 00000000..921a1b28 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/svc.rs @@ -0,0 +1,931 @@ +// Lateania world runtime: the authoritative, in-memory truth for one MUD world. +// +// One service per game room (the late "room" is the whole world). Many sessions +// share it via the manager's HashMap; each has its own `state::State`. Mutations +// serialize through `Arc>`; reads are lock-free against each +// session's cached snapshot. A background tick loop advances combat rounds and +// mob respawns, then publishes a fresh snapshot. +// +// Combat is round-based on the world tick (every `TICK_SECS`), matching the +// classic MUD feel and reusing the loop shape proven by Tron. + +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, Instant}, +}; + +use tokio::sync::{Mutex, broadcast, watch}; +use uuid::Uuid; + +use crate::app::{ + activity::{event::ActivityGame, publisher::ActivityPublisher}, + rooms::backend::RoomGameEvent, +}; + +use super::world::{Dir, MobSpawn, RoomId, World, seed_world}; + +/// World heartbeat. One combat round resolves per tick. +const TICK_SECS: u64 = 2; +/// A player who sends no command for this long is dropped from the world. +const PLAYER_IDLE_TIMEOUT_SECS: u64 = 10 * 60; +/// Player base stats for the slice (one class: Warrior). +const PLAYER_MAX_HP: i32 = 40; +const PLAYER_DAMAGE: i32 = 6; +/// How long a defeated player rests before respawning at the start room. +const PLAYER_RESPAWN_SECS: u64 = 8; + +#[derive(Clone)] +pub struct MudService { + room_id: Uuid, + activity: ActivityPublisher, + room_event_tx: broadcast::Sender, + snapshot_tx: watch::Sender, + snapshot_rx: watch::Receiver, + state: Arc>, +} + +// ---- Snapshot (what sessions render) ------------------------------------- + +/// A line in a player's scrolling message log. +#[derive(Clone, Debug)] +pub struct LogLine { + pub text: String, + pub kind: LogKind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogKind { + Normal, + Combat, + System, + Say, +} + +/// A mob as seen in a room. +#[derive(Clone, Debug)] +pub struct MobView { + pub name: String, + pub hp: i32, + pub max_hp: i32, +} + +/// One other player visible in the same room. +#[derive(Clone, Debug)] +pub struct OccupantView { + pub user_id: Uuid, + pub hp: i32, + pub max_hp: i32, + pub in_combat: bool, +} + +/// Per-session snapshot: filtered to the player's current room plus their own +/// character sheet and message log. This mirrors poker's public/private split - +/// each session only receives what its own player can see. +#[derive(Clone, Debug)] +pub struct MudSnapshot { + pub room_id: Uuid, + pub generation: u64, + /// Per-player views keyed by user id. A session reads its own entry. + pub players: HashMap, +} + +#[derive(Clone, Debug)] +pub struct PlayerView { + pub joined: bool, + pub alive: bool, + pub hp: i32, + pub max_hp: i32, + pub xp: i32, + pub level: i32, + pub room_name: String, + pub room_desc: String, + pub zone: String, + pub safe: bool, + pub exits: Vec<(Dir, String)>, + pub mobs: Vec, + pub occupants: Vec, + pub in_combat_with: Option, + pub log: Vec, + pub respawning: bool, +} + +impl PlayerView { + fn empty(room_id: Uuid) -> Self { + let _ = room_id; + Self { + joined: false, + alive: false, + hp: 0, + max_hp: 0, + xp: 0, + level: 1, + room_name: String::new(), + room_desc: String::new(), + zone: String::new(), + safe: true, + exits: Vec::new(), + mobs: Vec::new(), + occupants: Vec::new(), + in_combat_with: None, + log: Vec::new(), + respawning: false, + } + } +} + +impl MudService { + pub fn new(room_id: Uuid, activity: ActivityPublisher) -> Self { + let (room_event_tx, _) = broadcast::channel::(16); + Self::new_with_events(room_id, activity, room_event_tx) + } + + pub fn new_with_events( + room_id: Uuid, + activity: ActivityPublisher, + room_event_tx: broadcast::Sender, + ) -> Self { + let state = WorldState::new(room_id, seed_world()); + let initial = state.snapshot(); + let (snapshot_tx, snapshot_rx) = watch::channel(initial); + let svc = Self { + room_id, + activity, + room_event_tx, + snapshot_tx, + snapshot_rx, + state: Arc::new(Mutex::new(state)), + }; + svc.start_tick_loop(); + svc + } + + pub fn room_id(&self) -> Uuid { + self.room_id + } + + pub fn subscribe_state(&self) -> watch::Receiver { + self.snapshot_rx.clone() + } + + pub fn current_snapshot(&self) -> MudSnapshot { + self.snapshot_rx.borrow().clone() + } + + /// Number of adventurers currently in the world (for directory hints). + pub fn player_count(&self) -> usize { + self.snapshot_rx + .borrow() + .players + .values() + .filter(|p| p.joined) + .count() + } + + pub fn is_user_present(&self, user_id: Uuid) -> bool { + self.snapshot_rx + .borrow() + .players + .get(&user_id) + .is_some_and(|p| p.joined) + } + + // ---- Commands (fire-and-forget, *_task convention) ------------------- + + pub fn join_task(&self, user_id: Uuid) { + let svc = self.clone(); + tokio::spawn(async move { + let joined = { + let mut state = svc.state.lock().await; + let joined = state.join(user_id); + state.touch(user_id); + svc.publish(&state); + joined + }; + if joined { + let _ = svc.room_event_tx.send(RoomGameEvent::SeatJoined { + room_id: svc.room_id, + user_id, + }); + } + }); + } + + pub fn leave_task(&self, user_id: Uuid) { + let svc = self.clone(); + tokio::spawn(async move { + let mut state = svc.state.lock().await; + state.leave(user_id); + svc.publish(&state); + }); + } + + pub fn move_task(&self, user_id: Uuid, dir: Dir) { + let svc = self.clone(); + tokio::spawn(async move { + let mut state = svc.state.lock().await; + state.move_player(user_id, dir); + state.touch(user_id); + svc.publish(&state); + }); + } + + pub fn look_task(&self, user_id: Uuid) { + let svc = self.clone(); + tokio::spawn(async move { + let mut state = svc.state.lock().await; + state.look(user_id); + state.touch(user_id); + svc.publish(&state); + }); + } + + pub fn attack_task(&self, user_id: Uuid) { + let svc = self.clone(); + tokio::spawn(async move { + let mut state = svc.state.lock().await; + state.engage(user_id); + state.touch(user_id); + svc.publish(&state); + }); + } + + pub fn flee_task(&self, user_id: Uuid) { + let svc = self.clone(); + tokio::spawn(async move { + let mut state = svc.state.lock().await; + state.flee(user_id); + state.touch(user_id); + svc.publish(&state); + }); + } + + pub fn say_task(&self, user_id: Uuid, message: String) { + let svc = self.clone(); + tokio::spawn(async move { + let mut state = svc.state.lock().await; + state.say(user_id, &message); + state.touch(user_id); + svc.publish(&state); + }); + } + + pub fn touch_activity_task(&self, user_id: Uuid) { + let svc = self.clone(); + tokio::spawn(async move { + let mut state = svc.state.lock().await; + state.touch(user_id); + }); + } + + // ---- Tick loop ------------------------------------------------------ + + fn start_tick_loop(&self) { + let svc = self.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(TICK_SECS)); + loop { + ticker.tick().await; + let mut state = svc.state.lock().await; + let outcomes = state.tick(); + if state.dirty { + svc.publish(&state); + state.dirty = false; + } + drop(state); + for outcome in outcomes { + svc.activity.game_won_task( + outcome.user_id, + ActivityGame::Mud, + Some(format!("slew {}", outcome.mob_name)), + None, + ); + } + } + }); + } + + fn publish(&self, state: &WorldState) { + let _ = self.snapshot_tx.send(state.snapshot()); + } +} + +/// Reported when a player lands a killing blow, so the world feed can announce it. +struct KillOutcome { + user_id: Uuid, + mob_name: String, +} + +// ---- The authoritative world state --------------------------------------- + +struct PlayerState { + user_id: Uuid, + hp: i32, + max_hp: i32, + damage: i32, + xp: i32, + level: i32, + room: RoomId, + /// Mob instance id this player is fighting, if any. + target: Option, + last_activity: Instant, + /// Some(deadline) while the player is downed and waiting to respawn. + respawn_at: Option, + log: Vec, +} + +struct MobInstance { + spawn: MobSpawn, + hp: i32, + alive: bool, + /// Some(deadline) while dead and waiting to respawn. + respawn_at: Option, +} + +struct WorldState { + room_id: Uuid, + world: World, + players: HashMap, + mobs: HashMap, + generation: u64, + /// Set by tick() when something changed and a publish is warranted. + dirty: bool, +} + +const LOG_CAP: usize = 50; + +impl WorldState { + fn new(room_id: Uuid, world: World) -> Self { + let mobs = world + .spawns + .iter() + .map(|spawn| { + ( + spawn.id, + MobInstance { + spawn: spawn.clone(), + hp: spawn.max_hp, + alive: true, + respawn_at: None, + }, + ) + }) + .collect(); + Self { + room_id, + world, + players: HashMap::new(), + mobs, + generation: 0, + dirty: false, + } + } + + fn join(&mut self, user_id: Uuid) -> bool { + if self.players.contains_key(&user_id) { + return false; + } + let start = self.world.start_room; + let mut player = PlayerState { + user_id, + hp: PLAYER_MAX_HP, + max_hp: PLAYER_MAX_HP, + damage: PLAYER_DAMAGE, + xp: 0, + level: 1, + room: start, + target: None, + last_activity: Instant::now(), + respawn_at: None, + log: Vec::new(), + }; + push_log( + &mut player.log, + LogKind::System, + "You step into the world of Lateania.".to_string(), + ); + self.players.insert(user_id, player); + self.describe_room(user_id); + true + } + + fn leave(&mut self, user_id: Uuid) { + self.players.remove(&user_id); + } + + fn touch(&mut self, user_id: Uuid) { + if let Some(player) = self.players.get_mut(&user_id) { + player.last_activity = Instant::now(); + } + } + + fn move_player(&mut self, user_id: Uuid, dir: Dir) { + let Some(player) = self.players.get(&user_id) else { + return; + }; + if player.respawn_at.is_some() { + self.log_to(user_id, LogKind::System, "You are recovering.".to_string()); + return; + } + if player.target.is_some() { + self.log_to( + user_id, + LogKind::Combat, + "You can't leave - you're in combat! Flee first.".to_string(), + ); + return; + } + let Some(room) = self.world.room(player.room) else { + return; + }; + let Some(&dest) = room.exits.get(&dir) else { + self.log_to( + user_id, + LogKind::Normal, + format!("You can't go {}.", dir.label()), + ); + return; + }; + if let Some(player) = self.players.get_mut(&user_id) { + player.room = dest; + } + self.describe_room(user_id); + } + + fn look(&mut self, user_id: Uuid) { + self.describe_room(user_id); + } + + fn describe_room(&mut self, user_id: Uuid) { + let Some(player) = self.players.get(&user_id) else { + return; + }; + let room_id = player.room; + let Some(room) = self.world.room(room_id) else { + return; + }; + // Extract everything from the room (an immutable borrow of self.world) + // before any self.log_to call, which needs &mut self. + let name = room.name.to_string(); + let desc = room.desc.to_string(); + let mut exits: Vec<&'static str> = room.exits.keys().map(|d| d.label()).collect(); + exits.sort_unstable(); + let exit_text = if exits.is_empty() { + "none".to_string() + } else { + exits.join(", ") + }; + let mob_names: Vec = self + .mobs + .values() + .filter(|m| m.alive && m.spawn.home == room_id) + .map(|m| m.spawn.name.to_string()) + .collect(); + self.log_to(user_id, LogKind::Normal, format!("== {name} ==")); + self.log_to(user_id, LogKind::Normal, desc); + self.log_to(user_id, LogKind::System, format!("Exits: {exit_text}")); + for mob in mob_names { + self.log_to(user_id, LogKind::Combat, format!("{mob} is here.")); + } + } + + fn engage(&mut self, user_id: Uuid) { + let Some(player) = self.players.get(&user_id) else { + return; + }; + if player.respawn_at.is_some() { + return; + } + let room_id = player.room; + if self.world.room(room_id).is_some_and(|r| r.safe) { + self.log_to( + user_id, + LogKind::System, + "This is a safe haven. No fighting here.".to_string(), + ); + return; + } + let target = self + .mobs + .values() + .find(|m| m.alive && m.spawn.home == room_id) + .map(|m| m.spawn.id); + match target { + Some(mob_id) => { + let mob_name = self + .mobs + .get(&mob_id) + .map(|m| m.spawn.name.to_string()) + .unwrap_or_default(); + if let Some(player) = self.players.get_mut(&user_id) { + player.target = Some(mob_id); + } + self.log_to(user_id, LogKind::Combat, format!("You attack {mob_name}!")); + } + None => { + self.log_to( + user_id, + LogKind::Normal, + "There's nothing here to fight.".to_string(), + ); + } + } + } + + fn flee(&mut self, user_id: Uuid) { + let Some(player) = self.players.get(&user_id) else { + return; + }; + if player.target.is_none() { + self.log_to( + user_id, + LogKind::Normal, + "You're not fighting anything.".to_string(), + ); + return; + } + let room_id = player.room; + let exit = self + .world + .room(room_id) + .and_then(|r| r.exits.iter().next().map(|(dir, dest)| (*dir, *dest))); + if let Some(player) = self.players.get_mut(&user_id) { + player.target = None; + } + match exit { + Some((dir, dest)) => { + if let Some(player) = self.players.get_mut(&user_id) { + player.room = dest; + } + self.log_to( + user_id, + LogKind::Combat, + format!("You flee {}!", dir.label()), + ); + self.describe_room(user_id); + } + None => { + self.log_to( + user_id, + LogKind::Combat, + "You break off the fight.".to_string(), + ); + } + } + } + + fn say(&mut self, user_id: Uuid, message: &str) { + let trimmed = message.trim(); + if trimmed.is_empty() { + return; + } + let room_id = match self.players.get(&user_id) { + Some(player) => player.room, + None => return, + }; + let occupants: Vec = self + .players + .iter() + .filter(|(_, p)| p.room == room_id) + .map(|(id, _)| *id) + .collect(); + for occupant in occupants { + let prefix = if occupant == user_id { + "You say".to_string() + } else { + "Someone says".to_string() + }; + self.log_to(occupant, LogKind::Say, format!("{prefix}: {trimmed}")); + } + } + + /// Advance the world one round. Returns kills for the activity feed. + fn tick(&mut self) -> Vec { + let mut outcomes = Vec::new(); + let now = Instant::now(); + + // Respawn mobs whose timer elapsed. + for mob in self.mobs.values_mut() { + if !mob.alive + && let Some(at) = mob.respawn_at + && now >= at + { + mob.alive = true; + mob.hp = mob.spawn.max_hp; + mob.respawn_at = None; + self.dirty = true; + } + } + + // Respawn downed players whose rest elapsed. + let resurrecting: Vec = self + .players + .iter() + .filter(|(_, p)| p.respawn_at.is_some_and(|at| now >= at)) + .map(|(id, _)| *id) + .collect(); + for user_id in resurrecting { + let start = self.world.start_room; + if let Some(player) = self.players.get_mut(&user_id) { + player.hp = player.max_hp; + player.room = start; + player.target = None; + player.respawn_at = None; + } + self.log_to( + user_id, + LogKind::System, + "You wake at the Temple of the Dawn, restored.".to_string(), + ); + self.describe_room(user_id); + self.dirty = true; + } + + // Resolve one combat round per fighting player. + let fighters: Vec = self + .players + .iter() + .filter(|(_, p)| p.target.is_some() && p.respawn_at.is_none()) + .map(|(id, _)| *id) + .collect(); + + for user_id in fighters { + let (mob_id, player_damage) = match self.players.get(&user_id) { + Some(p) => (p.target, p.damage), + None => continue, + }; + let Some(mob_id) = mob_id else { continue }; + let Some(mob) = self.mobs.get_mut(&mob_id) else { + if let Some(player) = self.players.get_mut(&user_id) { + player.target = None; + } + continue; + }; + if !mob.alive { + if let Some(player) = self.players.get_mut(&user_id) { + player.target = None; + } + continue; + } + + // Player strikes mob. + mob.hp -= player_damage; + let mob_name = mob.spawn.name.to_string(); + self.dirty = true; + if mob.hp <= 0 { + mob.alive = false; + mob.hp = 0; + mob.respawn_at = Some(now + Duration::from_secs(mob.spawn.respawn_secs)); + let xp = mob.spawn.xp; + self.log_to( + user_id, + LogKind::Combat, + format!("You have slain {mob_name}! (+{xp} xp)"), + ); + if let Some(player) = self.players.get_mut(&user_id) { + player.target = None; + player.xp += xp; + let new_level = 1 + player.xp / 50; + if new_level > player.level { + player.level = new_level; + player.max_hp += 8; + player.hp = player.max_hp; + player.damage += 1; + let level = player.level; + self.log_to( + user_id, + LogKind::System, + format!("You reach level {level}! You feel stronger."), + ); + } + } + outcomes.push(KillOutcome { user_id, mob_name }); + continue; + } + + // Mob strikes back. + let mob_damage = mob.spawn.damage; + self.log_to( + user_id, + LogKind::Combat, + format!("You hit {mob_name}. It strikes back for {mob_damage}."), + ); + if let Some(player) = self.players.get_mut(&user_id) { + player.hp -= mob_damage; + if player.hp <= 0 { + player.hp = 0; + player.target = None; + player.respawn_at = Some(now + Duration::from_secs(PLAYER_RESPAWN_SECS)); + self.log_to( + user_id, + LogKind::System, + "You have fallen! Darkness takes you...".to_string(), + ); + } + } + } + + // Drop idle players from the world. + let idle: Vec = self + .players + .iter() + .filter(|(_, p)| { + p.last_activity.elapsed() >= Duration::from_secs(PLAYER_IDLE_TIMEOUT_SECS) + }) + .map(|(id, _)| *id) + .collect(); + for user_id in idle { + self.players.remove(&user_id); + self.dirty = true; + } + + if self.dirty { + self.generation = self.generation.wrapping_add(1); + } + outcomes + } + + fn log_to(&mut self, user_id: Uuid, kind: LogKind, text: String) { + if let Some(player) = self.players.get_mut(&user_id) { + push_log(&mut player.log, kind, text); + self.dirty = true; + } + } + + fn snapshot(&self) -> MudSnapshot { + let mut players = HashMap::new(); + for (user_id, player) in &self.players { + let room = self.world.room(player.room); + let (room_name, room_desc, zone, safe, exits) = match room { + Some(room) => { + let mut exits: Vec<(Dir, String)> = room + .exits + .keys() + .map(|d| (*d, d.label().to_string())) + .collect(); + exits.sort_by(|a, b| a.1.cmp(&b.1)); + ( + room.name.to_string(), + room.desc.to_string(), + room.zone.to_string(), + room.safe, + exits, + ) + } + None => (String::new(), String::new(), String::new(), true, Vec::new()), + }; + let mobs: Vec = self + .mobs + .values() + .filter(|m| m.alive && m.spawn.home == player.room) + .map(|m| MobView { + name: m.spawn.name.to_string(), + hp: m.hp, + max_hp: m.spawn.max_hp, + }) + .collect(); + let occupants: Vec = self + .players + .values() + .filter(|other| other.user_id != *user_id && other.room == player.room) + .map(|other| OccupantView { + user_id: other.user_id, + hp: other.hp, + max_hp: other.max_hp, + in_combat: other.target.is_some(), + }) + .collect(); + let in_combat_with = player.target.and_then(|mob_id| { + self.mobs + .get(&mob_id) + .filter(|m| m.alive) + .map(|m| m.spawn.name.to_string()) + }); + players.insert( + *user_id, + PlayerView { + joined: true, + alive: player.respawn_at.is_none(), + hp: player.hp, + max_hp: player.max_hp, + xp: player.xp, + level: player.level, + room_name, + room_desc, + zone, + safe, + exits, + mobs, + occupants, + in_combat_with, + log: player.log.clone(), + respawning: player.respawn_at.is_some(), + }, + ); + } + MudSnapshot { + room_id: self.room_id, + generation: self.generation, + players, + } + } +} + +fn push_log(log: &mut Vec, kind: LogKind, text: String) { + log.push(LogLine { text, kind }); + if log.len() > LOG_CAP { + let overflow = log.len() - LOG_CAP; + log.drain(0..overflow); + } +} + +/// A session whose player hasn't joined yet still needs a view to render. +pub fn empty_player_view(room_id: Uuid) -> PlayerView { + PlayerView::empty(room_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn uid(n: u128) -> Uuid { + Uuid::from_u128(n) + } + + fn test_state() -> WorldState { + WorldState::new(uid(999), seed_world()) + } + + #[test] + fn join_places_player_in_safe_start_room() { + let mut state = test_state(); + assert!(state.join(uid(1))); + let player = state.players.get(&uid(1)).expect("joined"); + assert_eq!(player.room, state.world.start_room); + assert_eq!(player.hp, PLAYER_MAX_HP); + // Re-join is a no-op. + assert!(!state.join(uid(1))); + } + + #[test] + fn movement_follows_exits_and_blocks_walls() { + let mut state = test_state(); + state.join(uid(1)); + // Square (1) -> south -> gate (5). + state.move_player(uid(1), Dir::South); + assert_eq!(state.players[&uid(1)].room, 5); + // Gate has no east exit. + state.move_player(uid(1), Dir::East); + assert_eq!(state.players[&uid(1)].room, 5); + } + + #[test] + fn cannot_fight_in_safe_room() { + let mut state = test_state(); + state.join(uid(1)); + state.engage(uid(1)); + assert!(state.players[&uid(1)].target.is_none()); + } + + #[test] + fn combat_kills_mob_and_awards_xp() { + let mut state = test_state(); + state.join(uid(1)); + // Walk to room 6 (goblin home): square -> gate -> open country. + state.move_player(uid(1), Dir::South); + state.move_player(uid(1), Dir::South); + assert_eq!(state.players[&uid(1)].room, 6); + state.engage(uid(1)); + assert!(state.players[&uid(1)].target.is_some()); + // Tick until the goblin (18 hp, player 6 dmg) dies. + let mut kills = Vec::new(); + for _ in 0..10 { + kills = state.tick(); + if state.players[&uid(1)].target.is_none() { + break; + } + } + assert!(state.players[&uid(1)].xp > 0, "player should gain xp"); + assert_eq!(kills.len(), 1); + } + + #[test] + fn say_reaches_others_in_same_room_only() { + let mut state = test_state(); + state.join(uid(1)); + state.join(uid(2)); + // uid(2) moves away. + state.move_player(uid(2), Dir::South); + state.say(uid(1), "hello"); + let p1_heard = state.players[&uid(1)] + .log + .iter() + .any(|l| l.text.contains("hello")); + let p2_heard = state.players[&uid(2)] + .log + .iter() + .any(|l| l.text.contains("hello")); + assert!(p1_heard, "speaker hears own message"); + assert!(!p2_heard, "player in another room does not hear"); + } +} diff --git a/late-ssh/src/app/rooms/mud/ui.rs b/late-ssh/src/app/rooms/mud/ui.rs new file mode 100644 index 00000000..cb3fdb83 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/ui.rs @@ -0,0 +1,245 @@ +// Rendering for Lateania. Reads the cached per-session snapshot and paints a +// two-column view: the scrolling adventure log on the left, a character/room +// panel on the right. Lock-free; never awaits or touches a service mutex. + +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::app::{ + common::theme, + rooms::mud::{ + state::State, + svc::{LogKind, PlayerView}, + }, +}; +use crate::usernames::UsernameLookup; + +const SIDE_WIDE: u16 = 30; +const SIDE_NARROW: u16 = 24; + +pub fn draw_game(frame: &mut Frame, area: Rect, state: &State, usernames: &UsernameLookup<'_>) { + let view = state.view(); + + if !view.joined { + let lines = vec![ + Line::from(Span::styled( + "Entering Lateania...", + Style::default() + .fg(theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + )), + Line::raw(""), + Line::from(Span::styled( + "World by Tasmania - thanks to late.sh and its contributors.", + Style::default().fg(theme::TEXT_DIM()), + )), + ]; + frame.render_widget(Paragraph::new(lines), area); + return; + } + + if area.width < 46 || area.height < 8 { + draw_compact(frame, area, &view); + return; + } + + let side_w = if area.width >= 78 { + SIDE_WIDE + } else { + SIDE_NARROW + }; + let columns = + Layout::horizontal([Constraint::Min(24), Constraint::Length(side_w)]).split(area); + draw_log(frame, columns[0], &view); + draw_side(frame, columns[1], state, &view, usernames); +} + +fn draw_compact(frame: &mut Frame, area: Rect, view: &PlayerView) { + let mut lines = vec![Line::from(vec![ + Span::styled( + view.room_name.clone(), + Style::default() + .fg(theme::AMBER()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" hp {}/{}", view.hp, view.max_hp), + Style::default().fg(hp_color(view.hp, view.max_hp)), + ), + ])]; + let tail = log_tail(view, area.height.saturating_sub(1) as usize); + for line in tail { + lines.push(log_line(line.0, line.1)); + } + frame.render_widget(Paragraph::new(lines), area); +} + +fn draw_log(frame: &mut Frame, area: Rect, view: &PlayerView) { + let capacity = area.height as usize; + let tail = log_tail(view, capacity); + let lines: Vec = tail + .into_iter() + .map(|(kind, text)| log_line(kind, text)) + .collect(); + frame.render_widget(Paragraph::new(lines), area); +} + +fn draw_side( + frame: &mut Frame, + area: Rect, + state: &State, + view: &PlayerView, + usernames: &UsernameLookup<'_>, +) { + let _ = state; + let mut lines = Vec::new(); + + lines.push(section("Adventurer")); + lines.push(stat_line("Level", view.level.to_string())); + lines.push(Line::from(vec![ + Span::styled(" HP ", Style::default().fg(theme::TEXT_DIM())), + Span::styled( + format!("{}/{}", view.hp, view.max_hp), + Style::default() + .fg(hp_color(view.hp, view.max_hp)) + .add_modifier(Modifier::BOLD), + ), + ])); + lines.push(stat_line("XP", view.xp.to_string())); + lines.push(Line::raw("")); + + lines.push(section("Here")); + lines.push(Line::from(Span::styled( + format!(" {}", view.zone), + Style::default().fg(theme::TEXT()), + ))); + let exits = if view.exits.is_empty() { + "none".to_string() + } else { + view.exits + .iter() + .map(|(_, name)| name.as_str()) + .collect::>() + .join(", ") + }; + lines.push(Line::from(vec![ + Span::styled(" exits ", Style::default().fg(theme::TEXT_DIM())), + Span::styled(exits, Style::default().fg(theme::AMBER_DIM())), + ])); + + if !view.mobs.is_empty() { + lines.push(Line::raw("")); + lines.push(section("Foes")); + for mob in &view.mobs { + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", mob.name), Style::default().fg(theme::ERROR())), + Span::styled( + format!("{}/{}", mob.hp, mob.max_hp), + Style::default().fg(theme::TEXT_DIM()), + ), + ])); + } + } + + if !view.occupants.is_empty() { + lines.push(Line::raw("")); + lines.push(section("Adventurers here")); + for occ in &view.occupants { + let name = usernames + .get(&occ.user_id) + .cloned() + .unwrap_or_else(|| "adventurer".to_string()); + let marker = if occ.in_combat { " (fighting)" } else { "" }; + lines.push(Line::from(Span::styled( + format!(" {name}{marker}"), + Style::default().fg(theme::SUCCESS()), + ))); + } + } + + lines.push(Line::raw("")); + lines.push(section("Commands")); + if view.respawning { + lines.push(Line::from(Span::styled( + " recovering...", + Style::default().fg(theme::TEXT_DIM()), + ))); + } else if view.in_combat_with.is_some() { + lines.push(hint("space/x", "strike")); + lines.push(hint("z", "flee")); + } else { + lines.push(hint("w a s d", "move")); + lines.push(hint("arrows", "move")); + lines.push(hint("space/x", "attack")); + lines.push(hint("o", "look")); + } + lines.push(hint("q / Esc", "leave")); + + frame.render_widget(Paragraph::new(lines), area); +} + +fn log_tail(view: &PlayerView, capacity: usize) -> Vec<(LogKind, String)> { + if capacity == 0 { + return Vec::new(); + } + let start = view.log.len().saturating_sub(capacity); + view.log[start..] + .iter() + .map(|line| (line.kind, line.text.clone())) + .collect() +} + +fn log_line(kind: LogKind, text: String) -> Line<'static> { + let color = match kind { + LogKind::Normal => theme::TEXT(), + LogKind::Combat => theme::ERROR(), + LogKind::System => theme::AMBER_DIM(), + LogKind::Say => theme::CHAT_BODY(), + }; + Line::from(Span::styled(text, Style::default().fg(color))) +} + +fn section(title: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(" - ", Style::default().fg(theme::BORDER())), + Span::styled( + title.to_string(), + Style::default() + .fg(theme::AMBER()) + .add_modifier(Modifier::BOLD), + ), + ]) +} + +fn stat_line(label: &str, value: String) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {label:<5}"), Style::default().fg(theme::TEXT_DIM())), + Span::styled(value, Style::default().fg(theme::TEXT_BRIGHT())), + ]) +} + +fn hint(key: &str, label: &str) -> Line<'static> { + Line::from(vec![ + Span::styled(format!(" {key}"), Style::default().fg(theme::AMBER_DIM())), + Span::styled(format!(" {label}"), Style::default().fg(theme::TEXT_DIM())), + ]) +} + +fn hp_color(hp: i32, max_hp: i32) -> ratatui::style::Color { + if max_hp <= 0 { + return theme::TEXT_DIM(); + } + let pct = (hp * 100) / max_hp; + if pct <= 25 { + theme::ERROR() + } else if pct <= 60 { + theme::AMBER() + } else { + theme::SUCCESS() + } +} diff --git a/late-ssh/src/app/rooms/mud/world.rs b/late-ssh/src/app/rooms/mud/world.rs new file mode 100644 index 00000000..58dc5e91 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/world.rs @@ -0,0 +1,300 @@ +// Static world definition for Lateania. +// +// Rooms and mob spawns are immutable data, loaded once into the service. The +// full design targets 200 rooms across nine zones (see the project design docs); +// this is the vertical-slice seed: the hub town of Embergate plus the first +// stretch of the King's Road, with a single hostile mob to prove combat. +// +// Content is deliberately data, not code: the slice hardcodes a small seed via +// `seed_world`, but the shape (rooms keyed by id, exits as a direction map) is +// the same one a future TOML/RON loader will produce. + +use std::collections::HashMap; + +/// Compass (plus vertical) directions a player can move. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Dir { + North, + South, + East, + West, + Up, + Down, +} + +impl Dir { + pub fn label(self) -> &'static str { + match self { + Self::North => "north", + Self::South => "south", + Self::East => "east", + Self::West => "west", + Self::Up => "up", + Self::Down => "down", + } + } + + pub fn short(self) -> &'static str { + match self { + Self::North => "n", + Self::South => "s", + Self::East => "e", + Self::West => "w", + Self::Up => "u", + Self::Down => "d", + } + } +} + +pub type RoomId = u32; + +/// A single location in the world: a node in the room graph. +#[derive(Clone, Debug)] +pub struct Room { + pub id: RoomId, + pub name: &'static str, + pub desc: &'static str, + pub zone: &'static str, + pub exits: HashMap, + /// True for towns and other no-combat zones. + pub safe: bool, +} + +/// A mob template that spawns at a home room. +#[derive(Clone, Debug)] +pub struct MobSpawn { + pub id: u32, + pub name: &'static str, + pub home: RoomId, + pub max_hp: i32, + pub damage: i32, + pub xp: i32, + /// Seconds before a slain mob respawns. + pub respawn_secs: u64, +} + +/// The immutable world: every room plus the mob roster. +#[derive(Clone, Debug)] +pub struct World { + pub rooms: HashMap, + pub spawns: Vec, + pub start_room: RoomId, +} + +impl World { + pub fn room(&self, id: RoomId) -> Option<&Room> { + self.rooms.get(&id) + } +} + +fn room( + id: RoomId, + name: &'static str, + zone: &'static str, + safe: bool, + desc: &'static str, + exits: &[(Dir, RoomId)], +) -> Room { + Room { + id, + name, + desc, + zone, + safe, + exits: exits.iter().copied().collect(), + } +} + +/// Build the vertical-slice world: Embergate (safe hub) + the King's Road. +pub fn seed_world() -> World { + let rooms = vec![ + room( + 1, + "Embergate - Town Square", + "Embergate", + true, + "Lanternlight pools on worn cobbles. The town square of Embergate hums \ + with quiet evening trade. A notice board leans by the well, and roads \ + lead off in every direction.", + &[(Dir::North, 2), (Dir::East, 3), (Dir::West, 4), (Dir::South, 5)], + ), + room( + 2, + "Embergate - The Gilded Flagon", + "Embergate", + true, + "A warm tavern thick with woodsmoke and laughter. Adventurers swap tall \ + tales over tankards. The square lies back to the south.", + &[(Dir::South, 1)], + ), + room( + 3, + "Embergate - Market Row", + "Embergate", + true, + "Shuttered stalls line a narrow lane. A smith's forge glows at the far \ + end. The square is back to the west.", + &[(Dir::West, 1)], + ), + room( + 4, + "Embergate - Temple of the Dawn", + "Embergate", + true, + "Pale columns rise toward a domed ceiling painted with sunrise. Clerics \ + move in hushed procession. The square is back to the east.", + &[(Dir::East, 1)], + ), + room( + 5, + "Embergate - South Gate", + "Embergate", + true, + "A heavy iron portcullis stands raised. Beyond it the King's Road \ + stretches into open country. The square is north.", + &[(Dir::North, 1), (Dir::South, 6)], + ), + room( + 6, + "The King's Road - Open Country", + "King's Road", + false, + "The cobbles give way to packed earth. Tall grass whispers on either \ + side and the town wall recedes behind you to the north.", + &[(Dir::North, 5), (Dir::South, 7)], + ), + room( + 7, + "The King's Road - The Old Milestone", + "King's Road", + false, + "A mossy milestone marks the leagues to far cities. A thin trail forks \ + east into a thicket; the road runs on south.", + &[(Dir::North, 6), (Dir::East, 8), (Dir::South, 9)], + ), + room( + 8, + "The King's Road - Bramble Thicket", + "King's Road", + false, + "Thorns crowd a dead-end clearing. Something has trampled the grass \ + here recently. The trail back is west.", + &[(Dir::West, 7)], + ), + room( + 9, + "The King's Road - Ruined Watchtower", + "King's Road", + false, + "A toppled watchtower slumps against the hillside, its stones scorched. \ + The road continues south into a shadowed defile; the way back is north.", + &[(Dir::North, 7), (Dir::South, 10)], + ), + room( + 10, + "The King's Road - The Defile", + "King's Road", + false, + "Steep banks close in on a gloomy cut in the hills. This is as far as \ + the road has been cleared. The way back is north.", + &[(Dir::North, 9)], + ), + ]; + + let spawns = vec![ + MobSpawn { + id: 1, + name: "a scrawny goblin", + home: 6, + max_hp: 18, + damage: 3, + xp: 12, + respawn_secs: 30, + }, + MobSpawn { + id: 2, + name: "a road bandit", + home: 8, + max_hp: 26, + damage: 5, + xp: 20, + respawn_secs: 45, + }, + MobSpawn { + id: 3, + name: "a gaunt wolf", + home: 9, + max_hp: 22, + damage: 4, + xp: 16, + respawn_secs: 40, + }, + ]; + + World { + rooms: rooms.into_iter().map(|r| (r.id, r)).collect(), + spawns, + start_room: 1, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_exit_resolves_to_a_real_room() { + let world = seed_world(); + for room in world.rooms.values() { + for (dir, target) in &room.exits { + assert!( + world.rooms.contains_key(target), + "room {} ({}) has a {} exit to missing room {}", + room.id, + room.name, + dir.label(), + target + ); + } + } + } + + #[test] + fn exits_are_reciprocal_where_expected() { + // Embergate square (1) <-> south gate (5): going south then north returns. + let world = seed_world(); + let square = world.room(1).expect("square exists"); + let gate_id = square.exits.get(&Dir::South).copied().expect("south exit"); + let gate = world.room(gate_id).expect("gate exists"); + assert_eq!(gate.exits.get(&Dir::North).copied(), Some(1)); + } + + #[test] + fn start_room_exists_and_is_safe() { + let world = seed_world(); + let start = world.room(world.start_room).expect("start room exists"); + assert!(start.safe, "players should spawn in a safe room"); + } + + #[test] + fn every_room_reachable_from_start() { + let world = seed_world(); + let mut seen = std::collections::HashSet::new(); + let mut stack = vec![world.start_room]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + continue; + } + if let Some(room) = world.room(id) { + for target in room.exits.values() { + stack.push(*target); + } + } + } + assert_eq!( + seen.len(), + world.rooms.len(), + "some rooms are unreachable from the start room" + ); + } +} diff --git a/late-ssh/src/app/rooms/registry.rs b/late-ssh/src/app/rooms/registry.rs index c43b515f..15a0dd40 100644 --- a/late-ssh/src/app/rooms/registry.rs +++ b/late-ssh/src/app/rooms/registry.rs @@ -9,6 +9,7 @@ use super::{ }, blackjack::manager::BlackjackTableManager, chess::manager::ChessTableManager, + mud::manager::MudTableManager, poker::manager::PokerTableManager, svc::{GameKind, RoomListItem}, tictactoe::manager::TicTacToeTableManager, @@ -29,6 +30,7 @@ pub struct RoomGameRegistry { asterion: AsterionRoomManager, blackjack: BlackjackTableManager, chess: ChessTableManager, + mud: MudTableManager, poker: PokerTableManager, tictactoe: TicTacToeTableManager, tron: TronTableManager, @@ -39,6 +41,7 @@ impl RoomGameRegistry { asterion: AsterionRoomManager, blackjack: BlackjackTableManager, chess: ChessTableManager, + mud: MudTableManager, poker: PokerTableManager, tictactoe: TicTacToeTableManager, tron: TronTableManager, @@ -47,6 +50,7 @@ impl RoomGameRegistry { asterion, blackjack, chess, + mud, poker, tictactoe, tron, @@ -58,6 +62,7 @@ impl RoomGameRegistry { GameKind::Asterion => &self.asterion, GameKind::Blackjack => &self.blackjack, GameKind::Chess => &self.chess, + GameKind::Mud => &self.mud, GameKind::Poker => &self.poker, GameKind::TicTacToe => &self.tictactoe, GameKind::Tron => &self.tron, diff --git a/late-ssh/src/main.rs b/late-ssh/src/main.rs index 32499b6c..3a5b71ba 100644 --- a/late-ssh/src/main.rs +++ b/late-ssh/src/main.rs @@ -217,10 +217,13 @@ async fn main() -> anyhow::Result<()> { chip_service.clone(), activity_publisher.clone(), ); + let mud_table_manager = + late_ssh::app::rooms::mud::manager::MudTableManager::new(activity_publisher.clone()); let room_game_registry = late_ssh::app::rooms::registry::RoomGameRegistry::new( asterion_room_manager, blackjack_table_manager.clone(), chess_table_manager, + mud_table_manager, poker_table_manager, tictactoe_table_manager, tron_table_manager, diff --git a/late-ssh/src/metrics.rs b/late-ssh/src/metrics.rs index ba1aa5e7..9bd0efa3 100644 --- a/late-ssh/src/metrics.rs +++ b/late-ssh/src/metrics.rs @@ -21,6 +21,7 @@ mod inner { ActivityGame::Blackjack => "blackjack", ActivityGame::Chess => "chess", ActivityGame::Minesweeper => "minesweeper", + ActivityGame::Mud => "mud", ActivityGame::Nonogram => "nonogram", ActivityGame::Poker => "poker", ActivityGame::Solitaire => "solitaire", From ac78de8100f8b465b8f22ef02533495931cf6398 Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Mon, 1 Jun 2026 13:34:44 +1000 Subject: [PATCH 02/20] feat(rooms): expand Lateania to 110 rooms across nine zones Grow the seed world from 10 rooms to 110, opening the King's Road southward into a continuous descent through seven new zones: Whisperwood (forest), Duskhollow Caverns, the Drowned Crypts, Emberpeak Mines, the Frostspire Ascent, the Sunken Citadel, and the demon realm of the Obsidian Throne. Each new zone past the safe hub adds regular mobs plus a named boss scaled by tier, ending at the final boss, the Archdemon Mal'gareth. Every room has a hand-written description. The world stays pure data behind seed_world(); only the data grew. The graph is fully connected and reciprocal: every exit resolves to a real room, every room is reachable from the start, and every passage can be walked back the way it came. Added inline tests for room count (110), unique mob spawn ids, and that every mob homes to a real room, alongside the existing exit-resolution and reachability tests. Signed-off-by: Tony Hosaroygard --- late-ssh/src/app/rooms/mud/world.rs | 1375 ++++++++++++++++++++++++++- 1 file changed, 1365 insertions(+), 10 deletions(-) diff --git a/late-ssh/src/app/rooms/mud/world.rs b/late-ssh/src/app/rooms/mud/world.rs index 58dc5e91..60430e2b 100644 --- a/late-ssh/src/app/rooms/mud/world.rs +++ b/late-ssh/src/app/rooms/mud/world.rs @@ -1,13 +1,22 @@ // Static world definition for Lateania. // -// Rooms and mob spawns are immutable data, loaded once into the service. The -// full design targets 200 rooms across nine zones (see the project design docs); -// this is the vertical-slice seed: the hub town of Embergate plus the first -// stretch of the King's Road, with a single hostile mob to prove combat. +// Rooms and mob spawns are immutable data, loaded once into the service. This +// seed is 110 rooms spanning nine zones, a continuous descent from the hub town +// of Embergate down through forest, caverns, crypts, mines, an ice peak, a +// sunken citadel, and finally the demon realm of the Obsidian Throne. Each zone +// past the safe hub has regular mobs plus a named boss, scaled by tier. // -// Content is deliberately data, not code: the slice hardcodes a small seed via -// `seed_world`, but the shape (rooms keyed by id, exits as a direction map) is -// the same one a future TOML/RON loader will produce. +// Zone layout (room id ranges): +// 1-5 Embergate (safe hub) 6-10 King's Road (tier 1-2) +// 11-30 Whisperwood (tier 2-3) 31-50 Duskhollow Caverns (tier 3-4) +// 51-65 Drowned Crypts (tier 4-5) 66-80 Emberpeak Mines (tier 5-6) +// 81-95 Frostspire Ascent (tier 6-7) 96-105 The Sunken Citadel (tier 7-8) +// 106-110 The Obsidian Throne (tier 9-10, final boss Mal'gareth) +// +// Content is deliberately data, not code: `seed_world` hardcodes the world, but +// the shape (rooms keyed by id, exits as a direction map) is exactly what a +// future TOML/RON loader will produce. The full design target is 200 rooms; the +// remaining zones widen the existing spines. use std::collections::HashMap; @@ -195,9 +204,1064 @@ pub fn seed_world() -> World { "The King's Road - The Defile", "King's Road", false, - "Steep banks close in on a gloomy cut in the hills. This is as far as \ - the road has been cleared. The way back is north.", - &[(Dir::North, 9)], + "Steep banks close in on a gloomy cut in the hills. The road ends \ + where a landslide once buried it; a narrow game-trail slips south \ + beneath leaning pines into older, darker country. The way back is \ + north.", + &[(Dir::North, 9), (Dir::South, 11)], + ), + // ---- Whisperwood (forest, tier 2-3) ----------------------------- + room( + 11, + "Whisperwood - The Threshold Oaks", + "Whisperwood", + false, + "Two oaks older than the kingdom lean together to form a living arch, \ + their bark carved with charms so weathered they read only as scars. \ + The air past them is cooler, greener, and somehow listening. The \ + trail back climbs north toward the defile.", + &[(Dir::North, 10), (Dir::South, 12)], + ), + room( + 12, + "Whisperwood - Fernlight Hollow", + "Whisperwood", + false, + "Sunlight falls in slow green coins through a canopy so high it feels \ + like a cathedral roof. Knee-deep ferns drink the light and hide the \ + ground entirely. Paths press south and east; the oaks stand north.", + &[(Dir::North, 11), (Dir::South, 13), (Dir::East, 14)], + ), + room( + 13, + "Whisperwood - The Murmuring Path", + "Whisperwood", + false, + "The forest earns its name here: a wind you cannot feel moves the high \ + leaves in long sighing syllables, almost words. You keep turning to \ + answer someone who is not there. North and south the path runs on.", + &[(Dir::North, 12), (Dir::South, 15)], + ), + room( + 14, + "Whisperwood - The Toadstool Ring", + "Whisperwood", + false, + "A perfect circle of scarlet toadstools rings a patch of unnaturally \ + soft moss. Old instinct tells you not to step inside it, and older \ + instinct tells you why. The hollow lies back to the west.", + &[(Dir::West, 12)], + ), + room( + 15, + "Whisperwood - The Leaning Birches", + "Whisperwood", + false, + "Pale birches lean every direction at once, as though the ground had \ + shrugged a century ago and never settled. Their peeling bark hangs in \ + curls like discarded parchment. Ways lead north, south, and west.", + &[(Dir::North, 13), (Dir::South, 16), (Dir::West, 17)], + ), + room( + 16, + "Whisperwood - Wolf-Run Gully", + "Whisperwood", + false, + "The land folds into a shallow gully floored with cracked mud, printed \ + over and over with the splayed tracks of a hunting pack. Tufts of grey \ + fur snag the bramble at nose height. The path continues north and south.", + &[(Dir::North, 15), (Dir::South, 18)], + ), + room( + 17, + "Whisperwood - The Hermit's Cairn", + "Whisperwood", + false, + "A waist-high pile of river stones marks a grave no one tends. Someone \ + has balanced a single acorn on the topmost rock; it has not fallen, \ + though the wind worries everything else here. East returns to the birches.", + &[(Dir::East, 15)], + ), + room( + 18, + "Whisperwood - Spider-Silk Crossing", + "Whisperwood", + false, + "Sheets of web span the gap between two dead elms, jeweled with dew and \ + the husks of things that stopped struggling long ago. The strands hum \ + faintly when you breathe. Paths lead north, south, and east.", + &[(Dir::North, 16), (Dir::South, 19), (Dir::East, 20)], + ), + room( + 19, + "Whisperwood - The Sunken Brook", + "Whisperwood", + false, + "A clear brook has cut itself a channel so deep it runs below the roots, \ + chuckling in the dark a body's length beneath your feet. The forest \ + smells of cold stone and watercress. North and south the way goes on.", + &[(Dir::North, 18), (Dir::South, 21)], + ), + room( + 20, + "Whisperwood - The Weaver's Hollow", + "Whisperwood", + false, + "Every branch in this dead-end hollow is strung with web until the trees \ + wear grey lace gowns. Small wrapped bundles turn slowly on invisible \ + threads. Nothing here is alive that should be. The crossing lies west.", + &[(Dir::West, 18)], + ), + room( + 21, + "Whisperwood - Stag-Horn Clearing", + "Whisperwood", + false, + "Sun pours into a wide clearing where the bleached antlers of some \ + enormous stag rise from the grass like the rafters of a roofless hall. \ + Songbirds nest in the tines. The path runs north and south.", + &[(Dir::North, 19), (Dir::South, 22)], + ), + room( + 22, + "Whisperwood - The Crossroads Stone", + "Whisperwood", + false, + "A moss-furred standing stone leans at the meeting of three trails, its \ + carved hand pointing nowhere that still exists. Offerings rot at its \ + base: bread, a copper ring, a child's wooden horse. Ways lead north, \ + south, and west.", + &[(Dir::North, 21), (Dir::South, 23), (Dir::West, 24)], + ), + room( + 23, + "Whisperwood - The Hanging Vale", + "Whisperwood", + false, + "The trees thin over a vale where curtains of pale moss hang so thick \ + they brush your shoulders as you pass, cool and faintly damp, like the \ + hands of the polite dead. The path presses north and south.", + &[(Dir::North, 22), (Dir::South, 25)], + ), + room( + 24, + "Whisperwood - The Drowned Shrine", + "Whisperwood", + false, + "A forgotten woodland shrine has sunk to its shoulders in black bog \ + water, only the carved face of some antlered god still breaking the \ + surface, watching the sky. Frogs go silent as you arrive. East returns \ + to the crossroads.", + &[(Dir::East, 22)], + ), + room( + 25, + "Whisperwood - The Char Circle", + "Whisperwood", + false, + "A ring of trees stands black and branchless, killed by a fire that \ + never spread past their own trunks. In the center the ground is glassy \ + and warm. The forest leans away from this place. North and south remain.", + &[(Dir::North, 23), (Dir::South, 26)], + ), + room( + 26, + "Whisperwood - The Greenway Fork", + "Whisperwood", + false, + "The undergrowth opens onto an ancient greenway, a road of turf so \ + straight it must have been laid by hands, now half-swallowed by the \ + forest reclaiming its own. Paths lead north, south, and east.", + &[(Dir::North, 25), (Dir::South, 27), (Dir::East, 28)], + ), + room( + 27, + "Whisperwood - The Lantern Trees", + "Whisperwood", + false, + "Clusters of luminous fungus climb these trunks in spiral ladders, \ + casting a soft blue-green glow that makes the dusk beneath the canopy \ + into a perpetual underwater twilight. The way runs north and south.", + &[(Dir::North, 26), (Dir::South, 29)], + ), + room( + 28, + "Whisperwood - The Elder Grove", + "Whisperwood", + false, + "At the heart of a ring of bowing trees stands one vast and ancient \ + treant, bark like cliff-stone, eyes like two cold green moons opening \ + slowly as you intrude on a silence kept for a thousand years. The \ + greenway lies west.", + &[(Dir::West, 26)], + ), + room( + 29, + "Whisperwood - The Root Stair", + "Whisperwood", + false, + "The land tilts downward and the roots of the great trees arrange \ + themselves into a rough descending stair, slick with leaf-mould and \ + generations of fallen rain. Cold air rises from below. North climbs \ + back; south leads on.", + &[(Dir::North, 27), (Dir::South, 30)], + ), + room( + 30, + "Whisperwood - The Sinking Gate", + "Whisperwood", + false, + "The forest floor opens at last into a sinkhole ringed by exposed roots, \ + a black throat breathing cave-cold air up into the green world. A rope \ + ladder, half-rotted, descends into the dark. North returns to the wood.", + &[(Dir::North, 29), (Dir::Down, 31)], + ), + // ---- Duskhollow Caverns (caves & undead, tier 3-4) -------------- + room( + 31, + "Duskhollow Caverns - The Drip Gallery", + "Duskhollow Caverns", + false, + "Your boots find stone. The cavern mouth drips in slow, patient music, \ + each drop ringing in a darkness so complete your lantern seems an \ + apology. Daylight is a memory up the ladder, north and above. The cave \ + pushes south.", + &[(Dir::Up, 30), (Dir::South, 32)], + ), + room( + 32, + "Duskhollow Caverns - The Forking Throat", + "Duskhollow Caverns", + false, + "The passage splits around a pillar of fused stalactite and stalagmite, \ + a stone hourglass taller than three men. Cold draughts breathe from \ + both branches. Ways lead north, south, and east.", + &[(Dir::North, 31), (Dir::South, 33), (Dir::East, 34)], + ), + room( + 33, + "Duskhollow Caverns - The Whispering Crawl", + "Duskhollow Caverns", + false, + "The ceiling drops until you must stoop, and the walls press close \ + enough to scrape both shoulders. Your own breathing comes back to you \ + changed, as though the rock were trying the sound in its mouth. North \ + and south.", + &[(Dir::North, 32), (Dir::South, 35)], + ), + room( + 34, + "Duskhollow Caverns - The Ossuary Niche", + "Duskhollow Caverns", + false, + "Someone stacked bones here, long ago and with terrible care: a wall of \ + skulls mortared with smaller bones, every empty socket aimed at the \ + room's one entrance. They have been waiting for company. West returns \ + to the fork.", + &[(Dir::West, 32)], + ), + room( + 35, + "Duskhollow Caverns - The Black Mirror", + "Duskhollow Caverns", + false, + "A still pool fills the cavern floor, so utterly without ripple it \ + throws your lanternlight back like polished obsidian. Something pale \ + rests at the bottom, and you decide not to learn what. Ways lead north, \ + south, and west.", + &[(Dir::North, 33), (Dir::South, 36), (Dir::West, 37)], + ), + room( + 36, + "Duskhollow Caverns - The Stalactite Nave", + "Duskhollow Caverns", + false, + "The chamber soars into a forest of hanging stone, fang upon fang \ + vanishing into a dark the lantern cannot reach. Drips fall from \ + impossible heights and burst cold against your neck. North and south \ + continue.", + &[(Dir::North, 35), (Dir::South, 38)], + ), + room( + 37, + "Duskhollow Caverns - The Sealed Door", + "Duskhollow Caverns", + false, + "A door of iron-banded oak, swollen and black with damp, has been chained \ + shut from this side and then, for good measure, from this side again. \ + Something scratches the far face, slow and tireless. East returns to \ + the pool.", + &[(Dir::East, 35)], + ), + room( + 38, + "Duskhollow Caverns - The Crystal Vein", + "Duskhollow Caverns", + false, + "A seam of clouded crystal threads the wall here, catching the lantern \ + and breaking it into a hundred trapped sparks that seem to drift like \ + slow snow inside the stone. It is beautiful and it is cold. Ways lead \ + north, south, and east.", + &[(Dir::North, 36), (Dir::South, 39), (Dir::East, 40)], + ), + room( + 39, + "Duskhollow Caverns - The Slumping Stair", + "Duskhollow Caverns", + false, + "Steps cut by long-dead miners sag and slide underfoot, half-melted by \ + the patient creep of mineral water. Each one bears a worn carved \ + number in a counting-script no living tongue still speaks. North and \ + south.", + &[(Dir::North, 38), (Dir::South, 41)], + ), + room( + 40, + "Duskhollow Caverns - The Gnawed Larder", + "Duskhollow Caverns", + false, + "Sacks and barrels rot in a side-chamber some lost expedition used for \ + stores. Everything organic has been gnawed to lace by teeth too \ + numerous and too small to think about. West returns to the vein.", + &[(Dir::West, 38)], + ), + room( + 41, + "Duskhollow Caverns - The Cold Hearth", + "Duskhollow Caverns", + false, + "A ring of fire-blackened stones holds a heap of ash that has not felt \ + warmth in centuries, yet the air above it shimmers as though it \ + remembers being hot. Bedrolls lie around it, occupied by their owners \ + still. North and south.", + &[(Dir::North, 39), (Dir::South, 42)], + ), + room( + 42, + "Duskhollow Caverns - The Hanging Bridge", + "Duskhollow Caverns", + false, + "A natural bridge of stone arches over a chasm whose bottom your lantern \ + never finds. Far below, something moves with a dragging, wet \ + deliberation. Best to cross quickly. Ways lead north, south, and west.", + &[(Dir::North, 41), (Dir::South, 43), (Dir::West, 44)], + ), + room( + 43, + "Duskhollow Caverns - The Fungal Garden", + "Duskhollow Caverns", + false, + "Pale mushrooms grow waist-high in nightmare profusion, their caps \ + exhaling faint spores that prickle in the lungs and paint the lantern \ + with a sickly halo. Things have been harvesting them. North and south.", + &[(Dir::North, 42), (Dir::South, 45)], + ), + room( + 44, + "Duskhollow Caverns - The Throne of Bones", + "Duskhollow Caverns", + false, + "A dead-end vault where the cavern floor rises into a dais, and upon it \ + a throne built entirely of fused skeletons leers in the lanternlight. \ + Its occupant lifts a crowned skull and regards you with two points of \ + cold blue fire. The bridge lies east.", + &[(Dir::East, 42)], + ), + room( + 45, + "Duskhollow Caverns - The Weeping Wall", + "Duskhollow Caverns", + false, + "Mineral water sheets down a vast flowstone wall in an endless silver \ + curtain, and the sound is so like grief that you find your own throat \ + tightening for no reason you can name. North and south go on.", + &[(Dir::North, 43), (Dir::South, 46)], + ), + room( + 46, + "Duskhollow Caverns - The Echo Junction", + "Duskhollow Caverns", + false, + "Five passages meet in a domed chamber that returns every sound \ + threefold, so that your single footstep becomes a marching company and \ + your whisper an argument. Ways lead north, south, and east.", + &[(Dir::North, 45), (Dir::South, 47), (Dir::East, 48)], + ), + room( + 47, + "Duskhollow Caverns - The Salt Flats", + "Duskhollow Caverns", + false, + "An ancient sea died here and left its ghost: a flat white plain of \ + salt crust that crunches like thin ice underfoot, glittering to the \ + edge of the light. The air tastes of old oceans. North and south.", + &[(Dir::North, 46), (Dir::South, 49)], + ), + room( + 48, + "Duskhollow Caverns - The Miner's End", + "Duskhollow Caverns", + false, + "A pick still stands buried in the dead-end wall where its owner left it, \ + and its owner left it because its owner is still here, slumped in the \ + corner, patient as the stone. West returns to the junction.", + &[(Dir::West, 46)], + ), + room( + 49, + "Duskhollow Caverns - The Drowned Stair", + "Duskhollow Caverns", + false, + "Steps descend into black water that has risen to swallow them, and \ + keeps rising, drip by patient drip. The air grows colder and carries \ + the green reek of a flooded tomb. North climbs back; south wades on.", + &[(Dir::North, 47), (Dir::South, 50)], + ), + room( + 50, + "Duskhollow Caverns - The Sunken Arch", + "Duskhollow Caverns", + false, + "A carved arch stands half-submerged at the cavern's lowest point, its \ + keystone graven with a drowned crown. Beyond and below it the water \ + becomes a flooded stair down into a deeper, older dark. North leads \ + back up.", + &[(Dir::North, 49), (Dir::Down, 51)], + ), + // ---- Drowned Crypts (water & undead, tier 4-5) ------------------ + room( + 51, + "Drowned Crypts - The Tide Vestibule", + "Drowned Crypts", + false, + "You descend into a flooded hall where black water laps at carved \ + sarcophagi like moored boats. The cold is total and intimate, the kind \ + that settles in the marrow and stays. Up returns to the caverns; the \ + crypt runs south.", + &[(Dir::Up, 50), (Dir::South, 52)], + ), + room( + 52, + "Drowned Crypts - The Sarcophagus Row", + "Drowned Crypts", + false, + "Stone coffins line both walls, their lids carved with the serene faces \ + of the long-dead. Several lids lie aside in the water. The faces beneath \ + are no longer serene. Ways lead north, south, and east.", + &[(Dir::North, 51), (Dir::South, 53), (Dir::East, 54)], + ), + room( + 53, + "Drowned Crypts - The Wading Nave", + "Drowned Crypts", + false, + "The water rises to your thighs here, cold enough to ache, and things \ + brush your legs in the dark that you choose to believe are only weeds. \ + The current pulls gently south. North and south.", + &[(Dir::North, 52), (Dir::South, 55)], + ), + room( + 54, + "Drowned Crypts - The Reliquary", + "Drowned Crypts", + false, + "Niches in this dead-end chamber once held holy relics; now they hold \ + only silt and the small bones of the desperate who came seeking them. \ + A single gold leaf still glints underwater. West returns to the row.", + &[(Dir::West, 52)], + ), + room( + 55, + "Drowned Crypts - The Black Font", + "Drowned Crypts", + false, + "A great basin dominates the chamber, brimming with water blacker than \ + the dark around it. The surface holds a perfect, impossible stillness, \ + and your reflection in it is slow to copy your movements. North and \ + south.", + &[(Dir::North, 53), (Dir::South, 56)], + ), + room( + 56, + "Drowned Crypts - The Pillared Deep", + "Drowned Crypts", + false, + "Rows of columns march off into water and darkness, each one carved as a \ + shrouded mourner, each one bowing slightly inward, so that to walk among \ + them is to be escorted by a procession of the grieving stone. Ways lead \ + north, south, and west.", + &[(Dir::North, 55), (Dir::South, 57), (Dir::West, 58)], + ), + room( + 57, + "Drowned Crypts - The Catafalque", + "Drowned Crypts", + false, + "A raised bier stands clear of the flood, draped in rotted velvet that \ + still holds, somehow, a deep imperial purple. The body upon it is gone. \ + The shape pressed into the velvet suggests it merely rose and walked \ + away. North and south.", + &[(Dir::North, 56), (Dir::South, 59)], + ), + room( + 58, + "Drowned Crypts - The Oubliette", + "Drowned Crypts", + false, + "A forgetting-hole: a dead-end shaft where prisoners were lowered and \ + the rope cut. The water here is deepest, and full of the patient, \ + upturned faces of everyone the crypt has ever swallowed. East returns \ + to the deep.", + &[(Dir::East, 56)], + ), + room( + 59, + "Drowned Crypts - The Choir of Salt", + "Drowned Crypts", + false, + "Stalactites of crystallized brine hang in ranks like organ pipes, and \ + when the slow current stirs the flood they keen a single sustained note \ + that you feel in your teeth more than hear. North and south.", + &[(Dir::North, 57), (Dir::South, 60)], + ), + room( + 60, + "Drowned Crypts - The Sunken Crossing", + "Drowned Crypts", + false, + "Submerged steps lead up onto a broad landing where three flooded halls \ + converge, their arches reflected in the still water until you cannot \ + tell stone from its double. Ways lead north, south, and east.", + &[(Dir::North, 59), (Dir::South, 61), (Dir::East, 62)], + ), + room( + 61, + "Drowned Crypts - The Pauper's Vault", + "Drowned Crypts", + false, + "Here the dead were given no coffins, only shelves, and the shelves have \ + long since spilled their burden into the flood. The water is thick with \ + the anonymous dead, turning slowly in the current. North and south.", + &[(Dir::North, 60), (Dir::South, 63)], + ), + room( + 62, + "Drowned Crypts - The Lich's Sanctum", + "Drowned Crypts", + false, + "The water falls away into a dry, candle-ringed sanctum where a robed \ + figure bends over a book bound in something that was once a face. It \ + does not turn. It says, in a voice like a closing tomb, that it has \ + been expecting you. The crossing lies west.", + &[(Dir::West, 60)], + ), + room( + 63, + "Drowned Crypts - The Weed-Choked Hall", + "Drowned Crypts", + false, + "Pale subterranean weed has colonized this hall in drifting curtains, \ + feeding on the dead and on the dark, and it parts reluctantly as you \ + pass, closing again behind you like a held breath let go. North and south.", + &[(Dir::North, 61), (Dir::South, 64)], + ), + room( + 64, + "Drowned Crypts - The Last Lantern", + "Drowned Crypts", + false, + "A bronze lantern hangs from the vaulted ceiling, and impossibly, \ + improbably, a small cold flame still burns within it, untended for \ + centuries. By its light the water ahead glitters with a different, \ + warmer mineral. North and south.", + &[(Dir::North, 63), (Dir::South, 65)], + ), + room( + 65, + "Drowned Crypts - The Ember Stair", + "Drowned Crypts", + false, + "The flood drains away up a stair cut from raw red stone, and the air \ + changes utterly: drier, sharper, carrying the faraway tang of smoke and \ + hot metal. Something deep in the rock is awake and burning. North \ + returns to the crypts; up climbs toward the heat.", + &[(Dir::North, 64), (Dir::Up, 66)], + ), + // ---- Emberpeak Mines (fire & dwarven ruin, tier 5-6) ------------ + room( + 66, + "Emberpeak Mines - The Cinder Gate", + "Emberpeak Mines", + false, + "You climb into a hewn hall where the very walls hold a sullen red \ + warmth, and runes carved by long-vanished dwarves still glow faintly in \ + the heat. Down leads back to the cold crypts; the mines open north.", + &[(Dir::Down, 65), (Dir::North, 67)], + ), + room( + 67, + "Emberpeak Mines - The Ore-Cart Junction", + "Emberpeak Mines", + false, + "Rusted rails cross and recross the floor, and a single ore-cart sits \ + where it stopped an age ago, still heaped with raw red ingots no one \ + ever came to claim. The metal is warm to the touch. Ways lead south, \ + north, and east.", + &[(Dir::South, 66), (Dir::North, 68), (Dir::East, 69)], + ), + room( + 68, + "Emberpeak Mines - The Bellows Hall", + "Emberpeak Mines", + false, + "Vast leather bellows, big as houses and cracked with age, flank a forge \ + channel cut into the floor. Far below, magma still pulses, and with \ + each pulse the dead bellows seem to stir, exhaling a gust of furnace \ + air. South and north.", + &[(Dir::South, 67), (Dir::North, 70)], + ), + room( + 69, + "Emberpeak Mines - The Collapsed Drift", + "Emberpeak Mines", + false, + "A mining drift ends in a wall of fallen rubble, and pinned within it, \ + reaching, are the fossilized arms of the miners who did not get out. The \ + stone here ticks with trapped heat. West returns to the junction.", + &[(Dir::West, 67)], + ), + room( + 70, + "Emberpeak Mines - The Glass Foundry", + "Emberpeak Mines", + false, + "The floor of this chamber is a frozen river of slag glass, swirled black \ + and red and gold, smooth enough to skate and just warm enough to remind \ + you what made it. Shapes are suspended within it. South and north.", + &[(Dir::South, 68), (Dir::North, 71)], + ), + room( + 71, + "Emberpeak Mines - The Anvil of Kings", + "Emberpeak Mines", + false, + "A single anvil the size of a cart squats on a basalt plinth, its face \ + worn into a shallow valley by ten thousand vanished hands. Strike it and \ + the whole mountain answers in a low bronze hum. Ways lead south, north, \ + and west.", + &[(Dir::South, 70), (Dir::North, 72), (Dir::West, 73)], + ), + room( + 72, + "Emberpeak Mines - The Smelter's Gallery", + "Emberpeak Mines", + false, + "Crucibles line a long gallery, each still cupping a disc of cooled \ + metal, each disc stamped with the seal of a dwarven house that no longer \ + exists in any memory but this one. The heat presses close. South and north.", + &[(Dir::South, 71), (Dir::North, 74)], + ), + room( + 73, + "Emberpeak Mines - The Slag Pit", + "Emberpeak Mines", + false, + "Waste from a thousand years of smelting was tipped into this dead-end \ + pit, and it never fully cooled. A crust shifts over molten depths, and \ + the air above it bends with heat. Something basks half-submerged. East \ + returns to the anvil.", + &[(Dir::East, 71)], + ), + room( + 74, + "Emberpeak Mines - The Vein of Fire", + "Emberpeak Mines", + false, + "A seam of raw firegold threads the wall, so hot it glows from within the \ + stone, lighting the chamber in a restless amber pulse like a heartbeat. \ + To mine it would be to mine a coal still burning. South and north.", + &[(Dir::South, 72), (Dir::North, 75)], + ), + room( + 75, + "Emberpeak Mines - The Cathedral Forge", + "Emberpeak Mines", + false, + "The mine opens into a forge built like a temple, its central furnace a \ + chimney of carved stone rising beyond the lantern's reach. The dwarves \ + worshipped fire here, and fire, it seems, still attends. Ways lead south, \ + north, and east.", + &[(Dir::South, 74), (Dir::North, 76), (Dir::East, 77)], + ), + room( + 76, + "Emberpeak Mines - The Quenching Pools", + "Emberpeak Mines", + false, + "Stone troughs that once cooled fresh-forged blades now hold black, \ + scummed water that steams without cease. The hiss is constant, almost a \ + voice, and the steam takes shapes you would rather it did not. South and north.", + &[(Dir::South, 75), (Dir::North, 78)], + ), + room( + 77, + "Emberpeak Mines - The Magma Heart", + "Emberpeak Mines", + false, + "A dead-end cavern open to the mountain's molten core, a lake of fire \ + whose light hurts to look upon. From its surface a vast figure heaves \ + itself upright, basalt and lava, sloughing flame, turning a furnace gaze \ + upon the small cold thing that has entered its house. The forge lies west.", + &[(Dir::West, 75)], + ), + room( + 78, + "Emberpeak Mines - The Ascending Flue", + "Emberpeak Mines", + false, + "A great chimney climbs the chamber, and the updraft through it is fierce \ + and hot, carrying sparks like upward-falling stars. Iron rungs set into \ + the flue lead toward a distant, paler light. South and north.", + &[(Dir::South, 76), (Dir::North, 79)], + ), + room( + 79, + "Emberpeak Mines - The Frost-Cracked Tunnel", + "Emberpeak Mines", + false, + "Strangely, the heat fails here all at once, and the walls wear a rime of \ + frost that has no business this deep in a burning mountain. Your breath \ + fogs. Something cold is bleeding down from above. South and north.", + &[(Dir::South, 78), (Dir::North, 80)], + ), + room( + 80, + "Emberpeak Mines - The Rimeward Gate", + "Emberpeak Mines", + false, + "The tunnel ends at a gate of fused ice and iron, beyond which a stair \ + climbs into killing cold and white light. Warm air dies against it. The \ + mines fall away south; up leads into winter.", + &[(Dir::South, 79), (Dir::Up, 81)], + ), + // ---- Frostspire Ascent (ice mountain, tier 6-7) ----------------- + room( + 81, + "Frostspire Ascent - The Threshold of Ice", + "Frostspire Ascent", + false, + "You emerge onto a mountainside of blue glacial ice, and the cold takes \ + your breath as a physical theft. Wind screams past, carrying snow like \ + ground glass. Down returns to the warm dark; the ascent climbs north.", + &[(Dir::Down, 80), (Dir::North, 82)], + ), + room( + 82, + "Frostspire Ascent - The Wind-Carved Pass", + "Frostspire Ascent", + false, + "The path threads a pass where the wind has sculpted the ice into a \ + gallery of blades and figures, frozen courtiers bowing eternally to a \ + gale that never tires of them. Ways lead south, north, and east.", + &[(Dir::South, 81), (Dir::North, 83), (Dir::East, 84)], + ), + room( + 83, + "Frostspire Ascent - The Glass Stair", + "Frostspire Ascent", + false, + "Steps of clear ice climb the slope, and through them you can see down \ + into the glacier's heart, where dark shapes are frozen at depths no \ + summer will ever reach. Do not look too long. South and north.", + &[(Dir::South, 82), (Dir::North, 85)], + ), + room( + 84, + "Frostspire Ascent - The Frozen Caravan", + "Frostspire Ascent", + false, + "A merchant train lies where the cold caught it: ponies, carts, and \ + huddled drivers all locked in clear ice, perfectly preserved, their last \ + expressions still legible. A dead-end, and a warning. West returns to \ + the pass.", + &[(Dir::West, 82)], + ), + room( + 85, + "Frostspire Ascent - The Singing Crevasse", + "Frostspire Ascent", + false, + "A crevasse splits the path, and the wind crossing its mouth draws from \ + the depths a sound between a flute and a scream, rising and falling, a \ + song the mountain has practiced for ten thousand winters. South and north.", + &[(Dir::South, 83), (Dir::North, 86)], + ), + room( + 86, + "Frostspire Ascent - The Aurora Shelf", + "Frostspire Ascent", + false, + "A broad ice shelf opens to the sky, and overhead the aurora pours in \ + silent rivers of green and violet light, painting the snow in colors \ + that have no warmth in them at all. Ways lead south, north, and west.", + &[(Dir::South, 85), (Dir::North, 87), (Dir::West, 88)], + ), + room( + 87, + "Frostspire Ascent - The Hoarfrost Shrine", + "Frostspire Ascent", + false, + "A shrine to some forgotten winter-god stands sheathed in feathered \ + hoarfrost, its offering-bowl heaped with frozen coins and the frozen \ + hands of those who lingered to leave them. The cold here has intent. \ + South and north.", + &[(Dir::South, 86), (Dir::North, 89)], + ), + room( + 88, + "Frostspire Ascent - The Wendigo's Larder", + "Frostspire Ascent", + false, + "A dead-end ice cave hung with frozen carcasses, neatly butchered, \ + neatly stored, by something that understands winter and is patient with \ + it. Not all the carcasses are animals. The shelf lies east.", + &[(Dir::East, 86)], + ), + room( + 89, + "Frostspire Ascent - The Knife-Edge Ridge", + "Frostspire Ascent", + false, + "The path narrows to a spine of wind-scoured ice with a killing drop to \ + either hand, the whole world falling away into white cloud below. You \ + cross it one careful step at a time. South and north.", + &[(Dir::South, 87), (Dir::North, 90)], + ), + room( + 90, + "Frostspire Ascent - The Sky Altar", + "Frostspire Ascent", + false, + "A flat shelf near the summit holds an altar of black stone, the only \ + dark thing in all this white, swept perpetually clear of snow by a wind \ + that seems to serve it. Ways lead south, north, and east.", + &[(Dir::South, 89), (Dir::North, 91), (Dir::East, 92)], + ), + room( + 91, + "Frostspire Ascent - The Last Camp", + "Frostspire Ascent", + false, + "A ring of frozen tents marks where some expedition made its final stand \ + against the mountain. The cold preserved everything: the banked fire, \ + the open journals, the climbers in their bags, sleeping the sleep that \ + does not end. South and north.", + &[(Dir::South, 90), (Dir::North, 93)], + ), + room( + 92, + "Frostspire Ascent - The Wyrm's Eyrie", + "Frostspire Ascent", + false, + "A dead-end hollow scoured into the peak itself, floored with the picked \ + bones of centuries of prey. Ice crusts the walls in great raked furrows. \ + Something vast and white uncoils from the frost, and the storm itself \ + seems to draw breath. The altar lies west.", + &[(Dir::West, 90)], + ), + room( + 93, + "Frostspire Ascent - The Cloud-Breaking Stair", + "Frostspire Ascent", + false, + "The stair climbs through the cloud-deck at last, and breaks above it \ + into a thin, brilliant, freezing sunlight, the whole storm reduced to a \ + white sea churning beneath your feet. South and north.", + &[(Dir::South, 91), (Dir::North, 94)], + ), + room( + 94, + "Frostspire Ascent - The Summit Approach", + "Frostspire Ascent", + false, + "The peak is close now, a black needle of stone breaking through the ice, \ + and set into its base is a doorway too straight and too dark to be \ + natural, exhaling a cold that even the mountain does not own. South and north.", + &[(Dir::South, 93), (Dir::North, 95)], + ), + room( + 95, + "Frostspire Ascent - The Sunken Gate", + "Frostspire Ascent", + false, + "A vast gate of black basalt stands half-buried in the summit ice, its \ + lintel carved with a citadel that should not be here, on a peak, at the \ + top of the world. The way in leads down, into stone, into the past. \ + South returns to the snow.", + &[(Dir::South, 94), (Dir::Up, 96)], + ), + // ---- The Sunken Citadel (megadungeon, tier 7-8) ----------------- + room( + 96, + "The Sunken Citadel - The Hall of Entry", + "The Sunken Citadel", + false, + "You pass from ice into a hall of black stone so vast the lantern cannot \ + find its roof, and the cold here is not winter's cold but something \ + older and more deliberate. The gate is down and behind; the citadel \ + opens north.", + &[(Dir::Down, 95), (Dir::North, 97)], + ), + room( + 97, + "The Sunken Citadel - The Gallery of Kings", + "The Sunken Citadel", + false, + "Statues of armored kings line a processional gallery, each twice the \ + height of a man, each with its carved face deliberately, completely \ + chiseled away. Whatever they ruled wished them forgotten. Ways lead \ + south, north, and east.", + &[(Dir::South, 96), (Dir::North, 98), (Dir::East, 99)], + ), + room( + 98, + "The Sunken Citadel - The Shattered Rotunda", + "The Sunken Citadel", + false, + "A domed chamber lies cracked open by some ancient cataclysm, its mosaic \ + floor depicting a war between things with too many wings, half of it \ + fallen into a chasm that swallowed the rest of the story. South and north.", + &[(Dir::South, 97), (Dir::North, 100)], + ), + room( + 99, + "The Sunken Citadel - The Reliquary of Saints", + "The Sunken Citadel", + false, + "Glass cases line this dead-end vault, each meant to hold a holy bone, \ + each shattered from within. Whatever sainthood was kept here did not \ + stay dead, and did not stay holy. West returns to the gallery.", + &[(Dir::West, 97)], + ), + room( + 100, + "The Sunken Citadel - The Drowned Throne Room", + "The Sunken Citadel", + false, + "Black water fills the lower half of a throne room built for giants, and \ + the throne itself rises from the flood, empty, its arms gripped by \ + skeletal hands that did not belong to whoever last sat there. South and north.", + &[(Dir::South, 98), (Dir::North, 101)], + ), + room( + 101, + "The Sunken Citadel - The Iron Library", + "The Sunken Citadel", + false, + "Books bound in beaten iron fill shelves three storeys high, their pages \ + metal leaf, their words etched in a script that hurts to focus on. Some \ + volumes are chained shut. Some chains have been broken outward. Ways lead \ + south, north, and west.", + &[(Dir::South, 100), (Dir::North, 102), (Dir::West, 103)], + ), + room( + 102, + "The Sunken Citadel - The Orrery Vault", + "The Sunken Citadel", + false, + "A great brass orrery hangs broken in the dark, its planets stilled \ + mid-orbit, and the constellation it models is no sky you have ever seen \ + or would wish to. One sphere, black and unlabeled, still slowly turns. \ + South and north.", + &[(Dir::South, 101), (Dir::North, 104)], + ), + room( + 103, + "The Sunken Citadel - The Oath-Breaker's Cell", + "The Sunken Citadel", + false, + "A dead-end chapel-cell where a paladin was once walled up alive for a \ + sin the citadel would not name. The wall is broken now, from the inside, \ + and the figure that kneels in the rubble lifts a ruined helm and a \ + blackened sword. The library lies east.", + &[(Dir::East, 101)], + ), + room( + 104, + "The Sunken Citadel - The Gallery of Whispers", + "The Sunken Citadel", + false, + "A long hall where the black stone has been worked into ten thousand \ + carved mouths, all open, and as you pass each one breathes a single word \ + of a sentence ten thousand years long that no one was ever meant to hear \ + the end of. South and north.", + &[(Dir::South, 102), (Dir::North, 105)], + ), + room( + 105, + "The Sunken Citadel - The Obsidian Descent", + "The Sunken Citadel", + false, + "The floor falls away into a stair of polished obsidian spiraling down \ + into a red-lit dark, and the heat that rises from below is not fire's \ + heat but the warmth of something vast and living and awake. South leads \ + back; down leads to the throne beneath.", + &[(Dir::South, 104), (Dir::Down, 106)], + ), + // ---- The Obsidian Throne (endgame demon realm, tier 9-10) ------- + room( + 106, + "The Obsidian Throne - The Threshold of Embers", + "The Obsidian Throne", + false, + "You step into a realm that is no longer stone but something between \ + flesh and volcanic glass, and it is warm, and it pulses, and it knows \ + you are here. The stair climbs up behind you toward the world; the \ + throne-realm spreads south.", + &[(Dir::Up, 105), (Dir::South, 107)], + ), + room( + 107, + "The Obsidian Throne - The Avenue of the Damned", + "The Obsidian Throne", + false, + "A wide black road runs between two endless rows of the bound damned, \ + figures of ash and ember frozen mid-scream, lighting your way with the \ + dull red glow of their own slow burning. They turn their heads to watch \ + you pass. North and south.", + &[(Dir::North, 106), (Dir::South, 108)], + ), + room( + 108, + "The Obsidian Throne - The Court of Cinders", + "The Obsidian Throne", + false, + "A vast antechamber where lesser demons hold a mockery of court, perched \ + on thrones of cooling lava, their attention turning to you all at once \ + like a hundred furnace doors swinging open. Ways lead north, south, and east.", + &[(Dir::North, 107), (Dir::South, 109), (Dir::East, 110)], + ), + room( + 109, + "The Obsidian Throne - The Well of Souls", + "The Obsidian Throne", + false, + "A dead-end shaft plunges into a red abyss, and from it rises a column \ + of the screaming, swirling damned, an updraft of agony that lights the \ + whole chamber the color of a wound. The court lies north.", + &[(Dir::North, 108)], + ), + room( + 110, + "The Obsidian Throne - The Throne of Mal'gareth", + "The Obsidian Throne", + false, + "The world ends in a chamber of black glass and red fire, and upon a \ + throne grown from the realm itself sits the Archdemon Mal'gareth, vast \ + and patient and terribly amused, rising now to its full and dreadful \ + height to greet the mortal who came so very far only to kneel. The court \ + lies west.", + &[(Dir::West, 108)], ), ]; @@ -229,6 +1293,272 @@ pub fn seed_world() -> World { xp: 16, respawn_secs: 40, }, + // ---- Whisperwood (tier 2-3) ------------------------------------- + MobSpawn { + id: 10, + name: "a snarling wolf", + home: 16, + max_hp: 30, + damage: 6, + xp: 26, + respawn_secs: 45, + }, + MobSpawn { + id: 11, + name: "a giant forest spider", + home: 18, + max_hp: 34, + damage: 7, + xp: 30, + respawn_secs: 50, + }, + MobSpawn { + id: 12, + name: "a bog-rotted corpse", + home: 24, + max_hp: 38, + damage: 6, + xp: 32, + respawn_secs: 50, + }, + // Boss: Whisperwood + MobSpawn { + id: 13, + name: "the Elder Treant", + home: 28, + max_hp: 120, + damage: 12, + xp: 150, + respawn_secs: 300, + }, + // ---- Duskhollow Caverns (tier 3-4) ------------------------------ + MobSpawn { + id: 20, + name: "a clattering skeleton", + home: 34, + max_hp: 44, + damage: 8, + xp: 40, + respawn_secs: 55, + }, + MobSpawn { + id: 21, + name: "a cave lurker", + home: 40, + max_hp: 50, + damage: 9, + xp: 46, + respawn_secs: 55, + }, + MobSpawn { + id: 22, + name: "a grave-cold wraith", + home: 48, + max_hp: 54, + damage: 10, + xp: 52, + respawn_secs: 60, + }, + // Boss: Duskhollow Caverns + MobSpawn { + id: 23, + name: "the Bone Tyrant", + home: 44, + max_hp: 180, + damage: 16, + xp: 220, + respawn_secs: 300, + }, + // ---- Drowned Crypts (tier 4-5) ---------------------------------- + MobSpawn { + id: 30, + name: "a drowned revenant", + home: 54, + max_hp: 60, + damage: 11, + xp: 60, + respawn_secs: 60, + }, + MobSpawn { + id: 31, + name: "a crypt ghoul", + home: 58, + max_hp: 66, + damage: 12, + xp: 66, + respawn_secs: 60, + }, + MobSpawn { + id: 32, + name: "a pale drowned thing", + home: 61, + max_hp: 70, + damage: 13, + xp: 72, + respawn_secs: 65, + }, + // Boss: Drowned Crypts + MobSpawn { + id: 33, + name: "the Lich Vael", + home: 62, + max_hp: 240, + damage: 20, + xp: 320, + respawn_secs: 360, + }, + // ---- Emberpeak Mines (tier 5-6) --------------------------------- + MobSpawn { + id: 40, + name: "a molten husk", + home: 69, + max_hp: 78, + damage: 14, + xp: 80, + respawn_secs: 65, + }, + MobSpawn { + id: 41, + name: "a forge-wight", + home: 72, + max_hp: 84, + damage: 15, + xp: 88, + respawn_secs: 70, + }, + MobSpawn { + id: 42, + name: "an ember salamander", + home: 73, + max_hp: 90, + damage: 16, + xp: 96, + respawn_secs: 70, + }, + // Boss: Emberpeak Mines + MobSpawn { + id: 43, + name: "the Magma Colossus", + home: 77, + max_hp: 320, + damage: 26, + xp: 440, + respawn_secs: 360, + }, + // ---- Frostspire Ascent (tier 6-7) ------------------------------- + MobSpawn { + id: 50, + name: "a frost-bound revenant", + home: 84, + max_hp: 96, + damage: 17, + xp: 104, + respawn_secs: 70, + }, + MobSpawn { + id: 51, + name: "a rime-clawed wendigo", + home: 88, + max_hp: 104, + damage: 19, + xp: 116, + respawn_secs: 75, + }, + MobSpawn { + id: 52, + name: "an ice-wraith", + home: 91, + max_hp: 110, + damage: 20, + xp: 124, + respawn_secs: 75, + }, + // Boss: Frostspire Ascent + MobSpawn { + id: 53, + name: "the Wyrm of Frostspire", + home: 92, + max_hp: 420, + damage: 32, + xp: 600, + respawn_secs: 420, + }, + // ---- The Sunken Citadel (tier 7-8) ------------------------------ + MobSpawn { + id: 60, + name: "a faceless sentinel", + home: 99, + max_hp: 120, + damage: 22, + xp: 140, + respawn_secs: 80, + }, + MobSpawn { + id: 61, + name: "an iron-bound horror", + home: 100, + max_hp: 130, + damage: 24, + xp: 152, + respawn_secs: 80, + }, + MobSpawn { + id: 62, + name: "a whispering shade", + home: 104, + max_hp: 140, + damage: 26, + xp: 164, + respawn_secs: 85, + }, + // Boss: The Sunken Citadel + MobSpawn { + id: 63, + name: "the Fallen Paladin", + home: 103, + max_hp: 520, + damage: 38, + xp: 820, + respawn_secs: 420, + }, + // ---- The Obsidian Throne (tier 9-10) ---------------------------- + MobSpawn { + id: 70, + name: "a cinder fiend", + home: 107, + max_hp: 160, + damage: 30, + xp: 200, + respawn_secs: 90, + }, + MobSpawn { + id: 71, + name: "a lava-throned demon", + home: 108, + max_hp: 180, + damage: 33, + xp: 230, + respawn_secs: 90, + }, + MobSpawn { + id: 72, + name: "a soul-wracked horror", + home: 109, + max_hp: 200, + damage: 36, + xp: 260, + respawn_secs: 95, + }, + // Final boss + MobSpawn { + id: 73, + name: "the Archdemon Mal'gareth", + home: 110, + max_hp: 800, + damage: 48, + xp: 1500, + respawn_secs: 600, + }, ]; World { @@ -276,6 +1606,31 @@ mod tests { assert!(start.safe, "players should spawn in a safe room"); } + #[test] + fn world_has_expected_size_and_every_mob_homes_to_a_real_room() { + let world = seed_world(); + assert_eq!(world.rooms.len(), 110, "expected 110 authored rooms"); + for spawn in &world.spawns { + assert!( + world.rooms.contains_key(&spawn.home), + "mob {} ({}) homes to missing room {}", + spawn.id, + spawn.name, + spawn.home + ); + } + } + + #[test] + fn mob_spawn_ids_are_unique() { + let world = seed_world(); + let mut ids: Vec = world.spawns.iter().map(|s| s.id).collect(); + ids.sort_unstable(); + let count = ids.len(); + ids.dedup(); + assert_eq!(count, ids.len(), "duplicate mob spawn id"); + } + #[test] fn every_room_reachable_from_start() { let world = seed_world(); From 8ff3586e08ebfa43a564fa1fec1d55642fbc6ac9 Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Mon, 1 Jun 2026 13:52:32 +1000 Subject: [PATCH 03/20] feat(rooms): Lateania classes, 50 levels, abilities, inventory, and shops Build out the core RPG systems for the MUD. Classes (classes.rs): five classes - Warrior, Mage, Cleric, Rogue, Ranger - each with a rich description, a one-line role tagline, a distinct resource (Rage/Mana/Energy/Focus), and a passive class trait (Unbreakable, Arcane Mastery, Light of the Dawn, Opportunist, Hunter's Instinct). Players choose a class on entry. A formula-driven progression runs to level 50 with a rising cubic xp curve, so the climb is a real grind; stats scale per class per level. Abilities (abilities.rs): 55 abilities, 11 per class, unlocking across levels 1-50. Spells, skills, poisons, and buffs are unified under one AbilityEffect enum (Strike, DamageOverTime, Heal, HealOverTime, Empower, Ward, Stun, Finisher) so the runtime needs a single resolution path. Each has a cost, cooldown, and flavorful description. The action bar exposes unlocked abilities on number keys 1-9. Items and economy (items.rs): an item catalog of weapons, armor across eight equipment slots, rings, trinkets, consumables, and valuables, with rarities and stat modifiers. A fleshed inventory: carry items, equip one per slot (recomputes attack/hp/armor), use consumables, earn gold from kills, buy and sell at shops. Town and shops: Embergate gains a shop district (rooms 201-205: outfitter, apothecary, curio cart, bank, wall walk) and richer descriptions for the square, tavern, temple, and market. Four NPC merchants - Bruna Ironhand the smith, Tomas Threadneedle the outfitter, Old Mirela the apothecary, and Pell the Magpie - each run a themed storefront the player can browse and trade at. Movement gains diagonals (ne/nw/se/sw on y/u/n/m and the Dir enum). The world is now 115 rooms, still fully connected and reciprocal (verified by parse plus the inline graph tests). The runtime tick now also advances ability cooldowns, resource regen, buff/shield timers, and mob damage-over-time and stuns. Inline tests cover the xp curve and 50-level round-trip, per-class growth, ability roster integrity (unique ids, a level-1 opener and level-50 capstone each), item and shop integrity, class selection, buying/equipping, and the Warrior death-save trait. Signed-off-by: Tony Hosaroygard --- late-ssh/src/app/rooms/mud/abilities.rs | 196 ++++ late-ssh/src/app/rooms/mud/classes.rs | 250 +++++ late-ssh/src/app/rooms/mud/input.rs | 214 ++++- late-ssh/src/app/rooms/mud/items.rs | 303 ++++++ late-ssh/src/app/rooms/mud/mod.rs | 3 + late-ssh/src/app/rooms/mud/state.rs | 115 ++- late-ssh/src/app/rooms/mud/svc.rs | 1147 ++++++++++++++++++----- late-ssh/src/app/rooms/mud/ui.rs | 379 +++++--- late-ssh/src/app/rooms/mud/world.rs | 117 ++- 9 files changed, 2310 insertions(+), 414 deletions(-) create mode 100644 late-ssh/src/app/rooms/mud/abilities.rs create mode 100644 late-ssh/src/app/rooms/mud/classes.rs create mode 100644 late-ssh/src/app/rooms/mud/items.rs diff --git a/late-ssh/src/app/rooms/mud/abilities.rs b/late-ssh/src/app/rooms/mud/abilities.rs new file mode 100644 index 00000000..644a8290 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/abilities.rs @@ -0,0 +1,196 @@ +// Abilities and spells for Lateania. +// +// Every class skill and spell is one `Ability` record: pure data, the same shape +// a future content file would hold. Abilities unlock by level (see classes.rs), +// cost the class resource, and apply one effect when used. Spells, skills, +// poisons, and buffs are unified under `AbilityEffect` so the combat runtime +// needs only one resolution path - the highest-leverage design choice in the +// engine. + +use super::classes::{Class, Resource}; + +/// What an ability does when it lands. Instant effects apply once; the timed +/// variants seed an ongoing effect resolved on each world tick. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AbilityEffect { + /// Immediate damage to the current target. + Strike, + /// Damage now, then more each tick for `duration` ticks (bleeds, poisons, burns). + DamageOverTime, + /// Restore the caster's health immediately. + Heal, + /// Restore health now and each tick for `duration` ticks. + HealOverTime, + /// Temporary bonus to outgoing damage for `duration` ticks. + Empower, + /// Temporary damage-absorbing shield for `duration` ticks. + Ward, + /// Skip the target's next attack (a stun / disable). + Stun, + /// Heavy single hit that also briefly empowers the caster (execute-style). + Finisher, +} + +impl AbilityEffect { + pub fn label(self) -> &'static str { + match self { + Self::Strike => "damage", + Self::DamageOverTime => "damage over time", + Self::Heal => "heal", + Self::HealOverTime => "heal over time", + Self::Empower => "empower", + Self::Ward => "shield", + Self::Stun => "stun", + Self::Finisher => "finisher", + } + } +} + +/// A single learnable ability or spell. +#[derive(Clone, Copy, Debug)] +pub struct Ability { + pub id: u32, + pub name: &'static str, + pub desc: &'static str, + pub class: Class, + /// Character level at which this unlocks. + pub level_req: i32, + /// Resource spent to use it. + pub cost: i32, + pub resource: Resource, + /// World ticks before it can be used again. + pub cooldown_ticks: u32, + pub effect: AbilityEffect, + /// Base magnitude (damage, heal, shield, or bonus depending on effect). + pub magnitude: i32, + /// Ticks an over-time or buff effect persists (0 for instant effects). + pub duration: u8, +} + +/// The full ability roster. Ordered by class, then unlock level. +pub const ABILITIES: &[Ability] = &[ + // ---- Warrior (Rage) ------------------------------------------------- + Ability { id: 100, name: "Cleave", desc: "A wide, brutal swing that bites deep into the enemy before you.", class: Class::Warrior, level_req: 1, cost: 10, resource: Resource::Rage, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 14, duration: 0 }, + Ability { id: 101, name: "Rend", desc: "Tear a ragged wound that bleeds the foe with every passing moment.", class: Class::Warrior, level_req: 4, cost: 12, resource: Resource::Rage, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, magnitude: 6, duration: 4 }, + Ability { id: 102, name: "Shield Wall", desc: "Set your stance and raise your guard, turning aside the next blows.", class: Class::Warrior, level_req: 8, cost: 15, resource: Resource::Rage, cooldown_ticks: 6, effect: AbilityEffect::Ward, magnitude: 30, duration: 3 }, + Ability { id: 103, name: "Shield Bash", desc: "Slam your shield into the enemy's skull, stunning it senseless.", class: Class::Warrior, level_req: 12, cost: 18, resource: Resource::Rage, cooldown_ticks: 5, effect: AbilityEffect::Stun, magnitude: 10, duration: 1 }, + Ability { id: 104, name: "Battle Fury", desc: "Loose a war-cry that floods your arms with killing strength.", class: Class::Warrior, level_req: 16, cost: 20, resource: Resource::Rage, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 10, duration: 4 }, + Ability { id: 105, name: "Sunder", desc: "A crushing two-handed blow that shatters armor and bone alike.", class: Class::Warrior, level_req: 20, cost: 22, resource: Resource::Rage, cooldown_ticks: 3, effect: AbilityEffect::Strike, magnitude: 30, duration: 0 }, + Ability { id: 106, name: "Bloodthirst", desc: "Strike with such savagery that the enemy's blood revives you.", class: Class::Warrior, level_req: 25, cost: 24, resource: Resource::Rage, cooldown_ticks: 4, effect: AbilityEffect::Heal, magnitude: 28, duration: 0 }, + Ability { id: 107, name: "Earthshaker", desc: "Drive your weapon into the ground; the shock rends everything near.", class: Class::Warrior, level_req: 30, cost: 28, resource: Resource::Rage, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, magnitude: 12, duration: 5 }, + Ability { id: 108, name: "Indomitable", desc: "Plant your feet and refuse to fall, shrugging off mortal wounds.", class: Class::Warrior, level_req: 36, cost: 30, resource: Resource::Rage, cooldown_ticks: 10, effect: AbilityEffect::Ward, magnitude: 70, duration: 4 }, + Ability { id: 109, name: "Reckless Onslaught", desc: "Abandon all defense for a flurry of devastating, empowered blows.", class: Class::Warrior, level_req: 42, cost: 35, resource: Resource::Rage, cooldown_ticks: 10, effect: AbilityEffect::Empower, magnitude: 22, duration: 5 }, + Ability { id: 110, name: "Executioner's Strike", desc: "A single annihilating blow meant to end the fight outright.", class: Class::Warrior, level_req: 50, cost: 45, resource: Resource::Rage, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 80, duration: 2 }, + // ---- Mage (Mana) ---------------------------------------------------- + Ability { id: 200, name: "Firebolt", desc: "A dart of conjured flame that sears whatever it strikes.", class: Class::Mage, level_req: 1, cost: 8, resource: Resource::Mana, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 16, duration: 0 }, + Ability { id: 201, name: "Frost Nova", desc: "Ice erupts around the foe, locking it in place for a heartbeat.", class: Class::Mage, level_req: 5, cost: 14, resource: Resource::Mana, cooldown_ticks: 5, effect: AbilityEffect::Stun, magnitude: 8, duration: 1 }, + Ability { id: 202, name: "Immolate", desc: "Wreath the enemy in clinging fire that burns long after the cast.", class: Class::Mage, level_req: 9, cost: 16, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::DamageOverTime, magnitude: 9, duration: 5 }, + Ability { id: 203, name: "Mana Shield", desc: "Weave raw arcana into a shimmering barrier of force.", class: Class::Mage, level_req: 13, cost: 18, resource: Resource::Mana, cooldown_ticks: 6, effect: AbilityEffect::Ward, magnitude: 35, duration: 3 }, + Ability { id: 204, name: "Arcane Focus", desc: "Sharpen your will until every spell strikes with greater force.", class: Class::Mage, level_req: 17, cost: 20, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 12, duration: 4 }, + Ability { id: 205, name: "Lightning Lance", desc: "A spear of white lightning that punches clean through the target.", class: Class::Mage, level_req: 22, cost: 24, resource: Resource::Mana, cooldown_ticks: 2, effect: AbilityEffect::Strike, magnitude: 34, duration: 0 }, + Ability { id: 206, name: "Siphon Life", desc: "Draw the warmth from the enemy and pour it into your own flesh.", class: Class::Mage, level_req: 27, cost: 26, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::Heal, magnitude: 30, duration: 0 }, + Ability { id: 207, name: "Blizzard", desc: "Call down a storm of razored ice that flays all it touches.", class: Class::Mage, level_req: 32, cost: 30, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, magnitude: 13, duration: 5 }, + Ability { id: 208, name: "Time Warp", desc: "Bend the moment so the enemy stands frozen between heartbeats.", class: Class::Mage, level_req: 38, cost: 34, resource: Resource::Mana, cooldown_ticks: 9, effect: AbilityEffect::Stun, magnitude: 14, duration: 2 }, + Ability { id: 209, name: "Arcane Overload", desc: "Let power flood every nerve until your spells blaze unstoppable.", class: Class::Mage, level_req: 44, cost: 38, resource: Resource::Mana, cooldown_ticks: 10, effect: AbilityEffect::Empower, magnitude: 24, duration: 5 }, + Ability { id: 210, name: "Meteor", desc: "Tear a burning star from the sky and bring it down on your foe.", class: Class::Mage, level_req: 50, cost: 50, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 90, duration: 2 }, + // ---- Cleric (Mana) -------------------------------------------------- + Ability { id: 300, name: "Smite", desc: "Call down a lance of holy light upon the unworthy.", class: Class::Cleric, level_req: 1, cost: 9, resource: Resource::Mana, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 13, duration: 0 }, + Ability { id: 301, name: "Mend", desc: "Knit flesh and seal wounds with a whispered prayer.", class: Class::Cleric, level_req: 3, cost: 12, resource: Resource::Mana, cooldown_ticks: 2, effect: AbilityEffect::Heal, magnitude: 22, duration: 0 }, + Ability { id: 302, name: "Renewal", desc: "A blessing that mends a little more with every breath you take.", class: Class::Cleric, level_req: 7, cost: 16, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::HealOverTime, magnitude: 8, duration: 5 }, + Ability { id: 303, name: "Sacred Ward", desc: "Surround yourself in a corona of divine protection.", class: Class::Cleric, level_req: 11, cost: 18, resource: Resource::Mana, cooldown_ticks: 6, effect: AbilityEffect::Ward, magnitude: 32, duration: 3 }, + Ability { id: 304, name: "Holy Fire", desc: "Sear the wicked with flame that judges as it burns.", class: Class::Cleric, level_req: 15, cost: 20, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::DamageOverTime, magnitude: 10, duration: 4 }, + Ability { id: 305, name: "Blessing of Might", desc: "Anoint yourself so each strike falls with righteous force.", class: Class::Cleric, level_req: 19, cost: 22, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 11, duration: 4 }, + Ability { id: 306, name: "Greater Heal", desc: "A surge of restoring grace that mends even grievous harm.", class: Class::Cleric, level_req: 24, cost: 26, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::Heal, magnitude: 50, duration: 0 }, + Ability { id: 307, name: "Hammer of Faith", desc: "A spectral warhammer crashes down with crushing zeal.", class: Class::Cleric, level_req: 29, cost: 28, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::Strike, magnitude: 32, duration: 0 }, + Ability { id: 308, name: "Sanctuary", desc: "Raise hallowed ground that turns aside the cruelest wounds.", class: Class::Cleric, level_req: 35, cost: 32, resource: Resource::Mana, cooldown_ticks: 9, effect: AbilityEffect::Ward, magnitude: 65, duration: 4 }, + Ability { id: 309, name: "Divine Radiance", desc: "Blaze with the light of the Dawn until evil cannot bear to strike.", class: Class::Cleric, level_req: 41, cost: 36, resource: Resource::Mana, cooldown_ticks: 7, effect: AbilityEffect::Stun, magnitude: 18, duration: 2 }, + Ability { id: 310, name: "Judgment", desc: "Pronounce the final verdict of heaven upon a doomed soul.", class: Class::Cleric, level_req: 50, cost: 48, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 78, duration: 2 }, + // ---- Rogue (Energy) ------------------------------------------------- + Ability { id: 400, name: "Backstab", desc: "Slip a blade between the ribs where it does the most harm.", class: Class::Rogue, level_req: 1, cost: 8, resource: Resource::Energy, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 18, duration: 0 }, + Ability { id: 401, name: "Envenom", desc: "Coat your blade so each cut festers with creeping poison.", class: Class::Rogue, level_req: 4, cost: 10, resource: Resource::Energy, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, magnitude: 7, duration: 5 }, + Ability { id: 402, name: "Blind", desc: "Fling grit and powder to leave your foe swinging at shadows.", class: Class::Rogue, level_req: 8, cost: 12, resource: Resource::Energy, cooldown_ticks: 5, effect: AbilityEffect::Stun, magnitude: 8, duration: 1 }, + Ability { id: 403, name: "Evasion", desc: "Move like smoke, slipping every blow aimed your way.", class: Class::Rogue, level_req: 12, cost: 14, resource: Resource::Energy, cooldown_ticks: 7, effect: AbilityEffect::Ward, magnitude: 28, duration: 3 }, + Ability { id: 404, name: "Cold Blood", desc: "Steady your hand and your heart for one perfect, lethal cut.", class: Class::Rogue, level_req: 16, cost: 16, resource: Resource::Energy, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 14, duration: 3 }, + Ability { id: 405, name: "Eviscerate", desc: "A flurry of blades that opens the enemy from hip to throat.", class: Class::Rogue, level_req: 21, cost: 20, resource: Resource::Energy, cooldown_ticks: 2, effect: AbilityEffect::Strike, magnitude: 32, duration: 0 }, + Ability { id: 406, name: "Crippling Toxin", desc: "A paralytic venom that seizes the muscles and stops the breath.", class: Class::Rogue, level_req: 26, cost: 22, resource: Resource::Energy, cooldown_ticks: 6, effect: AbilityEffect::Stun, magnitude: 12, duration: 2 }, + Ability { id: 407, name: "Hemorrhage", desc: "Strike a vein that will not close, bleeding the foe dry.", class: Class::Rogue, level_req: 31, cost: 24, resource: Resource::Energy, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, magnitude: 14, duration: 5 }, + Ability { id: 408, name: "Shadowstep", desc: "Vanish into shadow and return where no blade can find you.", class: Class::Rogue, level_req: 37, cost: 28, resource: Resource::Energy, cooldown_ticks: 9, effect: AbilityEffect::Ward, magnitude: 60, duration: 4 }, + Ability { id: 409, name: "Killing Spree", desc: "Become a whirlwind of steel, every cut emptier and crueler.", class: Class::Rogue, level_req: 43, cost: 32, resource: Resource::Energy, cooldown_ticks: 10, effect: AbilityEffect::Empower, magnitude: 26, duration: 5 }, + Ability { id: 410, name: "Assassinate", desc: "The single strike every rogue trains a lifetime to land.", class: Class::Rogue, level_req: 50, cost: 42, resource: Resource::Energy, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 88, duration: 2 }, + // ---- Ranger (Focus) ------------------------------------------------- + Ability { id: 500, name: "Aimed Shot", desc: "Draw, breathe, and loose an arrow exactly where it will hurt.", class: Class::Ranger, level_req: 1, cost: 8, resource: Resource::Focus, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 15, duration: 0 }, + Ability { id: 501, name: "Serpent Sting", desc: "An arrow tipped in adder-venom that sickens with every beat.", class: Class::Ranger, level_req: 4, cost: 11, resource: Resource::Focus, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, magnitude: 7, duration: 5 }, + Ability { id: 502, name: "Snare Trap", desc: "Set a hidden snare that seizes the foe fast in its teeth.", class: Class::Ranger, level_req: 8, cost: 13, resource: Resource::Focus, cooldown_ticks: 5, effect: AbilityEffect::Stun, magnitude: 9, duration: 1 }, + Ability { id: 503, name: "Mark of the Hunt", desc: "Read your quarry's weakness and let every shot find it.", class: Class::Ranger, level_req: 12, cost: 15, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 10, duration: 4 }, + Ability { id: 504, name: "Mend Companion", desc: "Tend your own hurts with the field-craft of a thousand camps.", class: Class::Ranger, level_req: 16, cost: 17, resource: Resource::Focus, cooldown_ticks: 4, effect: AbilityEffect::HealOverTime, magnitude: 9, duration: 5 }, + Ability { id: 505, name: "Piercing Arrow", desc: "A shot loosed with such force it drives clean through plate.", class: Class::Ranger, level_req: 21, cost: 20, resource: Resource::Focus, cooldown_ticks: 2, effect: AbilityEffect::Strike, magnitude: 33, duration: 0 }, + Ability { id: 506, name: "Thornwall", desc: "Raise a bramble of arrows at your feet to fend off the charge.", class: Class::Ranger, level_req: 26, cost: 22, resource: Resource::Focus, cooldown_ticks: 7, effect: AbilityEffect::Ward, magnitude: 40, duration: 4 }, + Ability { id: 507, name: "Volley", desc: "Rain a quiver's worth of shafts down on the staggering foe.", class: Class::Ranger, level_req: 31, cost: 26, resource: Resource::Focus, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, magnitude: 14, duration: 5 }, + Ability { id: 508, name: "Concussive Shot", desc: "An arrow to the temple that leaves the enemy reeling and blind.", class: Class::Ranger, level_req: 37, cost: 30, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Stun, magnitude: 16, duration: 2 }, + Ability { id: 509, name: "Trueshot Aura", desc: "Enter the hunter's stillness where no arrow is ever wasted.", class: Class::Ranger, level_req: 43, cost: 34, resource: Resource::Focus, cooldown_ticks: 10, effect: AbilityEffect::Empower, magnitude: 25, duration: 5 }, + Ability { id: 510, name: "Hail of Death", desc: "Black out the sky with arrows and let it fall like judgment.", class: Class::Ranger, level_req: 50, cost: 44, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 84, duration: 2 }, +]; + +/// Abilities a character of this class has unlocked at this level, lowest level first. +pub fn unlocked_for(class: Class, level: i32) -> Vec<&'static Ability> { + let mut out: Vec<&'static Ability> = ABILITIES + .iter() + .filter(|a| a.class == class && a.level_req <= level) + .collect(); + out.sort_by_key(|a| a.level_req); + out +} + +/// The ability a class learns exactly at `level`, if any (for level-up messages). +pub fn learned_at(class: Class, level: i32) -> Option<&'static Ability> { + ABILITIES + .iter() + .find(|a| a.class == class && a.level_req == level) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::rooms::mud::classes::Class; + + #[test] + fn every_class_has_a_level_one_ability() { + for class in Class::ALL { + let early = unlocked_for(class, 1); + assert!( + !early.is_empty(), + "{:?} has no level-1 ability", + class + ); + } + } + + #[test] + fn ability_ids_are_unique() { + let mut ids: Vec = ABILITIES.iter().map(|a| a.id).collect(); + ids.sort_unstable(); + let n = ids.len(); + ids.dedup(); + assert_eq!(n, ids.len(), "duplicate ability id"); + } + + #[test] + fn every_class_has_a_capstone_at_fifty() { + for class in Class::ALL { + let capstone = ABILITIES + .iter() + .any(|a| a.class == class && a.level_req == 50); + assert!(capstone, "{:?} has no level-50 capstone", class); + } + } + + #[test] + fn unlocks_are_monotonic_with_level() { + for class in Class::ALL { + let low = unlocked_for(class, 10).len(); + let high = unlocked_for(class, 50).len(); + assert!(high >= low, "{:?} unlocks should not shrink", class); + assert!(high >= 8, "{:?} should have a deep kit by 50", class); + } + } +} diff --git a/late-ssh/src/app/rooms/mud/classes.rs b/late-ssh/src/app/rooms/mud/classes.rs new file mode 100644 index 00000000..306f2dc0 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/classes.rs @@ -0,0 +1,250 @@ +// Character classes for Lateania. +// +// Five classes, each with a distinct resource, a passive class trait, a rich +// description, and a 50-level progression. Progression is formula-driven (data, +// not a hand-typed table) so balance lives in one place. Abilities unlock by +// level in abilities.rs. + +/// The five playable classes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Class { + Warrior, + Mage, + Cleric, + Rogue, + Ranger, +} + +/// The resource a class spends on abilities. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Resource { + Rage, + Mana, + Energy, + Focus, +} + +impl Resource { + pub fn label(self) -> &'static str { + match self { + Self::Rage => "Rage", + Self::Mana => "Mana", + Self::Energy => "Energy", + Self::Focus => "Focus", + } + } +} + +/// Per-level stat shape for one class, computed from the level. +#[derive(Clone, Copy, Debug)] +pub struct ClassStats { + pub max_hp: i32, + pub max_resource: i32, + pub attack: i32, + /// Resource regained per world tick. + pub resource_regen: i32, +} + +impl Class { + pub const ALL: [Class; 5] = [ + Class::Warrior, + Class::Mage, + Class::Cleric, + Class::Rogue, + Class::Ranger, + ]; + + /// The hard level ceiling. Reaching it is the long game. + pub const MAX_LEVEL: i32 = 50; + + pub fn name(self) -> &'static str { + match self { + Self::Warrior => "Warrior", + Self::Mage => "Mage", + Self::Cleric => "Cleric", + Self::Rogue => "Rogue", + Self::Ranger => "Ranger", + } + } + + pub fn resource(self) -> Resource { + match self { + Self::Warrior => Resource::Rage, + Self::Mage => Resource::Mana, + Self::Cleric => Resource::Mana, + Self::Rogue => Resource::Energy, + Self::Ranger => Resource::Focus, + } + } + + /// A one-line role summary for the character sheet. + pub fn tagline(self) -> &'static str { + match self { + Self::Warrior => "Frontline bulwark - trades blows and outlasts.", + Self::Mage => "Glass-cannon spellcaster - immense burst, fragile frame.", + Self::Cleric => "Holy battle-healer - sustains, smites the undead.", + Self::Rogue => "Lethal duelist - stealth, poison, and sudden death.", + Self::Ranger => "Patient hunter - ranged pressure and field-craft.", + } + } + + /// The flavorful long description shown when choosing or inspecting a class. + pub fn description(self) -> &'static str { + match self { + Self::Warrior => "Where the line breaks, the Warrior stands. Clad in iron and \ + certainty, they read a battle in the rhythm of falling blows and answer it \ + with their own. Rage is their fuel: it does not pool while they rest but \ + kindles in the fight itself, every wound taken and given stoking it higher \ + until they end the matter with a single, ruinous stroke. Warriors do not \ + dazzle. They endure, and what they endure, they outlive.", + Self::Mage => "The Mage holds the oldest and most dangerous bargain: power \ + without armor, knowledge without mercy. They unmake the world in syllables, \ + calling fire that clings, frost that locks the joints, and lightning that \ + forgets nothing it touches. Mana is their well, deep but not bottomless, and \ + a Mage caught between spells is a candle in a gale. Strike first, strike \ + hardest, and never let the enemy close the distance.", + Self::Cleric => "The Cleric carries the Dawn into dark places. Theirs is the \ + hardest road: to mend and to smite with the same hand, to stand in the ruin \ + and refuse to let a companion fall. Holy fire answers the wicked and \ + searing light judges the undead, while a whispered prayer knits torn flesh \ + whole. A party with a Cleric is a party that comes home; a Cleric alone is \ + a quiet, patient kind of unkillable.", + Self::Rogue => "The Rogue settles fights before they are fairly begun. They \ + trade plate for shadow and brawn for precision, finding the gap in the \ + guard, the vein that will not close, the breath of inattention that ends a \ + life. Energy floods back swiftly, rewarding the quick and the cruel with \ + flurry after flurry. A Rogue who is seen has already made a mistake; a Rogue \ + who is not will open you from hip to throat and be gone.", + Self::Ranger => "The Ranger belongs to the long marches and the patient kill. \ + Bow in hand and the wilds at their back, they wear the enemy down from a \ + distance no blade can answer, layering venom and volley and the cold \ + wisdom of a hundred camps. Focus is their discipline, spent on shots that \ + never waste and traps that never miss. Give a Ranger room and time, and the \ + fight is already lost - the quarry simply has not been told yet.", + } + } + + /// The passive class trait: a defining, always-on edge. + pub fn trait_name(self) -> &'static str { + match self { + Self::Warrior => "Unbreakable", + Self::Mage => "Arcane Mastery", + Self::Cleric => "Light of the Dawn", + Self::Rogue => "Opportunist", + Self::Ranger => "Hunter's Instinct", + } + } + + pub fn trait_desc(self) -> &'static str { + match self { + Self::Warrior => "The first killing blow each fight is survived at 1 HP instead of falling.", + Self::Mage => "Every offensive spell strikes for extra arcane damage.", + Self::Cleric => "All healing is amplified, and the undead take added holy damage.", + Self::Rogue => "The opening strike of a fight always lands as a critical hit.", + Self::Ranger => "Strikes against a wounded foe (below half health) hit harder.", + } + } + + /// Full stat block at a given level. Linear-plus-curve growth keeps all five + /// classes climbing meaningfully to level 50. + pub fn stats_at(self, level: i32) -> ClassStats { + let lvl = level.clamp(1, Self::MAX_LEVEL); + let l = (lvl - 1) as i32; // levels gained past 1 + match self { + Self::Warrior => ClassStats { + max_hp: 48 + l * 12, + max_resource: 100, + attack: 6 + l * 2, + resource_regen: 6, + }, + Self::Mage => ClassStats { + max_hp: 30 + l * 7, + max_resource: 60 + l * 4, + attack: 5 + l * 2, + resource_regen: 7, + }, + Self::Cleric => ClassStats { + max_hp: 38 + l * 9, + max_resource: 55 + l * 4, + attack: 5 + (l * 3) / 2, + resource_regen: 6, + }, + Self::Rogue => ClassStats { + max_hp: 34 + l * 8, + max_resource: 100, + attack: 6 + l * 2, + resource_regen: 12, + }, + Self::Ranger => ClassStats { + max_hp: 36 + l * 8, + max_resource: 80 + l * 2, + attack: 6 + l * 2, + resource_regen: 9, + }, + } + } + + pub fn from_index(i: usize) -> Class { + Self::ALL[i % Self::ALL.len()] + } +} + +/// Total experience required to reach a given level. Smoothly rising curve so +/// the climb to 50 is a real journey: ~50 xp for level 2, tens of thousands by 50. +pub fn xp_for_level(level: i32) -> i64 { + if level <= 1 { + return 0; + } + let l = level as i64; + // Cubic-ish curve: 25*(l-1)^2 + 15*(l-1)^3/10, tuned for a long grind. + let d = l - 1; + 25 * d * d + (15 * d * d * d) / 10 +} + +/// The level a given total xp corresponds to (1..=MAX_LEVEL). +pub fn level_for_xp(xp: i64) -> i32 { + let mut level = 1; + while level < Class::MAX_LEVEL && xp >= xp_for_level(level + 1) { + level += 1; + } + level +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fifty_levels_are_reachable_and_capped() { + // Enough xp for any conceivable grind still caps at MAX_LEVEL. + assert_eq!(level_for_xp(i64::MAX / 2), Class::MAX_LEVEL); + assert_eq!(level_for_xp(0), 1); + } + + #[test] + fn xp_curve_is_strictly_increasing() { + for l in 2..=Class::MAX_LEVEL { + assert!( + xp_for_level(l) > xp_for_level(l - 1), + "xp curve must rise at level {l}" + ); + } + } + + #[test] + fn level_and_xp_round_trip() { + for l in 1..=Class::MAX_LEVEL { + let xp = xp_for_level(l); + assert_eq!(level_for_xp(xp), l, "xp boundary for level {l}"); + } + } + + #[test] + fn every_class_grows_hp_to_fifty() { + for class in Class::ALL { + let lo = class.stats_at(1).max_hp; + let hi = class.stats_at(50).max_hp; + assert!(hi > lo * 3, "{:?} should grow substantially by 50", class); + } + } +} diff --git a/late-ssh/src/app/rooms/mud/input.rs b/late-ssh/src/app/rooms/mud/input.rs index 6b83e2a4..9ef5273d 100644 --- a/late-ssh/src/app/rooms/mud/input.rs +++ b/late-ssh/src/app/rooms/mud/input.rs @@ -1,36 +1,117 @@ // Key routing for Lateania. // -// IMPORTANT constraint: the rooms layer routes several keys to the embedded -// chat before a game ever sees them - notably `i`, `j`, `k` (and `d`/`r`/`e`/ -// `p`/`c`/`f`/`g` while a chat message is selected). See -// `rooms/input.rs::should_route_active_room_chat_key`. So the slice avoids those -// for movement and uses a key scheme that survives the chat-first heuristic: +// The rooms layer routes several keys to the embedded chat before a game ever +// sees them - notably `i`, `j`, `k` (and `d`/`r`/`e`/`p`/`c`/`f`/`g` while a +// chat message is selected). See `rooms/input.rs::should_route_active_room_chat_key`. +// Number keys 1-9 are NOT intercepted (TicTacToe uses them), so the action bar +// and list selection live there. // -// arrows / w a s d movement (n/s via w/s, e/w via d/a) -// < and > up / down -// space or x attack what's here -// z flee -// l look again -// Esc / q leave the world +// Key scheme: +// - Before choosing a class: 1-5 pick Warrior/Mage/Cleric/Rogue/Ranger. +// - Movement: w/a/s/d and arrows (N/S/E/W); y/u/b/n diagonals; < > up/down. +// - Combat: space/x attack; 1-9 use the ability in that action-bar slot; z flee. +// - Panels: c character, v abilities, o look, b shop, t inventory ("things"). +// In a list panel, 1-9 select a row, Enter activates (equip/use/buy), +// w/s move the cursor, x sells (inventory). +// - Esc / q leave the world. // -// A full typed MUD prompt ("attack goblin", "look chest") needs an input-capture -// mode that suppresses chat routing; that is deferred to a later phase and noted -// in the design docs. +// A full typed command prompt needs an input-capture mode; deferred. -use crate::app::rooms::{backend::InputAction, mud::state::State, mud::world::Dir}; +use crate::app::rooms::{ + backend::InputAction, + mud::classes::Class, + mud::state::{Panel, State}, + mud::world::Dir, +}; pub fn handle_key(state: &mut State, byte: u8) -> InputAction { + // Quit is always available. + if matches!(byte, 0x1B | b'q' | b'Q') { + state.leave_world(); + return InputAction::Leave; + } + + let view = state.view(); + + // Class selection gate: until a class is chosen, 1-5 pick it and nothing else acts. + if view.joined && !view.classed { + match byte { + b'1' => state.choose_class(Class::Warrior), + b'2' => state.choose_class(Class::Mage), + b'3' => state.choose_class(Class::Cleric), + b'4' => state.choose_class(Class::Rogue), + b'5' => state.choose_class(Class::Ranger), + _ => return InputAction::Ignored, + } + return InputAction::Handled; + } + + let panel = state.panel(); + let in_list = matches!(panel, Panel::Inventory | Panel::Shop); + + // Number keys: select a list row when a list panel is open, else use an ability. + if (b'1'..=b'9').contains(&byte) { + let n = (byte - b'1') as usize; + if in_list { + // Move cursor to the chosen row, then activate it. + // (cursor_down/up keep us in-bounds; jump by stepping.) + select_row(state, n); + state.activate_selection(); + } else { + state.use_ability((byte - b'0') as u8); + } + return InputAction::Handled; + } + match byte { - 0x1B | b'q' | b'Q' => { - state.leave_world(); - InputAction::Leave + // Panels. + b'c' | b'C' => { + state.toggle_panel(Panel::Character); + InputAction::Handled + } + b'v' | b'V' => { + state.toggle_panel(Panel::Abilities); + InputAction::Handled } + b't' | b'T' => { + state.toggle_panel(Panel::Inventory); + InputAction::Handled + } + b'b' | b'B' => { + // Shop only opens where a merchant stands. + if view.shop.is_some() { + state.toggle_panel(Panel::Shop); + } + InputAction::Handled + } + b'o' | b'O' => { + state.set_panel(Panel::Room); + state.look(); + InputAction::Handled + } + b'\r' | b'\n' => { + if in_list { + state.activate_selection(); + } else { + state.attack(); + } + InputAction::Handled + } + // Cursor movement inside list panels; otherwise N/S movement. b'w' | b'W' => { - state.go(Dir::North); + if in_list { + state.cursor_up(); + } else { + state.go(Dir::North); + } InputAction::Handled } b's' | b'S' => { - state.go(Dir::South); + if in_list { + state.cursor_down(); + } else { + state.go(Dir::South); + } InputAction::Handled } b'a' | b'A' | b'h' | b'H' => { @@ -38,12 +119,27 @@ pub fn handle_key(state: &mut State, byte: u8) -> InputAction { InputAction::Handled } b'd' | b'D' | b'l' | b'L' => { - // `l` doubles as east here for convenience; `d` is east. (Both are - // safe: chat only claims `d`/`l` when a message is selected, in which - // case the player is interacting with chat anyway.) state.go(Dir::East); InputAction::Handled } + // Diagonals (roguelike yubn). + b'y' | b'Y' => { + state.go(Dir::Northwest); + InputAction::Handled + } + b'u' | b'U' => { + state.go(Dir::Northeast); + InputAction::Handled + } + // Note: `b` is the shop key above, so southeast/southwest use n/m. + b'n' | b'N' => { + state.go(Dir::Southeast); + InputAction::Handled + } + b'm' | b'M' => { + state.go(Dir::Southwest); + InputAction::Handled + } b'<' | b',' => { state.go(Dir::Up); InputAction::Handled @@ -52,7 +148,16 @@ pub fn handle_key(state: &mut State, byte: u8) -> InputAction { state.go(Dir::Down); InputAction::Handled } - b' ' | b'x' | b'X' | b'\r' | b'\n' => { + // Combat. + b'x' | b'X' => { + if in_list { + state.sell_selection(); + } else if panel == Panel::Room || panel == Panel::Character || panel == Panel::Abilities { + state.attack(); + } + InputAction::Handled + } + b' ' => { state.attack(); InputAction::Handled } @@ -60,18 +165,41 @@ pub fn handle_key(state: &mut State, byte: u8) -> InputAction { state.flee(); InputAction::Handled } - b'o' | b'O' => { - state.look(); - InputAction::Handled - } _ => InputAction::Ignored, } } +/// Move the list cursor to row `target` by stepping (keeps in-bounds clamping). +fn select_row(state: &mut State, target: usize) { + let cur = state.cursor(); + if target > cur { + for _ in 0..(target - cur) { + state.cursor_down(); + } + } else { + for _ in 0..(cur - target) { + state.cursor_up(); + } + } +} + pub fn handle_arrow(state: &mut State, key: u8) -> bool { + let in_list = matches!(state.panel(), Panel::Inventory | Panel::Shop); match key { - b'A' => state.go(Dir::North), - b'B' => state.go(Dir::South), + b'A' => { + if in_list { + state.cursor_up(); + } else { + state.go(Dir::North); + } + } + b'B' => { + if in_list { + state.cursor_down(); + } else { + state.go(Dir::South); + } + } b'C' => state.go(Dir::East), b'D' => state.go(Dir::West), _ => return false, @@ -79,28 +207,18 @@ pub fn handle_arrow(state: &mut State, key: u8) -> bool { true } -/// The four CSI arrow finals this game consumes. Exposed for the input-routing -/// test so the mapping stays in one place. -pub fn arrow_maps_to_direction(key: u8) -> Option { - match key { - b'A' => Some(Dir::North), - b'B' => Some(Dir::South), - b'C' => Some(Dir::East), - b'D' => Some(Dir::West), - _ => None, - } -} - #[cfg(test)] mod tests { use super::*; #[test] - fn arrows_map_to_the_four_compass_directions() { - assert_eq!(arrow_maps_to_direction(b'A'), Some(Dir::North)); - assert_eq!(arrow_maps_to_direction(b'B'), Some(Dir::South)); - assert_eq!(arrow_maps_to_direction(b'C'), Some(Dir::East)); - assert_eq!(arrow_maps_to_direction(b'D'), Some(Dir::West)); - assert_eq!(arrow_maps_to_direction(b'Z'), None); + fn diagonal_keys_are_distinct_directions() { + // y/u/n/m map to the four diagonals; ensure no overlap with cardinals. + let diag = [Dir::Northwest, Dir::Northeast, Dir::Southeast, Dir::Southwest]; + for (i, a) in diag.iter().enumerate() { + for b in diag.iter().skip(i + 1) { + assert_ne!(a, b, "diagonals must be distinct"); + } + } } } diff --git a/late-ssh/src/app/rooms/mud/items.rs b/late-ssh/src/app/rooms/mud/items.rs new file mode 100644 index 00000000..1a5304f3 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/items.rs @@ -0,0 +1,303 @@ +// Items, equipment, inventory, and shop NPCs for Lateania. +// +// Items are static data with stat modifiers. A character carries an inventory of +// item ids and equips one item per slot; equipping recomputes derived stats. +// Consumables apply an effect when used. Shops are NPC-run storefronts in the +// town of Embergate, each NPC keyed to a room and selling a themed catalog. + +use super::classes::Class; + +/// Where an item can be worn. Consumables and valuables have no slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Slot { + Weapon, + Head, + Chest, + Legs, + Hands, + Feet, + Ring, + Trinket, +} + +impl Slot { + pub fn label(self) -> &'static str { + match self { + Self::Weapon => "weapon", + Self::Head => "head", + Self::Chest => "chest", + Self::Legs => "legs", + Self::Hands => "hands", + Self::Feet => "feet", + Self::Ring => "ring", + Self::Trinket => "trinket", + } + } + + pub const WEARABLE: [Slot; 8] = [ + Slot::Weapon, + Slot::Head, + Slot::Chest, + Slot::Legs, + Slot::Hands, + Slot::Feet, + Slot::Ring, + Slot::Trinket, + ]; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Rarity { + Common, + Uncommon, + Rare, + Epic, + Legendary, +} + +impl Rarity { + pub fn label(self) -> &'static str { + match self { + Self::Common => "common", + Self::Uncommon => "uncommon", + Self::Rare => "rare", + Self::Epic => "epic", + Self::Legendary => "legendary", + } + } +} + +/// What kind of thing an item is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ItemKind { + /// Worn in a slot; contributes stat mods. + Equipment(Slot), + /// Used from inventory; heals or restores resource. + Consumable { heal: i32, restore: i32 }, + /// Sold for gold; no other use. + Valuable, +} + +/// Flat stat bonuses an equipped item grants. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StatMods { + pub attack: i32, + pub max_hp: i32, + pub armor: i32, +} + +/// A static item definition. +#[derive(Clone, Copy, Debug)] +pub struct Item { + pub id: u32, + pub name: &'static str, + pub desc: &'static str, + pub kind: ItemKind, + pub rarity: Rarity, + pub mods: StatMods, + /// Buy price in gold; sells back at roughly half. + pub price: i64, + /// If set, this gear is tuned for one class (a hint, not a hard restriction). + pub class_hint: Option, +} + +impl Item { + pub fn slot(&self) -> Option { + match self.kind { + ItemKind::Equipment(slot) => Some(slot), + _ => None, + } + } + + pub fn sell_price(&self) -> i64 { + (self.price / 2).max(1) + } +} + +const fn eq( + id: u32, + name: &'static str, + desc: &'static str, + slot: Slot, + rarity: Rarity, + attack: i32, + max_hp: i32, + armor: i32, + price: i64, + class_hint: Option, +) -> Item { + Item { + id, + name, + desc, + kind: ItemKind::Equipment(slot), + rarity, + mods: StatMods { + attack, + max_hp, + armor, + }, + price, + class_hint, + } +} + +const fn consumable( + id: u32, + name: &'static str, + desc: &'static str, + rarity: Rarity, + heal: i32, + restore: i32, + price: i64, +) -> Item { + Item { + id, + name, + desc, + kind: ItemKind::Consumable { heal, restore }, + rarity, + mods: StatMods { + attack: 0, + max_hp: 0, + armor: 0, + }, + price, + class_hint: None, + } +} + +/// The full item catalog. +pub const ITEMS: &[Item] = &[ + // ---- Weapons (the Smithy) ------------------------------------------- + eq(1000, "Rusty Shortsword", "A pitted blade, but it holds an edge.", Slot::Weapon, Rarity::Common, 4, 0, 0, 25, None), + eq(1001, "Iron Longsword", "Honest steel, balanced and keen.", Slot::Weapon, Rarity::Common, 8, 0, 0, 80, Some(Class::Warrior)), + eq(1002, "Oak Hunting Bow", "A supple bow strung with waxed gut.", Slot::Weapon, Rarity::Common, 8, 0, 0, 80, Some(Class::Ranger)), + eq(1003, "Apprentice Staff", "Carved with channels for raw mana.", Slot::Weapon, Rarity::Common, 7, 0, 0, 75, Some(Class::Mage)), + eq(1004, "Twin Daggers", "A matched pair, light and wickedly quick.", Slot::Weapon, Rarity::Uncommon, 9, 0, 0, 110, Some(Class::Rogue)), + eq(1005, "Blessed Mace", "Its head is graven with the rising sun.", Slot::Weapon, Rarity::Uncommon, 8, 6, 0, 120, Some(Class::Cleric)), + eq(1006, "Steel Greatsword", "A two-handed brute that bites through mail.", Slot::Weapon, Rarity::Rare, 16, 0, 0, 320, Some(Class::Warrior)), + eq(1007, "Yew Warbow", "Tall as a man and twice as unforgiving.", Slot::Weapon, Rarity::Rare, 15, 0, 0, 300, Some(Class::Ranger)), + eq(1008, "Runed Battlestaff", "Old runes wake and glow when you hold it.", Slot::Weapon, Rarity::Rare, 15, 0, 0, 300, Some(Class::Mage)), + eq(1009, "Embergate Falchion", "Forged in the town's own furnace; ever warm.", Slot::Weapon, Rarity::Epic, 24, 8, 0, 900, None), + // ---- Armor (the Outfitter) ------------------------------------------ + eq(1100, "Padded Cap", "Quilted cloth, better than a bare head.", Slot::Head, Rarity::Common, 0, 6, 1, 20, None), + eq(1101, "Leather Jerkin", "Boiled hide, scarred from a previous owner.", Slot::Chest, Rarity::Common, 0, 12, 2, 45, None), + eq(1102, "Leather Leggings", "Supple and quiet on the road.", Slot::Legs, Rarity::Common, 0, 9, 2, 40, None), + eq(1103, "Worn Gloves", "The fingers are reinforced with hide.", Slot::Hands, Rarity::Common, 0, 4, 1, 18, None), + eq(1104, "Traveler's Boots", "Broken in across a hundred leagues.", Slot::Feet, Rarity::Common, 0, 5, 1, 22, None), + eq(1105, "Iron Helm", "A plain bucket of a helm, but it works.", Slot::Head, Rarity::Uncommon, 0, 14, 3, 90, Some(Class::Warrior)), + eq(1106, "Chainmail Hauberk", "Riveted links that turn a blade.", Slot::Chest, Rarity::Uncommon, 0, 26, 5, 180, Some(Class::Warrior)), + eq(1107, "Mage's Robe", "Woven with silver thread that hums faintly.", Slot::Chest, Rarity::Uncommon, 4, 16, 1, 170, Some(Class::Mage)), + eq(1108, "Shadowweave Vest", "Drinks the light; you are hard to look at.", Slot::Chest, Rarity::Rare, 6, 22, 3, 340, Some(Class::Rogue)), + eq(1109, "Dawnplate Cuirass", "Holy steel that gleams even in the dark.", Slot::Chest, Rarity::Epic, 4, 40, 8, 880, Some(Class::Cleric)), + // ---- Trinkets and rings (the Curio Cart) ---------------------------- + eq(1200, "Copper Band", "A simple ring, faintly lucky.", Slot::Ring, Rarity::Common, 1, 4, 0, 30, None), + eq(1201, "Garnet Ring", "The stone catches firelight and holds it.", Slot::Ring, Rarity::Uncommon, 3, 8, 0, 130, None), + eq(1202, "Signet of Embergate", "Marks the bearer as a friend of the town.", Slot::Ring, Rarity::Rare, 5, 14, 2, 360, None), + eq(1203, "Hare's-Foot Charm", "For luck, and the speed to use it.", Slot::Trinket, Rarity::Common, 2, 3, 0, 35, None), + eq(1204, "Vial of Saint's Tears", "Warm to the touch; it wards off despair.", Slot::Trinket, Rarity::Uncommon, 0, 18, 2, 150, None), + eq(1205, "Wyrmscale Talisman", "A single frost-dragon scale, cold forever.", Slot::Trinket, Rarity::Epic, 8, 20, 4, 820, None), + // ---- Consumables (the Apothecary) ----------------------------------- + consumable(1300, "Minor Healing Draught", "A bitter red tonic that closes small wounds.", Rarity::Common, 30, 0, 20), + consumable(1301, "Healing Potion", "The reliable choice of every sensible adventurer.", Rarity::Uncommon, 70, 0, 55), + consumable(1302, "Greater Healing Elixir", "Mends even grievous hurts in moments.", Rarity::Rare, 150, 0, 140), + consumable(1303, "Draught of Vigor", "Restores the fire that fuels your craft.", Rarity::Uncommon, 0, 60, 50), + consumable(1304, "Elixir of Renewal", "Restores both flesh and will at once.", Rarity::Epic, 120, 80, 220), + // ---- Valuables (sold to any merchant) ------------------------------- + Item { id: 1400, name: "Gold Ingot", desc: "A solid bar, good anywhere coin is taken.", kind: ItemKind::Valuable, rarity: Rarity::Uncommon, mods: StatMods { attack: 0, max_hp: 0, armor: 0 }, price: 200, class_hint: None }, + Item { id: 1401, name: "Cut Ruby", desc: "A merchant's eyes will light at the sight of it.", kind: ItemKind::Valuable, rarity: Rarity::Rare, mods: StatMods { attack: 0, max_hp: 0, armor: 0 }, price: 500, class_hint: None }, +]; + +pub fn item(id: u32) -> Option<&'static Item> { + ITEMS.iter().find(|i| i.id == id) +} + +/// A shop run by an NPC in a specific town room. +#[derive(Clone, Copy, Debug)] +pub struct Shop { + pub room: super::world::RoomId, + pub npc_name: &'static str, + pub shop_name: &'static str, + /// The line the NPC greets shoppers with. + pub greeting: &'static str, + pub stock: &'static [u32], +} + +/// Every storefront in Embergate, keyed to the room its NPC stands in. +pub const SHOPS: &[Shop] = &[ + Shop { + room: 3, // Market Row -> the smithy + npc_name: "Bruna Ironhand", + shop_name: "The Ember Forge", + greeting: "Bruna looks up from the anvil, soot on her brow. \"Steel for steel's work. What'll it be?\"", + stock: &[1000, 1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009], + }, + Shop { + room: 201, + npc_name: "Tomas Threadneedle", + shop_name: "The Outfitter's Stall", + greeting: "A wiry man peers over a counter heaped with hide and mail. \"Armor keeps a body breathing. Browse, browse.\"", + stock: &[1100, 1101, 1102, 1103, 1104, 1105, 1106, 1107, 1108, 1109], + }, + Shop { + room: 202, + npc_name: "Old Mirela", + shop_name: "The Apothecary", + greeting: "Shelves of bottles glint behind a stooped woman who smells of crushed herbs. \"Hurt, are you? I have just the thing.\"", + stock: &[1300, 1301, 1302, 1303, 1304], + }, + Shop { + room: 203, + npc_name: "Pell the Magpie", + shop_name: "The Curio Cart", + greeting: "A grinning fellow guards a cart of glittering oddments. \"Rings, charms, lucky bits and bobs! All genuine, mostly.\"", + stock: &[1200, 1201, 1202, 1203, 1204, 1205], + }, +]; + +pub fn shop_at(room: super::world::RoomId) -> Option<&'static Shop> { + SHOPS.iter().find(|s| s.room == room) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_ids_are_unique() { + let mut ids: Vec = ITEMS.iter().map(|i| i.id).collect(); + ids.sort_unstable(); + let n = ids.len(); + ids.dedup(); + assert_eq!(n, ids.len(), "duplicate item id"); + } + + #[test] + fn every_shop_sells_real_items() { + for shop in SHOPS { + assert!(!shop.stock.is_empty(), "{} has no stock", shop.shop_name); + for id in shop.stock { + assert!(item(*id).is_some(), "shop sells missing item {id}"); + } + } + } + + #[test] + fn equipment_reports_its_slot() { + for it in ITEMS { + if let ItemKind::Equipment(slot) = it.kind { + assert_eq!(it.slot(), Some(slot)); + } else { + assert_eq!(it.slot(), None); + } + } + } + + #[test] + fn sell_price_is_never_zero() { + for it in ITEMS { + assert!(it.sell_price() >= 1, "{} sells for nothing", it.name); + } + } +} diff --git a/late-ssh/src/app/rooms/mud/mod.rs b/late-ssh/src/app/rooms/mud/mod.rs index 237a5df6..7c667c97 100644 --- a/late-ssh/src/app/rooms/mud/mod.rs +++ b/late-ssh/src/app/rooms/mud/mod.rs @@ -3,8 +3,11 @@ // World & design by Tasmania (Tony Hosaroygard) - hardlygospel.github.io // With heartfelt thanks to the creator of late.sh and every developer who // contributes to it. This world stands on the foundation you built. +pub mod abilities; +pub mod classes; pub mod create_modal; pub mod input; +pub mod items; pub mod manager; pub mod state; pub mod svc; diff --git a/late-ssh/src/app/rooms/mud/state.rs b/late-ssh/src/app/rooms/mud/state.rs index 5a24ab5e..a0378d54 100644 --- a/late-ssh/src/app/rooms/mud/state.rs +++ b/late-ssh/src/app/rooms/mud/state.rs @@ -1,21 +1,36 @@ // Per-session client wrapper for a Lateania world. // // One State per session. Holds a cached snapshot drained from the service's -// watch channel each tick, plus local-only UI state (log scroll). All real -// actions delegate to the service's *_task methods; this struct never blocks -// and never mutates world truth. +// watch channel each tick, plus local-only UI state: which side panel is open +// (room / character / abilities / inventory / shop) and a selection cursor for +// list panels. All real actions delegate to the service's *_task methods; this +// struct never blocks and never mutates world truth. use tokio::sync::watch; use uuid::Uuid; +use super::classes::Class; use super::svc::{MudService, MudSnapshot, PlayerView, empty_player_view}; use super::world::Dir; +/// Which side panel the session is looking at. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Panel { + Room, + Character, + Abilities, + Inventory, + Shop, +} + pub struct State { user_id: Uuid, snapshot: MudSnapshot, svc: MudService, snapshot_rx: watch::Receiver, + panel: Panel, + /// Selection cursor for the inventory/shop list panels. + cursor: usize, } impl State { @@ -27,8 +42,9 @@ impl State { snapshot, svc, snapshot_rx, + panel: Panel::Room, + cursor: 0, }; - // Auto-join the world on entry; the slice has no separate "sit" step. state.svc.join_task(user_id); state } @@ -51,20 +67,67 @@ impl State { self.svc.touch_activity_task(self.user_id); } - /// This player's view, or an empty placeholder until the join lands. pub fn view(&self) -> PlayerView { self.snapshot .players .get(&self.user_id) .cloned() - .unwrap_or_else(|| empty_player_view(self.snapshot.room_id)) + .unwrap_or_else(empty_player_view) } pub fn player_count(&self) -> usize { self.snapshot.players.values().filter(|p| p.joined).count() } - // ---- Actions (delegate to the service) ------------------------------ + pub fn panel(&self) -> Panel { + self.panel + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn set_panel(&mut self, panel: Panel) { + if self.panel != panel { + self.panel = panel; + self.cursor = 0; + } + } + + pub fn toggle_panel(&mut self, panel: Panel) { + if self.panel == panel { + self.panel = Panel::Room; + } else { + self.panel = panel; + } + self.cursor = 0; + } + + /// Current list length for whichever list panel is active (for cursor clamp). + fn list_len(&self) -> usize { + match self.panel { + Panel::Inventory => self.view().inventory.len(), + Panel::Shop => self.view().shop.map(|s| s.entries.len()).unwrap_or(0), + _ => 0, + } + } + + pub fn cursor_up(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + pub fn cursor_down(&mut self) { + let len = self.list_len(); + if len > 0 && self.cursor + 1 < len { + self.cursor += 1; + } + } + + // ---- Actions -------------------------------------------------------- + + pub fn choose_class(&self, class: Class) { + self.svc.choose_class_task(self.user_id, class); + } pub fn go(&self, dir: Dir) { self.svc.move_task(self.user_id, dir); @@ -78,6 +141,10 @@ impl State { self.svc.attack_task(self.user_id); } + pub fn use_ability(&self, slot: u8) { + self.svc.ability_task(self.user_id, slot); + } + pub fn flee(&self) { self.svc.flee_task(self.user_id); } @@ -85,4 +152,38 @@ impl State { pub fn leave_world(&self) { self.svc.leave_task(self.user_id); } + + /// Context action on the selected list row (equip/use in inventory, buy in shop). + pub fn activate_selection(&self) { + match self.panel { + Panel::Inventory => { + let view = self.view(); + if let Some(row) = view.inventory.get(self.cursor) { + if row.slot.is_some() { + self.svc.equip_task(self.user_id, row.item_id); + } else { + self.svc.use_item_task(self.user_id, row.item_id); + } + } + } + Panel::Shop => { + if let Some(shop) = self.view().shop { + if let Some(entry) = shop.entries.get(self.cursor) { + self.svc.buy_task(self.user_id, entry.item_id); + } + } + } + _ => {} + } + } + + /// Secondary action: sell the selected inventory row at a shop. + pub fn sell_selection(&self) { + if self.panel == Panel::Inventory { + let view = self.view(); + if let Some(row) = view.inventory.get(self.cursor) { + self.svc.sell_task(self.user_id, row.item_id); + } + } + } } diff --git a/late-ssh/src/app/rooms/mud/svc.rs b/late-ssh/src/app/rooms/mud/svc.rs index 921a1b28..e7739d7a 100644 --- a/late-ssh/src/app/rooms/mud/svc.rs +++ b/late-ssh/src/app/rooms/mud/svc.rs @@ -3,11 +3,12 @@ // One service per game room (the late "room" is the whole world). Many sessions // share it via the manager's HashMap; each has its own `state::State`. Mutations // serialize through `Arc>`; reads are lock-free against each -// session's cached snapshot. A background tick loop advances combat rounds and -// mob respawns, then publishes a fresh snapshot. +// session's cached snapshot. A background tick loop advances combat rounds, +// effects, resource regen, and respawns, then publishes a fresh snapshot. // -// Combat is round-based on the world tick (every `TICK_SECS`), matching the -// classic MUD feel and reusing the loop shape proven by Tron. +// Systems wired here: five classes with a 50-level progression and a passive +// trait (classes.rs), abilities and spells unified under one effect resolver +// (abilities.rs), and an inventory / equipment / gold / shop economy (items.rs). use std::{ collections::HashMap, @@ -23,17 +24,19 @@ use crate::app::{ rooms::backend::RoomGameEvent, }; +use super::abilities::{Ability, AbilityEffect, learned_at, unlocked_for}; +use super::classes::{Class, level_for_xp, xp_for_level}; +use super::items::{ItemKind, Slot, item, shop_at}; use super::world::{Dir, MobSpawn, RoomId, World, seed_world}; /// World heartbeat. One combat round resolves per tick. const TICK_SECS: u64 = 2; /// A player who sends no command for this long is dropped from the world. const PLAYER_IDLE_TIMEOUT_SECS: u64 = 10 * 60; -/// Player base stats for the slice (one class: Warrior). -const PLAYER_MAX_HP: i32 = 40; -const PLAYER_DAMAGE: i32 = 6; -/// How long a defeated player rests before respawning at the start room. +/// How long a defeated player rests before respawning at the temple. const PLAYER_RESPAWN_SECS: u64 = 8; +/// Gold every new adventurer starts with. +const STARTING_GOLD: i64 = 120; #[derive(Clone)] pub struct MudService { @@ -47,7 +50,6 @@ pub struct MudService { // ---- Snapshot (what sessions render) ------------------------------------- -/// A line in a player's scrolling message log. #[derive(Clone, Debug)] pub struct LogLine { pub text: String, @@ -60,9 +62,9 @@ pub enum LogKind { Combat, System, Say, + Loot, } -/// A mob as seen in a room. #[derive(Clone, Debug)] pub struct MobView { pub name: String, @@ -70,7 +72,6 @@ pub struct MobView { pub max_hp: i32, } -/// One other player visible in the same room. #[derive(Clone, Debug)] pub struct OccupantView { pub user_id: Uuid, @@ -79,25 +80,74 @@ pub struct OccupantView { pub in_combat: bool, } -/// Per-session snapshot: filtered to the player's current room plus their own -/// character sheet and message log. This mirrors poker's public/private split - -/// each session only receives what its own player can see. +/// One known ability as shown on the action bar. +#[derive(Clone, Debug)] +pub struct AbilityView { + pub slot: u8, + pub name: String, + pub cost: i32, + pub ready: bool, + pub effect: String, +} + +/// One inventory line. +#[derive(Clone, Debug)] +pub struct InvView { + pub item_id: u32, + pub name: String, + pub rarity: String, + pub slot: Option, + pub equipped: bool, + pub sell_price: i64, +} + +/// One shop listing. +#[derive(Clone, Debug)] +pub struct ShopEntryView { + pub item_id: u32, + pub name: String, + pub rarity: String, + pub price: i64, + pub affordable: bool, +} + +#[derive(Clone, Debug)] +pub struct ShopView { + pub npc_name: String, + pub shop_name: String, + pub greeting: String, + pub entries: Vec, +} + +/// Which side panel a session is viewing (local UI mode echoed in the snapshot +/// only for the shop, which is world-driven; inventory/abilities are derived). #[derive(Clone, Debug)] pub struct MudSnapshot { pub room_id: Uuid, pub generation: u64, - /// Per-player views keyed by user id. A session reads its own entry. pub players: HashMap, } #[derive(Clone, Debug)] pub struct PlayerView { pub joined: bool, + pub classed: bool, + pub class_name: String, + pub trait_name: String, + pub trait_desc: String, + pub resource_name: String, + pub resource: i32, + pub max_resource: i32, pub alive: bool, pub hp: i32, pub max_hp: i32, - pub xp: i32, + pub attack: i32, + pub armor: i32, + pub xp: i64, + pub xp_into_level: i64, + pub xp_for_next: i64, pub level: i32, + pub gold: i64, pub room_name: String, pub room_desc: String, pub zone: String, @@ -106,20 +156,34 @@ pub struct PlayerView { pub mobs: Vec, pub occupants: Vec, pub in_combat_with: Option, + pub abilities: Vec, + pub inventory: Vec, + pub shop: Option, pub log: Vec, pub respawning: bool, } impl PlayerView { - fn empty(room_id: Uuid) -> Self { - let _ = room_id; + fn empty() -> Self { Self { joined: false, + classed: false, + class_name: String::new(), + trait_name: String::new(), + trait_desc: String::new(), + resource_name: String::new(), + resource: 0, + max_resource: 0, alive: false, hp: 0, max_hp: 0, + attack: 0, + armor: 0, xp: 0, + xp_into_level: 0, + xp_for_next: 0, level: 1, + gold: 0, room_name: String::new(), room_desc: String::new(), zone: String::new(), @@ -128,12 +192,19 @@ impl PlayerView { mobs: Vec::new(), occupants: Vec::new(), in_combat_with: None, + abilities: Vec::new(), + inventory: Vec::new(), + shop: None, log: Vec::new(), respawning: false, } } } +pub fn empty_player_view() -> PlayerView { + PlayerView::empty() +} + impl MudService { pub fn new(room_id: Uuid, activity: ActivityPublisher) -> Self { let (room_event_tx, _) = broadcast::channel::(16); @@ -172,7 +243,6 @@ impl MudService { self.snapshot_rx.borrow().clone() } - /// Number of adventurers currently in the world (for directory hints). pub fn player_count(&self) -> usize { self.snapshot_rx .borrow() @@ -192,6 +262,16 @@ impl MudService { // ---- Commands (fire-and-forget, *_task convention) ------------------- + fn mutate(&self, user_id: Uuid, f: F) { + let svc = self.clone(); + tokio::spawn(async move { + let mut state = svc.state.lock().await; + f(&mut state); + state.touch(user_id); + svc.publish(&state); + }); + } + pub fn join_task(&self, user_id: Uuid) { let svc = self.clone(); tokio::spawn(async move { @@ -220,54 +300,48 @@ impl MudService { }); } + pub fn choose_class_task(&self, user_id: Uuid, class: Class) { + self.mutate(user_id, move |s| s.choose_class(user_id, class)); + } + pub fn move_task(&self, user_id: Uuid, dir: Dir) { - let svc = self.clone(); - tokio::spawn(async move { - let mut state = svc.state.lock().await; - state.move_player(user_id, dir); - state.touch(user_id); - svc.publish(&state); - }); + self.mutate(user_id, move |s| s.move_player(user_id, dir)); } pub fn look_task(&self, user_id: Uuid) { - let svc = self.clone(); - tokio::spawn(async move { - let mut state = svc.state.lock().await; - state.look(user_id); - state.touch(user_id); - svc.publish(&state); - }); + self.mutate(user_id, move |s| s.look(user_id)); } pub fn attack_task(&self, user_id: Uuid) { - let svc = self.clone(); - tokio::spawn(async move { - let mut state = svc.state.lock().await; - state.engage(user_id); - state.touch(user_id); - svc.publish(&state); - }); + self.mutate(user_id, move |s| s.engage(user_id)); + } + + pub fn ability_task(&self, user_id: Uuid, slot: u8) { + self.mutate(user_id, move |s| s.use_ability(user_id, slot)); } pub fn flee_task(&self, user_id: Uuid) { - let svc = self.clone(); - tokio::spawn(async move { - let mut state = svc.state.lock().await; - state.flee(user_id); - state.touch(user_id); - svc.publish(&state); - }); + self.mutate(user_id, move |s| s.flee(user_id)); } pub fn say_task(&self, user_id: Uuid, message: String) { - let svc = self.clone(); - tokio::spawn(async move { - let mut state = svc.state.lock().await; - state.say(user_id, &message); - state.touch(user_id); - svc.publish(&state); - }); + self.mutate(user_id, move |s| s.say(user_id, &message)); + } + + pub fn equip_task(&self, user_id: Uuid, item_id: u32) { + self.mutate(user_id, move |s| s.equip(user_id, item_id)); + } + + pub fn use_item_task(&self, user_id: Uuid, item_id: u32) { + self.mutate(user_id, move |s| s.use_item(user_id, item_id)); + } + + pub fn buy_task(&self, user_id: Uuid, item_id: u32) { + self.mutate(user_id, move |s| s.buy(user_id, item_id)); + } + + pub fn sell_task(&self, user_id: Uuid, item_id: u32) { + self.mutate(user_id, move |s| s.sell(user_id, item_id)); } pub fn touch_activity_task(&self, user_id: Uuid) { @@ -278,8 +352,6 @@ impl MudService { }); } - // ---- Tick loop ------------------------------------------------------ - fn start_tick_loop(&self) { let svc = self.clone(); tokio::spawn(async move { @@ -310,35 +382,92 @@ impl MudService { } } -/// Reported when a player lands a killing blow, so the world feed can announce it. struct KillOutcome { user_id: Uuid, mob_name: String, } +// ---- Active effects (spells, poisons, buffs unified) --------------------- + +#[derive(Clone, Copy)] +struct ActiveEffect { + kind: AbilityEffect, + magnitude: i32, + remaining: u8, +} + // ---- The authoritative world state --------------------------------------- struct PlayerState { user_id: Uuid, + class: Option, hp: i32, - max_hp: i32, - damage: i32, - xp: i32, + base_max_hp: i32, + resource: i32, + max_resource: i32, + resource_regen: i32, + base_attack: i32, + xp: i64, level: i32, + gold: i64, room: RoomId, - /// Mob instance id this player is fighting, if any. target: Option, + /// Outgoing-damage buff remaining ticks and magnitude. + empower: i32, + empower_ticks: u8, + /// Absorb shield remaining. + shield: i32, + shield_ticks: u8, + /// Ticks the player is stunned (skips their action). + stunned: u8, + /// Healing-over-time on self. + self_effects: Vec, + /// Per-ability cooldowns: ability id -> ticks remaining. + cooldowns: HashMap, + inventory: Vec, + equipped: HashMap, + /// True once the class trait's death-save has been spent this life (Warrior). + death_save_used: bool, last_activity: Instant, - /// Some(deadline) while the player is downed and waiting to respawn. respawn_at: Option, log: Vec, } +impl PlayerState { + fn equipment_mods(&self) -> (i32, i32, i32) { + let mut attack = 0; + let mut hp = 0; + let mut armor = 0; + for id in self.equipped.values() { + if let Some(it) = item(*id) { + attack += it.mods.attack; + hp += it.mods.max_hp; + armor += it.mods.armor; + } + } + (attack, hp, armor) + } + + fn max_hp(&self) -> i32 { + let (_, hp, _) = self.equipment_mods(); + self.base_max_hp + hp + } + + fn attack(&self) -> i32 { + let (atk, _, _) = self.equipment_mods(); + self.base_attack + atk + self.empower + } + + fn armor(&self) -> i32 { + let (_, _, armor) = self.equipment_mods(); + armor + } +} + struct MobInstance { spawn: MobSpawn, hp: i32, alive: bool, - /// Some(deadline) while dead and waiting to respawn. respawn_at: Option, } @@ -347,12 +476,18 @@ struct WorldState { world: World, players: HashMap, mobs: HashMap, + /// mob id -> stun ticks remaining. + mob_stuns: HashMap, + /// mob id -> active damage-over-time stacks (owner, per-tick, remaining). + mob_dots: HashMap>, + /// Kills accumulated during a tick, drained for the activity feed. + pending_kills: Vec, generation: u64, - /// Set by tick() when something changed and a publish is warranted. dirty: bool, } -const LOG_CAP: usize = 50; +const LOG_CAP: usize = 60; +const TEMPLE_ROOM: RoomId = 4; impl WorldState { fn new(room_id: Uuid, world: World) -> Self { @@ -376,6 +511,9 @@ impl WorldState { world, players: HashMap::new(), mobs, + mob_stuns: HashMap::new(), + mob_dots: HashMap::new(), + pending_kills: Vec::new(), generation: 0, dirty: false, } @@ -388,13 +526,28 @@ impl WorldState { let start = self.world.start_room; let mut player = PlayerState { user_id, - hp: PLAYER_MAX_HP, - max_hp: PLAYER_MAX_HP, - damage: PLAYER_DAMAGE, + class: None, + hp: 30, + base_max_hp: 30, + resource: 0, + max_resource: 0, + resource_regen: 0, + base_attack: 4, xp: 0, level: 1, + gold: STARTING_GOLD, room: start, target: None, + empower: 0, + empower_ticks: 0, + shield: 0, + shield_ticks: 0, + stunned: 0, + self_effects: Vec::new(), + cooldowns: HashMap::new(), + inventory: vec![1000, 1300, 1300], // a rusty sword and two minor draughts + equipped: HashMap::new(), + death_save_used: false, last_activity: Instant::now(), respawn_at: None, log: Vec::new(), @@ -402,13 +555,41 @@ impl WorldState { push_log( &mut player.log, LogKind::System, - "You step into the world of Lateania.".to_string(), + "Welcome to Lateania. Choose your calling to begin.".to_string(), ); self.players.insert(user_id, player); - self.describe_room(user_id); true } + fn choose_class(&mut self, user_id: Uuid, class: Class) { + let already = self + .players + .get(&user_id) + .map(|p| p.class.is_some()) + .unwrap_or(true); + if already { + return; + } + let stats = class.stats_at(1); + if let Some(p) = self.players.get_mut(&user_id) { + p.class = Some(class); + p.base_max_hp = stats.max_hp; + p.max_resource = stats.max_resource; + p.resource = stats.max_resource; + p.resource_regen = stats.resource_regen; + p.base_attack = stats.attack; + p.hp = p.max_hp(); + } + let name = class.name(); + let trait_name = class.trait_name(); + self.log_to( + user_id, + LogKind::System, + format!("You are now a {name}. Your trait: {trait_name}."), + ); + self.describe_room(user_id); + } + fn leave(&mut self, user_id: Uuid) { self.players.remove(&user_id); } @@ -419,7 +600,17 @@ impl WorldState { } } + fn is_classed(&self, user_id: Uuid) -> bool { + self.players + .get(&user_id) + .map(|p| p.class.is_some()) + .unwrap_or(false) + } + fn move_player(&mut self, user_id: Uuid, dir: Dir) { + if !self.is_classed(user_id) { + return; + } let Some(player) = self.players.get(&user_id) else { return; }; @@ -431,7 +622,7 @@ impl WorldState { self.log_to( user_id, LogKind::Combat, - "You can't leave - you're in combat! Flee first.".to_string(), + "You can't leave - you're in combat! Flee (z) first.".to_string(), ); return; } @@ -464,8 +655,6 @@ impl WorldState { let Some(room) = self.world.room(room_id) else { return; }; - // Extract everything from the room (an immutable borrow of self.world) - // before any self.log_to call, which needs &mut self. let name = room.name.to_string(); let desc = room.desc.to_string(); let mut exits: Vec<&'static str> = room.exits.keys().map(|d| d.label()).collect(); @@ -481,15 +670,26 @@ impl WorldState { .filter(|m| m.alive && m.spawn.home == room_id) .map(|m| m.spawn.name.to_string()) .collect(); + let shop = shop_at(room_id); self.log_to(user_id, LogKind::Normal, format!("== {name} ==")); self.log_to(user_id, LogKind::Normal, desc); self.log_to(user_id, LogKind::System, format!("Exits: {exit_text}")); + if let Some(shop) = shop { + self.log_to( + user_id, + LogKind::Loot, + format!("{} tends {} here. Press b to browse.", shop.npc_name, shop.shop_name), + ); + } for mob in mob_names { self.log_to(user_id, LogKind::Combat, format!("{mob} is here.")); } } fn engage(&mut self, user_id: Uuid) { + if !self.is_classed(user_id) { + return; + } let Some(player) = self.players.get(&user_id) else { return; }; @@ -520,7 +720,7 @@ impl WorldState { if let Some(player) = self.players.get_mut(&user_id) { player.target = Some(mob_id); } - self.log_to(user_id, LogKind::Combat, format!("You attack {mob_name}!")); + self.log_to(user_id, LogKind::Combat, format!("You close with {mob_name}!")); } None => { self.log_to( @@ -532,18 +732,263 @@ impl WorldState { } } - fn flee(&mut self, user_id: Uuid) { + /// Cast/use the ability in the given action-bar slot (1-based). + fn use_ability(&mut self, user_id: Uuid, slot: u8) { let Some(player) = self.players.get(&user_id) else { return; }; - if player.target.is_none() { + let Some(class) = player.class else { + return; + }; + if player.respawn_at.is_some() { + return; + } + let known = unlocked_for(class, player.level); + let Some(ability) = known.get(slot.saturating_sub(1) as usize).copied() else { + self.log_to(user_id, LogKind::System, "No ability in that slot.".to_string()); + return; + }; + // Validate cost + cooldown against the truth. + let on_cd = player + .cooldowns + .get(&ability.id) + .copied() + .unwrap_or(0) + > 0; + if on_cd { + self.log_to(user_id, LogKind::System, format!("{} is not ready.", ability.name)); + return; + } + if player.resource < ability.cost { self.log_to( user_id, - LogKind::Normal, - "You're not fighting anything.".to_string(), + LogKind::System, + format!("Not enough {} for {}.", class.resource().label(), ability.name), ); return; } + // Targeted offensive abilities need a foe. + let needs_target = matches!( + ability.effect, + AbilityEffect::Strike + | AbilityEffect::DamageOverTime + | AbilityEffect::Stun + | AbilityEffect::Finisher + ); + if needs_target && player.target.is_none() { + self.log_to(user_id, LogKind::Combat, "You have no target.".to_string()); + return; + } + // Spend and set cooldown. + if let Some(p) = self.players.get_mut(&user_id) { + p.resource -= ability.cost; + p.cooldowns.insert(ability.id, ability.cooldown_ticks); + } + self.apply_ability(user_id, class, ability); + } + + fn apply_ability(&mut self, user_id: Uuid, class: Class, ability: &Ability) { + match ability.effect { + AbilityEffect::Heal => { + let amount = self.amplified_heal(class, ability.magnitude); + self.heal_player(user_id, amount); + self.log_to( + user_id, + LogKind::Combat, + format!("{} restores {} health.", ability.name, amount), + ); + } + AbilityEffect::HealOverTime => { + if let Some(p) = self.players.get_mut(&user_id) { + p.self_effects.push(ActiveEffect { + kind: AbilityEffect::HealOverTime, + magnitude: ability.magnitude, + remaining: ability.duration, + }); + } + self.log_to(user_id, LogKind::Combat, format!("{} begins to mend you.", ability.name)); + } + AbilityEffect::Empower => { + if let Some(p) = self.players.get_mut(&user_id) { + p.empower = ability.magnitude; + p.empower_ticks = ability.duration; + } + self.log_to(user_id, LogKind::Combat, format!("{} surges through you (+{} damage).", ability.name, ability.magnitude)); + } + AbilityEffect::Ward => { + if let Some(p) = self.players.get_mut(&user_id) { + p.shield = ability.magnitude; + p.shield_ticks = ability.duration; + } + self.log_to(user_id, LogKind::Combat, format!("{} shields you ({} absorb).", ability.name, ability.magnitude)); + } + AbilityEffect::Strike => { + let dmg = self.spell_damage(class, ability.magnitude, user_id); + self.damage_target(user_id, dmg, &ability.name); + } + AbilityEffect::Finisher => { + let dmg = self.spell_damage(class, ability.magnitude, user_id); + if let Some(p) = self.players.get_mut(&user_id) { + p.empower = p.empower.max(ability.magnitude / 8); + p.empower_ticks = p.empower_ticks.max(ability.duration); + } + self.damage_target(user_id, dmg, &ability.name); + } + AbilityEffect::DamageOverTime => { + let tick = self.spell_damage(class, ability.magnitude, user_id); + self.seed_mob_dot(user_id, tick, ability.duration, &ability.name); + } + AbilityEffect::Stun => { + let target = self.players.get(&user_id).and_then(|p| p.target); + let dmg = self.spell_damage(class, ability.magnitude, user_id); + self.damage_target(user_id, dmg, &ability.name); + // Only stun if the target survived the hit. + if let Some(mob_id) = target + && self.mobs.get(&mob_id).is_some_and(|m| m.alive) + { + self.mob_stuns.insert(mob_id, ability.duration); + self.log_to( + user_id, + LogKind::Combat, + format!("{} leaves the foe reeling!", ability.name), + ); + } + } + } + } + + fn amplified_heal(&self, class: Class, base: i32) -> i32 { + if class == Class::Cleric { + base + base / 4 // Light of the Dawn + } else { + base + } + } + + fn spell_damage(&self, class: Class, base: i32, user_id: Uuid) -> i32 { + let mut dmg = base; + if class == Class::Mage { + dmg += dmg / 5; // Arcane Mastery + } + if class == Class::Ranger { + // Hunter's Instinct: more vs wounded foe. + if let Some(mob_id) = self.players.get(&user_id).and_then(|p| p.target) { + if let Some(mob) = self.mobs.get(&mob_id) { + if mob.hp * 2 < mob.spawn.max_hp { + dmg += dmg / 4; + } + } + } + } + dmg + } + + fn heal_player(&mut self, user_id: Uuid, amount: i32) { + if let Some(p) = self.players.get_mut(&user_id) { + let max = p.max_hp(); + p.hp = (p.hp + amount).min(max); + self.dirty = true; + } + } + + fn damage_target(&mut self, user_id: Uuid, dmg: i32, source: &str) { + let Some(mob_id) = self.players.get(&user_id).and_then(|p| p.target) else { + return; + }; + let (mob_name, dead) = { + let Some(mob) = self.mobs.get_mut(&mob_id) else { + return; + }; + if !mob.alive { + return; + } + mob.hp -= dmg; + (mob.spawn.name.to_string(), mob.hp <= 0) + }; + self.dirty = true; + self.log_to(user_id, LogKind::Combat, format!("{source} hits {mob_name} for {dmg}.")); + if dead { + self.kill_mob(user_id, mob_id); + } + } + + fn seed_mob_dot(&mut self, user_id: Uuid, per_tick: i32, duration: u8, source: &str) { + let Some(mob_id) = self.players.get(&user_id).and_then(|p| p.target) else { + return; + }; + self.mob_dots + .entry(mob_id) + .or_default() + .push((user_id, per_tick, duration)); + self.log_to(user_id, LogKind::Combat, format!("{source} festers in the foe.", )); + self.dirty = true; + } + + fn kill_mob(&mut self, user_id: Uuid, mob_id: u32) { + let (mob_name, xp, respawn) = match self.mobs.get_mut(&mob_id) { + Some(mob) => { + mob.alive = false; + mob.hp = 0; + let r = mob.spawn.respawn_secs; + mob.respawn_at = Some(Instant::now() + Duration::from_secs(r)); + (mob.spawn.name.to_string(), mob.spawn.xp, r) + } + None => return, + }; + let _ = respawn; + let gold = 3 + xp / 4; + self.log_to(user_id, LogKind::Loot, format!("You have slain {mob_name}! (+{xp} xp, +{gold} gold)")); + if let Some(p) = self.players.get_mut(&user_id) { + p.target = None; + p.xp += xp as i64; + p.gold += gold as i64; + } + self.check_level_up(user_id); + self.pending_kills.push(KillOutcome { user_id, mob_name }); + self.dirty = true; + } + + fn check_level_up(&mut self, user_id: Uuid) { + let (class, xp, old_level) = match self.players.get(&user_id) { + Some(p) => (p.class, p.xp, p.level), + None => return, + }; + let Some(class) = class else { return }; + let new_level = level_for_xp(xp); + if new_level <= old_level { + return; + } + let stats = class.stats_at(new_level); + if let Some(p) = self.players.get_mut(&user_id) { + p.level = new_level; + p.base_max_hp = stats.max_hp; + p.max_resource = stats.max_resource; + p.base_attack = stats.attack; + p.resource_regen = stats.resource_regen; + p.hp = p.max_hp(); + p.resource = p.max_resource; + } + self.log_to(user_id, LogKind::System, format!("You reach level {new_level}!")); + // Announce any abilities gained between old and new level. + for lvl in (old_level + 1)..=new_level { + if let Some(a) = learned_at(class, lvl) { + self.log_to( + user_id, + LogKind::System, + format!("You learn {} (level {}): {}", a.name, lvl, a.desc), + ); + } + } + } + + fn flee(&mut self, user_id: Uuid) { + let Some(player) = self.players.get(&user_id) else { + return; + }; + if player.target.is_none() { + self.log_to(user_id, LogKind::Normal, "You're not fighting anything.".to_string()); + return; + } let room_id = player.room; let exit = self .world @@ -557,19 +1002,11 @@ impl WorldState { if let Some(player) = self.players.get_mut(&user_id) { player.room = dest; } - self.log_to( - user_id, - LogKind::Combat, - format!("You flee {}!", dir.label()), - ); + self.log_to(user_id, LogKind::Combat, format!("You flee {}!", dir.label())); self.describe_room(user_id); } None => { - self.log_to( - user_id, - LogKind::Combat, - "You break off the fight.".to_string(), - ); + self.log_to(user_id, LogKind::Combat, "You break off the fight.".to_string()); } } } @@ -599,12 +1036,111 @@ impl WorldState { } } - /// Advance the world one round. Returns kills for the activity feed. + // ---- Inventory / equipment / economy -------------------------------- + + fn equip(&mut self, user_id: Uuid, item_id: u32) { + let Some(it) = item(item_id) else { return }; + let Some(slot) = it.slot() else { + self.log_to(user_id, LogKind::System, format!("{} cannot be equipped.", it.name)); + return; + }; + let has = self + .players + .get(&user_id) + .map(|p| p.inventory.contains(&item_id)) + .unwrap_or(false); + if !has { + return; + } + if let Some(p) = self.players.get_mut(&user_id) { + // Return the currently-equipped item to the pack. + if let Some(old) = p.equipped.insert(slot, item_id) { + p.inventory.push(old); + } + if let Some(pos) = p.inventory.iter().position(|i| *i == item_id) { + p.inventory.remove(pos); + } + let max = p.max_hp(); + p.hp = p.hp.min(max); + } + self.log_to(user_id, LogKind::Loot, format!("You equip {} ({}).", it.name, slot.label())); + } + + fn use_item(&mut self, user_id: Uuid, item_id: u32) { + let Some(it) = item(item_id) else { return }; + let ItemKind::Consumable { heal, restore } = it.kind else { + self.log_to(user_id, LogKind::System, format!("You can't use {}.", it.name)); + return; + }; + let has = self + .players + .get(&user_id) + .map(|p| p.inventory.contains(&item_id)) + .unwrap_or(false); + if !has { + return; + } + if let Some(p) = self.players.get_mut(&user_id) { + if let Some(pos) = p.inventory.iter().position(|i| *i == item_id) { + p.inventory.remove(pos); + } + let max = p.max_hp(); + p.hp = (p.hp + heal).min(max); + p.resource = (p.resource + restore).min(p.max_resource); + } + self.log_to(user_id, LogKind::Loot, format!("You use {}.", it.name)); + self.dirty = true; + } + + fn buy(&mut self, user_id: Uuid, item_id: u32) { + let room_id = match self.players.get(&user_id) { + Some(p) => p.room, + None => return, + }; + let Some(shop) = shop_at(room_id) else { + self.log_to(user_id, LogKind::System, "There is no shop here.".to_string()); + return; + }; + if !shop.stock.contains(&item_id) { + return; + } + let Some(it) = item(item_id) else { return }; + let gold = self.players.get(&user_id).map(|p| p.gold).unwrap_or(0); + if gold < it.price { + self.log_to(user_id, LogKind::System, format!("You can't afford {} ({}g).", it.name, it.price)); + return; + } + if let Some(p) = self.players.get_mut(&user_id) { + p.gold -= it.price; + p.inventory.push(item_id); + } + self.log_to(user_id, LogKind::Loot, format!("You buy {} for {}g.", it.name, it.price)); + } + + fn sell(&mut self, user_id: Uuid, item_id: u32) { + if shop_at(self.players.get(&user_id).map(|p| p.room).unwrap_or(0)).is_none() { + self.log_to(user_id, LogKind::System, "You need a merchant to sell.".to_string()); + return; + } + let Some(it) = item(item_id) else { return }; + let price = it.sell_price(); + if let Some(p) = self.players.get_mut(&user_id) { + if let Some(pos) = p.inventory.iter().position(|i| *i == item_id) { + p.inventory.remove(pos); + p.gold += price; + } else { + return; + } + } + self.log_to(user_id, LogKind::Loot, format!("You sell {} for {}g.", it.name, price)); + } + + // ---- Tick ----------------------------------------------------------- + fn tick(&mut self) -> Vec { - let mut outcomes = Vec::new(); + self.pending_kills.clear(); let now = Instant::now(); - // Respawn mobs whose timer elapsed. for mob in self.mobs.values_mut() { if !mob.alive && let Some(at) = mob.respawn_at @@ -617,7 +1153,40 @@ impl WorldState { } } - // Respawn downed players whose rest elapsed. + // Mob damage-over-time from player abilities. + let dot_ids: Vec = self.mob_dots.keys().copied().collect(); + for mob_id in dot_ids { + let mut total = 0; + let mut owner = None; + if let Some(stacks) = self.mob_dots.get_mut(&mob_id) { + for (uid, per, rem) in stacks.iter_mut() { + if *rem > 0 { + total += *per; + *rem -= 1; + owner = Some(*uid); + } + } + stacks.retain(|(_, _, rem)| *rem > 0); + if stacks.is_empty() { + self.mob_dots.remove(&mob_id); + } + } + if total > 0 { + if let Some(mob) = self.mobs.get_mut(&mob_id) { + if mob.alive { + mob.hp -= total; + self.dirty = true; + if mob.hp <= 0 { + if let Some(uid) = owner { + self.kill_mob(uid, mob_id); + } + } + } + } + } + } + + // Respawn downed players. let resurrecting: Vec = self .players .iter() @@ -625,23 +1194,63 @@ impl WorldState { .map(|(id, _)| *id) .collect(); for user_id in resurrecting { - let start = self.world.start_room; if let Some(player) = self.players.get_mut(&user_id) { - player.hp = player.max_hp; - player.room = start; + player.hp = player.max_hp(); + player.resource = player.max_resource; + player.room = TEMPLE_ROOM; player.target = None; player.respawn_at = None; + player.death_save_used = false; + player.shield = 0; + player.empower = 0; } - self.log_to( - user_id, - LogKind::System, - "You wake at the Temple of the Dawn, restored.".to_string(), - ); + self.log_to(user_id, LogKind::System, "You wake at the Temple of the Dawn, restored.".to_string()); self.describe_room(user_id); self.dirty = true; } - // Resolve one combat round per fighting player. + // Per-player upkeep: regen, buff/shield/effect timers, cooldowns. + let player_ids: Vec = self.players.keys().copied().collect(); + for uid in &player_ids { + let mut hot_heal = 0; + if let Some(p) = self.players.get_mut(uid) { + if p.class.is_some() && p.respawn_at.is_none() { + p.resource = (p.resource + p.resource_regen).min(p.max_resource); + } + if p.empower_ticks > 0 { + p.empower_ticks -= 1; + if p.empower_ticks == 0 { + p.empower = 0; + } + } + if p.shield_ticks > 0 { + p.shield_ticks -= 1; + if p.shield_ticks == 0 { + p.shield = 0; + } + } + if p.stunned > 0 { + p.stunned -= 1; + } + for e in p.self_effects.iter_mut() { + if e.kind == AbilityEffect::HealOverTime && e.remaining > 0 { + hot_heal += e.magnitude; + e.remaining -= 1; + } + } + p.self_effects.retain(|e| e.remaining > 0); + for cd in p.cooldowns.values_mut() { + if *cd > 0 { + *cd -= 1; + } + } + } + if hot_heal > 0 { + self.heal_player(*uid, hot_heal); + } + } + + // Resolve a combat round for each engaged player. let fighters: Vec = self .players .iter() @@ -650,88 +1259,49 @@ impl WorldState { .collect(); for user_id in fighters { - let (mob_id, player_damage) = match self.players.get(&user_id) { - Some(p) => (p.target, p.damage), + let (mob_id, player_atk) = match self.players.get(&user_id) { + Some(p) => (p.target, p.attack()), None => continue, }; let Some(mob_id) = mob_id else { continue }; - let Some(mob) = self.mobs.get_mut(&mob_id) else { - if let Some(player) = self.players.get_mut(&user_id) { - player.target = None; - } - continue; - }; - if !mob.alive { - if let Some(player) = self.players.get_mut(&user_id) { - player.target = None; + let alive = self.mobs.get(&mob_id).map(|m| m.alive).unwrap_or(false); + if !alive { + if let Some(p) = self.players.get_mut(&user_id) { + p.target = None; } continue; } - - // Player strikes mob. - mob.hp -= player_damage; - let mob_name = mob.spawn.name.to_string(); - self.dirty = true; - if mob.hp <= 0 { - mob.alive = false; - mob.hp = 0; - mob.respawn_at = Some(now + Duration::from_secs(mob.spawn.respawn_secs)); - let xp = mob.spawn.xp; - self.log_to( - user_id, - LogKind::Combat, - format!("You have slain {mob_name}! (+{xp} xp)"), - ); - if let Some(player) = self.players.get_mut(&user_id) { - player.target = None; - player.xp += xp; - let new_level = 1 + player.xp / 50; - if new_level > player.level { - player.level = new_level; - player.max_hp += 8; - player.hp = player.max_hp; - player.damage += 1; - let level = player.level; - self.log_to( - user_id, - LogKind::System, - format!("You reach level {level}! You feel stronger."), - ); - } - } - outcomes.push(KillOutcome { user_id, mob_name }); + // Auto-attack. + if let Some(mob) = self.mobs.get_mut(&mob_id) { + mob.hp -= player_atk; + self.dirty = true; + } + let dead = self.mobs.get(&mob_id).map(|m| m.hp <= 0).unwrap_or(false); + if dead { + self.kill_mob(user_id, mob_id); continue; } - - // Mob strikes back. - let mob_damage = mob.spawn.damage; - self.log_to( - user_id, - LogKind::Combat, - format!("You hit {mob_name}. It strikes back for {mob_damage}."), - ); - if let Some(player) = self.players.get_mut(&user_id) { - player.hp -= mob_damage; - if player.hp <= 0 { - player.hp = 0; - player.target = None; - player.respawn_at = Some(now + Duration::from_secs(PLAYER_RESPAWN_SECS)); - self.log_to( - user_id, - LogKind::System, - "You have fallen! Darkness takes you...".to_string(), - ); + // Mob strikes back unless stunned. + let stunned = self.mob_stuns.get(&mob_id).copied().unwrap_or(0) > 0; + if let Some(v) = self.mob_stuns.get_mut(&mob_id) { + if *v > 0 { + *v -= 1; } } + if stunned { + self.log_to(user_id, LogKind::Combat, "The foe is stunned and cannot strike.".to_string()); + continue; + } + let mob_damage = self.mobs.get(&mob_id).map(|m| m.spawn.damage).unwrap_or(0); + let mob_name = self.mobs.get(&mob_id).map(|m| m.spawn.name.to_string()).unwrap_or_default(); + self.strike_player(user_id, mob_damage, &mob_name); } - // Drop idle players from the world. + // Drop idle players. let idle: Vec = self .players .iter() - .filter(|(_, p)| { - p.last_activity.elapsed() >= Duration::from_secs(PLAYER_IDLE_TIMEOUT_SECS) - }) + .filter(|(_, p)| p.last_activity.elapsed() >= Duration::from_secs(PLAYER_IDLE_TIMEOUT_SECS)) .map(|(id, _)| *id) .collect(); for user_id in idle { @@ -742,7 +1312,40 @@ impl WorldState { if self.dirty { self.generation = self.generation.wrapping_add(1); } - outcomes + std::mem::take(&mut self.pending_kills) + } + + fn strike_player(&mut self, user_id: Uuid, raw: i32, mob_name: &str) { + let now = Instant::now(); + let Some(p) = self.players.get_mut(&user_id) else { + return; + }; + // Armor reduces incoming, shield absorbs the rest first. + let armor = p.armor(); + let mut dmg = (raw - armor / 2).max(1); + if p.shield > 0 { + let absorbed = p.shield.min(dmg); + p.shield -= absorbed; + dmg -= absorbed; + } + p.hp -= dmg; + self.dirty = true; + if p.hp <= 0 { + // Warrior trait: survive the first lethal blow at 1 HP. + if p.class == Some(Class::Warrior) && !p.death_save_used { + p.death_save_used = true; + p.hp = 1; + self.log_to(user_id, LogKind::System, "Unbreakable! You refuse to fall.".to_string()); + self.log_to(user_id, LogKind::Combat, format!("{mob_name} strikes you to the brink.")); + return; + } + p.hp = 0; + p.target = None; + p.respawn_at = Some(now + Duration::from_secs(PLAYER_RESPAWN_SECS)); + self.log_to(user_id, LogKind::System, "You have fallen! Darkness takes you...".to_string()); + } else { + self.log_to(user_id, LogKind::Combat, format!("{mob_name} hits you for {dmg}.")); + } } fn log_to(&mut self, user_id: Uuid, kind: LogKind, text: String) { @@ -791,7 +1394,7 @@ impl WorldState { .map(|other| OccupantView { user_id: other.user_id, hp: other.hp, - max_hp: other.max_hp, + max_hp: other.max_hp(), in_combat: other.target.is_some(), }) .collect(); @@ -801,15 +1404,104 @@ impl WorldState { .filter(|m| m.alive) .map(|m| m.spawn.name.to_string()) }); + + let (classed, class_name, trait_name, trait_desc, resource_name) = match player.class { + Some(c) => ( + true, + c.name().to_string(), + c.trait_name().to_string(), + c.trait_desc().to_string(), + c.resource().label().to_string(), + ), + None => (false, String::new(), String::new(), String::new(), String::new()), + }; + + let abilities: Vec = match player.class { + Some(c) => unlocked_for(c, player.level) + .iter() + .enumerate() + .map(|(i, a)| AbilityView { + slot: (i + 1) as u8, + name: a.name.to_string(), + cost: a.cost, + ready: player.cooldowns.get(&a.id).copied().unwrap_or(0) == 0 + && player.resource >= a.cost, + effect: a.effect.label().to_string(), + }) + .collect(), + None => Vec::new(), + }; + + let inventory: Vec = player + .inventory + .iter() + .filter_map(|id| item(*id)) + .map(|it| InvView { + item_id: it.id, + name: it.name.to_string(), + rarity: it.rarity.label().to_string(), + slot: it.slot().map(|s| s.label().to_string()), + equipped: false, + sell_price: it.sell_price(), + }) + .chain(player.equipped.values().filter_map(|id| item(*id)).map(|it| { + InvView { + item_id: it.id, + name: it.name.to_string(), + rarity: it.rarity.label().to_string(), + slot: it.slot().map(|s| s.label().to_string()), + equipped: true, + sell_price: it.sell_price(), + } + })) + .collect(); + + let shop = shop_at(player.room).map(|shop| ShopView { + npc_name: shop.npc_name.to_string(), + shop_name: shop.shop_name.to_string(), + greeting: shop.greeting.to_string(), + entries: shop + .stock + .iter() + .filter_map(|id| item(*id)) + .map(|it| ShopEntryView { + item_id: it.id, + name: it.name.to_string(), + rarity: it.rarity.label().to_string(), + price: it.price, + affordable: player.gold >= it.price, + }) + .collect(), + }); + + let xp_into = player.xp - xp_for_level(player.level); + let xp_next = if player.level >= Class::MAX_LEVEL { + 0 + } else { + xp_for_level(player.level + 1) - xp_for_level(player.level) + }; + players.insert( *user_id, PlayerView { joined: true, + classed, + class_name, + trait_name, + trait_desc, + resource_name, + resource: player.resource, + max_resource: player.max_resource, alive: player.respawn_at.is_none(), hp: player.hp, - max_hp: player.max_hp, + max_hp: player.max_hp(), + attack: player.attack(), + armor: player.armor(), xp: player.xp, + xp_into_level: xp_into.max(0), + xp_for_next: xp_next, level: player.level, + gold: player.gold, room_name, room_desc, zone, @@ -818,6 +1510,9 @@ impl WorldState { mobs, occupants, in_combat_with, + abilities, + inventory, + shop, log: player.log.clone(), respawning: player.respawn_at.is_some(), }, @@ -839,11 +1534,6 @@ fn push_log(log: &mut Vec, kind: LogKind, text: String) { } } -/// A session whose player hasn't joined yet still needs a view to render. -pub fn empty_player_view(room_id: Uuid) -> PlayerView { - PlayerView::empty(room_id) -} - #[cfg(test)] mod tests { use super::*; @@ -852,80 +1542,67 @@ mod tests { Uuid::from_u128(n) } - fn test_state() -> WorldState { + fn world() -> WorldState { WorldState::new(uid(999), seed_world()) } #[test] - fn join_places_player_in_safe_start_room() { - let mut state = test_state(); - assert!(state.join(uid(1))); - let player = state.players.get(&uid(1)).expect("joined"); - assert_eq!(player.room, state.world.start_room); - assert_eq!(player.hp, PLAYER_MAX_HP); - // Re-join is a no-op. - assert!(!state.join(uid(1))); + fn join_then_choose_class_sets_stats() { + let mut s = world(); + assert!(s.join(uid(1))); + assert!(!s.is_classed(uid(1))); + s.choose_class(uid(1), Class::Mage); + assert!(s.is_classed(uid(1))); + let p = s.players.get(&uid(1)).unwrap(); + assert_eq!(p.class, Some(Class::Mage)); + assert!(p.max_resource > 0); + assert_eq!(p.hp, p.max_hp()); } #[test] - fn movement_follows_exits_and_blocks_walls() { - let mut state = test_state(); - state.join(uid(1)); - // Square (1) -> south -> gate (5). - state.move_player(uid(1), Dir::South); - assert_eq!(state.players[&uid(1)].room, 5); - // Gate has no east exit. - state.move_player(uid(1), Dir::East); - assert_eq!(state.players[&uid(1)].room, 5); + fn unclassed_player_cannot_move_or_fight() { + let mut s = world(); + s.join(uid(1)); + s.move_player(uid(1), Dir::South); + assert_eq!(s.players[&uid(1)].room, s.world.start_room); + s.engage(uid(1)); + assert!(s.players[&uid(1)].target.is_none()); } #[test] - fn cannot_fight_in_safe_room() { - let mut state = test_state(); - state.join(uid(1)); - state.engage(uid(1)); - assert!(state.players[&uid(1)].target.is_none()); + fn buying_costs_gold_and_adds_item() { + let mut s = world(); + s.join(uid(1)); + s.choose_class(uid(1), Class::Warrior); + // Walk to the smith (room 3, east of square). + s.move_player(uid(1), Dir::East); + assert_eq!(s.players[&uid(1)].room, 3); + let before = s.players[&uid(1)].gold; + s.buy(uid(1), 1001); // Iron Longsword, 80g + let p = &s.players[&uid(1)]; + assert_eq!(p.gold, before - 80); + assert!(p.inventory.contains(&1001)); } #[test] - fn combat_kills_mob_and_awards_xp() { - let mut state = test_state(); - state.join(uid(1)); - // Walk to room 6 (goblin home): square -> gate -> open country. - state.move_player(uid(1), Dir::South); - state.move_player(uid(1), Dir::South); - assert_eq!(state.players[&uid(1)].room, 6); - state.engage(uid(1)); - assert!(state.players[&uid(1)].target.is_some()); - // Tick until the goblin (18 hp, player 6 dmg) dies. - let mut kills = Vec::new(); - for _ in 0..10 { - kills = state.tick(); - if state.players[&uid(1)].target.is_none() { - break; - } - } - assert!(state.players[&uid(1)].xp > 0, "player should gain xp"); - assert_eq!(kills.len(), 1); + fn equipping_a_weapon_raises_attack() { + let mut s = world(); + s.join(uid(1)); + s.choose_class(uid(1), Class::Warrior); + let base = s.players[&uid(1)].attack(); + s.players.get_mut(&uid(1)).unwrap().inventory.push(1006); // greatsword +16 + s.equip(uid(1), 1006); + assert!(s.players[&uid(1)].attack() > base); } #[test] - fn say_reaches_others_in_same_room_only() { - let mut state = test_state(); - state.join(uid(1)); - state.join(uid(2)); - // uid(2) moves away. - state.move_player(uid(2), Dir::South); - state.say(uid(1), "hello"); - let p1_heard = state.players[&uid(1)] - .log - .iter() - .any(|l| l.text.contains("hello")); - let p2_heard = state.players[&uid(2)] - .log - .iter() - .any(|l| l.text.contains("hello")); - assert!(p1_heard, "speaker hears own message"); - assert!(!p2_heard, "player in another room does not hear"); + fn warrior_survives_first_lethal_blow() { + let mut s = world(); + s.join(uid(1)); + s.choose_class(uid(1), Class::Warrior); + s.strike_player(uid(1), 9999, "a test foe"); + assert_eq!(s.players[&uid(1)].hp, 1, "Unbreakable should save the warrior"); + s.strike_player(uid(1), 9999, "a test foe"); + assert!(s.players[&uid(1)].respawn_at.is_some(), "second blow falls"); } } diff --git a/late-ssh/src/app/rooms/mud/ui.rs b/late-ssh/src/app/rooms/mud/ui.rs index cb3fdb83..42e948ee 100644 --- a/late-ssh/src/app/rooms/mud/ui.rs +++ b/late-ssh/src/app/rooms/mud/ui.rs @@ -1,6 +1,7 @@ // Rendering for Lateania. Reads the cached per-session snapshot and paints a -// two-column view: the scrolling adventure log on the left, a character/room -// panel on the right. Lock-free; never awaits or touches a service mutex. +// two-column view: the scrolling adventure log on the left, a context side panel +// on the right (room / character / abilities / inventory / shop). Before a class +// is chosen it shows the class-selection screen. Lock-free; never awaits. use ratatui::{ Frame, @@ -13,79 +14,100 @@ use ratatui::{ use crate::app::{ common::theme, rooms::mud::{ - state::State, + classes::Class, + state::{Panel, State}, svc::{LogKind, PlayerView}, }, }; use crate::usernames::UsernameLookup; -const SIDE_WIDE: u16 = 30; -const SIDE_NARROW: u16 = 24; +const SIDE_WIDE: u16 = 34; +const SIDE_NARROW: u16 = 28; pub fn draw_game(frame: &mut Frame, area: Rect, state: &State, usernames: &UsernameLookup<'_>) { let view = state.view(); if !view.joined { - let lines = vec![ - Line::from(Span::styled( + frame.render_widget( + Paragraph::new(vec![Line::from(Span::styled( "Entering Lateania...", - Style::default() - .fg(theme::AMBER_GLOW()) - .add_modifier(Modifier::BOLD), - )), - Line::raw(""), - Line::from(Span::styled( - "World by Tasmania - thanks to late.sh and its contributors.", - Style::default().fg(theme::TEXT_DIM()), - )), - ]; - frame.render_widget(Paragraph::new(lines), area); + Style::default().fg(theme::AMBER_GLOW()), + ))]), + area, + ); return; } - if area.width < 46 || area.height < 8 { + if !view.classed { + draw_class_select(frame, area, &view); + return; + } + + if area.width < 50 || area.height < 9 { draw_compact(frame, area, &view); return; } - let side_w = if area.width >= 78 { - SIDE_WIDE - } else { - SIDE_NARROW - }; - let columns = - Layout::horizontal([Constraint::Min(24), Constraint::Length(side_w)]).split(area); - draw_log(frame, columns[0], &view); - draw_side(frame, columns[1], state, &view, usernames); + let side_w = if area.width >= 84 { SIDE_WIDE } else { SIDE_NARROW }; + let cols = Layout::horizontal([Constraint::Min(26), Constraint::Length(side_w)]).split(area); + draw_log(frame, cols[0], &view); + draw_side(frame, cols[1], state, &view, usernames); +} + +fn draw_class_select(frame: &mut Frame, area: Rect, _view: &PlayerView) { + let mut lines = vec![ + Line::from(Span::styled( + "~ LATEANIA ~", + Style::default().fg(theme::AMBER_GLOW()).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + "Choose your calling. Press its number.", + Style::default().fg(theme::TEXT_DIM()), + )), + Line::raw(""), + ]; + for (i, class) in Class::ALL.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled( + format!(" {} ", i + 1), + Style::default().fg(theme::BG_CANVAS()).bg(theme::AMBER()).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {} ", class.name()), + Style::default().fg(theme::TEXT_BRIGHT()).add_modifier(Modifier::BOLD), + ), + Span::styled( + class.tagline().to_string(), + Style::default().fg(theme::TEXT()), + ), + ])); + lines.push(Line::from(Span::styled( + format!(" trait: {} - {}", class.trait_name(), class.trait_desc()), + Style::default().fg(theme::TEXT_DIM()), + ))); + } + lines.push(Line::raw("")); + lines.push(Line::from(Span::styled( + "World by Tasmania - thanks to late.sh and its contributors.", + Style::default().fg(theme::TEXT_FAINT()), + ))); + frame.render_widget(Paragraph::new(lines), area); } fn draw_compact(frame: &mut Frame, area: Rect, view: &PlayerView) { let mut lines = vec![Line::from(vec![ - Span::styled( - view.room_name.clone(), - Style::default() - .fg(theme::AMBER()) - .add_modifier(Modifier::BOLD), - ), - Span::styled( - format!(" hp {}/{}", view.hp, view.max_hp), - Style::default().fg(hp_color(view.hp, view.max_hp)), - ), + Span::styled(view.room_name.clone(), Style::default().fg(theme::AMBER()).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}/{}hp", view.hp, view.max_hp), Style::default().fg(hp_color(view.hp, view.max_hp))), ])]; - let tail = log_tail(view, area.height.saturating_sub(1) as usize); - for line in tail { - lines.push(log_line(line.0, line.1)); + for (kind, text) in log_tail(view, area.height.saturating_sub(1) as usize) { + lines.push(log_line(kind, text)); } frame.render_widget(Paragraph::new(lines), area); } fn draw_log(frame: &mut Frame, area: Rect, view: &PlayerView) { - let capacity = area.height as usize; - let tail = log_tail(view, capacity); - let lines: Vec = tail - .into_iter() - .map(|(kind, text)| log_line(kind, text)) - .collect(); + let tail = log_tail(view, area.height as usize); + let lines: Vec = tail.into_iter().map(|(k, t)| log_line(k, t)).collect(); frame.render_widget(Paragraph::new(lines), area); } @@ -96,102 +118,208 @@ fn draw_side( view: &PlayerView, usernames: &UsernameLookup<'_>, ) { - let _ = state; - let mut lines = Vec::new(); + let lines = match state.panel() { + Panel::Room => room_panel(view, usernames), + Panel::Character => character_panel(view), + Panel::Abilities => abilities_panel(view), + Panel::Inventory => inventory_panel(view, state.cursor()), + Panel::Shop => shop_panel(view, state.cursor()), + }; + frame.render_widget(Paragraph::new(lines), area); +} - lines.push(section("Adventurer")); - lines.push(stat_line("Level", view.level.to_string())); - lines.push(Line::from(vec![ - Span::styled(" HP ", Style::default().fg(theme::TEXT_DIM())), - Span::styled( - format!("{}/{}", view.hp, view.max_hp), - Style::default() - .fg(hp_color(view.hp, view.max_hp)) - .add_modifier(Modifier::BOLD), - ), - ])); - lines.push(stat_line("XP", view.xp.to_string())); - lines.push(Line::raw("")); +fn vitals(view: &PlayerView) -> Vec> { + vec![ + Line::from(vec![ + Span::styled(format!("{} ", view.class_name), Style::default().fg(theme::AMBER()).add_modifier(Modifier::BOLD)), + Span::styled(format!("lvl {}", view.level), Style::default().fg(theme::TEXT_BRIGHT())), + ]), + Line::from(vec![ + Span::styled("HP ", Style::default().fg(theme::TEXT_DIM())), + Span::styled(format!("{}/{}", view.hp, view.max_hp), Style::default().fg(hp_color(view.hp, view.max_hp)).add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(format!("{:<4}", short_res(&view.resource_name)), Style::default().fg(theme::TEXT_DIM())), + Span::styled(format!("{}/{}", view.resource, view.max_resource), Style::default().fg(theme::MENTION())), + ]), + Line::from(vec![ + Span::styled("gold ", Style::default().fg(theme::TEXT_DIM())), + Span::styled(format!("{}", view.gold), Style::default().fg(theme::BADGE_GOLD())), + ]), + ] +} +fn room_panel(view: &PlayerView, usernames: &UsernameLookup<'_>) -> Vec> { + let mut lines = vitals(view); + lines.push(Line::raw("")); lines.push(section("Here")); - lines.push(Line::from(Span::styled( - format!(" {}", view.zone), - Style::default().fg(theme::TEXT()), - ))); + lines.push(Line::from(Span::styled(format!(" {}", view.zone), Style::default().fg(theme::TEXT())))); let exits = if view.exits.is_empty() { "none".to_string() } else { - view.exits - .iter() - .map(|(_, name)| name.as_str()) - .collect::>() - .join(", ") + view.exits.iter().map(|(_, n)| n.as_str()).collect::>().join(", ") }; lines.push(Line::from(vec![ Span::styled(" exits ", Style::default().fg(theme::TEXT_DIM())), Span::styled(exits, Style::default().fg(theme::AMBER_DIM())), ])); - if !view.mobs.is_empty() { - lines.push(Line::raw("")); lines.push(section("Foes")); for mob in &view.mobs { lines.push(Line::from(vec![ Span::styled(format!(" {} ", mob.name), Style::default().fg(theme::ERROR())), - Span::styled( - format!("{}/{}", mob.hp, mob.max_hp), - Style::default().fg(theme::TEXT_DIM()), - ), + Span::styled(format!("{}/{}", mob.hp, mob.max_hp), Style::default().fg(theme::TEXT_DIM())), ])); } } - if !view.occupants.is_empty() { - lines.push(Line::raw("")); lines.push(section("Adventurers here")); for occ in &view.occupants { - let name = usernames - .get(&occ.user_id) - .cloned() - .unwrap_or_else(|| "adventurer".to_string()); - let marker = if occ.in_combat { " (fighting)" } else { "" }; - lines.push(Line::from(Span::styled( - format!(" {name}{marker}"), - Style::default().fg(theme::SUCCESS()), - ))); + let name = usernames.get(&occ.user_id).cloned().unwrap_or_else(|| "adventurer".to_string()); + let tag = if occ.in_combat { " (fighting)" } else { "" }; + lines.push(Line::from(Span::styled(format!(" {name}{tag}"), Style::default().fg(theme::SUCCESS())))); } } + lines.push(Line::raw("")); + lines.extend(footer_hints(view)); + lines +} +fn character_panel(view: &PlayerView) -> Vec> { + let mut lines = vitals(view); lines.push(Line::raw("")); - lines.push(section("Commands")); - if view.respawning { + lines.push(section("Combat")); + lines.push(stat("attack", view.attack.to_string())); + lines.push(stat("armor", view.armor.to_string())); + lines.push(Line::raw("")); + lines.push(section("Trait")); + lines.push(Line::from(Span::styled(format!(" {}", view.trait_name), Style::default().fg(theme::AMBER()).add_modifier(Modifier::BOLD)))); + lines.extend(wrap(&view.trait_desc, 30)); + lines.push(Line::raw("")); + lines.push(section("Experience")); + if view.xp_for_next > 0 { lines.push(Line::from(Span::styled( - " recovering...", - Style::default().fg(theme::TEXT_DIM()), + format!(" {}/{} to next", view.xp_into_level, view.xp_for_next), + Style::default().fg(theme::TEXT()), ))); - } else if view.in_combat_with.is_some() { + } else { + lines.push(Line::from(Span::styled(" max level reached", Style::default().fg(theme::BADGE_GOLD())))); + } + lines.push(Line::raw("")); + lines.push(hint("c", "close v abilities t bag")); + lines +} + +fn abilities_panel(view: &PlayerView) -> Vec> { + let mut lines = vec![section("Abilities")]; + if view.abilities.is_empty() { + lines.push(Line::from(Span::styled(" none yet", Style::default().fg(theme::TEXT_DIM())))); + } + for a in &view.abilities { + let color = if a.ready { theme::TEXT_BRIGHT() } else { theme::TEXT_FAINT() }; + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", a.slot), Style::default().fg(theme::BG_CANVAS()).bg(if a.ready { theme::AMBER() } else { theme::BORDER_DIM() })), + Span::styled(format!(" {}", a.name), Style::default().fg(color).add_modifier(Modifier::BOLD)), + Span::styled(format!(" {}c {}", a.cost, a.effect), Style::default().fg(theme::TEXT_DIM())), + ])); + } + lines.push(Line::raw("")); + lines.push(hint("1-9", "use ability in combat")); + lines.push(hint("v", "close")); + lines +} + +fn inventory_panel(view: &PlayerView, cursor: usize) -> Vec> { + let mut lines = vec![ + section("Inventory"), + Line::from(Span::styled(format!(" {} gold", view.gold), Style::default().fg(theme::BADGE_GOLD()))), + ]; + if view.inventory.is_empty() { + lines.push(Line::from(Span::styled(" (empty)", Style::default().fg(theme::TEXT_DIM())))); + } + for (i, it) in view.inventory.iter().enumerate() { + let selected = i == cursor; + let marker = if selected { ">" } else { " " }; + let tag = if it.equipped { + " [worn]".to_string() + } else if let Some(slot) = &it.slot { + format!(" ({slot})") + } else { + String::new() + }; + let style = if selected { + Style::default().fg(theme::TEXT_BRIGHT()).bg(theme::BG_SELECTION()).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(rarity_color(&it.rarity)) + }; + lines.push(Line::from(Span::styled(format!("{marker} {}{}", it.name, tag), style))); + } + lines.push(Line::raw("")); + lines.push(hint("w/s", "select Enter equip/use")); + lines.push(hint("x", "sell (at a shop) t close")); + lines +} + +fn shop_panel(view: &PlayerView, cursor: usize) -> Vec> { + let Some(shop) = &view.shop else { + return vec![Line::from(Span::styled("No shop here.", Style::default().fg(theme::TEXT_DIM())))]; + }; + let mut lines = vec![ + Line::from(Span::styled(shop.shop_name.clone(), Style::default().fg(theme::AMBER_GLOW()).add_modifier(Modifier::BOLD))), + Line::from(Span::styled(format!("{} - your gold: {}", shop.npc_name, view.gold), Style::default().fg(theme::TEXT_DIM()))), + Line::raw(""), + ]; + for (i, e) in shop.entries.iter().enumerate() { + let selected = i == cursor; + let marker = if selected { ">" } else { " " }; + let price_color = if e.affordable { theme::BADGE_GOLD() } else { theme::ERROR() }; + let name_style = if selected { + Style::default().fg(theme::TEXT_BRIGHT()).bg(theme::BG_SELECTION()).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(rarity_color(&e.rarity)) + }; + lines.push(Line::from(vec![ + Span::styled(format!("{marker} {}", e.name), name_style), + Span::styled(format!(" {}g", e.price), Style::default().fg(price_color)), + ])); + } + lines.push(Line::raw("")); + lines.push(hint("w/s", "select Enter buy")); + lines.push(hint("b", "leave shop")); + lines +} + +fn footer_hints(view: &PlayerView) -> Vec> { + let mut lines = vec![section("Commands")]; + if view.respawning { + lines.push(Line::from(Span::styled(" recovering...", Style::default().fg(theme::TEXT_DIM())))); + return lines; + } + if view.in_combat_with.is_some() { lines.push(hint("space/x", "strike")); + lines.push(hint("1-9", "use ability")); lines.push(hint("z", "flee")); } else { - lines.push(hint("w a s d", "move")); - lines.push(hint("arrows", "move")); - lines.push(hint("space/x", "attack")); - lines.push(hint("o", "look")); + lines.push(hint("wasd/arrows", "move")); + lines.push(hint("yunm", "diagonals")); + lines.push(hint("space", "attack o look")); } - lines.push(hint("q / Esc", "leave")); - - frame.render_widget(Paragraph::new(lines), area); + lines.push(hint("c v t", "sheet abilities bag")); + if view.shop.is_some() { + lines.push(hint("b", "shop")); + } + lines.push(hint("q", "leave")); + lines } +// ---- helpers ------------------------------------------------------------- + fn log_tail(view: &PlayerView, capacity: usize) -> Vec<(LogKind, String)> { if capacity == 0 { return Vec::new(); } let start = view.log.len().saturating_sub(capacity); - view.log[start..] - .iter() - .map(|line| (line.kind, line.text.clone())) - .collect() + view.log[start..].iter().map(|l| (l.kind, l.text.clone())).collect() } fn log_line(kind: LogKind, text: String) -> Line<'static> { @@ -200,6 +328,7 @@ fn log_line(kind: LogKind, text: String) -> Line<'static> { LogKind::Combat => theme::ERROR(), LogKind::System => theme::AMBER_DIM(), LogKind::Say => theme::CHAT_BODY(), + LogKind::Loot => theme::SUCCESS(), }; Line::from(Span::styled(text, Style::default().fg(color))) } @@ -207,18 +336,13 @@ fn log_line(kind: LogKind, text: String) -> Line<'static> { fn section(title: &str) -> Line<'static> { Line::from(vec![ Span::styled(" - ", Style::default().fg(theme::BORDER())), - Span::styled( - title.to_string(), - Style::default() - .fg(theme::AMBER()) - .add_modifier(Modifier::BOLD), - ), + Span::styled(title.to_string(), Style::default().fg(theme::AMBER()).add_modifier(Modifier::BOLD)), ]) } -fn stat_line(label: &str, value: String) -> Line<'static> { +fn stat(label: &str, value: String) -> Line<'static> { Line::from(vec![ - Span::styled(format!(" {label:<5}"), Style::default().fg(theme::TEXT_DIM())), + Span::styled(format!(" {label:<7}"), Style::default().fg(theme::TEXT_DIM())), Span::styled(value, Style::default().fg(theme::TEXT_BRIGHT())), ]) } @@ -230,6 +354,27 @@ fn hint(key: &str, label: &str) -> Line<'static> { ]) } +fn wrap(text: &str, width: usize) -> Vec> { + let mut out = Vec::new(); + let mut line = String::from(" "); + for word in text.split_whitespace() { + if line.len() + word.len() + 1 > width && line.trim().len() > 0 { + out.push(Line::from(Span::styled(line.clone(), Style::default().fg(theme::TEXT_DIM())))); + line = String::from(" "); + } + line.push_str(word); + line.push(' '); + } + if !line.trim().is_empty() { + out.push(Line::from(Span::styled(line, Style::default().fg(theme::TEXT_DIM())))); + } + out +} + +fn short_res(name: &str) -> String { + name.chars().take(4).collect() +} + fn hp_color(hp: i32, max_hp: i32) -> ratatui::style::Color { if max_hp <= 0 { return theme::TEXT_DIM(); @@ -243,3 +388,13 @@ fn hp_color(hp: i32, max_hp: i32) -> ratatui::style::Color { theme::SUCCESS() } } + +fn rarity_color(rarity: &str) -> ratatui::style::Color { + match rarity { + "uncommon" => theme::SUCCESS(), + "rare" => theme::MENTION(), + "epic" => theme::AMBER_GLOW(), + "legendary" => theme::BADGE_GOLD(), + _ => theme::TEXT(), + } +} diff --git a/late-ssh/src/app/rooms/mud/world.rs b/late-ssh/src/app/rooms/mud/world.rs index 60430e2b..0af83ed6 100644 --- a/late-ssh/src/app/rooms/mud/world.rs +++ b/late-ssh/src/app/rooms/mud/world.rs @@ -20,13 +20,17 @@ use std::collections::HashMap; -/// Compass (plus vertical) directions a player can move. +/// Compass (with diagonals and vertical) directions a player can move. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Dir { North, South, East, West, + Northeast, + Northwest, + Southeast, + Southwest, Up, Down, } @@ -38,6 +42,10 @@ impl Dir { Self::South => "south", Self::East => "east", Self::West => "west", + Self::Northeast => "northeast", + Self::Northwest => "northwest", + Self::Southeast => "southeast", + Self::Southwest => "southwest", Self::Up => "up", Self::Down => "down", } @@ -49,6 +57,10 @@ impl Dir { Self::South => "s", Self::East => "e", Self::West => "w", + Self::Northeast => "ne", + Self::Northwest => "nw", + Self::Southeast => "se", + Self::Southwest => "sw", Self::Up => "u", Self::Down => "d", } @@ -122,9 +134,14 @@ pub fn seed_world() -> World { "Embergate - Town Square", "Embergate", true, - "Lanternlight pools on worn cobbles. The town square of Embergate hums \ - with quiet evening trade. A notice board leans by the well, and roads \ - lead off in every direction.", + "Lanternlight pools on worn cobbles, and the great bronze brazier at the \ + square's heart throws a restless amber glow over the town that takes its \ + name from it. Embergate hums with evening trade: a fiddler saws by the \ + well, children chase a dog between the legs of off-duty guardsmen, and \ + the smell of the baker's last loaves hangs warm in the air. A notice \ + board leans by the well, thick with bounties and lost-cat pleas alike. \ + The Gilded Flagon glows north, the temple west, Market Row east, and the \ + South Gate and open road lie south.", &[(Dir::North, 2), (Dir::East, 3), (Dir::West, 4), (Dir::South, 5)], ), room( @@ -132,26 +149,39 @@ pub fn seed_world() -> World { "Embergate - The Gilded Flagon", "Embergate", true, - "A warm tavern thick with woodsmoke and laughter. Adventurers swap tall \ - tales over tankards. The square lies back to the south.", + "Woodsmoke, spilled ale, and roasting meat tangle in the air of the town's \ + beloved tavern. A great hearth roars at one end; long tables run with \ + candle-wax and carved initials. Adventurers swap tall tales over tankards, \ + a card game simmers toward a brawl in the corner, and the barkeep polishes \ + a horn cup that will never come clean. It is warm, loud, and safe - the \ + last of those rarer than the others. The square lies south.", &[(Dir::South, 1)], ), room( 3, - "Embergate - Market Row", + "Embergate - Market Row & the Ember Forge", "Embergate", true, - "Shuttered stalls line a narrow lane. A smith's forge glows at the far \ - end. The square is back to the west.", - &[(Dir::West, 1)], + "The lane narrows into a clamor of commerce, awnings snapping overhead and \ + barkers crying their wares. At the far end the open front of the Ember \ + Forge breathes furnace-heat into the street, where BRUNA IRONHAND, the \ + town smith, works a glowing billet with blows that ring off the rooftops. \ + Racks of blades, bows, and staves gleam at her shoulder, for sale to any \ + who can pay. The square lies west; the rest of the market district opens \ + east.", + &[(Dir::West, 1), (Dir::East, 201)], ), room( 4, "Embergate - Temple of the Dawn", "Embergate", true, - "Pale columns rise toward a domed ceiling painted with sunrise. Clerics \ - move in hushed procession. The square is back to the east.", + "Pale columns rise toward a domed ceiling painted with a sunrise so vivid it \ + seems to warm the cold stone beneath. Clerics in white move in hushed \ + procession, and a hundred candles gutter at the feet of a gilded sun. Here \ + the wounded are mended and the dead are mourned; here, it is said, a fallen \ + adventurer's spirit is gathered up and returned to the world. A sense of \ + grave, patient mercy fills the air. The square lies east.", &[(Dir::East, 1)], ), room( @@ -163,6 +193,69 @@ pub fn seed_world() -> World { stretches into open country. The square is north.", &[(Dir::North, 1), (Dir::South, 6)], ), + // ---- Embergate shop district (safe) ----------------------------- + room( + 201, + "Embergate - The Outfitter's Stall", + "Embergate", + true, + "The market widens into a square of canvas stalls. Dominating it is the \ + Outfitter's, where TOMAS THREADNEEDLE presides over teetering heaps of \ + boiled leather, riveted mail, woven robes, and stout boots, all of it for \ + sale. He squints at every passerby as though measuring them for a coffin or \ + a cuirass, whichever they need first. The forge lies west; the lane runs on \ + north and east.", + &[(Dir::West, 3), (Dir::North, 202), (Dir::East, 203)], + ), + room( + 202, + "Embergate - The Apothecary", + "Embergate", + true, + "A narrow shopfront crammed floor to rafter with bottles, jars, and bundled \ + herbs that fill the air with a sharp green reek. OLD MIRELA, bent nearly \ + double, shuffles between the shelves dispensing draughts and elixirs to any \ + with coin and an ailment. A cauldron mutters in the back. Nothing here is \ + quite labeled, but she always seems to know which bottle is which. The \ + outfitter's lies south.", + &[(Dir::South, 201)], + ), + room( + 203, + "Embergate - The Curio Cart", + "Embergate", + true, + "A gaudy painted cart blocks half the lane, hung with charms, rings, and \ + trinkets that wink in the lanternlight. PELL THE MAGPIE leans against it \ + with a grin too wide to wholly trust, talking up the luck and virtue of his \ + wares to a skeptical crowd. Some of it may even be enchanted. The \ + outfitter's lies west; a quieter street runs east.", + &[(Dir::West, 201), (Dir::East, 204)], + ), + room( + 204, + "Embergate - The Bank of Embergate", + "Embergate", + true, + "A squat, iron-doored building stands aloof from the market bustle, the only \ + stone-built shop on the row. Within, a humorless clerk tallies coin behind a \ + grille and a vault door broods at the back. Adventurers store their \ + hard-won gold here against the day a dungeon empties their purse. The curio \ + cart lies west; the town wall walk runs north.", + &[(Dir::West, 203), (Dir::North, 205)], + ), + room( + 205, + "Embergate - The Wall Walk", + "Embergate", + true, + "Stone steps climb to a parapet atop the town wall, where a single guardsman \ + keeps a bored vigil over the dark country beyond. From here all of Embergate \ + spreads out below, lamplit and small and worth defending, and past the wall \ + the King's Road runs off into a night full of teeth. The bank lies back down \ + to the south.", + &[(Dir::South, 204)], + ), room( 6, "The King's Road - Open Country", From 691722226559cc869464e13cf6b3334fa764404f Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Mon, 1 Jun 2026 13:59:14 +1000 Subject: [PATCH 04/20] feat(rooms): boss loot drops and the Rogue's Opportunist trait Two fixes to the Lateania combat systems. Loot: MobSpawn gains a loot table and a boss flag. Every zone boss (the Elder Treant through the Archdemon Mal'gareth) now drops a guaranteed item from a themed, tier-appropriate table when slain; regular mobs have a modest chance at a common drop. Drops land straight in the pack and are announced in the log, with bosses calling theirs out loudly. Rolls use the existing rand dependency. Rogue trait: Opportunist was defined but never applied. The Rogue now arms an opening strike when engaging, and the first auto-attack of the fight lands as a doubled critical hit before the flag clears. This completes trait coverage - all five classes now have their passive live (Warrior death-save, Mage and Cleric amplification, Ranger wounded-bonus, Rogue opening crit). Inline tests cover that every boss has a valid loot table, all mob loot references real items, and that the Rogue arms and spends its opening crit while other classes do not. Signed-off-by: Tony Hosaroygard --- late-ssh/src/app/rooms/mud/svc.rs | 82 +++++++++++++++++++++-- late-ssh/src/app/rooms/mud/world.rs | 100 ++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 5 deletions(-) diff --git a/late-ssh/src/app/rooms/mud/svc.rs b/late-ssh/src/app/rooms/mud/svc.rs index e7739d7a..53af1489 100644 --- a/late-ssh/src/app/rooms/mud/svc.rs +++ b/late-ssh/src/app/rooms/mud/svc.rs @@ -16,6 +16,7 @@ use std::{ time::{Duration, Instant}, }; +use rand::Rng; use tokio::sync::{Mutex, broadcast, watch}; use uuid::Uuid; @@ -412,6 +413,8 @@ struct PlayerState { gold: i64, room: RoomId, target: Option, + /// True from engaging until the first auto-attack lands (Rogue opening crit). + opening_strike: bool, /// Outgoing-damage buff remaining ticks and magnitude. empower: i32, empower_ticks: u8, @@ -538,6 +541,7 @@ impl WorldState { gold: STARTING_GOLD, room: start, target: None, + opening_strike: false, empower: 0, empower_ticks: 0, shield: 0, @@ -719,6 +723,8 @@ impl WorldState { .unwrap_or_default(); if let Some(player) = self.players.get_mut(&user_id) { player.target = Some(mob_id); + // Opportunist: the Rogue's first strike of a fight always crits. + player.opening_strike = player.class == Some(Class::Rogue); } self.log_to(user_id, LogKind::Combat, format!("You close with {mob_name}!")); } @@ -925,17 +931,21 @@ impl WorldState { } fn kill_mob(&mut self, user_id: Uuid, mob_id: u32) { - let (mob_name, xp, respawn) = match self.mobs.get_mut(&mob_id) { + let (mob_name, xp, loot, boss) = match self.mobs.get_mut(&mob_id) { Some(mob) => { mob.alive = false; mob.hp = 0; let r = mob.spawn.respawn_secs; mob.respawn_at = Some(Instant::now() + Duration::from_secs(r)); - (mob.spawn.name.to_string(), mob.spawn.xp, r) + ( + mob.spawn.name.to_string(), + mob.spawn.xp, + mob.spawn.loot, + mob.spawn.boss, + ) } None => return, }; - let _ = respawn; let gold = 3 + xp / 4; self.log_to(user_id, LogKind::Loot, format!("You have slain {mob_name}! (+{xp} xp, +{gold} gold)")); if let Some(p) = self.players.get_mut(&user_id) { @@ -943,11 +953,39 @@ impl WorldState { p.xp += xp as i64; p.gold += gold as i64; } + self.roll_loot(user_id, &mob_name, loot, boss); self.check_level_up(user_id); self.pending_kills.push(KillOutcome { user_id, mob_name }); self.dirty = true; } + /// Award loot from a slain mob. Bosses always drop one item from their table; + /// regular mobs have a modest chance at a common drop. + fn roll_loot(&mut self, user_id: Uuid, mob_name: &str, loot: &'static [u32], boss: bool) { + if loot.is_empty() { + return; + } + let mut rng = rand::thread_rng(); + // Regular mobs: roughly one kill in four yields something. + if !boss && rng.gen_range(0..100) >= 25 { + return; + } + let pick = loot[rng.gen_range(0..loot.len())]; + let Some(it) = item(pick) else { return }; + if let Some(p) = self.players.get_mut(&user_id) { + p.inventory.push(pick); + } + if boss { + self.log_to( + user_id, + LogKind::Loot, + format!("{mob_name} drops {} ({})! It falls into your pack.", it.name, it.rarity.label()), + ); + } else { + self.log_to(user_id, LogKind::Loot, format!("You loot {} from the corpse.", it.name)); + } + } + fn check_level_up(&mut self, user_id: Uuid) { let (class, xp, old_level) = match self.players.get(&user_id) { Some(p) => (p.class, p.xp, p.level), @@ -1259,8 +1297,8 @@ impl WorldState { .collect(); for user_id in fighters { - let (mob_id, player_atk) = match self.players.get(&user_id) { - Some(p) => (p.target, p.attack()), + let (mob_id, base_atk, opening) = match self.players.get(&user_id) { + Some(p) => (p.target, p.attack(), p.opening_strike), None => continue, }; let Some(mob_id) = mob_id else { continue }; @@ -1271,6 +1309,14 @@ impl WorldState { } continue; } + // Opportunist: the Rogue's opening strike of a fight lands as a crit. + let player_atk = if opening { base_atk * 2 } else { base_atk }; + if opening { + if let Some(p) = self.players.get_mut(&user_id) { + p.opening_strike = false; + } + self.log_to(user_id, LogKind::Combat, "Opportunist! Your opening strike lands true.".to_string()); + } // Auto-attack. if let Some(mob) = self.mobs.get_mut(&mob_id) { mob.hp -= player_atk; @@ -1595,6 +1641,32 @@ mod tests { assert!(s.players[&uid(1)].attack() > base); } + #[test] + fn rogue_opening_strike_is_flagged_then_consumed() { + let mut s = world(); + s.join(uid(1)); + s.choose_class(uid(1), Class::Rogue); + // Move to a combat room with a mob (room 6, goblin) and engage. + s.move_player(uid(1), Dir::South); + s.move_player(uid(1), Dir::South); + s.engage(uid(1)); + assert!(s.players[&uid(1)].opening_strike, "rogue arms opening crit"); + // One tick resolves the auto-attack and consumes the opening strike. + s.tick(); + assert!(!s.players[&uid(1)].opening_strike, "opening crit is spent"); + } + + #[test] + fn warrior_does_not_arm_opening_strike() { + let mut s = world(); + s.join(uid(1)); + s.choose_class(uid(1), Class::Warrior); + s.move_player(uid(1), Dir::South); + s.move_player(uid(1), Dir::South); + s.engage(uid(1)); + assert!(!s.players[&uid(1)].opening_strike, "only rogues get the crit"); + } + #[test] fn warrior_survives_first_lethal_blow() { let mut s = world(); diff --git a/late-ssh/src/app/rooms/mud/world.rs b/late-ssh/src/app/rooms/mud/world.rs index 0af83ed6..d1d313ee 100644 --- a/late-ssh/src/app/rooms/mud/world.rs +++ b/late-ssh/src/app/rooms/mud/world.rs @@ -92,6 +92,11 @@ pub struct MobSpawn { pub xp: i32, /// Seconds before a slain mob respawns. pub respawn_secs: u64, + /// Item ids this mob can drop. Regular mobs have a chance at common gear; + /// bosses are guaranteed to drop one item from a richer table. + pub loot: &'static [u32], + /// True for zone bosses: drops are guaranteed and announced loudly. + pub boss: bool, } /// The immutable world: every room plus the mob roster. @@ -1367,6 +1372,8 @@ pub fn seed_world() -> World { damage: 3, xp: 12, respawn_secs: 30, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 2, @@ -1376,6 +1383,8 @@ pub fn seed_world() -> World { damage: 5, xp: 20, respawn_secs: 45, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 3, @@ -1385,6 +1394,8 @@ pub fn seed_world() -> World { damage: 4, xp: 16, respawn_secs: 40, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, // ---- Whisperwood (tier 2-3) ------------------------------------- MobSpawn { @@ -1395,6 +1406,8 @@ pub fn seed_world() -> World { damage: 6, xp: 26, respawn_secs: 45, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 11, @@ -1404,6 +1417,8 @@ pub fn seed_world() -> World { damage: 7, xp: 30, respawn_secs: 50, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 12, @@ -1413,6 +1428,8 @@ pub fn seed_world() -> World { damage: 6, xp: 32, respawn_secs: 50, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, // Boss: Whisperwood MobSpawn { @@ -1423,6 +1440,8 @@ pub fn seed_world() -> World { damage: 12, xp: 150, respawn_secs: 300, + loot: &[1006, 1201, 1301], + boss: true, }, // ---- Duskhollow Caverns (tier 3-4) ------------------------------ MobSpawn { @@ -1433,6 +1452,8 @@ pub fn seed_world() -> World { damage: 8, xp: 40, respawn_secs: 55, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 21, @@ -1442,6 +1463,8 @@ pub fn seed_world() -> World { damage: 9, xp: 46, respawn_secs: 55, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 22, @@ -1451,6 +1474,8 @@ pub fn seed_world() -> World { damage: 10, xp: 52, respawn_secs: 60, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, // Boss: Duskhollow Caverns MobSpawn { @@ -1461,6 +1486,8 @@ pub fn seed_world() -> World { damage: 16, xp: 220, respawn_secs: 300, + loot: &[1105, 1202, 1302], + boss: true, }, // ---- Drowned Crypts (tier 4-5) ---------------------------------- MobSpawn { @@ -1471,6 +1498,8 @@ pub fn seed_world() -> World { damage: 11, xp: 60, respawn_secs: 60, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 31, @@ -1480,6 +1509,8 @@ pub fn seed_world() -> World { damage: 12, xp: 66, respawn_secs: 60, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 32, @@ -1489,6 +1520,8 @@ pub fn seed_world() -> World { damage: 13, xp: 72, respawn_secs: 65, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, // Boss: Drowned Crypts MobSpawn { @@ -1499,6 +1532,8 @@ pub fn seed_world() -> World { damage: 20, xp: 320, respawn_secs: 360, + loot: &[1008, 1204, 1302], + boss: true, }, // ---- Emberpeak Mines (tier 5-6) --------------------------------- MobSpawn { @@ -1509,6 +1544,8 @@ pub fn seed_world() -> World { damage: 14, xp: 80, respawn_secs: 65, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 41, @@ -1518,6 +1555,8 @@ pub fn seed_world() -> World { damage: 15, xp: 88, respawn_secs: 70, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 42, @@ -1527,6 +1566,8 @@ pub fn seed_world() -> World { damage: 16, xp: 96, respawn_secs: 70, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, // Boss: Emberpeak Mines MobSpawn { @@ -1537,6 +1578,8 @@ pub fn seed_world() -> World { damage: 26, xp: 440, respawn_secs: 360, + loot: &[1009, 1205, 1304], + boss: true, }, // ---- Frostspire Ascent (tier 6-7) ------------------------------- MobSpawn { @@ -1547,6 +1590,8 @@ pub fn seed_world() -> World { damage: 17, xp: 104, respawn_secs: 70, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 51, @@ -1556,6 +1601,8 @@ pub fn seed_world() -> World { damage: 19, xp: 116, respawn_secs: 75, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 52, @@ -1565,6 +1612,8 @@ pub fn seed_world() -> World { damage: 20, xp: 124, respawn_secs: 75, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, // Boss: Frostspire Ascent MobSpawn { @@ -1575,6 +1624,8 @@ pub fn seed_world() -> World { damage: 32, xp: 600, respawn_secs: 420, + loot: &[1007, 1205, 1304], + boss: true, }, // ---- The Sunken Citadel (tier 7-8) ------------------------------ MobSpawn { @@ -1585,6 +1636,8 @@ pub fn seed_world() -> World { damage: 22, xp: 140, respawn_secs: 80, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 61, @@ -1594,6 +1647,8 @@ pub fn seed_world() -> World { damage: 24, xp: 152, respawn_secs: 80, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 62, @@ -1603,6 +1658,8 @@ pub fn seed_world() -> World { damage: 26, xp: 164, respawn_secs: 85, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, // Boss: The Sunken Citadel MobSpawn { @@ -1613,6 +1670,8 @@ pub fn seed_world() -> World { damage: 38, xp: 820, respawn_secs: 420, + loot: &[1109, 1202, 1304], + boss: true, }, // ---- The Obsidian Throne (tier 9-10) ---------------------------- MobSpawn { @@ -1623,6 +1682,8 @@ pub fn seed_world() -> World { damage: 30, xp: 200, respawn_secs: 90, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 71, @@ -1632,6 +1693,8 @@ pub fn seed_world() -> World { damage: 33, xp: 230, respawn_secs: 90, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, MobSpawn { id: 72, @@ -1641,6 +1704,8 @@ pub fn seed_world() -> World { damage: 36, xp: 260, respawn_secs: 95, + loot: &[1000, 1100, 1103, 1300], + boss: false, }, // Final boss MobSpawn { @@ -1651,6 +1716,8 @@ pub fn seed_world() -> World { damage: 48, xp: 1500, respawn_secs: 600, + loot: &[1009, 1205, 1401], + boss: true, }, ]; @@ -1724,6 +1791,39 @@ mod tests { assert_eq!(count, ids.len(), "duplicate mob spawn id"); } + #[test] + fn every_boss_has_a_guaranteed_loot_table() { + let world = seed_world(); + let bosses: Vec<_> = world.spawns.iter().filter(|s| s.boss).collect(); + assert!(bosses.len() >= 7, "expected at least 7 zone bosses"); + for boss in bosses { + assert!(!boss.loot.is_empty(), "boss {} has no loot", boss.name); + for id in boss.loot { + assert!( + crate::app::rooms::mud::items::item(*id).is_some(), + "boss {} drops missing item {}", + boss.name, + id + ); + } + } + } + + #[test] + fn all_mob_loot_references_real_items() { + let world = seed_world(); + for spawn in &world.spawns { + for id in spawn.loot { + assert!( + crate::app::rooms::mud::items::item(*id).is_some(), + "mob {} drops missing item {}", + spawn.name, + id + ); + } + } + } + #[test] fn every_room_reachable_from_start() { let world = seed_world(); From d037c77375fd6fdbe759db0a30e5af0af8139bdf Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Mon, 1 Jun 2026 14:09:03 +1000 Subject: [PATCH 05/20] feat(rooms): persistent Lateania characters across sessions Characters now survive logout. Progress - class, level, xp, gold, current hp, inventory, and equipped gear - is saved to the database and restored on return, so the world is genuinely persistent rather than resetting each session. Storage: a new mud_characters table (migration 067) holds one row per user with a schema-versioned JSON blob, mirroring the runtime_state trade-off game_rooms already makes - the game owns the blob's shape and can add fields without a migration each time. late_core::models::mud_character loads and upserts it. Game side: persist.rs defines SavedCharacter, the durable slice of a player, serialized with serde. Transient combat state (target, effects, cooldowns, respawn timers) is intentionally not saved, so a character reloads at full readiness in a safe room rather than resuming a logged-out fight. MudService now carries a Db handle (threaded through the manager and main.rs): join loads and hydrates any saved character before taking the world lock, leave captures and saves it, and a 60-second autosave loop persists every present character so an idle-timeout drop loses nothing. Corrupt or empty blobs degrade to a fresh character instead of crashing, and missing fields fall back to defaults, so old saves keep loading as the shape evolves. Inventory and equipment are validated against the live item catalog on load, dropping any items that no longer exist. Inline tests cover the SavedCharacter JSON round-trip, empty-blob handling, and partial/old-blob defaulting. Signed-off-by: Tony Hosaroygard --- .../migrations/067_create_mud_characters.sql | 11 ++ late-core/src/models/mod.rs | 1 + late-core/src/models/mud_character.rs | 49 ++++++ late-ssh/src/app/rooms/mud/classes.rs | 22 +++ late-ssh/src/app/rooms/mud/manager.rs | 13 +- late-ssh/src/app/rooms/mud/mod.rs | 1 + late-ssh/src/app/rooms/mud/persist.rs | 137 +++++++++++++++ late-ssh/src/app/rooms/mud/svc.rs | 160 +++++++++++++++++- late-ssh/src/main.rs | 6 +- 9 files changed, 390 insertions(+), 10 deletions(-) create mode 100644 late-core/migrations/067_create_mud_characters.sql create mode 100644 late-core/src/models/mud_character.rs create mode 100644 late-ssh/src/app/rooms/mud/persist.rs diff --git a/late-core/migrations/067_create_mud_characters.sql b/late-core/migrations/067_create_mud_characters.sql new file mode 100644 index 00000000..4d99f462 --- /dev/null +++ b/late-core/migrations/067_create_mud_characters.sql @@ -0,0 +1,11 @@ +-- Persistent Lateania (MUD) characters. One character per user, stored as a +-- schema-versioned JSON blob so the game can evolve its character shape without +-- a migration per field. The game owns the blob's contents; the table only +-- guarantees one row per user and tracks when it was last saved. +CREATE TABLE mud_characters ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + created TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + updated TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + data JSONB NOT NULL DEFAULT '{}'::jsonb +); diff --git a/late-core/src/models/mod.rs b/late-core/src/models/mod.rs index 27cec8b6..d3266986 100644 --- a/late-core/src/models/mod.rs +++ b/late-core/src/models/mod.rs @@ -23,6 +23,7 @@ pub mod media_source; pub mod mention_feed_read; pub mod minesweeper; pub mod moderation_audit_log; +pub mod mud_character; pub mod nonogram; pub mod notification; pub mod pet; diff --git a/late-core/src/models/mud_character.rs b/late-core/src/models/mud_character.rs new file mode 100644 index 00000000..3d07f6c6 --- /dev/null +++ b/late-core/src/models/mud_character.rs @@ -0,0 +1,49 @@ +// Persistent Lateania (MUD) character storage. +// +// One row per user holding a schema-versioned JSON blob. The MUD game owns the +// blob's shape; this model only loads and upserts it. Keeping the character as +// opaque JSON lets the game add fields (new stats, inventory, quest flags) +// without a migration each time, the same trade game_rooms.runtime_state makes. + +use anyhow::Result; +use serde_json::Value; +use tokio_postgres::Client; +use uuid::Uuid; + +crate::model! { + table = "mud_characters"; + params = MudCharacterParams; + struct MudCharacter { + @data + pub user_id: Uuid, + pub data: Value, + } +} + +impl MudCharacter { + /// Load a user's saved character blob, if they have one. + pub async fn load(client: &Client, user_id: Uuid) -> Result> { + let row = client + .query_opt( + "SELECT data FROM mud_characters WHERE user_id = $1", + &[&user_id], + ) + .await?; + Ok(row.map(|r| r.get::<_, Value>("data"))) + } + + /// Insert or overwrite a user's character blob. + pub async fn save(client: &Client, user_id: Uuid, data: Value) -> Result<()> { + client + .execute( + "INSERT INTO mud_characters (user_id, data) + VALUES ($1, $2) + ON CONFLICT (user_id) DO UPDATE + SET data = EXCLUDED.data, + updated = current_timestamp", + &[&user_id, &data], + ) + .await?; + Ok(()) + } +} diff --git a/late-ssh/src/app/rooms/mud/classes.rs b/late-ssh/src/app/rooms/mud/classes.rs index 306f2dc0..57929ff8 100644 --- a/late-ssh/src/app/rooms/mud/classes.rs +++ b/late-ssh/src/app/rooms/mud/classes.rs @@ -187,6 +187,28 @@ impl Class { pub fn from_index(i: usize) -> Class { Self::ALL[i % Self::ALL.len()] } + + /// Stable lowercase key for persistence (never reorder these strings). + pub fn as_key(self) -> &'static str { + match self { + Self::Warrior => "warrior", + Self::Mage => "mage", + Self::Cleric => "cleric", + Self::Rogue => "rogue", + Self::Ranger => "ranger", + } + } + + pub fn from_key(key: &str) -> Option { + match key { + "warrior" => Some(Self::Warrior), + "mage" => Some(Self::Mage), + "cleric" => Some(Self::Cleric), + "rogue" => Some(Self::Rogue), + "ranger" => Some(Self::Ranger), + _ => None, + } + } } /// Total experience required to reach a given level. Smoothly rising curve so diff --git a/late-ssh/src/app/rooms/mud/manager.rs b/late-ssh/src/app/rooms/mud/manager.rs index 0c80aeb4..99b47f8d 100644 --- a/late-ssh/src/app/rooms/mud/manager.rs +++ b/late-ssh/src/app/rooms/mud/manager.rs @@ -11,7 +11,7 @@ use std::{ sync::{Arc, Mutex}, }; -use late_core::MutexRecover; +use late_core::{MutexRecover, db::Db}; use tokio::sync::broadcast; use uuid::Uuid; @@ -33,15 +33,17 @@ const WORLD_CAPACITY_HINT: usize = 64; #[derive(Clone)] pub struct MudTableManager { activity: ActivityPublisher, + db: Db, tables: Arc>>, event_tx: broadcast::Sender, } impl MudTableManager { - pub fn new(activity: ActivityPublisher) -> Self { + pub fn new(activity: ActivityPublisher, db: Db) -> Self { let (event_tx, _) = broadcast::channel::(256); Self { activity, + db, tables: Arc::new(Mutex::new(HashMap::new())), event_tx, } @@ -52,7 +54,12 @@ impl MudTableManager { tables .entry(room.id) .or_insert_with(|| { - MudService::new_with_events(room.id, self.activity.clone(), self.event_tx.clone()) + MudService::new_with_events( + room.id, + self.activity.clone(), + self.db.clone(), + self.event_tx.clone(), + ) }) .clone() } diff --git a/late-ssh/src/app/rooms/mud/mod.rs b/late-ssh/src/app/rooms/mud/mod.rs index 7c667c97..278d03d2 100644 --- a/late-ssh/src/app/rooms/mud/mod.rs +++ b/late-ssh/src/app/rooms/mud/mod.rs @@ -9,6 +9,7 @@ pub mod create_modal; pub mod input; pub mod items; pub mod manager; +pub mod persist; pub mod state; pub mod svc; pub mod ui; diff --git a/late-ssh/src/app/rooms/mud/persist.rs b/late-ssh/src/app/rooms/mud/persist.rs new file mode 100644 index 00000000..7c393b80 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/persist.rs @@ -0,0 +1,137 @@ +// Character persistence for Lateania. +// +// A `SavedCharacter` is the durable slice of a player: class, progression, gold, +// vitals, and gear. It serializes to the JSON blob stored in the mud_characters +// table (see late_core::models::mud_character). Transient combat state (current +// target, active effects, cooldowns, respawn timers) is deliberately NOT saved - +// a character reloads at full readiness in a safe room. +// +// The struct is versioned. Unknown/missing fields fall back to defaults via +// serde, so adding fields later never breaks an old save. + +use serde::{Deserialize, Serialize}; + +use super::classes::Class; +use super::world::RoomId; + +const SCHEMA_VERSION: u32 = 1; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SavedCharacter { + #[serde(default)] + pub version: u32, + /// Stable class key (see Class::as_key); None means "not yet chosen". + #[serde(default)] + pub class: Option, + #[serde(default)] + pub xp: i64, + #[serde(default = "one")] + pub level: i32, + #[serde(default)] + pub gold: i64, + /// Saved current HP (clamped to max on load). + #[serde(default)] + pub hp: i32, + /// Room the character logged out in; reloaded here if it still exists. + #[serde(default = "start_room")] + pub room: RoomId, + #[serde(default)] + pub inventory: Vec, + /// Equipped items as (slot-key, item-id) pairs. + #[serde(default)] + pub equipped: Vec<(String, u32)>, +} + +fn one() -> i32 { + 1 +} + +fn start_room() -> RoomId { + 1 +} + +impl SavedCharacter { + pub fn new_for( + class: Option, + xp: i64, + level: i32, + gold: i64, + hp: i32, + room: RoomId, + inventory: Vec, + equipped: Vec<(String, u32)>, + ) -> Self { + Self { + version: SCHEMA_VERSION, + class: class.map(|c| c.as_key().to_string()), + xp, + level, + gold, + hp, + room, + inventory, + equipped, + } + } + + pub fn class(&self) -> Option { + self.class.as_deref().and_then(Class::from_key) + } + + pub fn to_json(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({})) + } + + /// Parse a stored blob; returns None if it is empty or unreadable, so a + /// corrupt save degrades to "fresh character" instead of crashing. + pub fn from_json(value: &serde_json::Value) -> Option { + if value.is_null() || value == &serde_json::json!({}) { + return None; + } + serde_json::from_value(value.clone()).ok() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_through_json() { + let c = SavedCharacter::new_for( + Some(Class::Rogue), + 1234, + 7, + 560, + 42, + 18, + vec![1300, 1301], + vec![("weapon".to_string(), 1004)], + ); + let json = c.to_json(); + let back = SavedCharacter::from_json(&json).expect("parses"); + assert_eq!(back.class(), Some(Class::Rogue)); + assert_eq!(back.xp, 1234); + assert_eq!(back.level, 7); + assert_eq!(back.gold, 560); + assert_eq!(back.inventory, vec![1300, 1301]); + assert_eq!(back.equipped, vec![("weapon".to_string(), 1004)]); + } + + #[test] + fn empty_blob_is_treated_as_no_save() { + assert!(SavedCharacter::from_json(&serde_json::json!({})).is_none()); + assert!(SavedCharacter::from_json(&serde_json::Value::Null).is_none()); + } + + #[test] + fn missing_fields_fall_back_to_defaults() { + // A minimal/old blob with only a class should still load. + let json = serde_json::json!({ "class": "mage" }); + let c = SavedCharacter::from_json(&json).expect("parses partial"); + assert_eq!(c.class(), Some(Class::Mage)); + assert_eq!(c.level, 1); + assert_eq!(c.room, 1); + assert!(c.inventory.is_empty()); + } +} diff --git a/late-ssh/src/app/rooms/mud/svc.rs b/late-ssh/src/app/rooms/mud/svc.rs index 53af1489..be8e058d 100644 --- a/late-ssh/src/app/rooms/mud/svc.rs +++ b/late-ssh/src/app/rooms/mud/svc.rs @@ -16,6 +16,7 @@ use std::{ time::{Duration, Instant}, }; +use late_core::{db::Db, models::mud_character::MudCharacter}; use rand::Rng; use tokio::sync::{Mutex, broadcast, watch}; use uuid::Uuid; @@ -28,6 +29,7 @@ use crate::app::{ use super::abilities::{Ability, AbilityEffect, learned_at, unlocked_for}; use super::classes::{Class, level_for_xp, xp_for_level}; use super::items::{ItemKind, Slot, item, shop_at}; +use super::persist::SavedCharacter; use super::world::{Dir, MobSpawn, RoomId, World, seed_world}; /// World heartbeat. One combat round resolves per tick. @@ -39,10 +41,14 @@ const PLAYER_RESPAWN_SECS: u64 = 8; /// Gold every new adventurer starts with. const STARTING_GOLD: i64 = 120; +/// How often the world autosaves every present character's progress. +const AUTOSAVE_SECS: u64 = 60; + #[derive(Clone)] pub struct MudService { room_id: Uuid, activity: ActivityPublisher, + db: Db, room_event_tx: broadcast::Sender, snapshot_tx: watch::Sender, snapshot_rx: watch::Receiver, @@ -207,14 +213,15 @@ pub fn empty_player_view() -> PlayerView { } impl MudService { - pub fn new(room_id: Uuid, activity: ActivityPublisher) -> Self { + pub fn new(room_id: Uuid, activity: ActivityPublisher, db: Db) -> Self { let (room_event_tx, _) = broadcast::channel::(16); - Self::new_with_events(room_id, activity, room_event_tx) + Self::new_with_events(room_id, activity, db, room_event_tx) } pub fn new_with_events( room_id: Uuid, activity: ActivityPublisher, + db: Db, room_event_tx: broadcast::Sender, ) -> Self { let state = WorldState::new(room_id, seed_world()); @@ -223,12 +230,14 @@ impl MudService { let svc = Self { room_id, activity, + db, room_event_tx, snapshot_tx, snapshot_rx, state: Arc::new(Mutex::new(state)), }; svc.start_tick_loop(); + svc.start_autosave_loop(); svc } @@ -276,9 +285,27 @@ impl MudService { pub fn join_task(&self, user_id: Uuid) { let svc = self.clone(); tokio::spawn(async move { + // Load any saved character BEFORE taking the world lock (DB is async). + let saved = match svc.db.get().await { + Ok(client) => match MudCharacter::load(&client, user_id).await { + Ok(Some(blob)) => SavedCharacter::from_json(&blob), + Ok(None) => None, + Err(error) => { + tracing::warn!(%user_id, ?error, "failed to load mud character"); + None + } + }, + Err(error) => { + tracing::warn!(%user_id, ?error, "no db client for mud character load"); + None + } + }; let joined = { let mut state = svc.state.lock().await; let joined = state.join(user_id); + if joined && let Some(saved) = saved { + state.hydrate(user_id, &saved); + } state.touch(user_id); svc.publish(&state); joined @@ -295,9 +322,49 @@ impl MudService { pub fn leave_task(&self, user_id: Uuid) { let svc = self.clone(); tokio::spawn(async move { - let mut state = svc.state.lock().await; - state.leave(user_id); - svc.publish(&state); + // Capture the durable character under the lock, then remove the player. + let saved = { + let mut state = svc.state.lock().await; + let saved = state.export_saved(user_id); + state.leave(user_id); + svc.publish(&state); + saved + }; + if let Some(saved) = saved { + svc.persist(user_id, saved).await; + } + }); + } + + /// Write one character blob to the database (best-effort). + async fn persist(&self, user_id: Uuid, saved: SavedCharacter) { + match self.db.get().await { + Ok(client) => { + if let Err(error) = MudCharacter::save(&client, user_id, saved.to_json()).await { + tracing::warn!(%user_id, ?error, "failed to save mud character"); + } + } + Err(error) => { + tracing::warn!(%user_id, ?error, "no db client for mud character save"); + } + } + } + + fn start_autosave_loop(&self) { + let svc = self.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(Duration::from_secs(AUTOSAVE_SECS)); + ticker.tick().await; // skip the immediate first tick + loop { + ticker.tick().await; + let saves: Vec<(Uuid, SavedCharacter)> = { + let state = svc.state.lock().await; + state.export_all_saved() + }; + for (user_id, saved) in saves { + svc.persist(user_id, saved).await; + } + } }); } @@ -598,6 +665,89 @@ impl WorldState { self.players.remove(&user_id); } + /// Apply a saved character onto a freshly-joined player. Restores class, + /// progression, gold, gear, and inventory; reloads at a safe room with full + /// vitals so a logged-out fight never resumes mid-swing. + fn hydrate(&mut self, user_id: Uuid, saved: &SavedCharacter) { + let Some(class) = saved.class() else { + // No class chosen last time; leave the player at the select screen. + return; + }; + let level = saved.level.clamp(1, Class::MAX_LEVEL); + let stats = class.stats_at(level); + let room = if self.world.room(saved.room).is_some_and(|r| r.safe) { + saved.room + } else { + self.world.start_room + }; + if let Some(p) = self.players.get_mut(&user_id) { + p.class = Some(class); + p.level = level; + p.xp = saved.xp.max(0); + p.gold = saved.gold.max(0); + p.base_max_hp = stats.max_hp; + p.max_resource = stats.max_resource; + p.resource = stats.max_resource; + p.resource_regen = stats.resource_regen; + p.base_attack = stats.attack; + p.room = room; + p.inventory = saved + .inventory + .iter() + .copied() + .filter(|id| item(*id).is_some()) + .collect(); + p.equipped.clear(); + for (slot_key, id) in &saved.equipped { + if let Some(it) = item(*id) + && let Some(slot) = it.slot() + && slot.label() == slot_key + { + p.equipped.insert(slot, *id); + } + } + // Restore vitals last so equipment max-hp is already in effect. + let max = p.max_hp(); + p.hp = if saved.hp > 0 { saved.hp.min(max) } else { max }; + } + let name = class.name(); + self.log_to( + user_id, + LogKind::System, + format!("Welcome back. Your {name} stands ready (level {level})."), + ); + self.describe_room(user_id); + } + + /// The durable slice of one player, if they have chosen a class (otherwise + /// there is nothing worth saving yet). + fn export_saved(&self, user_id: Uuid) -> Option { + let p = self.players.get(&user_id)?; + p.class?; // unclassed -> nothing to persist + let equipped: Vec<(String, u32)> = p + .equipped + .iter() + .map(|(slot, id)| (slot.label().to_string(), *id)) + .collect(); + Some(SavedCharacter::new_for( + p.class, + p.xp, + p.level, + p.gold, + p.hp.max(1), + p.room, + p.inventory.clone(), + equipped, + )) + } + + fn export_all_saved(&self) -> Vec<(Uuid, SavedCharacter)> { + self.players + .keys() + .filter_map(|uid| self.export_saved(*uid).map(|s| (*uid, s))) + .collect() + } + fn touch(&mut self, user_id: Uuid) { if let Some(player) = self.players.get_mut(&user_id) { player.last_activity = Instant::now(); diff --git a/late-ssh/src/main.rs b/late-ssh/src/main.rs index 3a5b71ba..45851c86 100644 --- a/late-ssh/src/main.rs +++ b/late-ssh/src/main.rs @@ -217,8 +217,10 @@ async fn main() -> anyhow::Result<()> { chip_service.clone(), activity_publisher.clone(), ); - let mud_table_manager = - late_ssh::app::rooms::mud::manager::MudTableManager::new(activity_publisher.clone()); + let mud_table_manager = late_ssh::app::rooms::mud::manager::MudTableManager::new( + activity_publisher.clone(), + db.clone(), + ); let room_game_registry = late_ssh::app::rooms::registry::RoomGameRegistry::new( asterion_room_manager, blackjack_table_manager.clone(), From b9b0798dae5b5f17d9e4d74579f7cf22946900f8 Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Mon, 1 Jun 2026 14:30:27 +1000 Subject: [PATCH 06/20] feat(rooms): interactive combat damage types, 200 rooms, 67 enemy types Three big additions to Lateania. Damage types (damage.rs): every offensive ability and every mob attack now carries a DamageType - Physical, Fire, Frost, Holy, Shadow, Poison, Arcane, or Lightning. Mobs have a DamageProfile declaring the school they deal plus the schools they resist (half damage) and are weak to (+50%), so element choice is a real tactical lever. Undead are weak to Holy and resist Shadow, fire-things resist Fire and melt to Frost, constructs shrug off Physical and break to Arcane, and so on. Combat resolves all of it through one multiplier and calls out resists and weaknesses in the log ("it's weak to this!"). Armor now blunts physical blows fully but only half-shields against elemental damage, so caster foes punch through plate. All 55 abilities were tagged with a thematic school (Mage fire/frost/arcane/lightning, Cleric holy, Rogue/Ranger physical+poison, Warrior physical). World to 200 rooms (world.rs): eight new exploration wings branch off existing zones, taking the world from 115 to 200 hand-described rooms. Each wing ends in a new named miniboss (the Hexcrone of the Glade, the Barrow King, the Tide-Drowned Leviathan, the Forgeheart Guardian, the Heart-of-Winter Wyrm, the Warden of the Sealed Heart, the Herald of Mal'gareth, the Bandit Chief Garrote). Wings are built through a link() helper that wires every exit reciprocally by construction, so the one-way-exit class of bug cannot occur; anchors and id ranges are spaced to never collide. Enemy variety: the roster grows from ~30 to 67 distinct enemy types, each tagged with a damage profile, spread across the new wings and scaled by tier. Inline tests now assert 200 rooms, 50+ distinct enemy types, the damage resist/weak math, and that every mob (including wing mobs) homes to a real room, alongside the existing reachability and reciprocity checks. Signed-off-by: Tony Hosaroygard --- late-ssh/src/app/rooms/mud/abilities.rs | 113 ++++---- late-ssh/src/app/rooms/mud/damage.rs | 143 ++++++++++ late-ssh/src/app/rooms/mud/mod.rs | 1 + late-ssh/src/app/rooms/mud/svc.rs | 98 +++++-- late-ssh/src/app/rooms/mud/world.rs | 338 +++++++++++++++++++++++- 5 files changed, 612 insertions(+), 81 deletions(-) create mode 100644 late-ssh/src/app/rooms/mud/damage.rs diff --git a/late-ssh/src/app/rooms/mud/abilities.rs b/late-ssh/src/app/rooms/mud/abilities.rs index 644a8290..ab540bc9 100644 --- a/late-ssh/src/app/rooms/mud/abilities.rs +++ b/late-ssh/src/app/rooms/mud/abilities.rs @@ -8,6 +8,7 @@ // engine. use super::classes::{Class, Resource}; +use super::damage::DamageType; /// What an ability does when it lands. Instant effects apply once; the timed /// variants seed an ongoing effect resolved on each world tick. @@ -61,6 +62,8 @@ pub struct Ability { /// World ticks before it can be used again. pub cooldown_ticks: u32, pub effect: AbilityEffect, + /// Damage school for offensive abilities (ignored by heals/buffs/wards). + pub damage_type: DamageType, /// Base magnitude (damage, heal, shield, or bonus depending on effect). pub magnitude: i32, /// Ticks an over-time or buff effect persists (0 for instant effects). @@ -70,65 +73,65 @@ pub struct Ability { /// The full ability roster. Ordered by class, then unlock level. pub const ABILITIES: &[Ability] = &[ // ---- Warrior (Rage) ------------------------------------------------- - Ability { id: 100, name: "Cleave", desc: "A wide, brutal swing that bites deep into the enemy before you.", class: Class::Warrior, level_req: 1, cost: 10, resource: Resource::Rage, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 14, duration: 0 }, - Ability { id: 101, name: "Rend", desc: "Tear a ragged wound that bleeds the foe with every passing moment.", class: Class::Warrior, level_req: 4, cost: 12, resource: Resource::Rage, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, magnitude: 6, duration: 4 }, - Ability { id: 102, name: "Shield Wall", desc: "Set your stance and raise your guard, turning aside the next blows.", class: Class::Warrior, level_req: 8, cost: 15, resource: Resource::Rage, cooldown_ticks: 6, effect: AbilityEffect::Ward, magnitude: 30, duration: 3 }, - Ability { id: 103, name: "Shield Bash", desc: "Slam your shield into the enemy's skull, stunning it senseless.", class: Class::Warrior, level_req: 12, cost: 18, resource: Resource::Rage, cooldown_ticks: 5, effect: AbilityEffect::Stun, magnitude: 10, duration: 1 }, - Ability { id: 104, name: "Battle Fury", desc: "Loose a war-cry that floods your arms with killing strength.", class: Class::Warrior, level_req: 16, cost: 20, resource: Resource::Rage, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 10, duration: 4 }, - Ability { id: 105, name: "Sunder", desc: "A crushing two-handed blow that shatters armor and bone alike.", class: Class::Warrior, level_req: 20, cost: 22, resource: Resource::Rage, cooldown_ticks: 3, effect: AbilityEffect::Strike, magnitude: 30, duration: 0 }, - Ability { id: 106, name: "Bloodthirst", desc: "Strike with such savagery that the enemy's blood revives you.", class: Class::Warrior, level_req: 25, cost: 24, resource: Resource::Rage, cooldown_ticks: 4, effect: AbilityEffect::Heal, magnitude: 28, duration: 0 }, - Ability { id: 107, name: "Earthshaker", desc: "Drive your weapon into the ground; the shock rends everything near.", class: Class::Warrior, level_req: 30, cost: 28, resource: Resource::Rage, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, magnitude: 12, duration: 5 }, - Ability { id: 108, name: "Indomitable", desc: "Plant your feet and refuse to fall, shrugging off mortal wounds.", class: Class::Warrior, level_req: 36, cost: 30, resource: Resource::Rage, cooldown_ticks: 10, effect: AbilityEffect::Ward, magnitude: 70, duration: 4 }, - Ability { id: 109, name: "Reckless Onslaught", desc: "Abandon all defense for a flurry of devastating, empowered blows.", class: Class::Warrior, level_req: 42, cost: 35, resource: Resource::Rage, cooldown_ticks: 10, effect: AbilityEffect::Empower, magnitude: 22, duration: 5 }, - Ability { id: 110, name: "Executioner's Strike", desc: "A single annihilating blow meant to end the fight outright.", class: Class::Warrior, level_req: 50, cost: 45, resource: Resource::Rage, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 80, duration: 2 }, + Ability { id: 100, name: "Cleave", desc: "A wide, brutal swing that bites deep into the enemy before you.", class: Class::Warrior, level_req: 1, cost: 10, resource: Resource::Rage, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 14, duration: 0 }, + Ability { id: 101, name: "Rend", desc: "Tear a ragged wound that bleeds the foe with every passing moment.", class: Class::Warrior, level_req: 4, cost: 12, resource: Resource::Rage, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Physical, magnitude: 6, duration: 4 }, + Ability { id: 102, name: "Shield Wall", desc: "Set your stance and raise your guard, turning aside the next blows.", class: Class::Warrior, level_req: 8, cost: 15, resource: Resource::Rage, cooldown_ticks: 6, effect: AbilityEffect::Ward, damage_type: DamageType::Physical, magnitude: 30, duration: 3 }, + Ability { id: 103, name: "Shield Bash", desc: "Slam your shield into the enemy's skull, stunning it senseless.", class: Class::Warrior, level_req: 12, cost: 18, resource: Resource::Rage, cooldown_ticks: 5, effect: AbilityEffect::Stun, damage_type: DamageType::Physical, magnitude: 10, duration: 1 }, + Ability { id: 104, name: "Battle Fury", desc: "Loose a war-cry that floods your arms with killing strength.", class: Class::Warrior, level_req: 16, cost: 20, resource: Resource::Rage, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 10, duration: 4 }, + Ability { id: 105, name: "Sunder", desc: "A crushing two-handed blow that shatters armor and bone alike.", class: Class::Warrior, level_req: 20, cost: 22, resource: Resource::Rage, cooldown_ticks: 3, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 30, duration: 0 }, + Ability { id: 106, name: "Bloodthirst", desc: "Strike with such savagery that the enemy's blood revives you.", class: Class::Warrior, level_req: 25, cost: 24, resource: Resource::Rage, cooldown_ticks: 4, effect: AbilityEffect::Heal, damage_type: DamageType::Physical, magnitude: 28, duration: 0 }, + Ability { id: 107, name: "Earthshaker", desc: "Drive your weapon into the ground; the shock rends everything near.", class: Class::Warrior, level_req: 30, cost: 28, resource: Resource::Rage, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Physical, magnitude: 12, duration: 5 }, + Ability { id: 108, name: "Indomitable", desc: "Plant your feet and refuse to fall, shrugging off mortal wounds.", class: Class::Warrior, level_req: 36, cost: 30, resource: Resource::Rage, cooldown_ticks: 10, effect: AbilityEffect::Ward, damage_type: DamageType::Physical, magnitude: 70, duration: 4 }, + Ability { id: 109, name: "Reckless Onslaught", desc: "Abandon all defense for a flurry of devastating, empowered blows.", class: Class::Warrior, level_req: 42, cost: 35, resource: Resource::Rage, cooldown_ticks: 10, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 22, duration: 5 }, + Ability { id: 110, name: "Executioner's Strike", desc: "A single annihilating blow meant to end the fight outright.", class: Class::Warrior, level_req: 50, cost: 45, resource: Resource::Rage, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Physical, magnitude: 80, duration: 2 }, // ---- Mage (Mana) ---------------------------------------------------- - Ability { id: 200, name: "Firebolt", desc: "A dart of conjured flame that sears whatever it strikes.", class: Class::Mage, level_req: 1, cost: 8, resource: Resource::Mana, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 16, duration: 0 }, - Ability { id: 201, name: "Frost Nova", desc: "Ice erupts around the foe, locking it in place for a heartbeat.", class: Class::Mage, level_req: 5, cost: 14, resource: Resource::Mana, cooldown_ticks: 5, effect: AbilityEffect::Stun, magnitude: 8, duration: 1 }, - Ability { id: 202, name: "Immolate", desc: "Wreath the enemy in clinging fire that burns long after the cast.", class: Class::Mage, level_req: 9, cost: 16, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::DamageOverTime, magnitude: 9, duration: 5 }, - Ability { id: 203, name: "Mana Shield", desc: "Weave raw arcana into a shimmering barrier of force.", class: Class::Mage, level_req: 13, cost: 18, resource: Resource::Mana, cooldown_ticks: 6, effect: AbilityEffect::Ward, magnitude: 35, duration: 3 }, - Ability { id: 204, name: "Arcane Focus", desc: "Sharpen your will until every spell strikes with greater force.", class: Class::Mage, level_req: 17, cost: 20, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 12, duration: 4 }, - Ability { id: 205, name: "Lightning Lance", desc: "A spear of white lightning that punches clean through the target.", class: Class::Mage, level_req: 22, cost: 24, resource: Resource::Mana, cooldown_ticks: 2, effect: AbilityEffect::Strike, magnitude: 34, duration: 0 }, - Ability { id: 206, name: "Siphon Life", desc: "Draw the warmth from the enemy and pour it into your own flesh.", class: Class::Mage, level_req: 27, cost: 26, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::Heal, magnitude: 30, duration: 0 }, - Ability { id: 207, name: "Blizzard", desc: "Call down a storm of razored ice that flays all it touches.", class: Class::Mage, level_req: 32, cost: 30, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, magnitude: 13, duration: 5 }, - Ability { id: 208, name: "Time Warp", desc: "Bend the moment so the enemy stands frozen between heartbeats.", class: Class::Mage, level_req: 38, cost: 34, resource: Resource::Mana, cooldown_ticks: 9, effect: AbilityEffect::Stun, magnitude: 14, duration: 2 }, - Ability { id: 209, name: "Arcane Overload", desc: "Let power flood every nerve until your spells blaze unstoppable.", class: Class::Mage, level_req: 44, cost: 38, resource: Resource::Mana, cooldown_ticks: 10, effect: AbilityEffect::Empower, magnitude: 24, duration: 5 }, - Ability { id: 210, name: "Meteor", desc: "Tear a burning star from the sky and bring it down on your foe.", class: Class::Mage, level_req: 50, cost: 50, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 90, duration: 2 }, + Ability { id: 200, name: "Firebolt", desc: "A dart of conjured flame that sears whatever it strikes.", class: Class::Mage, level_req: 1, cost: 8, resource: Resource::Mana, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Fire, magnitude: 16, duration: 0 }, + Ability { id: 201, name: "Frost Nova", desc: "Ice erupts around the foe, locking it in place for a heartbeat.", class: Class::Mage, level_req: 5, cost: 14, resource: Resource::Mana, cooldown_ticks: 5, effect: AbilityEffect::Stun, damage_type: DamageType::Frost, magnitude: 8, duration: 1 }, + Ability { id: 202, name: "Immolate", desc: "Wreath the enemy in clinging fire that burns long after the cast.", class: Class::Mage, level_req: 9, cost: 16, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Fire, magnitude: 9, duration: 5 }, + Ability { id: 203, name: "Mana Shield", desc: "Weave raw arcana into a shimmering barrier of force.", class: Class::Mage, level_req: 13, cost: 18, resource: Resource::Mana, cooldown_ticks: 6, effect: AbilityEffect::Ward, damage_type: DamageType::Arcane, magnitude: 35, duration: 3 }, + Ability { id: 204, name: "Arcane Focus", desc: "Sharpen your will until every spell strikes with greater force.", class: Class::Mage, level_req: 17, cost: 20, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Arcane, magnitude: 12, duration: 4 }, + Ability { id: 205, name: "Lightning Lance", desc: "A spear of white lightning that punches clean through the target.", class: Class::Mage, level_req: 22, cost: 24, resource: Resource::Mana, cooldown_ticks: 2, effect: AbilityEffect::Strike, damage_type: DamageType::Lightning, magnitude: 34, duration: 0 }, + Ability { id: 206, name: "Siphon Life", desc: "Draw the warmth from the enemy and pour it into your own flesh.", class: Class::Mage, level_req: 27, cost: 26, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::Heal, damage_type: DamageType::Shadow, magnitude: 30, duration: 0 }, + Ability { id: 207, name: "Blizzard", desc: "Call down a storm of razored ice that flays all it touches.", class: Class::Mage, level_req: 32, cost: 30, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Frost, magnitude: 13, duration: 5 }, + Ability { id: 208, name: "Time Warp", desc: "Bend the moment so the enemy stands frozen between heartbeats.", class: Class::Mage, level_req: 38, cost: 34, resource: Resource::Mana, cooldown_ticks: 9, effect: AbilityEffect::Stun, damage_type: DamageType::Arcane, magnitude: 14, duration: 2 }, + Ability { id: 209, name: "Arcane Overload", desc: "Let power flood every nerve until your spells blaze unstoppable.", class: Class::Mage, level_req: 44, cost: 38, resource: Resource::Mana, cooldown_ticks: 10, effect: AbilityEffect::Empower, damage_type: DamageType::Arcane, magnitude: 24, duration: 5 }, + Ability { id: 210, name: "Meteor", desc: "Tear a burning star from the sky and bring it down on your foe.", class: Class::Mage, level_req: 50, cost: 50, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Fire, magnitude: 90, duration: 2 }, // ---- Cleric (Mana) -------------------------------------------------- - Ability { id: 300, name: "Smite", desc: "Call down a lance of holy light upon the unworthy.", class: Class::Cleric, level_req: 1, cost: 9, resource: Resource::Mana, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 13, duration: 0 }, - Ability { id: 301, name: "Mend", desc: "Knit flesh and seal wounds with a whispered prayer.", class: Class::Cleric, level_req: 3, cost: 12, resource: Resource::Mana, cooldown_ticks: 2, effect: AbilityEffect::Heal, magnitude: 22, duration: 0 }, - Ability { id: 302, name: "Renewal", desc: "A blessing that mends a little more with every breath you take.", class: Class::Cleric, level_req: 7, cost: 16, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::HealOverTime, magnitude: 8, duration: 5 }, - Ability { id: 303, name: "Sacred Ward", desc: "Surround yourself in a corona of divine protection.", class: Class::Cleric, level_req: 11, cost: 18, resource: Resource::Mana, cooldown_ticks: 6, effect: AbilityEffect::Ward, magnitude: 32, duration: 3 }, - Ability { id: 304, name: "Holy Fire", desc: "Sear the wicked with flame that judges as it burns.", class: Class::Cleric, level_req: 15, cost: 20, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::DamageOverTime, magnitude: 10, duration: 4 }, - Ability { id: 305, name: "Blessing of Might", desc: "Anoint yourself so each strike falls with righteous force.", class: Class::Cleric, level_req: 19, cost: 22, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 11, duration: 4 }, - Ability { id: 306, name: "Greater Heal", desc: "A surge of restoring grace that mends even grievous harm.", class: Class::Cleric, level_req: 24, cost: 26, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::Heal, magnitude: 50, duration: 0 }, - Ability { id: 307, name: "Hammer of Faith", desc: "A spectral warhammer crashes down with crushing zeal.", class: Class::Cleric, level_req: 29, cost: 28, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::Strike, magnitude: 32, duration: 0 }, - Ability { id: 308, name: "Sanctuary", desc: "Raise hallowed ground that turns aside the cruelest wounds.", class: Class::Cleric, level_req: 35, cost: 32, resource: Resource::Mana, cooldown_ticks: 9, effect: AbilityEffect::Ward, magnitude: 65, duration: 4 }, - Ability { id: 309, name: "Divine Radiance", desc: "Blaze with the light of the Dawn until evil cannot bear to strike.", class: Class::Cleric, level_req: 41, cost: 36, resource: Resource::Mana, cooldown_ticks: 7, effect: AbilityEffect::Stun, magnitude: 18, duration: 2 }, - Ability { id: 310, name: "Judgment", desc: "Pronounce the final verdict of heaven upon a doomed soul.", class: Class::Cleric, level_req: 50, cost: 48, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 78, duration: 2 }, + Ability { id: 300, name: "Smite", desc: "Call down a lance of holy light upon the unworthy.", class: Class::Cleric, level_req: 1, cost: 9, resource: Resource::Mana, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Holy, magnitude: 13, duration: 0 }, + Ability { id: 301, name: "Mend", desc: "Knit flesh and seal wounds with a whispered prayer.", class: Class::Cleric, level_req: 3, cost: 12, resource: Resource::Mana, cooldown_ticks: 2, effect: AbilityEffect::Heal, damage_type: DamageType::Holy, magnitude: 22, duration: 0 }, + Ability { id: 302, name: "Renewal", desc: "A blessing that mends a little more with every breath you take.", class: Class::Cleric, level_req: 7, cost: 16, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::HealOverTime, damage_type: DamageType::Holy, magnitude: 8, duration: 5 }, + Ability { id: 303, name: "Sacred Ward", desc: "Surround yourself in a corona of divine protection.", class: Class::Cleric, level_req: 11, cost: 18, resource: Resource::Mana, cooldown_ticks: 6, effect: AbilityEffect::Ward, damage_type: DamageType::Holy, magnitude: 32, duration: 3 }, + Ability { id: 304, name: "Holy Fire", desc: "Sear the wicked with flame that judges as it burns.", class: Class::Cleric, level_req: 15, cost: 20, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Holy, magnitude: 10, duration: 4 }, + Ability { id: 305, name: "Blessing of Might", desc: "Anoint yourself so each strike falls with righteous force.", class: Class::Cleric, level_req: 19, cost: 22, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Holy, magnitude: 11, duration: 4 }, + Ability { id: 306, name: "Greater Heal", desc: "A surge of restoring grace that mends even grievous harm.", class: Class::Cleric, level_req: 24, cost: 26, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::Heal, damage_type: DamageType::Holy, magnitude: 50, duration: 0 }, + Ability { id: 307, name: "Hammer of Faith", desc: "A spectral warhammer crashes down with crushing zeal.", class: Class::Cleric, level_req: 29, cost: 28, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::Strike, damage_type: DamageType::Holy, magnitude: 32, duration: 0 }, + Ability { id: 308, name: "Sanctuary", desc: "Raise hallowed ground that turns aside the cruelest wounds.", class: Class::Cleric, level_req: 35, cost: 32, resource: Resource::Mana, cooldown_ticks: 9, effect: AbilityEffect::Ward, damage_type: DamageType::Holy, magnitude: 65, duration: 4 }, + Ability { id: 309, name: "Divine Radiance", desc: "Blaze with the light of the Dawn until evil cannot bear to strike.", class: Class::Cleric, level_req: 41, cost: 36, resource: Resource::Mana, cooldown_ticks: 7, effect: AbilityEffect::Stun, damage_type: DamageType::Holy, magnitude: 18, duration: 2 }, + Ability { id: 310, name: "Judgment", desc: "Pronounce the final verdict of heaven upon a doomed soul.", class: Class::Cleric, level_req: 50, cost: 48, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Holy, magnitude: 78, duration: 2 }, // ---- Rogue (Energy) ------------------------------------------------- - Ability { id: 400, name: "Backstab", desc: "Slip a blade between the ribs where it does the most harm.", class: Class::Rogue, level_req: 1, cost: 8, resource: Resource::Energy, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 18, duration: 0 }, - Ability { id: 401, name: "Envenom", desc: "Coat your blade so each cut festers with creeping poison.", class: Class::Rogue, level_req: 4, cost: 10, resource: Resource::Energy, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, magnitude: 7, duration: 5 }, - Ability { id: 402, name: "Blind", desc: "Fling grit and powder to leave your foe swinging at shadows.", class: Class::Rogue, level_req: 8, cost: 12, resource: Resource::Energy, cooldown_ticks: 5, effect: AbilityEffect::Stun, magnitude: 8, duration: 1 }, - Ability { id: 403, name: "Evasion", desc: "Move like smoke, slipping every blow aimed your way.", class: Class::Rogue, level_req: 12, cost: 14, resource: Resource::Energy, cooldown_ticks: 7, effect: AbilityEffect::Ward, magnitude: 28, duration: 3 }, - Ability { id: 404, name: "Cold Blood", desc: "Steady your hand and your heart for one perfect, lethal cut.", class: Class::Rogue, level_req: 16, cost: 16, resource: Resource::Energy, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 14, duration: 3 }, - Ability { id: 405, name: "Eviscerate", desc: "A flurry of blades that opens the enemy from hip to throat.", class: Class::Rogue, level_req: 21, cost: 20, resource: Resource::Energy, cooldown_ticks: 2, effect: AbilityEffect::Strike, magnitude: 32, duration: 0 }, - Ability { id: 406, name: "Crippling Toxin", desc: "A paralytic venom that seizes the muscles and stops the breath.", class: Class::Rogue, level_req: 26, cost: 22, resource: Resource::Energy, cooldown_ticks: 6, effect: AbilityEffect::Stun, magnitude: 12, duration: 2 }, - Ability { id: 407, name: "Hemorrhage", desc: "Strike a vein that will not close, bleeding the foe dry.", class: Class::Rogue, level_req: 31, cost: 24, resource: Resource::Energy, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, magnitude: 14, duration: 5 }, - Ability { id: 408, name: "Shadowstep", desc: "Vanish into shadow and return where no blade can find you.", class: Class::Rogue, level_req: 37, cost: 28, resource: Resource::Energy, cooldown_ticks: 9, effect: AbilityEffect::Ward, magnitude: 60, duration: 4 }, - Ability { id: 409, name: "Killing Spree", desc: "Become a whirlwind of steel, every cut emptier and crueler.", class: Class::Rogue, level_req: 43, cost: 32, resource: Resource::Energy, cooldown_ticks: 10, effect: AbilityEffect::Empower, magnitude: 26, duration: 5 }, - Ability { id: 410, name: "Assassinate", desc: "The single strike every rogue trains a lifetime to land.", class: Class::Rogue, level_req: 50, cost: 42, resource: Resource::Energy, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 88, duration: 2 }, + Ability { id: 400, name: "Backstab", desc: "Slip a blade between the ribs where it does the most harm.", class: Class::Rogue, level_req: 1, cost: 8, resource: Resource::Energy, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 18, duration: 0 }, + Ability { id: 401, name: "Envenom", desc: "Coat your blade so each cut festers with creeping poison.", class: Class::Rogue, level_req: 4, cost: 10, resource: Resource::Energy, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Poison, magnitude: 7, duration: 5 }, + Ability { id: 402, name: "Blind", desc: "Fling grit and powder to leave your foe swinging at shadows.", class: Class::Rogue, level_req: 8, cost: 12, resource: Resource::Energy, cooldown_ticks: 5, effect: AbilityEffect::Stun, damage_type: DamageType::Physical, magnitude: 8, duration: 1 }, + Ability { id: 403, name: "Evasion", desc: "Move like smoke, slipping every blow aimed your way.", class: Class::Rogue, level_req: 12, cost: 14, resource: Resource::Energy, cooldown_ticks: 7, effect: AbilityEffect::Ward, damage_type: DamageType::Physical, magnitude: 28, duration: 3 }, + Ability { id: 404, name: "Cold Blood", desc: "Steady your hand and your heart for one perfect, lethal cut.", class: Class::Rogue, level_req: 16, cost: 16, resource: Resource::Energy, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 14, duration: 3 }, + Ability { id: 405, name: "Eviscerate", desc: "A flurry of blades that opens the enemy from hip to throat.", class: Class::Rogue, level_req: 21, cost: 20, resource: Resource::Energy, cooldown_ticks: 2, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 32, duration: 0 }, + Ability { id: 406, name: "Crippling Toxin", desc: "A paralytic venom that seizes the muscles and stops the breath.", class: Class::Rogue, level_req: 26, cost: 22, resource: Resource::Energy, cooldown_ticks: 6, effect: AbilityEffect::Stun, damage_type: DamageType::Poison, magnitude: 12, duration: 2 }, + Ability { id: 407, name: "Hemorrhage", desc: "Strike a vein that will not close, bleeding the foe dry.", class: Class::Rogue, level_req: 31, cost: 24, resource: Resource::Energy, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Physical, magnitude: 14, duration: 5 }, + Ability { id: 408, name: "Shadowstep", desc: "Vanish into shadow and return where no blade can find you.", class: Class::Rogue, level_req: 37, cost: 28, resource: Resource::Energy, cooldown_ticks: 9, effect: AbilityEffect::Ward, damage_type: DamageType::Shadow, magnitude: 60, duration: 4 }, + Ability { id: 409, name: "Killing Spree", desc: "Become a whirlwind of steel, every cut emptier and crueler.", class: Class::Rogue, level_req: 43, cost: 32, resource: Resource::Energy, cooldown_ticks: 10, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 26, duration: 5 }, + Ability { id: 410, name: "Assassinate", desc: "The single strike every rogue trains a lifetime to land.", class: Class::Rogue, level_req: 50, cost: 42, resource: Resource::Energy, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Physical, magnitude: 88, duration: 2 }, // ---- Ranger (Focus) ------------------------------------------------- - Ability { id: 500, name: "Aimed Shot", desc: "Draw, breathe, and loose an arrow exactly where it will hurt.", class: Class::Ranger, level_req: 1, cost: 8, resource: Resource::Focus, cooldown_ticks: 1, effect: AbilityEffect::Strike, magnitude: 15, duration: 0 }, - Ability { id: 501, name: "Serpent Sting", desc: "An arrow tipped in adder-venom that sickens with every beat.", class: Class::Ranger, level_req: 4, cost: 11, resource: Resource::Focus, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, magnitude: 7, duration: 5 }, - Ability { id: 502, name: "Snare Trap", desc: "Set a hidden snare that seizes the foe fast in its teeth.", class: Class::Ranger, level_req: 8, cost: 13, resource: Resource::Focus, cooldown_ticks: 5, effect: AbilityEffect::Stun, magnitude: 9, duration: 1 }, - Ability { id: 503, name: "Mark of the Hunt", desc: "Read your quarry's weakness and let every shot find it.", class: Class::Ranger, level_req: 12, cost: 15, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Empower, magnitude: 10, duration: 4 }, - Ability { id: 504, name: "Mend Companion", desc: "Tend your own hurts with the field-craft of a thousand camps.", class: Class::Ranger, level_req: 16, cost: 17, resource: Resource::Focus, cooldown_ticks: 4, effect: AbilityEffect::HealOverTime, magnitude: 9, duration: 5 }, - Ability { id: 505, name: "Piercing Arrow", desc: "A shot loosed with such force it drives clean through plate.", class: Class::Ranger, level_req: 21, cost: 20, resource: Resource::Focus, cooldown_ticks: 2, effect: AbilityEffect::Strike, magnitude: 33, duration: 0 }, - Ability { id: 506, name: "Thornwall", desc: "Raise a bramble of arrows at your feet to fend off the charge.", class: Class::Ranger, level_req: 26, cost: 22, resource: Resource::Focus, cooldown_ticks: 7, effect: AbilityEffect::Ward, magnitude: 40, duration: 4 }, - Ability { id: 507, name: "Volley", desc: "Rain a quiver's worth of shafts down on the staggering foe.", class: Class::Ranger, level_req: 31, cost: 26, resource: Resource::Focus, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, magnitude: 14, duration: 5 }, - Ability { id: 508, name: "Concussive Shot", desc: "An arrow to the temple that leaves the enemy reeling and blind.", class: Class::Ranger, level_req: 37, cost: 30, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Stun, magnitude: 16, duration: 2 }, - Ability { id: 509, name: "Trueshot Aura", desc: "Enter the hunter's stillness where no arrow is ever wasted.", class: Class::Ranger, level_req: 43, cost: 34, resource: Resource::Focus, cooldown_ticks: 10, effect: AbilityEffect::Empower, magnitude: 25, duration: 5 }, - Ability { id: 510, name: "Hail of Death", desc: "Black out the sky with arrows and let it fall like judgment.", class: Class::Ranger, level_req: 50, cost: 44, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Finisher, magnitude: 84, duration: 2 }, + Ability { id: 500, name: "Aimed Shot", desc: "Draw, breathe, and loose an arrow exactly where it will hurt.", class: Class::Ranger, level_req: 1, cost: 8, resource: Resource::Focus, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 15, duration: 0 }, + Ability { id: 501, name: "Serpent Sting", desc: "An arrow tipped in adder-venom that sickens with every beat.", class: Class::Ranger, level_req: 4, cost: 11, resource: Resource::Focus, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Poison, magnitude: 7, duration: 5 }, + Ability { id: 502, name: "Snare Trap", desc: "Set a hidden snare that seizes the foe fast in its teeth.", class: Class::Ranger, level_req: 8, cost: 13, resource: Resource::Focus, cooldown_ticks: 5, effect: AbilityEffect::Stun, damage_type: DamageType::Physical, magnitude: 9, duration: 1 }, + Ability { id: 503, name: "Mark of the Hunt", desc: "Read your quarry's weakness and let every shot find it.", class: Class::Ranger, level_req: 12, cost: 15, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 10, duration: 4 }, + Ability { id: 504, name: "Mend Companion", desc: "Tend your own hurts with the field-craft of a thousand camps.", class: Class::Ranger, level_req: 16, cost: 17, resource: Resource::Focus, cooldown_ticks: 4, effect: AbilityEffect::HealOverTime, damage_type: DamageType::Physical, magnitude: 9, duration: 5 }, + Ability { id: 505, name: "Piercing Arrow", desc: "A shot loosed with such force it drives clean through plate.", class: Class::Ranger, level_req: 21, cost: 20, resource: Resource::Focus, cooldown_ticks: 2, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 33, duration: 0 }, + Ability { id: 506, name: "Thornwall", desc: "Raise a bramble of arrows at your feet to fend off the charge.", class: Class::Ranger, level_req: 26, cost: 22, resource: Resource::Focus, cooldown_ticks: 7, effect: AbilityEffect::Ward, damage_type: DamageType::Physical, magnitude: 40, duration: 4 }, + Ability { id: 507, name: "Volley", desc: "Rain a quiver's worth of shafts down on the staggering foe.", class: Class::Ranger, level_req: 31, cost: 26, resource: Resource::Focus, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Physical, magnitude: 14, duration: 5 }, + Ability { id: 508, name: "Concussive Shot", desc: "An arrow to the temple that leaves the enemy reeling and blind.", class: Class::Ranger, level_req: 37, cost: 30, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Stun, damage_type: DamageType::Physical, magnitude: 16, duration: 2 }, + Ability { id: 509, name: "Trueshot Aura", desc: "Enter the hunter's stillness where no arrow is ever wasted.", class: Class::Ranger, level_req: 43, cost: 34, resource: Resource::Focus, cooldown_ticks: 10, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 25, duration: 5 }, + Ability { id: 510, name: "Hail of Death", desc: "Black out the sky with arrows and let it fall like judgment.", class: Class::Ranger, level_req: 50, cost: 44, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Physical, magnitude: 84, duration: 2 }, ]; /// Abilities a character of this class has unlocked at this level, lowest level first. diff --git a/late-ssh/src/app/rooms/mud/damage.rs b/late-ssh/src/app/rooms/mud/damage.rs new file mode 100644 index 00000000..1400db89 --- /dev/null +++ b/late-ssh/src/app/rooms/mud/damage.rs @@ -0,0 +1,143 @@ +// Damage types and the resistance system for Lateania. +// +// Every offensive ability and every mob attack carries a DamageType. Mobs have a +// resistance profile - the types they shrug off and the types that flay them - +// so element choice is a real tactical lever rather than flavor. Damage resolves +// through a single multiplier in the combat runtime. + +/// The schools of damage. Physical is the plain weapon/auto-attack school; +/// the rest are elemental or divine and key off mob weaknesses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DamageType { + Physical, + Fire, + Frost, + Holy, + Shadow, + Poison, + Arcane, + Lightning, +} + +impl DamageType { + pub fn label(self) -> &'static str { + match self { + Self::Physical => "physical", + Self::Fire => "fire", + Self::Frost => "frost", + Self::Holy => "holy", + Self::Shadow => "shadow", + Self::Poison => "poison", + Self::Arcane => "arcane", + Self::Lightning => "lightning", + } + } + + /// A short colored-word tag for combat log flavor. + pub fn verb(self) -> &'static str { + match self { + Self::Physical => "strikes", + Self::Fire => "burns", + Self::Frost => "freezes", + Self::Holy => "sears", + Self::Shadow => "withers", + Self::Poison => "poisons", + Self::Arcane => "blasts", + Self::Lightning => "shocks", + } + } +} + +/// How a mob responds to each damage type. Resist halves; Weak adds 50%. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Defense { + Resist, + Normal, + Weak, +} + +impl Defense { + /// Damage multiplier in percent (50 = half, 100 = normal, 150 = +50%). + pub fn multiplier_pct(self) -> i32 { + match self { + Self::Resist => 50, + Self::Normal => 100, + Self::Weak => 150, + } + } +} + +/// A mob's full damage profile: the type it deals, plus up to one resisted and +/// one weak school. Built as data on each MobSpawn. +#[derive(Clone, Copy, Debug)] +pub struct DamageProfile { + /// The damage type this mob's own attacks deal. + pub attack_type: DamageType, + /// The school this mob resists (takes half), if any. + pub resist: Option, + /// The school this mob is weak to (takes +50%), if any. + pub weak: Option, +} + +impl DamageProfile { + pub const fn new( + attack_type: DamageType, + resist: Option, + weak: Option, + ) -> Self { + Self { + attack_type, + resist, + weak, + } + } + + /// Plain physical bruiser with no elemental quirks. + pub const fn physical() -> Self { + Self::new(DamageType::Physical, None, None) + } + + pub fn defense_against(&self, incoming: DamageType) -> Defense { + if self.weak == Some(incoming) { + Defense::Weak + } else if self.resist == Some(incoming) { + Defense::Resist + } else { + Defense::Normal + } + } + + /// Resolve incoming damage of a school against this profile. + pub fn apply(&self, raw: i32, incoming: DamageType) -> (i32, Defense) { + let def = self.defense_against(incoming); + let scaled = (raw * def.multiplier_pct() / 100).max(1); + (scaled, def) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn weakness_amplifies_and_resist_reduces() { + let undead = DamageProfile::new( + DamageType::Shadow, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ); + let (holy, def_h) = undead.apply(100, DamageType::Holy); + let (shadow, def_s) = undead.apply(100, DamageType::Shadow); + let (phys, def_p) = undead.apply(100, DamageType::Physical); + assert_eq!((holy, def_h), (150, Defense::Weak)); + assert_eq!((shadow, def_s), (50, Defense::Resist)); + assert_eq!((phys, def_p), (100, Defense::Normal)); + } + + #[test] + fn damage_never_drops_below_one() { + let p = DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), None); + let (dmg, _) = p.apply(1, DamageType::Fire); + assert!(dmg >= 1); + } +} diff --git a/late-ssh/src/app/rooms/mud/mod.rs b/late-ssh/src/app/rooms/mud/mod.rs index 278d03d2..78d8c13c 100644 --- a/late-ssh/src/app/rooms/mud/mod.rs +++ b/late-ssh/src/app/rooms/mud/mod.rs @@ -6,6 +6,7 @@ pub mod abilities; pub mod classes; pub mod create_modal; +pub mod damage; pub mod input; pub mod items; pub mod manager; diff --git a/late-ssh/src/app/rooms/mud/svc.rs b/late-ssh/src/app/rooms/mud/svc.rs index be8e058d..f6836040 100644 --- a/late-ssh/src/app/rooms/mud/svc.rs +++ b/late-ssh/src/app/rooms/mud/svc.rs @@ -28,6 +28,7 @@ use crate::app::{ use super::abilities::{Ability, AbilityEffect, learned_at, unlocked_for}; use super::classes::{Class, level_for_xp, xp_for_level}; +use super::damage::{Defense, DamageType}; use super::items::{ItemKind, Slot, item, shop_at}; use super::persist::SavedCharacter; use super::world::{Dir, MobSpawn, RoomId, World, seed_world}; @@ -980,7 +981,7 @@ impl WorldState { } AbilityEffect::Strike => { let dmg = self.spell_damage(class, ability.magnitude, user_id); - self.damage_target(user_id, dmg, &ability.name); + self.damage_target(user_id, dmg, ability.damage_type, &ability.name); } AbilityEffect::Finisher => { let dmg = self.spell_damage(class, ability.magnitude, user_id); @@ -988,16 +989,16 @@ impl WorldState { p.empower = p.empower.max(ability.magnitude / 8); p.empower_ticks = p.empower_ticks.max(ability.duration); } - self.damage_target(user_id, dmg, &ability.name); + self.damage_target(user_id, dmg, ability.damage_type, &ability.name); } AbilityEffect::DamageOverTime => { let tick = self.spell_damage(class, ability.magnitude, user_id); - self.seed_mob_dot(user_id, tick, ability.duration, &ability.name); + self.seed_mob_dot(user_id, tick, ability.damage_type, ability.duration, &ability.name); } AbilityEffect::Stun => { let target = self.players.get(&user_id).and_then(|p| p.target); let dmg = self.spell_damage(class, ability.magnitude, user_id); - self.damage_target(user_id, dmg, &ability.name); + self.damage_target(user_id, dmg, ability.damage_type, &ability.name); // Only stun if the target survived the hit. if let Some(mob_id) = target && self.mobs.get(&mob_id).is_some_and(|m| m.alive) @@ -1047,36 +1048,55 @@ impl WorldState { } } - fn damage_target(&mut self, user_id: Uuid, dmg: i32, source: &str) { + fn damage_target(&mut self, user_id: Uuid, raw: i32, dtype: DamageType, source: &str) { let Some(mob_id) = self.players.get(&user_id).and_then(|p| p.target) else { return; }; - let (mob_name, dead) = { + let (mob_name, dmg, defense, dead) = { let Some(mob) = self.mobs.get_mut(&mob_id) else { return; }; if !mob.alive { return; } + let (dmg, defense) = mob.spawn.profile.apply(raw, dtype); mob.hp -= dmg; - (mob.spawn.name.to_string(), mob.hp <= 0) + (mob.spawn.name.to_string(), dmg, defense, mob.hp <= 0) }; self.dirty = true; - self.log_to(user_id, LogKind::Combat, format!("{source} hits {mob_name} for {dmg}.")); + let tag = defense_tag(defense, dtype); + self.log_to( + user_id, + LogKind::Combat, + format!("{source} hits {mob_name} for {dmg} {}{}.", dtype.label(), tag), + ); if dead { self.kill_mob(user_id, mob_id); } } - fn seed_mob_dot(&mut self, user_id: Uuid, per_tick: i32, duration: u8, source: &str) { + fn seed_mob_dot( + &mut self, + user_id: Uuid, + per_tick: i32, + dtype: DamageType, + duration: u8, + source: &str, + ) { let Some(mob_id) = self.players.get(&user_id).and_then(|p| p.target) else { return; }; + // Bake the resist/weak multiplier into the per-tick number once, up front. + let scaled = self + .mobs + .get(&mob_id) + .map(|m| m.spawn.profile.apply(per_tick, dtype).0) + .unwrap_or(per_tick); self.mob_dots .entry(mob_id) .or_default() - .push((user_id, per_tick, duration)); - self.log_to(user_id, LogKind::Combat, format!("{source} festers in the foe.", )); + .push((user_id, scaled, duration)); + self.log_to(user_id, LogKind::Combat, format!("{source} festers in the foe ({} damage).", dtype.label())); self.dirty = true; } @@ -1467,12 +1487,17 @@ impl WorldState { } self.log_to(user_id, LogKind::Combat, "Opportunist! Your opening strike lands true.".to_string()); } - // Auto-attack. - if let Some(mob) = self.mobs.get_mut(&mob_id) { - mob.hp -= player_atk; + // Auto-attack is physical and runs through the mob's resistances, + // so a physical-resistant foe rewards switching to spells. + let dead = { + let Some(mob) = self.mobs.get_mut(&mob_id) else { + continue; + }; + let (dealt, _) = mob.spawn.profile.apply(player_atk, DamageType::Physical); + mob.hp -= dealt; self.dirty = true; - } - let dead = self.mobs.get(&mob_id).map(|m| m.hp <= 0).unwrap_or(false); + mob.hp <= 0 + }; if dead { self.kill_mob(user_id, mob_id); continue; @@ -1488,9 +1513,18 @@ impl WorldState { self.log_to(user_id, LogKind::Combat, "The foe is stunned and cannot strike.".to_string()); continue; } - let mob_damage = self.mobs.get(&mob_id).map(|m| m.spawn.damage).unwrap_or(0); - let mob_name = self.mobs.get(&mob_id).map(|m| m.spawn.name.to_string()).unwrap_or_default(); - self.strike_player(user_id, mob_damage, &mob_name); + let (mob_damage, mob_dtype, mob_name) = self + .mobs + .get(&mob_id) + .map(|m| { + ( + m.spawn.damage, + m.spawn.profile.attack_type, + m.spawn.name.to_string(), + ) + }) + .unwrap_or((0, DamageType::Physical, String::new())); + self.strike_player(user_id, mob_damage, mob_dtype, &mob_name); } // Drop idle players. @@ -1511,14 +1545,20 @@ impl WorldState { std::mem::take(&mut self.pending_kills) } - fn strike_player(&mut self, user_id: Uuid, raw: i32, mob_name: &str) { + fn strike_player(&mut self, user_id: Uuid, raw: i32, dtype: DamageType, mob_name: &str) { let now = Instant::now(); let Some(p) = self.players.get_mut(&user_id) else { return; }; - // Armor reduces incoming, shield absorbs the rest first. + // Armor blunts physical blows fully but only half-protects against + // elemental and other schools, so caster foes hit harder through plate. let armor = p.armor(); - let mut dmg = (raw - armor / 2).max(1); + let reduction = if dtype == DamageType::Physical { + armor / 2 + } else { + armor / 4 + }; + let mut dmg = (raw - reduction).max(1); if p.shield > 0 { let absorbed = p.shield.min(dmg); p.shield -= absorbed; @@ -1526,13 +1566,14 @@ impl WorldState { } p.hp -= dmg; self.dirty = true; + let verb = dtype.verb(); if p.hp <= 0 { // Warrior trait: survive the first lethal blow at 1 HP. if p.class == Some(Class::Warrior) && !p.death_save_used { p.death_save_used = true; p.hp = 1; self.log_to(user_id, LogKind::System, "Unbreakable! You refuse to fall.".to_string()); - self.log_to(user_id, LogKind::Combat, format!("{mob_name} strikes you to the brink.")); + self.log_to(user_id, LogKind::Combat, format!("{mob_name} {verb} you to the brink.")); return; } p.hp = 0; @@ -1540,7 +1581,7 @@ impl WorldState { p.respawn_at = Some(now + Duration::from_secs(PLAYER_RESPAWN_SECS)); self.log_to(user_id, LogKind::System, "You have fallen! Darkness takes you...".to_string()); } else { - self.log_to(user_id, LogKind::Combat, format!("{mob_name} hits you for {dmg}.")); + self.log_to(user_id, LogKind::Combat, format!("{mob_name} {verb} you for {dmg}.")); } } @@ -1722,6 +1763,15 @@ impl WorldState { } } +/// A short combat-log suffix announcing a resist or weakness, empty for normal. +fn defense_tag(defense: Defense, _dtype: DamageType) -> &'static str { + match defense { + Defense::Weak => " - it's weak to this!", + Defense::Resist => " - resisted", + Defense::Normal => "", + } +} + fn push_log(log: &mut Vec, kind: LogKind, text: String) { log.push(LogLine { text, kind }); if log.len() > LOG_CAP { diff --git a/late-ssh/src/app/rooms/mud/world.rs b/late-ssh/src/app/rooms/mud/world.rs index d1d313ee..f111ed10 100644 --- a/late-ssh/src/app/rooms/mud/world.rs +++ b/late-ssh/src/app/rooms/mud/world.rs @@ -20,6 +20,8 @@ use std::collections::HashMap; +use super::damage::{DamageProfile, DamageType}; + /// Compass (with diagonals and vertical) directions a player can move. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Dir { @@ -65,6 +67,21 @@ impl Dir { Self::Down => "d", } } + + pub fn opposite(self) -> Dir { + match self { + Self::North => Self::South, + Self::South => Self::North, + Self::East => Self::West, + Self::West => Self::East, + Self::Northeast => Self::Southwest, + Self::Southwest => Self::Northeast, + Self::Northwest => Self::Southeast, + Self::Southeast => Self::Northwest, + Self::Up => Self::Down, + Self::Down => Self::Up, + } + } } pub type RoomId = u32; @@ -97,6 +114,8 @@ pub struct MobSpawn { pub loot: &'static [u32], /// True for zone bosses: drops are guaranteed and announced loudly. pub boss: bool, + /// Damage school dealt, plus resisted and weak schools, for interactive combat. + pub profile: DamageProfile, } /// The immutable world: every room plus the mob roster. @@ -1374,6 +1393,7 @@ pub fn seed_world() -> World { respawn_secs: 30, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, None, None), }, MobSpawn { id: 2, @@ -1385,6 +1405,7 @@ pub fn seed_world() -> World { respawn_secs: 45, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, None, None), }, MobSpawn { id: 3, @@ -1396,6 +1417,7 @@ pub fn seed_world() -> World { respawn_secs: 40, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, None, None), }, // ---- Whisperwood (tier 2-3) ------------------------------------- MobSpawn { @@ -1408,6 +1430,7 @@ pub fn seed_world() -> World { respawn_secs: 45, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, None, None), }, MobSpawn { id: 11, @@ -1419,6 +1442,7 @@ pub fn seed_world() -> World { respawn_secs: 50, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Poison, None, None), }, MobSpawn { id: 12, @@ -1430,6 +1454,7 @@ pub fn seed_world() -> World { respawn_secs: 50, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), }, // Boss: Whisperwood MobSpawn { @@ -1442,6 +1467,7 @@ pub fn seed_world() -> World { respawn_secs: 300, loot: &[1006, 1201, 1301], boss: true, + profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Physical), Some(DamageType::Fire)), }, // ---- Duskhollow Caverns (tier 3-4) ------------------------------ MobSpawn { @@ -1454,6 +1480,7 @@ pub fn seed_world() -> World { respawn_secs: 55, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Shadow), Some(DamageType::Holy)), }, MobSpawn { id: 21, @@ -1465,6 +1492,7 @@ pub fn seed_world() -> World { respawn_secs: 55, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, None, None), }, MobSpawn { id: 22, @@ -1476,6 +1504,7 @@ pub fn seed_world() -> World { respawn_secs: 60, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), }, // Boss: Duskhollow Caverns MobSpawn { @@ -1488,6 +1517,7 @@ pub fn seed_world() -> World { respawn_secs: 300, loot: &[1105, 1202, 1302], boss: true, + profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), }, // ---- Drowned Crypts (tier 4-5) ---------------------------------- MobSpawn { @@ -1500,6 +1530,7 @@ pub fn seed_world() -> World { respawn_secs: 60, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), }, MobSpawn { id: 31, @@ -1511,6 +1542,7 @@ pub fn seed_world() -> World { respawn_secs: 60, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Shadow), Some(DamageType::Holy)), }, MobSpawn { id: 32, @@ -1522,6 +1554,7 @@ pub fn seed_world() -> World { respawn_secs: 65, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Frost, Some(DamageType::Frost), Some(DamageType::Fire)), }, // Boss: Drowned Crypts MobSpawn { @@ -1534,6 +1567,7 @@ pub fn seed_world() -> World { respawn_secs: 360, loot: &[1008, 1204, 1302], boss: true, + profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), }, // ---- Emberpeak Mines (tier 5-6) --------------------------------- MobSpawn { @@ -1546,6 +1580,7 @@ pub fn seed_world() -> World { respawn_secs: 65, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Frost)), }, MobSpawn { id: 41, @@ -1557,6 +1592,7 @@ pub fn seed_world() -> World { respawn_secs: 70, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Fire), Some(DamageType::Frost)), }, MobSpawn { id: 42, @@ -1568,6 +1604,7 @@ pub fn seed_world() -> World { respawn_secs: 70, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Frost)), }, // Boss: Emberpeak Mines MobSpawn { @@ -1580,6 +1617,7 @@ pub fn seed_world() -> World { respawn_secs: 360, loot: &[1009, 1205, 1304], boss: true, + profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Frost)), }, // ---- Frostspire Ascent (tier 6-7) ------------------------------- MobSpawn { @@ -1592,6 +1630,7 @@ pub fn seed_world() -> World { respawn_secs: 70, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Frost, Some(DamageType::Frost), Some(DamageType::Fire)), }, MobSpawn { id: 51, @@ -1603,6 +1642,7 @@ pub fn seed_world() -> World { respawn_secs: 75, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Frost), Some(DamageType::Fire)), }, MobSpawn { id: 52, @@ -1614,6 +1654,7 @@ pub fn seed_world() -> World { respawn_secs: 75, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Frost, Some(DamageType::Frost), Some(DamageType::Fire)), }, // Boss: Frostspire Ascent MobSpawn { @@ -1626,6 +1667,7 @@ pub fn seed_world() -> World { respawn_secs: 420, loot: &[1007, 1205, 1304], boss: true, + profile: DamageProfile::new(DamageType::Frost, Some(DamageType::Frost), Some(DamageType::Fire)), }, // ---- The Sunken Citadel (tier 7-8) ------------------------------ MobSpawn { @@ -1638,6 +1680,7 @@ pub fn seed_world() -> World { respawn_secs: 80, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Physical), Some(DamageType::Arcane)), }, MobSpawn { id: 61, @@ -1649,6 +1692,7 @@ pub fn seed_world() -> World { respawn_secs: 80, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Physical), Some(DamageType::Arcane)), }, MobSpawn { id: 62, @@ -1660,6 +1704,7 @@ pub fn seed_world() -> World { respawn_secs: 85, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), }, // Boss: The Sunken Citadel MobSpawn { @@ -1672,6 +1717,7 @@ pub fn seed_world() -> World { respawn_secs: 420, loot: &[1109, 1202, 1304], boss: true, + profile: DamageProfile::new(DamageType::Holy, Some(DamageType::Physical), Some(DamageType::Shadow)), }, // ---- The Obsidian Throne (tier 9-10) ---------------------------- MobSpawn { @@ -1684,6 +1730,7 @@ pub fn seed_world() -> World { respawn_secs: 90, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Holy)), }, MobSpawn { id: 71, @@ -1695,6 +1742,7 @@ pub fn seed_world() -> World { respawn_secs: 90, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Holy)), }, MobSpawn { id: 72, @@ -1706,6 +1754,7 @@ pub fn seed_world() -> World { respawn_secs: 95, loot: &[1000, 1100, 1103, 1300], boss: false, + profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), }, // Final boss MobSpawn { @@ -1718,16 +1767,288 @@ pub fn seed_world() -> World { respawn_secs: 600, loot: &[1009, 1205, 1401], boss: true, + profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), }, ]; + let mut rooms: HashMap = rooms.into_iter().map(|r| (r.id, r)).collect(); + let mut spawns = spawns; + + // Append the deeper-exploration wings (rooms 300+), reciprocal by construction. + extend_world(&mut rooms, &mut spawns); + World { - rooms: rooms.into_iter().map(|r| (r.id, r)).collect(), + rooms, spawns, start_room: 1, } } +// ---- World extension wings (the path from 115 to 200 rooms) --------------- +// +// Each wing is a chain of rooms branching off an existing "anchor" room into a +// zone, linked head-to-tail. Links are wired in BOTH directions here, so a wing +// can never produce a one-way exit (the class of bug hand-authoring is prone +// to). Wing room ids start at 300 to stay clear of the base world. + +/// One room in a wing: its name, description, and the direction that leads +/// DEEPER (to the next room in the chain). The return link is added automatically. +struct WingRoom { + name: &'static str, + desc: &'static str, + /// Direction from this room to the next in the chain. + onward: Dir, +} + +/// Link two rooms reciprocally: `from` gets `dir` -> `to`, `to` gets the +/// opposite back to `from`. Never overwrites an existing exit. +fn link(rooms: &mut HashMap, from: RoomId, dir: Dir, to: RoomId) { + if let Some(r) = rooms.get_mut(&from) { + r.exits.entry(dir).or_insert(to); + } + if let Some(r) = rooms.get_mut(&to) { + r.exits.entry(dir.opposite()).or_insert(from); + } +} + +/// Append a chain of wing rooms to `rooms`, anchored to `anchor` via `entry` +/// (the direction from the anchor into the wing's first room). Returns the id of +/// the wing's last (deepest) room so callers can place a boss/mob there. +fn add_wing( + rooms: &mut HashMap, + zone: &'static str, + safe: bool, + anchor: RoomId, + entry: Dir, + start_id: RoomId, + chain: &[WingRoom], +) -> RoomId { + let mut prev = anchor; + let mut prev_dir = entry; + let mut id = start_id; + for wing in chain { + rooms.insert( + id, + Room { + id, + name: wing.name, + desc: wing.desc, + zone, + exits: HashMap::new(), + safe, + }, + ); + link(rooms, prev, prev_dir, id); + prev = id; + prev_dir = wing.onward; + id += 1; + } + id - 1 +} + +fn wr(name: &'static str, desc: &'static str, onward: Dir) -> WingRoom { + WingRoom { name, desc, onward } +} + +fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { + let mut next_mob: u32 = 300; + let mut mob = |spawns: &mut Vec, + name: &'static str, + home: RoomId, + hp: i32, + dmg: i32, + xp: i32, + boss: bool, + loot: &'static [u32], + profile: DamageProfile| { + let id = next_mob; + next_mob += 1; + spawns.push(MobSpawn { + id, + name, + home, + max_hp: hp, + damage: dmg, + xp, + respawn_secs: if boss { 320 } else { 55 }, + loot, + boss, + profile, + }); + }; + + fn p(at: DamageType, res: Option, weak: Option) -> DamageProfile { + DamageProfile::new(at, res, weak) + } + use DamageType as D; + + // Each wing: (zone, anchor, entry dir, onward dir, id base, rooms). Id bases + // are 30 apart so a wing can grow to 30 rooms without colliding. Mobs are + // placed relative to the captured start/end ids, never hardcoded. + + // ---- Whisperwood: The Sunken Glade (12 rooms) ----------------------- + let start = 300; + let last = add_wing(rooms, "Whisperwood", false, 14, Dir::North, start, &[ + wr("Whisperwood - The Mushroom Stair", "Shelves of bracket-fungus climb a slope like a giant's staircase, soft and cold underfoot, spores drifting in the lanternlight. North; the ring lies south.", Dir::North), + wr("Whisperwood - The Glowcap Grotto", "A hollow beneath an upturned root glimmers with luminous caps in blue and green, a drowned dreamlike light over soft loam. North.", Dir::North), + wr("Whisperwood - The Toadstool Court", "Rings within rings of fungus carpet a clearing, and the longer you stand the more you feel watched by things at ankle height. North.", Dir::North), + wr("Whisperwood - The Weeping Willow", "A willow vast as a tower trails its branches to the ground, and the wind in them makes a sound exactly like a woman crying. North.", Dir::North), + wr("Whisperwood - The Bog Causeway", "A path of half-sunk logs crosses a black bog that breathes bubbles and worse. Stepping wrong here is a quiet way to vanish. North.", Dir::North), + wr("Whisperwood - The Drowned Oak", "An oak has fallen full-length into the bog and rotted into a hollow tunnel; you walk through the inside of a dead giant. North.", Dir::North), + wr("Whisperwood - The Witch's Hut", "A crooked hut leans on chicken-scratch foundations, windows dark, door ajar on a single creaking hinge. North.", Dir::North), + wr("Whisperwood - The Hag's Garden", "Behind the hut a garden grows things no garden should: pale gourds with faces, vines that flinch from the light. North.", Dir::North), + wr("Whisperwood - The Bone Orchard", "Trees here have grown around old bones until trunk and skeleton are one, and the fruit they bear is best left unpicked. North.", Dir::North), + wr("Whisperwood - The Moonwell", "A perfectly round well brims with water that glows faintly silver, reflecting a moon not in tonight's sky. North.", Dir::North), + wr("Whisperwood - The Whispering Stones", "A ring of leaning stones mutters among themselves, falling silent the instant you turn to listen. North.", Dir::North), + wr("Whisperwood - The Sunken Glade", "The trees draw back from a circle of green where a single shaft of moonlight falls, beautiful and far too quiet, where something has waited a very long time. The way back is south.", Dir::North), + ]); + mob(spawns, "a will-o'-wisp", start + 1, 26, 6, 24, false, COMMON_LOOT, p(D::Fire, None, Some(D::Frost))); + mob(spawns, "a giant glowcap spider", start + 5, 34, 7, 30, false, COMMON_LOOT, p(D::Poison, None, Some(D::Fire))); + mob(spawns, "a bog-mire lurker", start + 8, 40, 8, 36, false, COMMON_LOOT, p(D::Poison, Some(D::Poison), Some(D::Fire))); + mob(spawns, "the Hexcrone of the Glade", last, 130, 13, 165, true, &[1006, 1201, 1302], p(D::Shadow, Some(D::Shadow), Some(D::Holy))); + + // ---- Duskhollow: The Barrow Deep (11 rooms) ------------------------- + let start = 330; + let last = add_wing(rooms, "Duskhollow Caverns", false, 37, Dir::West, start, &[ + wr("Duskhollow - Behind the Sealed Door", "The chained door gives onto a passage no light has touched in centuries, the air dead and close and faintly sweet with old decay. West.", Dir::West), + wr("Duskhollow - The Gravewater Pool", "Black water fills a basin to the brim, pale shapes drifting just beneath its skin, neither sunk nor surfaced. West.", Dir::West), + wr("Duskhollow - The Creeping Dark", "The lantern seems to shrink here, the dark pressing in close enough to feel, patient and almost fond. West.", Dir::West), + wr("Duskhollow - The Hall of Urns", "Thousands of clay urns line shelves to the unseen ceiling, each holding forgotten ash. Many are broken, their contents not where they should be. West.", Dir::West), + wr("Duskhollow - The Mourner's Stair", "Steps worn into a smooth trough by centuries of grieving feet descend into a deeper cold. West.", Dir::West), + wr("Duskhollow - The Catacomb Maze", "Passages branch and rejoin among walls of stacked bone until direction loses meaning; only the draught from ahead keeps you true. West.", Dir::West), + wr("Duskhollow - The Lamentation Hall", "A vast chamber where the slightest sound returns as a chorus of weeping, until you cannot tell the echo from the dead. West.", Dir::West), + wr("Duskhollow - The Gilded Tomb", "A single tomb of beaten gold gleams untouched by the rot, its lid carved with a sleeping king who is no longer inside. West.", Dir::West), + wr("Duskhollow - The Guardian's Rest", "Stone sentinels line the final approach, each with a real sword rusted into its carved hands, each having taken one step from its plinth. West.", Dir::West), + wr("Duskhollow - The Barrow King's Vault", "A burial chamber fit for a king who refused the grave: gold heaped in the dark, and at its center a throne where a crowned and withered thing turns its head. The way out is east.", Dir::West), + ]); + mob(spawns, "a tomb-rat swarm", start + 1, 38, 7, 30, false, COMMON_LOOT, p(D::Physical, None, Some(D::Fire))); + mob(spawns, "a grave moth cloud", start + 3, 44, 8, 38, false, COMMON_LOOT, p(D::Poison, None, Some(D::Holy))); + mob(spawns, "a shambling barrow-guard", start + 6, 52, 9, 48, false, COMMON_LOOT, p(D::Physical, Some(D::Shadow), Some(D::Holy))); + mob(spawns, "a clutch of bonepickers", start + 8, 56, 10, 54, false, COMMON_LOOT, p(D::Physical, Some(D::Shadow), Some(D::Holy))); + mob(spawns, "the Barrow King", last, 190, 17, 235, true, &[1105, 1202, 1302], p(D::Shadow, Some(D::Shadow), Some(D::Holy))); + + // ---- Drowned Crypts: The Tidal Catacombs (11 rooms) ----------------- + let start = 360; + let last = add_wing(rooms, "Drowned Crypts", false, 54, Dir::South, start, &[ + wr("Drowned Crypts - The Brine Stair", "Salt-crusted steps spiral down into water that rises to meet you, cold as a drowned bell. South.", Dir::South), + wr("Drowned Crypts - The Coral Ossuary", "Bone and pale coral have grown into one another until you cannot tell which the dead were and which the sea made. South.", Dir::South), + wr("Drowned Crypts - The Kelp Forest", "Ropes of black kelp rise from the flooded dark and sway though there is no current, parting reluctantly as you wade. South.", Dir::South), + wr("Drowned Crypts - The Sunken Chapel", "A chapel stands fully submerged, pews in drowned rows, its altar candle somehow trailing a thread of smoke up through the water. South.", Dir::South), + wr("Drowned Crypts - The Pearl Vault", "Drowned treasure spills from broken chests, every coin and pearl furred with the same pale rot. South.", Dir::South), + wr("Drowned Crypts - The Anemone Garden", "Things that might be flowers and might be mouths carpet the walls, opening and closing in slow patient unison. South.", Dir::South), + wr("Drowned Crypts - The Siren's Landing", "A dry shelf above the flood holds a single carved seat facing the water, where something once sat to sing ships down. South.", Dir::South), + wr("Drowned Crypts - The Black Trench", "The floor falls away into a trench whose bottom the lantern never finds, and from which a slow cold current breathes. South.", Dir::South), + wr("Drowned Crypts - The Bone Reef", "A reef built entirely of the bones of the drowned rises in pale ramparts, and things nest in its hollows. South.", Dir::South), + wr("Drowned Crypts - The Leviathan's Maw", "A vast flooded cavern dominated by the rib-cage of something that should not fit in any sea, and in its shadow a drowned horror stirs. The way back is north.", Dir::South), + ]); + mob(spawns, "a drowned acolyte", start + 1, 58, 11, 60, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Lightning))); + mob(spawns, "a kelp-strangler", start + 3, 64, 12, 66, false, COMMON_LOOT, p(D::Poison, Some(D::Frost), Some(D::Fire))); + mob(spawns, "a reef-thing", start + 6, 70, 13, 72, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Lightning))); + mob(spawns, "a brine-bloated drowned", start + 8, 74, 13, 76, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Lightning))); + mob(spawns, "the Tide-Drowned Leviathan", last, 260, 21, 340, true, &[1008, 1204, 1302], p(D::Frost, Some(D::Frost), Some(D::Lightning))); + + // ---- Emberpeak: The Deep Forge (11 rooms) --------------------------- + let start = 390; + let last = add_wing(rooms, "Emberpeak Mines", false, 69, Dir::North, start, &[ + wr("Emberpeak - The Cleared Drift", "Fresh rubble dragged aside; beyond it the dwarven tunnels run on, hot and red-lit. North.", Dir::North), + wr("Emberpeak - The Ore Sorters", "Conveyor troughs of cold black iron still hold their last sorted heaps of glittering ore, untouched for an age. North.", Dir::North), + wr("Emberpeak - The Gem Cutters' Hall", "Workbenches stand abandoned mid-task, half-cut gems clamped in vices, catching the forge-light like trapped sparks. North.", Dir::North), + wr("Emberpeak - The Molten Channel", "A river of slow magma crosses the hall in a stone trough, and the air above it shimmers hard enough to bend the sight. North.", Dir::North), + wr("Emberpeak - The Bellows Engine", "A vast machine of leather and iron still wheezes faintly, breathing furnace-air into tunnels no one tends. North.", Dir::North), + wr("Emberpeak - The Slag Cathedral", "Waste glass and slag have been stacked into soaring buttresses, a cathedral built by accident over a thousand years of work. North.", Dir::North), + wr("Emberpeak - The Runesmith's Sanctum", "Walls of dwarven runes pulse with banked heat, and a forge of black iron broods at the heart, never gone cold. North.", Dir::North), + wr("Emberpeak - The Ash Vault", "Knee-deep grey ash fills a sealed vault, and something has been writing in it, over and over, the same dwarven word for sorry. North.", Dir::North), + wr("Emberpeak - The Firewalk", "A narrow bridge crosses a lake of fire, the stone underfoot warm enough to feel through boots. North.", Dir::North), + wr("Emberpeak - The Heart of the Forge", "The deepest forge of all, open to a vein of living magma, where a guardian of fused slag and fire heaves itself upright. The way out is south.", Dir::North), + ]); + mob(spawns, "a coal-wretch", start + 1, 80, 14, 84, false, COMMON_LOOT, p(D::Fire, Some(D::Fire), Some(D::Frost))); + mob(spawns, "a cinder-imp", start + 3, 84, 14, 86, false, COMMON_LOOT, p(D::Fire, Some(D::Fire), Some(D::Frost))); + mob(spawns, "a runeforged sentry", start + 6, 88, 15, 90, false, COMMON_LOOT, p(D::Fire, Some(D::Physical), Some(D::Frost))); + mob(spawns, "a slag golem", start + 8, 94, 16, 96, false, COMMON_LOOT, p(D::Physical, Some(D::Fire), Some(D::Frost))); + mob(spawns, "the Forgeheart Guardian", last, 340, 27, 460, true, &[1009, 1205, 1304], p(D::Fire, Some(D::Fire), Some(D::Frost))); + + // ---- Frostspire: The Glacier's Heart (11 rooms) --------------------- + let start = 420; + let last = add_wing(rooms, "Frostspire Ascent", false, 84, Dir::North, start, &[ + wr("Frostspire - The Blue Descent", "A stair carved into the glacier itself plunges into translucent blue depths, the cold deepening with every step. North.", Dir::North), + wr("Frostspire - The Frozen Falls", "A waterfall caught mid-plunge forms a curtain of clear ice three storeys high, and behind it, dimly, something moves. North.", Dir::North), + wr("Frostspire - The Rime Galleries", "Halls of ice branch in every direction, their walls so clear you see the frozen dark of the glacier's interior pressing close. North.", Dir::North), + wr("Frostspire - The Mammoth Graveyard", "Tusked giants lie where the ice took them an age ago, perfectly kept, their great frozen eyes still open. North.", Dir::North), + wr("Frostspire - The Aurora Cavern", "Light from the surface filters down through fathoms of ice and breaks into slow drifting color across the cavern floor. North.", Dir::North), + wr("Frostspire - The Frostbound Hoard", "A dragon's hoard sheathed entirely in clear ice, every coin and crown visible and utterly unreachable. North.", Dir::North), + wr("Frostspire - The Silent Crevasse", "A crack in the glacier so deep the cold pouring from it stops your breath, and the silence is total enough to hear your own heart. North.", Dir::North), + wr("Frostspire - The Wyrm's Spine", "You walk the frozen length of some titanic serpent locked in the ice, scale after scale underfoot for a hundred paces. North.", Dir::North), + wr("Frostspire - The Last Warmth", "A geothermal vent has kept one small chamber bearable, and the bones around the dead fire say others found it too late. North.", Dir::North), + wr("Frostspire - The Glacier's Heart", "At the glacier's frozen core, a chamber of impossible blue holds an elder ice-wyrm coiled in eternal sleep, waking now, slow and vast and furious. The way back is south.", Dir::North), + ]); + mob(spawns, "a frost-bound wretch", start + 1, 100, 17, 106, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Fire))); + mob(spawns, "an ice-stalker", start + 3, 104, 18, 110, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Fire))); + mob(spawns, "a glacial revenant", start + 6, 110, 18, 116, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Fire))); + mob(spawns, "a hoarfrost wraith", start + 8, 114, 19, 120, false, COMMON_LOOT, p(D::Frost, Some(D::Physical), Some(D::Fire))); + mob(spawns, "the Heart-of-Winter Wyrm", last, 440, 33, 620, true, &[1007, 1205, 1304], p(D::Frost, Some(D::Frost), Some(D::Fire))); + + // ---- Sunken Citadel: The Forbidden Wing (10 rooms) ------------------ + let start = 450; + let last = add_wing(rooms, "The Sunken Citadel", false, 99, Dir::North, start, &[ + wr("Citadel - The Sealed Wing", "A wing the citadel tried to wall away from itself, the bricks bulging outward as though something pushed from within. North.", Dir::North), + wr("Citadel - The Mirror Gallery", "Black mirrors line a hall, and your reflection is always a half-second late and, you slowly realize, not always copying what you do. North.", Dir::North), + wr("Citadel - The Forgotten Archive", "Shelves of iron books stand toppled and burned, and the ash still holds the shape of words that hurt to almost-read. North.", Dir::North), + wr("Citadel - The Astronomer's Tower", "A ruined observatory open to a sky full of wrong stars, its brass telescope aimed at a darkness that seems to aim back. North.", Dir::North), + wr("Citadel - The Hall of Hands", "Ten thousand carved stone hands reach from the walls, and as you pass, the nearest ones slowly, gently, turn to follow. North.", Dir::North), + wr("Citadel - The Drowned Laboratory", "Flooded benches hold apparatus of glass and bone, and things in jars track you with eyes that should not still be wet. North.", Dir::North), + wr("Citadel - The Whispering Crypt", "The carved mouths of the citadel reach their loudest here, all speaking the last word of the long sentence at once. North.", Dir::North), + wr("Citadel - The Throne of Echoes", "An empty throne faces a hall built to carry a single voice forever; the air still trembles faintly with the last command given. North.", Dir::North), + wr("Citadel - The Vault of Saints", "Sarcophagi of the citadel's holy dead stand cracked open from within, their occupants risen to a sanctity gone sour. North.", Dir::North), + wr("Citadel - The Antechamber of the Heart", "The black stone turns warm and almost soft here, and the lantern dims as though something ahead is drinking the light. North.", Dir::North), + wr("Citadel - The Sealed Heart", "The forbidden room at the citadel's core, where a being of folded shadow and starlight unfurls from the dark it was bound in. The way out is south.", Dir::North), + ]); + mob(spawns, "a hollow archivist", start + 2, 122, 22, 144, false, COMMON_LOOT, p(D::Shadow, Some(D::Physical), Some(D::Holy))); + mob(spawns, "a mirror-wraith", start + 4, 128, 23, 150, false, COMMON_LOOT, p(D::Shadow, Some(D::Physical), Some(D::Holy))); + mob(spawns, "a grasping hand-swarm", start + 6, 132, 24, 156, false, COMMON_LOOT, p(D::Physical, Some(D::Physical), Some(D::Arcane))); + mob(spawns, "the Warden of the Sealed Heart", last, 540, 39, 840, true, &[1109, 1202, 1304], p(D::Shadow, Some(D::Shadow), Some(D::Holy))); + + // ---- Obsidian Throne: The Infernal Depths (10 rooms) ---------------- + let start = 480; + let last = add_wing(rooms, "The Obsidian Throne", false, 109, Dir::South, start, &[ + wr("Obsidian Throne - The Burning Descent", "A stair of cooling lava leads down into a heat that is almost a sound, a low roar at the edge of hearing. South.", Dir::South), + wr("Obsidian Throne - The Furnace of Sins", "Vast furnaces line a hall where the damned are unmade and remade, screaming on a loop ten thousand years long. South.", Dir::South), + wr("Obsidian Throne - The Chained Legion", "Rank upon rank of bound demons stand frozen at attention, and ten thousand burning eyes track you down the length of the hall. South.", Dir::South), + wr("Obsidian Throne - The Pact Chamber", "A round room of black glass where bargains were struck with the throne itself; the contracts still hang in the air, written in light, waiting. South.", Dir::South), + wr("Obsidian Throne - The River of Fire", "A true river of flame crosses the dark, and a ferryman of ash waits at its bank with an open, expectant hand. South.", Dir::South), + wr("Obsidian Throne - The Gallery of Torments", "Each alcove holds a single damned soul in eternal, inventive agony, and each turns its head to beg you for an end. South.", Dir::South), + wr("Obsidian Throne - The Brimstone Bridge", "A bridge of fused bone arches over an abyss that glows the deep red of a banked forge, exhaling sulphur. South.", Dir::South), + wr("Obsidian Throne - The Hall of Broken Oaths", "Shattered contracts litter the floor, and the air is thick with the ghosts of promises the throne was glad to see broken. South.", Dir::South), + wr("Obsidian Throne - The Weeping Pits", "Pits of black tar bubble and sigh, and each rising bubble briefly wears a face that mouths a name before it bursts. South.", Dir::South), + wr("Obsidian Throne - The Antechamber of the Abyss", "The realm thins toward something worse, the black glass going translucent on a void that has no bottom and no patience. South.", Dir::South), + wr("Obsidian Throne - The Abyssal Gate", "The realm bottoms out at a gate into pure abyss, guarded by a herald of Mal'gareth who will not let a soul pass either way. The way back is north.", Dir::South), + ]); + mob(spawns, "a chained tormentor", start + 2, 168, 30, 206, false, COMMON_LOOT, p(D::Fire, Some(D::Fire), Some(D::Holy))); + mob(spawns, "a tormented soul-husk", start + 4, 174, 31, 212, false, COMMON_LOOT, p(D::Shadow, Some(D::Fire), Some(D::Holy))); + mob(spawns, "an ash ferryman", start + 6, 182, 32, 222, false, COMMON_LOOT, p(D::Fire, Some(D::Fire), Some(D::Holy))); + mob(spawns, "the Herald of Mal'gareth", last, 620, 43, 1100, true, &[1009, 1205, 1401], p(D::Shadow, Some(D::Fire), Some(D::Holy))); + + // ---- King's Road: The Bandit Trail (9 rooms, low-level detour) ------ + let start = 510; + let last = add_wing(rooms, "King's Road", false, 8, Dir::East, start, &[ + wr("King's Road - The Poacher's Trail", "A narrow trail worn by furtive feet winds east through the brush, snares glinting in the undergrowth. East.", Dir::East), + wr("King's Road - The Hollow Tree", "A hollow oak big enough to shelter in has been used as exactly that; a cold campfire and gnawed bones say by whom. East.", Dir::East), + wr("King's Road - The Abandoned Farmstead", "A burned-out farm slumps in a clearing, its fields gone to weed, its well gone to black water. East.", Dir::East), + wr("King's Road - The Scarecrow Field", "Rags on crossed sticks lean at wrong angles across a dead field, and you count one more of them on the way out than on the way in. East.", Dir::East), + wr("King's Road - The Crossroads Gibbet", "An iron gibbet creaks at a forgotten crossroads, its occupant long since flown to bone. East.", Dir::East), + wr("King's Road - The Smuggler's Cellar", "A trapdoor in the ruin of an inn drops to a cellar of stolen goods, half of it spoiled, all of it watched. East.", Dir::East), + wr("King's Road - The Watchpost", "A half-built bandit watchpost overlooks the trail, its lookout's stool still warm, its lookout suddenly not in sight. East.", Dir::East), + wr("King's Road - The Camp Approach", "The trees thin toward firelight and rough laughter; you are clearly expected, and clearly not welcome. East.", Dir::East), + wr("King's Road - The Bandit Camp", "A ring of tattered tents around a guttering fire marks the lair of the road's bandit crew, and their chief rises, hand on hilt, to greet the fool who found them. The way back is west.", Dir::East), + ]); + mob(spawns, "a feral poacher's hound", start + 1, 26, 5, 22, false, COMMON_LOOT, DamageProfile::physical()); + mob(spawns, "a road cutthroat", start + 4, 30, 6, 24, false, COMMON_LOOT, DamageProfile::physical()); + mob(spawns, "a crossbow bandit", start + 6, 32, 7, 28, false, COMMON_LOOT, DamageProfile::physical()); + mob(spawns, "the Bandit Chief Garrote", last, 110, 12, 130, true, &[1006, 1201, 1301], DamageProfile::physical()); +} + +/// Common low-tier drop pool shared by wandering wing mobs. +const COMMON_LOOT: &[u32] = &[1000, 1100, 1103, 1300]; + #[cfg(test)] mod tests { use super::*; @@ -1769,7 +2090,7 @@ mod tests { #[test] fn world_has_expected_size_and_every_mob_homes_to_a_real_room() { let world = seed_world(); - assert_eq!(world.rooms.len(), 110, "expected 110 authored rooms"); + assert_eq!(world.rooms.len(), 200, "expected 200 authored rooms"); for spawn in &world.spawns { assert!( world.rooms.contains_key(&spawn.home), @@ -1781,6 +2102,19 @@ mod tests { } } + #[test] + fn there_are_at_least_fifty_distinct_enemy_types() { + let world = seed_world(); + let mut names: Vec<&str> = world.spawns.iter().map(|s| s.name).collect(); + names.sort_unstable(); + names.dedup(); + assert!( + names.len() >= 50, + "expected 50+ distinct enemy types, found {}", + names.len() + ); + } + #[test] fn mob_spawn_ids_are_unique() { let world = seed_world(); From 779413cdeacb36364ebb3e11e5c800c8ffdf1eee Mon Sep 17 00:00:00 2001 From: Mike Clark Date: Tue, 2 Jun 2026 19:10:44 -0600 Subject: [PATCH 07/20] Fix Lateania check failures --- late-ssh/src/app/hub/shop/svc.rs | 8 +- late-ssh/src/app/render.rs | 2 +- late-ssh/src/app/rooms/mud/abilities.rs | 831 ++++++++++++++-- late-ssh/src/app/rooms/mud/classes.rs | 36 +- late-ssh/src/app/rooms/mud/input.rs | 12 +- late-ssh/src/app/rooms/mud/items.rs | 419 +++++++- late-ssh/src/app/rooms/mud/manager.rs | 6 +- late-ssh/src/app/rooms/mud/persist.rs | 58 +- late-ssh/src/app/rooms/mud/state.rs | 8 +- late-ssh/src/app/rooms/mud/svc.rs | 339 +++++-- late-ssh/src/app/rooms/mud/ui.rs | 226 ++++- late-ssh/src/app/rooms/mud/world.rs | 1189 +++++++++++++++++++---- late-ssh/tests/helpers/mod.rs | 5 +- vendor/potatis/mos6502/src/mos6502.rs | 2 +- vendor/potatis/nes/src/nes.rs | 2 +- 15 files changed, 2692 insertions(+), 451 deletions(-) diff --git a/late-ssh/src/app/hub/shop/svc.rs b/late-ssh/src/app/hub/shop/svc.rs index 593de443..37759865 100644 --- a/late-ssh/src/app/hub/shop/svc.rs +++ b/late-ssh/src/app/hub/shop/svc.rs @@ -334,12 +334,10 @@ impl ShopService { } else { Ok("Cleared displayed badge".to_string()) } + } else if slot == BONSAI_VARIANT_SLOT { + Ok("Classic Bonsai already active".to_string()) } else { - if slot == BONSAI_VARIANT_SLOT { - Ok("Classic Bonsai already active".to_string()) - } else { - Ok("No badge is displayed".to_string()) - } + Ok("No badge is displayed".to_string()) } } diff --git a/late-ssh/src/app/render.rs b/late-ssh/src/app/render.rs index 38968bb9..87a4eb78 100644 --- a/late-ssh/src/app/render.rs +++ b/late-ssh/src/app/render.rs @@ -530,7 +530,7 @@ impl App { let voice_snapshot = self.voice.snapshot(); let voice_participant_count = voice_snapshot.participants.len(); let voice_view = crate::app::voice::ui::VoiceRoomView { - snapshot: &voice_snapshot, + snapshot: voice_snapshot, current_user_id: self.user_id, paired_cli_supports_voice, browser_listen_url: &voice_browser_listen_url, diff --git a/late-ssh/src/app/rooms/mud/abilities.rs b/late-ssh/src/app/rooms/mud/abilities.rs index ab540bc9..7cc00340 100644 --- a/late-ssh/src/app/rooms/mud/abilities.rs +++ b/late-ssh/src/app/rooms/mud/abilities.rs @@ -73,65 +73,780 @@ pub struct Ability { /// The full ability roster. Ordered by class, then unlock level. pub const ABILITIES: &[Ability] = &[ // ---- Warrior (Rage) ------------------------------------------------- - Ability { id: 100, name: "Cleave", desc: "A wide, brutal swing that bites deep into the enemy before you.", class: Class::Warrior, level_req: 1, cost: 10, resource: Resource::Rage, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 14, duration: 0 }, - Ability { id: 101, name: "Rend", desc: "Tear a ragged wound that bleeds the foe with every passing moment.", class: Class::Warrior, level_req: 4, cost: 12, resource: Resource::Rage, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Physical, magnitude: 6, duration: 4 }, - Ability { id: 102, name: "Shield Wall", desc: "Set your stance and raise your guard, turning aside the next blows.", class: Class::Warrior, level_req: 8, cost: 15, resource: Resource::Rage, cooldown_ticks: 6, effect: AbilityEffect::Ward, damage_type: DamageType::Physical, magnitude: 30, duration: 3 }, - Ability { id: 103, name: "Shield Bash", desc: "Slam your shield into the enemy's skull, stunning it senseless.", class: Class::Warrior, level_req: 12, cost: 18, resource: Resource::Rage, cooldown_ticks: 5, effect: AbilityEffect::Stun, damage_type: DamageType::Physical, magnitude: 10, duration: 1 }, - Ability { id: 104, name: "Battle Fury", desc: "Loose a war-cry that floods your arms with killing strength.", class: Class::Warrior, level_req: 16, cost: 20, resource: Resource::Rage, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 10, duration: 4 }, - Ability { id: 105, name: "Sunder", desc: "A crushing two-handed blow that shatters armor and bone alike.", class: Class::Warrior, level_req: 20, cost: 22, resource: Resource::Rage, cooldown_ticks: 3, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 30, duration: 0 }, - Ability { id: 106, name: "Bloodthirst", desc: "Strike with such savagery that the enemy's blood revives you.", class: Class::Warrior, level_req: 25, cost: 24, resource: Resource::Rage, cooldown_ticks: 4, effect: AbilityEffect::Heal, damage_type: DamageType::Physical, magnitude: 28, duration: 0 }, - Ability { id: 107, name: "Earthshaker", desc: "Drive your weapon into the ground; the shock rends everything near.", class: Class::Warrior, level_req: 30, cost: 28, resource: Resource::Rage, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Physical, magnitude: 12, duration: 5 }, - Ability { id: 108, name: "Indomitable", desc: "Plant your feet and refuse to fall, shrugging off mortal wounds.", class: Class::Warrior, level_req: 36, cost: 30, resource: Resource::Rage, cooldown_ticks: 10, effect: AbilityEffect::Ward, damage_type: DamageType::Physical, magnitude: 70, duration: 4 }, - Ability { id: 109, name: "Reckless Onslaught", desc: "Abandon all defense for a flurry of devastating, empowered blows.", class: Class::Warrior, level_req: 42, cost: 35, resource: Resource::Rage, cooldown_ticks: 10, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 22, duration: 5 }, - Ability { id: 110, name: "Executioner's Strike", desc: "A single annihilating blow meant to end the fight outright.", class: Class::Warrior, level_req: 50, cost: 45, resource: Resource::Rage, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Physical, magnitude: 80, duration: 2 }, + Ability { + id: 100, + name: "Cleave", + desc: "A wide, brutal swing that bites deep into the enemy before you.", + class: Class::Warrior, + level_req: 1, + cost: 10, + resource: Resource::Rage, + cooldown_ticks: 1, + effect: AbilityEffect::Strike, + damage_type: DamageType::Physical, + magnitude: 14, + duration: 0, + }, + Ability { + id: 101, + name: "Rend", + desc: "Tear a ragged wound that bleeds the foe with every passing moment.", + class: Class::Warrior, + level_req: 4, + cost: 12, + resource: Resource::Rage, + cooldown_ticks: 2, + effect: AbilityEffect::DamageOverTime, + damage_type: DamageType::Physical, + magnitude: 6, + duration: 4, + }, + Ability { + id: 102, + name: "Shield Wall", + desc: "Set your stance and raise your guard, turning aside the next blows.", + class: Class::Warrior, + level_req: 8, + cost: 15, + resource: Resource::Rage, + cooldown_ticks: 6, + effect: AbilityEffect::Ward, + damage_type: DamageType::Physical, + magnitude: 30, + duration: 3, + }, + Ability { + id: 103, + name: "Shield Bash", + desc: "Slam your shield into the enemy's skull, stunning it senseless.", + class: Class::Warrior, + level_req: 12, + cost: 18, + resource: Resource::Rage, + cooldown_ticks: 5, + effect: AbilityEffect::Stun, + damage_type: DamageType::Physical, + magnitude: 10, + duration: 1, + }, + Ability { + id: 104, + name: "Battle Fury", + desc: "Loose a war-cry that floods your arms with killing strength.", + class: Class::Warrior, + level_req: 16, + cost: 20, + resource: Resource::Rage, + cooldown_ticks: 8, + effect: AbilityEffect::Empower, + damage_type: DamageType::Physical, + magnitude: 10, + duration: 4, + }, + Ability { + id: 105, + name: "Sunder", + desc: "A crushing two-handed blow that shatters armor and bone alike.", + class: Class::Warrior, + level_req: 20, + cost: 22, + resource: Resource::Rage, + cooldown_ticks: 3, + effect: AbilityEffect::Strike, + damage_type: DamageType::Physical, + magnitude: 30, + duration: 0, + }, + Ability { + id: 106, + name: "Bloodthirst", + desc: "Strike with such savagery that the enemy's blood revives you.", + class: Class::Warrior, + level_req: 25, + cost: 24, + resource: Resource::Rage, + cooldown_ticks: 4, + effect: AbilityEffect::Heal, + damage_type: DamageType::Physical, + magnitude: 28, + duration: 0, + }, + Ability { + id: 107, + name: "Earthshaker", + desc: "Drive your weapon into the ground; the shock rends everything near.", + class: Class::Warrior, + level_req: 30, + cost: 28, + resource: Resource::Rage, + cooldown_ticks: 4, + effect: AbilityEffect::DamageOverTime, + damage_type: DamageType::Physical, + magnitude: 12, + duration: 5, + }, + Ability { + id: 108, + name: "Indomitable", + desc: "Plant your feet and refuse to fall, shrugging off mortal wounds.", + class: Class::Warrior, + level_req: 36, + cost: 30, + resource: Resource::Rage, + cooldown_ticks: 10, + effect: AbilityEffect::Ward, + damage_type: DamageType::Physical, + magnitude: 70, + duration: 4, + }, + Ability { + id: 109, + name: "Reckless Onslaught", + desc: "Abandon all defense for a flurry of devastating, empowered blows.", + class: Class::Warrior, + level_req: 42, + cost: 35, + resource: Resource::Rage, + cooldown_ticks: 10, + effect: AbilityEffect::Empower, + damage_type: DamageType::Physical, + magnitude: 22, + duration: 5, + }, + Ability { + id: 110, + name: "Executioner's Strike", + desc: "A single annihilating blow meant to end the fight outright.", + class: Class::Warrior, + level_req: 50, + cost: 45, + resource: Resource::Rage, + cooldown_ticks: 8, + effect: AbilityEffect::Finisher, + damage_type: DamageType::Physical, + magnitude: 80, + duration: 2, + }, // ---- Mage (Mana) ---------------------------------------------------- - Ability { id: 200, name: "Firebolt", desc: "A dart of conjured flame that sears whatever it strikes.", class: Class::Mage, level_req: 1, cost: 8, resource: Resource::Mana, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Fire, magnitude: 16, duration: 0 }, - Ability { id: 201, name: "Frost Nova", desc: "Ice erupts around the foe, locking it in place for a heartbeat.", class: Class::Mage, level_req: 5, cost: 14, resource: Resource::Mana, cooldown_ticks: 5, effect: AbilityEffect::Stun, damage_type: DamageType::Frost, magnitude: 8, duration: 1 }, - Ability { id: 202, name: "Immolate", desc: "Wreath the enemy in clinging fire that burns long after the cast.", class: Class::Mage, level_req: 9, cost: 16, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Fire, magnitude: 9, duration: 5 }, - Ability { id: 203, name: "Mana Shield", desc: "Weave raw arcana into a shimmering barrier of force.", class: Class::Mage, level_req: 13, cost: 18, resource: Resource::Mana, cooldown_ticks: 6, effect: AbilityEffect::Ward, damage_type: DamageType::Arcane, magnitude: 35, duration: 3 }, - Ability { id: 204, name: "Arcane Focus", desc: "Sharpen your will until every spell strikes with greater force.", class: Class::Mage, level_req: 17, cost: 20, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Arcane, magnitude: 12, duration: 4 }, - Ability { id: 205, name: "Lightning Lance", desc: "A spear of white lightning that punches clean through the target.", class: Class::Mage, level_req: 22, cost: 24, resource: Resource::Mana, cooldown_ticks: 2, effect: AbilityEffect::Strike, damage_type: DamageType::Lightning, magnitude: 34, duration: 0 }, - Ability { id: 206, name: "Siphon Life", desc: "Draw the warmth from the enemy and pour it into your own flesh.", class: Class::Mage, level_req: 27, cost: 26, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::Heal, damage_type: DamageType::Shadow, magnitude: 30, duration: 0 }, - Ability { id: 207, name: "Blizzard", desc: "Call down a storm of razored ice that flays all it touches.", class: Class::Mage, level_req: 32, cost: 30, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Frost, magnitude: 13, duration: 5 }, - Ability { id: 208, name: "Time Warp", desc: "Bend the moment so the enemy stands frozen between heartbeats.", class: Class::Mage, level_req: 38, cost: 34, resource: Resource::Mana, cooldown_ticks: 9, effect: AbilityEffect::Stun, damage_type: DamageType::Arcane, magnitude: 14, duration: 2 }, - Ability { id: 209, name: "Arcane Overload", desc: "Let power flood every nerve until your spells blaze unstoppable.", class: Class::Mage, level_req: 44, cost: 38, resource: Resource::Mana, cooldown_ticks: 10, effect: AbilityEffect::Empower, damage_type: DamageType::Arcane, magnitude: 24, duration: 5 }, - Ability { id: 210, name: "Meteor", desc: "Tear a burning star from the sky and bring it down on your foe.", class: Class::Mage, level_req: 50, cost: 50, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Fire, magnitude: 90, duration: 2 }, + Ability { + id: 200, + name: "Firebolt", + desc: "A dart of conjured flame that sears whatever it strikes.", + class: Class::Mage, + level_req: 1, + cost: 8, + resource: Resource::Mana, + cooldown_ticks: 1, + effect: AbilityEffect::Strike, + damage_type: DamageType::Fire, + magnitude: 16, + duration: 0, + }, + Ability { + id: 201, + name: "Frost Nova", + desc: "Ice erupts around the foe, locking it in place for a heartbeat.", + class: Class::Mage, + level_req: 5, + cost: 14, + resource: Resource::Mana, + cooldown_ticks: 5, + effect: AbilityEffect::Stun, + damage_type: DamageType::Frost, + magnitude: 8, + duration: 1, + }, + Ability { + id: 202, + name: "Immolate", + desc: "Wreath the enemy in clinging fire that burns long after the cast.", + class: Class::Mage, + level_req: 9, + cost: 16, + resource: Resource::Mana, + cooldown_ticks: 3, + effect: AbilityEffect::DamageOverTime, + damage_type: DamageType::Fire, + magnitude: 9, + duration: 5, + }, + Ability { + id: 203, + name: "Mana Shield", + desc: "Weave raw arcana into a shimmering barrier of force.", + class: Class::Mage, + level_req: 13, + cost: 18, + resource: Resource::Mana, + cooldown_ticks: 6, + effect: AbilityEffect::Ward, + damage_type: DamageType::Arcane, + magnitude: 35, + duration: 3, + }, + Ability { + id: 204, + name: "Arcane Focus", + desc: "Sharpen your will until every spell strikes with greater force.", + class: Class::Mage, + level_req: 17, + cost: 20, + resource: Resource::Mana, + cooldown_ticks: 8, + effect: AbilityEffect::Empower, + damage_type: DamageType::Arcane, + magnitude: 12, + duration: 4, + }, + Ability { + id: 205, + name: "Lightning Lance", + desc: "A spear of white lightning that punches clean through the target.", + class: Class::Mage, + level_req: 22, + cost: 24, + resource: Resource::Mana, + cooldown_ticks: 2, + effect: AbilityEffect::Strike, + damage_type: DamageType::Lightning, + magnitude: 34, + duration: 0, + }, + Ability { + id: 206, + name: "Siphon Life", + desc: "Draw the warmth from the enemy and pour it into your own flesh.", + class: Class::Mage, + level_req: 27, + cost: 26, + resource: Resource::Mana, + cooldown_ticks: 4, + effect: AbilityEffect::Heal, + damage_type: DamageType::Shadow, + magnitude: 30, + duration: 0, + }, + Ability { + id: 207, + name: "Blizzard", + desc: "Call down a storm of razored ice that flays all it touches.", + class: Class::Mage, + level_req: 32, + cost: 30, + resource: Resource::Mana, + cooldown_ticks: 4, + effect: AbilityEffect::DamageOverTime, + damage_type: DamageType::Frost, + magnitude: 13, + duration: 5, + }, + Ability { + id: 208, + name: "Time Warp", + desc: "Bend the moment so the enemy stands frozen between heartbeats.", + class: Class::Mage, + level_req: 38, + cost: 34, + resource: Resource::Mana, + cooldown_ticks: 9, + effect: AbilityEffect::Stun, + damage_type: DamageType::Arcane, + magnitude: 14, + duration: 2, + }, + Ability { + id: 209, + name: "Arcane Overload", + desc: "Let power flood every nerve until your spells blaze unstoppable.", + class: Class::Mage, + level_req: 44, + cost: 38, + resource: Resource::Mana, + cooldown_ticks: 10, + effect: AbilityEffect::Empower, + damage_type: DamageType::Arcane, + magnitude: 24, + duration: 5, + }, + Ability { + id: 210, + name: "Meteor", + desc: "Tear a burning star from the sky and bring it down on your foe.", + class: Class::Mage, + level_req: 50, + cost: 50, + resource: Resource::Mana, + cooldown_ticks: 8, + effect: AbilityEffect::Finisher, + damage_type: DamageType::Fire, + magnitude: 90, + duration: 2, + }, // ---- Cleric (Mana) -------------------------------------------------- - Ability { id: 300, name: "Smite", desc: "Call down a lance of holy light upon the unworthy.", class: Class::Cleric, level_req: 1, cost: 9, resource: Resource::Mana, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Holy, magnitude: 13, duration: 0 }, - Ability { id: 301, name: "Mend", desc: "Knit flesh and seal wounds with a whispered prayer.", class: Class::Cleric, level_req: 3, cost: 12, resource: Resource::Mana, cooldown_ticks: 2, effect: AbilityEffect::Heal, damage_type: DamageType::Holy, magnitude: 22, duration: 0 }, - Ability { id: 302, name: "Renewal", desc: "A blessing that mends a little more with every breath you take.", class: Class::Cleric, level_req: 7, cost: 16, resource: Resource::Mana, cooldown_ticks: 4, effect: AbilityEffect::HealOverTime, damage_type: DamageType::Holy, magnitude: 8, duration: 5 }, - Ability { id: 303, name: "Sacred Ward", desc: "Surround yourself in a corona of divine protection.", class: Class::Cleric, level_req: 11, cost: 18, resource: Resource::Mana, cooldown_ticks: 6, effect: AbilityEffect::Ward, damage_type: DamageType::Holy, magnitude: 32, duration: 3 }, - Ability { id: 304, name: "Holy Fire", desc: "Sear the wicked with flame that judges as it burns.", class: Class::Cleric, level_req: 15, cost: 20, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Holy, magnitude: 10, duration: 4 }, - Ability { id: 305, name: "Blessing of Might", desc: "Anoint yourself so each strike falls with righteous force.", class: Class::Cleric, level_req: 19, cost: 22, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Holy, magnitude: 11, duration: 4 }, - Ability { id: 306, name: "Greater Heal", desc: "A surge of restoring grace that mends even grievous harm.", class: Class::Cleric, level_req: 24, cost: 26, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::Heal, damage_type: DamageType::Holy, magnitude: 50, duration: 0 }, - Ability { id: 307, name: "Hammer of Faith", desc: "A spectral warhammer crashes down with crushing zeal.", class: Class::Cleric, level_req: 29, cost: 28, resource: Resource::Mana, cooldown_ticks: 3, effect: AbilityEffect::Strike, damage_type: DamageType::Holy, magnitude: 32, duration: 0 }, - Ability { id: 308, name: "Sanctuary", desc: "Raise hallowed ground that turns aside the cruelest wounds.", class: Class::Cleric, level_req: 35, cost: 32, resource: Resource::Mana, cooldown_ticks: 9, effect: AbilityEffect::Ward, damage_type: DamageType::Holy, magnitude: 65, duration: 4 }, - Ability { id: 309, name: "Divine Radiance", desc: "Blaze with the light of the Dawn until evil cannot bear to strike.", class: Class::Cleric, level_req: 41, cost: 36, resource: Resource::Mana, cooldown_ticks: 7, effect: AbilityEffect::Stun, damage_type: DamageType::Holy, magnitude: 18, duration: 2 }, - Ability { id: 310, name: "Judgment", desc: "Pronounce the final verdict of heaven upon a doomed soul.", class: Class::Cleric, level_req: 50, cost: 48, resource: Resource::Mana, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Holy, magnitude: 78, duration: 2 }, + Ability { + id: 300, + name: "Smite", + desc: "Call down a lance of holy light upon the unworthy.", + class: Class::Cleric, + level_req: 1, + cost: 9, + resource: Resource::Mana, + cooldown_ticks: 1, + effect: AbilityEffect::Strike, + damage_type: DamageType::Holy, + magnitude: 13, + duration: 0, + }, + Ability { + id: 301, + name: "Mend", + desc: "Knit flesh and seal wounds with a whispered prayer.", + class: Class::Cleric, + level_req: 3, + cost: 12, + resource: Resource::Mana, + cooldown_ticks: 2, + effect: AbilityEffect::Heal, + damage_type: DamageType::Holy, + magnitude: 22, + duration: 0, + }, + Ability { + id: 302, + name: "Renewal", + desc: "A blessing that mends a little more with every breath you take.", + class: Class::Cleric, + level_req: 7, + cost: 16, + resource: Resource::Mana, + cooldown_ticks: 4, + effect: AbilityEffect::HealOverTime, + damage_type: DamageType::Holy, + magnitude: 8, + duration: 5, + }, + Ability { + id: 303, + name: "Sacred Ward", + desc: "Surround yourself in a corona of divine protection.", + class: Class::Cleric, + level_req: 11, + cost: 18, + resource: Resource::Mana, + cooldown_ticks: 6, + effect: AbilityEffect::Ward, + damage_type: DamageType::Holy, + magnitude: 32, + duration: 3, + }, + Ability { + id: 304, + name: "Holy Fire", + desc: "Sear the wicked with flame that judges as it burns.", + class: Class::Cleric, + level_req: 15, + cost: 20, + resource: Resource::Mana, + cooldown_ticks: 3, + effect: AbilityEffect::DamageOverTime, + damage_type: DamageType::Holy, + magnitude: 10, + duration: 4, + }, + Ability { + id: 305, + name: "Blessing of Might", + desc: "Anoint yourself so each strike falls with righteous force.", + class: Class::Cleric, + level_req: 19, + cost: 22, + resource: Resource::Mana, + cooldown_ticks: 8, + effect: AbilityEffect::Empower, + damage_type: DamageType::Holy, + magnitude: 11, + duration: 4, + }, + Ability { + id: 306, + name: "Greater Heal", + desc: "A surge of restoring grace that mends even grievous harm.", + class: Class::Cleric, + level_req: 24, + cost: 26, + resource: Resource::Mana, + cooldown_ticks: 3, + effect: AbilityEffect::Heal, + damage_type: DamageType::Holy, + magnitude: 50, + duration: 0, + }, + Ability { + id: 307, + name: "Hammer of Faith", + desc: "A spectral warhammer crashes down with crushing zeal.", + class: Class::Cleric, + level_req: 29, + cost: 28, + resource: Resource::Mana, + cooldown_ticks: 3, + effect: AbilityEffect::Strike, + damage_type: DamageType::Holy, + magnitude: 32, + duration: 0, + }, + Ability { + id: 308, + name: "Sanctuary", + desc: "Raise hallowed ground that turns aside the cruelest wounds.", + class: Class::Cleric, + level_req: 35, + cost: 32, + resource: Resource::Mana, + cooldown_ticks: 9, + effect: AbilityEffect::Ward, + damage_type: DamageType::Holy, + magnitude: 65, + duration: 4, + }, + Ability { + id: 309, + name: "Divine Radiance", + desc: "Blaze with the light of the Dawn until evil cannot bear to strike.", + class: Class::Cleric, + level_req: 41, + cost: 36, + resource: Resource::Mana, + cooldown_ticks: 7, + effect: AbilityEffect::Stun, + damage_type: DamageType::Holy, + magnitude: 18, + duration: 2, + }, + Ability { + id: 310, + name: "Judgment", + desc: "Pronounce the final verdict of heaven upon a doomed soul.", + class: Class::Cleric, + level_req: 50, + cost: 48, + resource: Resource::Mana, + cooldown_ticks: 8, + effect: AbilityEffect::Finisher, + damage_type: DamageType::Holy, + magnitude: 78, + duration: 2, + }, // ---- Rogue (Energy) ------------------------------------------------- - Ability { id: 400, name: "Backstab", desc: "Slip a blade between the ribs where it does the most harm.", class: Class::Rogue, level_req: 1, cost: 8, resource: Resource::Energy, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 18, duration: 0 }, - Ability { id: 401, name: "Envenom", desc: "Coat your blade so each cut festers with creeping poison.", class: Class::Rogue, level_req: 4, cost: 10, resource: Resource::Energy, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Poison, magnitude: 7, duration: 5 }, - Ability { id: 402, name: "Blind", desc: "Fling grit and powder to leave your foe swinging at shadows.", class: Class::Rogue, level_req: 8, cost: 12, resource: Resource::Energy, cooldown_ticks: 5, effect: AbilityEffect::Stun, damage_type: DamageType::Physical, magnitude: 8, duration: 1 }, - Ability { id: 403, name: "Evasion", desc: "Move like smoke, slipping every blow aimed your way.", class: Class::Rogue, level_req: 12, cost: 14, resource: Resource::Energy, cooldown_ticks: 7, effect: AbilityEffect::Ward, damage_type: DamageType::Physical, magnitude: 28, duration: 3 }, - Ability { id: 404, name: "Cold Blood", desc: "Steady your hand and your heart for one perfect, lethal cut.", class: Class::Rogue, level_req: 16, cost: 16, resource: Resource::Energy, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 14, duration: 3 }, - Ability { id: 405, name: "Eviscerate", desc: "A flurry of blades that opens the enemy from hip to throat.", class: Class::Rogue, level_req: 21, cost: 20, resource: Resource::Energy, cooldown_ticks: 2, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 32, duration: 0 }, - Ability { id: 406, name: "Crippling Toxin", desc: "A paralytic venom that seizes the muscles and stops the breath.", class: Class::Rogue, level_req: 26, cost: 22, resource: Resource::Energy, cooldown_ticks: 6, effect: AbilityEffect::Stun, damage_type: DamageType::Poison, magnitude: 12, duration: 2 }, - Ability { id: 407, name: "Hemorrhage", desc: "Strike a vein that will not close, bleeding the foe dry.", class: Class::Rogue, level_req: 31, cost: 24, resource: Resource::Energy, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Physical, magnitude: 14, duration: 5 }, - Ability { id: 408, name: "Shadowstep", desc: "Vanish into shadow and return where no blade can find you.", class: Class::Rogue, level_req: 37, cost: 28, resource: Resource::Energy, cooldown_ticks: 9, effect: AbilityEffect::Ward, damage_type: DamageType::Shadow, magnitude: 60, duration: 4 }, - Ability { id: 409, name: "Killing Spree", desc: "Become a whirlwind of steel, every cut emptier and crueler.", class: Class::Rogue, level_req: 43, cost: 32, resource: Resource::Energy, cooldown_ticks: 10, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 26, duration: 5 }, - Ability { id: 410, name: "Assassinate", desc: "The single strike every rogue trains a lifetime to land.", class: Class::Rogue, level_req: 50, cost: 42, resource: Resource::Energy, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Physical, magnitude: 88, duration: 2 }, + Ability { + id: 400, + name: "Backstab", + desc: "Slip a blade between the ribs where it does the most harm.", + class: Class::Rogue, + level_req: 1, + cost: 8, + resource: Resource::Energy, + cooldown_ticks: 1, + effect: AbilityEffect::Strike, + damage_type: DamageType::Physical, + magnitude: 18, + duration: 0, + }, + Ability { + id: 401, + name: "Envenom", + desc: "Coat your blade so each cut festers with creeping poison.", + class: Class::Rogue, + level_req: 4, + cost: 10, + resource: Resource::Energy, + cooldown_ticks: 2, + effect: AbilityEffect::DamageOverTime, + damage_type: DamageType::Poison, + magnitude: 7, + duration: 5, + }, + Ability { + id: 402, + name: "Blind", + desc: "Fling grit and powder to leave your foe swinging at shadows.", + class: Class::Rogue, + level_req: 8, + cost: 12, + resource: Resource::Energy, + cooldown_ticks: 5, + effect: AbilityEffect::Stun, + damage_type: DamageType::Physical, + magnitude: 8, + duration: 1, + }, + Ability { + id: 403, + name: "Evasion", + desc: "Move like smoke, slipping every blow aimed your way.", + class: Class::Rogue, + level_req: 12, + cost: 14, + resource: Resource::Energy, + cooldown_ticks: 7, + effect: AbilityEffect::Ward, + damage_type: DamageType::Physical, + magnitude: 28, + duration: 3, + }, + Ability { + id: 404, + name: "Cold Blood", + desc: "Steady your hand and your heart for one perfect, lethal cut.", + class: Class::Rogue, + level_req: 16, + cost: 16, + resource: Resource::Energy, + cooldown_ticks: 8, + effect: AbilityEffect::Empower, + damage_type: DamageType::Physical, + magnitude: 14, + duration: 3, + }, + Ability { + id: 405, + name: "Eviscerate", + desc: "A flurry of blades that opens the enemy from hip to throat.", + class: Class::Rogue, + level_req: 21, + cost: 20, + resource: Resource::Energy, + cooldown_ticks: 2, + effect: AbilityEffect::Strike, + damage_type: DamageType::Physical, + magnitude: 32, + duration: 0, + }, + Ability { + id: 406, + name: "Crippling Toxin", + desc: "A paralytic venom that seizes the muscles and stops the breath.", + class: Class::Rogue, + level_req: 26, + cost: 22, + resource: Resource::Energy, + cooldown_ticks: 6, + effect: AbilityEffect::Stun, + damage_type: DamageType::Poison, + magnitude: 12, + duration: 2, + }, + Ability { + id: 407, + name: "Hemorrhage", + desc: "Strike a vein that will not close, bleeding the foe dry.", + class: Class::Rogue, + level_req: 31, + cost: 24, + resource: Resource::Energy, + cooldown_ticks: 4, + effect: AbilityEffect::DamageOverTime, + damage_type: DamageType::Physical, + magnitude: 14, + duration: 5, + }, + Ability { + id: 408, + name: "Shadowstep", + desc: "Vanish into shadow and return where no blade can find you.", + class: Class::Rogue, + level_req: 37, + cost: 28, + resource: Resource::Energy, + cooldown_ticks: 9, + effect: AbilityEffect::Ward, + damage_type: DamageType::Shadow, + magnitude: 60, + duration: 4, + }, + Ability { + id: 409, + name: "Killing Spree", + desc: "Become a whirlwind of steel, every cut emptier and crueler.", + class: Class::Rogue, + level_req: 43, + cost: 32, + resource: Resource::Energy, + cooldown_ticks: 10, + effect: AbilityEffect::Empower, + damage_type: DamageType::Physical, + magnitude: 26, + duration: 5, + }, + Ability { + id: 410, + name: "Assassinate", + desc: "The single strike every rogue trains a lifetime to land.", + class: Class::Rogue, + level_req: 50, + cost: 42, + resource: Resource::Energy, + cooldown_ticks: 8, + effect: AbilityEffect::Finisher, + damage_type: DamageType::Physical, + magnitude: 88, + duration: 2, + }, // ---- Ranger (Focus) ------------------------------------------------- - Ability { id: 500, name: "Aimed Shot", desc: "Draw, breathe, and loose an arrow exactly where it will hurt.", class: Class::Ranger, level_req: 1, cost: 8, resource: Resource::Focus, cooldown_ticks: 1, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 15, duration: 0 }, - Ability { id: 501, name: "Serpent Sting", desc: "An arrow tipped in adder-venom that sickens with every beat.", class: Class::Ranger, level_req: 4, cost: 11, resource: Resource::Focus, cooldown_ticks: 2, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Poison, magnitude: 7, duration: 5 }, - Ability { id: 502, name: "Snare Trap", desc: "Set a hidden snare that seizes the foe fast in its teeth.", class: Class::Ranger, level_req: 8, cost: 13, resource: Resource::Focus, cooldown_ticks: 5, effect: AbilityEffect::Stun, damage_type: DamageType::Physical, magnitude: 9, duration: 1 }, - Ability { id: 503, name: "Mark of the Hunt", desc: "Read your quarry's weakness and let every shot find it.", class: Class::Ranger, level_req: 12, cost: 15, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 10, duration: 4 }, - Ability { id: 504, name: "Mend Companion", desc: "Tend your own hurts with the field-craft of a thousand camps.", class: Class::Ranger, level_req: 16, cost: 17, resource: Resource::Focus, cooldown_ticks: 4, effect: AbilityEffect::HealOverTime, damage_type: DamageType::Physical, magnitude: 9, duration: 5 }, - Ability { id: 505, name: "Piercing Arrow", desc: "A shot loosed with such force it drives clean through plate.", class: Class::Ranger, level_req: 21, cost: 20, resource: Resource::Focus, cooldown_ticks: 2, effect: AbilityEffect::Strike, damage_type: DamageType::Physical, magnitude: 33, duration: 0 }, - Ability { id: 506, name: "Thornwall", desc: "Raise a bramble of arrows at your feet to fend off the charge.", class: Class::Ranger, level_req: 26, cost: 22, resource: Resource::Focus, cooldown_ticks: 7, effect: AbilityEffect::Ward, damage_type: DamageType::Physical, magnitude: 40, duration: 4 }, - Ability { id: 507, name: "Volley", desc: "Rain a quiver's worth of shafts down on the staggering foe.", class: Class::Ranger, level_req: 31, cost: 26, resource: Resource::Focus, cooldown_ticks: 4, effect: AbilityEffect::DamageOverTime, damage_type: DamageType::Physical, magnitude: 14, duration: 5 }, - Ability { id: 508, name: "Concussive Shot", desc: "An arrow to the temple that leaves the enemy reeling and blind.", class: Class::Ranger, level_req: 37, cost: 30, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Stun, damage_type: DamageType::Physical, magnitude: 16, duration: 2 }, - Ability { id: 509, name: "Trueshot Aura", desc: "Enter the hunter's stillness where no arrow is ever wasted.", class: Class::Ranger, level_req: 43, cost: 34, resource: Resource::Focus, cooldown_ticks: 10, effect: AbilityEffect::Empower, damage_type: DamageType::Physical, magnitude: 25, duration: 5 }, - Ability { id: 510, name: "Hail of Death", desc: "Black out the sky with arrows and let it fall like judgment.", class: Class::Ranger, level_req: 50, cost: 44, resource: Resource::Focus, cooldown_ticks: 8, effect: AbilityEffect::Finisher, damage_type: DamageType::Physical, magnitude: 84, duration: 2 }, + Ability { + id: 500, + name: "Aimed Shot", + desc: "Draw, breathe, and loose an arrow exactly where it will hurt.", + class: Class::Ranger, + level_req: 1, + cost: 8, + resource: Resource::Focus, + cooldown_ticks: 1, + effect: AbilityEffect::Strike, + damage_type: DamageType::Physical, + magnitude: 15, + duration: 0, + }, + Ability { + id: 501, + name: "Serpent Sting", + desc: "An arrow tipped in adder-venom that sickens with every beat.", + class: Class::Ranger, + level_req: 4, + cost: 11, + resource: Resource::Focus, + cooldown_ticks: 2, + effect: AbilityEffect::DamageOverTime, + damage_type: DamageType::Poison, + magnitude: 7, + duration: 5, + }, + Ability { + id: 502, + name: "Snare Trap", + desc: "Set a hidden snare that seizes the foe fast in its teeth.", + class: Class::Ranger, + level_req: 8, + cost: 13, + resource: Resource::Focus, + cooldown_ticks: 5, + effect: AbilityEffect::Stun, + damage_type: DamageType::Physical, + magnitude: 9, + duration: 1, + }, + Ability { + id: 503, + name: "Mark of the Hunt", + desc: "Read your quarry's weakness and let every shot find it.", + class: Class::Ranger, + level_req: 12, + cost: 15, + resource: Resource::Focus, + cooldown_ticks: 8, + effect: AbilityEffect::Empower, + damage_type: DamageType::Physical, + magnitude: 10, + duration: 4, + }, + Ability { + id: 504, + name: "Mend Companion", + desc: "Tend your own hurts with the field-craft of a thousand camps.", + class: Class::Ranger, + level_req: 16, + cost: 17, + resource: Resource::Focus, + cooldown_ticks: 4, + effect: AbilityEffect::HealOverTime, + damage_type: DamageType::Physical, + magnitude: 9, + duration: 5, + }, + Ability { + id: 505, + name: "Piercing Arrow", + desc: "A shot loosed with such force it drives clean through plate.", + class: Class::Ranger, + level_req: 21, + cost: 20, + resource: Resource::Focus, + cooldown_ticks: 2, + effect: AbilityEffect::Strike, + damage_type: DamageType::Physical, + magnitude: 33, + duration: 0, + }, + Ability { + id: 506, + name: "Thornwall", + desc: "Raise a bramble of arrows at your feet to fend off the charge.", + class: Class::Ranger, + level_req: 26, + cost: 22, + resource: Resource::Focus, + cooldown_ticks: 7, + effect: AbilityEffect::Ward, + damage_type: DamageType::Physical, + magnitude: 40, + duration: 4, + }, + Ability { + id: 507, + name: "Volley", + desc: "Rain a quiver's worth of shafts down on the staggering foe.", + class: Class::Ranger, + level_req: 31, + cost: 26, + resource: Resource::Focus, + cooldown_ticks: 4, + effect: AbilityEffect::DamageOverTime, + damage_type: DamageType::Physical, + magnitude: 14, + duration: 5, + }, + Ability { + id: 508, + name: "Concussive Shot", + desc: "An arrow to the temple that leaves the enemy reeling and blind.", + class: Class::Ranger, + level_req: 37, + cost: 30, + resource: Resource::Focus, + cooldown_ticks: 8, + effect: AbilityEffect::Stun, + damage_type: DamageType::Physical, + magnitude: 16, + duration: 2, + }, + Ability { + id: 509, + name: "Trueshot Aura", + desc: "Enter the hunter's stillness where no arrow is ever wasted.", + class: Class::Ranger, + level_req: 43, + cost: 34, + resource: Resource::Focus, + cooldown_ticks: 10, + effect: AbilityEffect::Empower, + damage_type: DamageType::Physical, + magnitude: 25, + duration: 5, + }, + Ability { + id: 510, + name: "Hail of Death", + desc: "Black out the sky with arrows and let it fall like judgment.", + class: Class::Ranger, + level_req: 50, + cost: 44, + resource: Resource::Focus, + cooldown_ticks: 8, + effect: AbilityEffect::Finisher, + damage_type: DamageType::Physical, + magnitude: 84, + duration: 2, + }, ]; /// Abilities a character of this class has unlocked at this level, lowest level first. @@ -160,11 +875,7 @@ mod tests { fn every_class_has_a_level_one_ability() { for class in Class::ALL { let early = unlocked_for(class, 1); - assert!( - !early.is_empty(), - "{:?} has no level-1 ability", - class - ); + assert!(!early.is_empty(), "{:?} has no level-1 ability", class); } } diff --git a/late-ssh/src/app/rooms/mud/classes.rs b/late-ssh/src/app/rooms/mud/classes.rs index 57929ff8..3b3542ad 100644 --- a/late-ssh/src/app/rooms/mud/classes.rs +++ b/late-ssh/src/app/rooms/mud/classes.rs @@ -91,36 +91,46 @@ impl Class { /// The flavorful long description shown when choosing or inspecting a class. pub fn description(self) -> &'static str { match self { - Self::Warrior => "Where the line breaks, the Warrior stands. Clad in iron and \ + Self::Warrior => { + "Where the line breaks, the Warrior stands. Clad in iron and \ certainty, they read a battle in the rhythm of falling blows and answer it \ with their own. Rage is their fuel: it does not pool while they rest but \ kindles in the fight itself, every wound taken and given stoking it higher \ until they end the matter with a single, ruinous stroke. Warriors do not \ - dazzle. They endure, and what they endure, they outlive.", - Self::Mage => "The Mage holds the oldest and most dangerous bargain: power \ + dazzle. They endure, and what they endure, they outlive." + } + Self::Mage => { + "The Mage holds the oldest and most dangerous bargain: power \ without armor, knowledge without mercy. They unmake the world in syllables, \ calling fire that clings, frost that locks the joints, and lightning that \ forgets nothing it touches. Mana is their well, deep but not bottomless, and \ a Mage caught between spells is a candle in a gale. Strike first, strike \ - hardest, and never let the enemy close the distance.", - Self::Cleric => "The Cleric carries the Dawn into dark places. Theirs is the \ + hardest, and never let the enemy close the distance." + } + Self::Cleric => { + "The Cleric carries the Dawn into dark places. Theirs is the \ hardest road: to mend and to smite with the same hand, to stand in the ruin \ and refuse to let a companion fall. Holy fire answers the wicked and \ searing light judges the undead, while a whispered prayer knits torn flesh \ whole. A party with a Cleric is a party that comes home; a Cleric alone is \ - a quiet, patient kind of unkillable.", - Self::Rogue => "The Rogue settles fights before they are fairly begun. They \ + a quiet, patient kind of unkillable." + } + Self::Rogue => { + "The Rogue settles fights before they are fairly begun. They \ trade plate for shadow and brawn for precision, finding the gap in the \ guard, the vein that will not close, the breath of inattention that ends a \ life. Energy floods back swiftly, rewarding the quick and the cruel with \ flurry after flurry. A Rogue who is seen has already made a mistake; a Rogue \ - who is not will open you from hip to throat and be gone.", - Self::Ranger => "The Ranger belongs to the long marches and the patient kill. \ + who is not will open you from hip to throat and be gone." + } + Self::Ranger => { + "The Ranger belongs to the long marches and the patient kill. \ Bow in hand and the wilds at their back, they wear the enemy down from a \ distance no blade can answer, layering venom and volley and the cold \ wisdom of a hundred camps. Focus is their discipline, spent on shots that \ never waste and traps that never miss. Give a Ranger room and time, and the \ - fight is already lost - the quarry simply has not been told yet.", + fight is already lost - the quarry simply has not been told yet." + } } } @@ -137,7 +147,9 @@ impl Class { pub fn trait_desc(self) -> &'static str { match self { - Self::Warrior => "The first killing blow each fight is survived at 1 HP instead of falling.", + Self::Warrior => { + "The first killing blow each fight is survived at 1 HP instead of falling." + } Self::Mage => "Every offensive spell strikes for extra arcane damage.", Self::Cleric => "All healing is amplified, and the undead take added holy damage.", Self::Rogue => "The opening strike of a fight always lands as a critical hit.", @@ -149,7 +161,7 @@ impl Class { /// classes climbing meaningfully to level 50. pub fn stats_at(self, level: i32) -> ClassStats { let lvl = level.clamp(1, Self::MAX_LEVEL); - let l = (lvl - 1) as i32; // levels gained past 1 + let l = lvl - 1; // levels gained past 1 match self { Self::Warrior => ClassStats { max_hp: 48 + l * 12, diff --git a/late-ssh/src/app/rooms/mud/input.rs b/late-ssh/src/app/rooms/mud/input.rs index 9ef5273d..8b19202a 100644 --- a/late-ssh/src/app/rooms/mud/input.rs +++ b/late-ssh/src/app/rooms/mud/input.rs @@ -58,7 +58,7 @@ pub fn handle_key(state: &mut State, byte: u8) -> InputAction { select_row(state, n); state.activate_selection(); } else { - state.use_ability((byte - b'0') as u8); + state.use_ability(byte - b'0'); } return InputAction::Handled; } @@ -152,7 +152,8 @@ pub fn handle_key(state: &mut State, byte: u8) -> InputAction { b'x' | b'X' => { if in_list { state.sell_selection(); - } else if panel == Panel::Room || panel == Panel::Character || panel == Panel::Abilities { + } else if panel == Panel::Room || panel == Panel::Character || panel == Panel::Abilities + { state.attack(); } InputAction::Handled @@ -214,7 +215,12 @@ mod tests { #[test] fn diagonal_keys_are_distinct_directions() { // y/u/n/m map to the four diagonals; ensure no overlap with cardinals. - let diag = [Dir::Northwest, Dir::Northeast, Dir::Southeast, Dir::Southwest]; + let diag = [ + Dir::Northwest, + Dir::Northeast, + Dir::Southeast, + Dir::Southwest, + ]; for (i, a) in diag.iter().enumerate() { for b in diag.iter().skip(i + 1) { assert_ne!(a, b, "diagonals must be distinct"); diff --git a/late-ssh/src/app/rooms/mud/items.rs b/late-ssh/src/app/rooms/mud/items.rs index 1a5304f3..2e728997 100644 --- a/late-ssh/src/app/rooms/mud/items.rs +++ b/late-ssh/src/app/rooms/mud/items.rs @@ -114,6 +114,7 @@ impl Item { } } +#[allow(clippy::too_many_arguments)] const fn eq( id: u32, name: &'static str, @@ -170,43 +171,395 @@ const fn consumable( /// The full item catalog. pub const ITEMS: &[Item] = &[ // ---- Weapons (the Smithy) ------------------------------------------- - eq(1000, "Rusty Shortsword", "A pitted blade, but it holds an edge.", Slot::Weapon, Rarity::Common, 4, 0, 0, 25, None), - eq(1001, "Iron Longsword", "Honest steel, balanced and keen.", Slot::Weapon, Rarity::Common, 8, 0, 0, 80, Some(Class::Warrior)), - eq(1002, "Oak Hunting Bow", "A supple bow strung with waxed gut.", Slot::Weapon, Rarity::Common, 8, 0, 0, 80, Some(Class::Ranger)), - eq(1003, "Apprentice Staff", "Carved with channels for raw mana.", Slot::Weapon, Rarity::Common, 7, 0, 0, 75, Some(Class::Mage)), - eq(1004, "Twin Daggers", "A matched pair, light and wickedly quick.", Slot::Weapon, Rarity::Uncommon, 9, 0, 0, 110, Some(Class::Rogue)), - eq(1005, "Blessed Mace", "Its head is graven with the rising sun.", Slot::Weapon, Rarity::Uncommon, 8, 6, 0, 120, Some(Class::Cleric)), - eq(1006, "Steel Greatsword", "A two-handed brute that bites through mail.", Slot::Weapon, Rarity::Rare, 16, 0, 0, 320, Some(Class::Warrior)), - eq(1007, "Yew Warbow", "Tall as a man and twice as unforgiving.", Slot::Weapon, Rarity::Rare, 15, 0, 0, 300, Some(Class::Ranger)), - eq(1008, "Runed Battlestaff", "Old runes wake and glow when you hold it.", Slot::Weapon, Rarity::Rare, 15, 0, 0, 300, Some(Class::Mage)), - eq(1009, "Embergate Falchion", "Forged in the town's own furnace; ever warm.", Slot::Weapon, Rarity::Epic, 24, 8, 0, 900, None), + eq( + 1000, + "Rusty Shortsword", + "A pitted blade, but it holds an edge.", + Slot::Weapon, + Rarity::Common, + 4, + 0, + 0, + 25, + None, + ), + eq( + 1001, + "Iron Longsword", + "Honest steel, balanced and keen.", + Slot::Weapon, + Rarity::Common, + 8, + 0, + 0, + 80, + Some(Class::Warrior), + ), + eq( + 1002, + "Oak Hunting Bow", + "A supple bow strung with waxed gut.", + Slot::Weapon, + Rarity::Common, + 8, + 0, + 0, + 80, + Some(Class::Ranger), + ), + eq( + 1003, + "Apprentice Staff", + "Carved with channels for raw mana.", + Slot::Weapon, + Rarity::Common, + 7, + 0, + 0, + 75, + Some(Class::Mage), + ), + eq( + 1004, + "Twin Daggers", + "A matched pair, light and wickedly quick.", + Slot::Weapon, + Rarity::Uncommon, + 9, + 0, + 0, + 110, + Some(Class::Rogue), + ), + eq( + 1005, + "Blessed Mace", + "Its head is graven with the rising sun.", + Slot::Weapon, + Rarity::Uncommon, + 8, + 6, + 0, + 120, + Some(Class::Cleric), + ), + eq( + 1006, + "Steel Greatsword", + "A two-handed brute that bites through mail.", + Slot::Weapon, + Rarity::Rare, + 16, + 0, + 0, + 320, + Some(Class::Warrior), + ), + eq( + 1007, + "Yew Warbow", + "Tall as a man and twice as unforgiving.", + Slot::Weapon, + Rarity::Rare, + 15, + 0, + 0, + 300, + Some(Class::Ranger), + ), + eq( + 1008, + "Runed Battlestaff", + "Old runes wake and glow when you hold it.", + Slot::Weapon, + Rarity::Rare, + 15, + 0, + 0, + 300, + Some(Class::Mage), + ), + eq( + 1009, + "Embergate Falchion", + "Forged in the town's own furnace; ever warm.", + Slot::Weapon, + Rarity::Epic, + 24, + 8, + 0, + 900, + None, + ), // ---- Armor (the Outfitter) ------------------------------------------ - eq(1100, "Padded Cap", "Quilted cloth, better than a bare head.", Slot::Head, Rarity::Common, 0, 6, 1, 20, None), - eq(1101, "Leather Jerkin", "Boiled hide, scarred from a previous owner.", Slot::Chest, Rarity::Common, 0, 12, 2, 45, None), - eq(1102, "Leather Leggings", "Supple and quiet on the road.", Slot::Legs, Rarity::Common, 0, 9, 2, 40, None), - eq(1103, "Worn Gloves", "The fingers are reinforced with hide.", Slot::Hands, Rarity::Common, 0, 4, 1, 18, None), - eq(1104, "Traveler's Boots", "Broken in across a hundred leagues.", Slot::Feet, Rarity::Common, 0, 5, 1, 22, None), - eq(1105, "Iron Helm", "A plain bucket of a helm, but it works.", Slot::Head, Rarity::Uncommon, 0, 14, 3, 90, Some(Class::Warrior)), - eq(1106, "Chainmail Hauberk", "Riveted links that turn a blade.", Slot::Chest, Rarity::Uncommon, 0, 26, 5, 180, Some(Class::Warrior)), - eq(1107, "Mage's Robe", "Woven with silver thread that hums faintly.", Slot::Chest, Rarity::Uncommon, 4, 16, 1, 170, Some(Class::Mage)), - eq(1108, "Shadowweave Vest", "Drinks the light; you are hard to look at.", Slot::Chest, Rarity::Rare, 6, 22, 3, 340, Some(Class::Rogue)), - eq(1109, "Dawnplate Cuirass", "Holy steel that gleams even in the dark.", Slot::Chest, Rarity::Epic, 4, 40, 8, 880, Some(Class::Cleric)), + eq( + 1100, + "Padded Cap", + "Quilted cloth, better than a bare head.", + Slot::Head, + Rarity::Common, + 0, + 6, + 1, + 20, + None, + ), + eq( + 1101, + "Leather Jerkin", + "Boiled hide, scarred from a previous owner.", + Slot::Chest, + Rarity::Common, + 0, + 12, + 2, + 45, + None, + ), + eq( + 1102, + "Leather Leggings", + "Supple and quiet on the road.", + Slot::Legs, + Rarity::Common, + 0, + 9, + 2, + 40, + None, + ), + eq( + 1103, + "Worn Gloves", + "The fingers are reinforced with hide.", + Slot::Hands, + Rarity::Common, + 0, + 4, + 1, + 18, + None, + ), + eq( + 1104, + "Traveler's Boots", + "Broken in across a hundred leagues.", + Slot::Feet, + Rarity::Common, + 0, + 5, + 1, + 22, + None, + ), + eq( + 1105, + "Iron Helm", + "A plain bucket of a helm, but it works.", + Slot::Head, + Rarity::Uncommon, + 0, + 14, + 3, + 90, + Some(Class::Warrior), + ), + eq( + 1106, + "Chainmail Hauberk", + "Riveted links that turn a blade.", + Slot::Chest, + Rarity::Uncommon, + 0, + 26, + 5, + 180, + Some(Class::Warrior), + ), + eq( + 1107, + "Mage's Robe", + "Woven with silver thread that hums faintly.", + Slot::Chest, + Rarity::Uncommon, + 4, + 16, + 1, + 170, + Some(Class::Mage), + ), + eq( + 1108, + "Shadowweave Vest", + "Drinks the light; you are hard to look at.", + Slot::Chest, + Rarity::Rare, + 6, + 22, + 3, + 340, + Some(Class::Rogue), + ), + eq( + 1109, + "Dawnplate Cuirass", + "Holy steel that gleams even in the dark.", + Slot::Chest, + Rarity::Epic, + 4, + 40, + 8, + 880, + Some(Class::Cleric), + ), // ---- Trinkets and rings (the Curio Cart) ---------------------------- - eq(1200, "Copper Band", "A simple ring, faintly lucky.", Slot::Ring, Rarity::Common, 1, 4, 0, 30, None), - eq(1201, "Garnet Ring", "The stone catches firelight and holds it.", Slot::Ring, Rarity::Uncommon, 3, 8, 0, 130, None), - eq(1202, "Signet of Embergate", "Marks the bearer as a friend of the town.", Slot::Ring, Rarity::Rare, 5, 14, 2, 360, None), - eq(1203, "Hare's-Foot Charm", "For luck, and the speed to use it.", Slot::Trinket, Rarity::Common, 2, 3, 0, 35, None), - eq(1204, "Vial of Saint's Tears", "Warm to the touch; it wards off despair.", Slot::Trinket, Rarity::Uncommon, 0, 18, 2, 150, None), - eq(1205, "Wyrmscale Talisman", "A single frost-dragon scale, cold forever.", Slot::Trinket, Rarity::Epic, 8, 20, 4, 820, None), + eq( + 1200, + "Copper Band", + "A simple ring, faintly lucky.", + Slot::Ring, + Rarity::Common, + 1, + 4, + 0, + 30, + None, + ), + eq( + 1201, + "Garnet Ring", + "The stone catches firelight and holds it.", + Slot::Ring, + Rarity::Uncommon, + 3, + 8, + 0, + 130, + None, + ), + eq( + 1202, + "Signet of Embergate", + "Marks the bearer as a friend of the town.", + Slot::Ring, + Rarity::Rare, + 5, + 14, + 2, + 360, + None, + ), + eq( + 1203, + "Hare's-Foot Charm", + "For luck, and the speed to use it.", + Slot::Trinket, + Rarity::Common, + 2, + 3, + 0, + 35, + None, + ), + eq( + 1204, + "Vial of Saint's Tears", + "Warm to the touch; it wards off despair.", + Slot::Trinket, + Rarity::Uncommon, + 0, + 18, + 2, + 150, + None, + ), + eq( + 1205, + "Wyrmscale Talisman", + "A single frost-dragon scale, cold forever.", + Slot::Trinket, + Rarity::Epic, + 8, + 20, + 4, + 820, + None, + ), // ---- Consumables (the Apothecary) ----------------------------------- - consumable(1300, "Minor Healing Draught", "A bitter red tonic that closes small wounds.", Rarity::Common, 30, 0, 20), - consumable(1301, "Healing Potion", "The reliable choice of every sensible adventurer.", Rarity::Uncommon, 70, 0, 55), - consumable(1302, "Greater Healing Elixir", "Mends even grievous hurts in moments.", Rarity::Rare, 150, 0, 140), - consumable(1303, "Draught of Vigor", "Restores the fire that fuels your craft.", Rarity::Uncommon, 0, 60, 50), - consumable(1304, "Elixir of Renewal", "Restores both flesh and will at once.", Rarity::Epic, 120, 80, 220), + consumable( + 1300, + "Minor Healing Draught", + "A bitter red tonic that closes small wounds.", + Rarity::Common, + 30, + 0, + 20, + ), + consumable( + 1301, + "Healing Potion", + "The reliable choice of every sensible adventurer.", + Rarity::Uncommon, + 70, + 0, + 55, + ), + consumable( + 1302, + "Greater Healing Elixir", + "Mends even grievous hurts in moments.", + Rarity::Rare, + 150, + 0, + 140, + ), + consumable( + 1303, + "Draught of Vigor", + "Restores the fire that fuels your craft.", + Rarity::Uncommon, + 0, + 60, + 50, + ), + consumable( + 1304, + "Elixir of Renewal", + "Restores both flesh and will at once.", + Rarity::Epic, + 120, + 80, + 220, + ), // ---- Valuables (sold to any merchant) ------------------------------- - Item { id: 1400, name: "Gold Ingot", desc: "A solid bar, good anywhere coin is taken.", kind: ItemKind::Valuable, rarity: Rarity::Uncommon, mods: StatMods { attack: 0, max_hp: 0, armor: 0 }, price: 200, class_hint: None }, - Item { id: 1401, name: "Cut Ruby", desc: "A merchant's eyes will light at the sight of it.", kind: ItemKind::Valuable, rarity: Rarity::Rare, mods: StatMods { attack: 0, max_hp: 0, armor: 0 }, price: 500, class_hint: None }, + Item { + id: 1400, + name: "Gold Ingot", + desc: "A solid bar, good anywhere coin is taken.", + kind: ItemKind::Valuable, + rarity: Rarity::Uncommon, + mods: StatMods { + attack: 0, + max_hp: 0, + armor: 0, + }, + price: 200, + class_hint: None, + }, + Item { + id: 1401, + name: "Cut Ruby", + desc: "A merchant's eyes will light at the sight of it.", + kind: ItemKind::Valuable, + rarity: Rarity::Rare, + mods: StatMods { + attack: 0, + max_hp: 0, + armor: 0, + }, + price: 500, + class_hint: None, + }, ]; pub fn item(id: u32) -> Option<&'static Item> { diff --git a/late-ssh/src/app/rooms/mud/manager.rs b/late-ssh/src/app/rooms/mud/manager.rs index 99b47f8d..8b881c74 100644 --- a/late-ssh/src/app/rooms/mud/manager.rs +++ b/late-ssh/src/app/rooms/mud/manager.rs @@ -118,11 +118,7 @@ impl RoomGameManager for MudTableManager { } fn seat_join_ascii(&self) -> &'static [&'static str] { - &[ - r" /\ ", - r" |==| ", - r" | | ", - ] + &[r" /\ ", r" |==| ", r" | | "] } fn enter( diff --git a/late-ssh/src/app/rooms/mud/persist.rs b/late-ssh/src/app/rooms/mud/persist.rs index 7c393b80..fc4cff72 100644 --- a/late-ssh/src/app/rooms/mud/persist.rs +++ b/late-ssh/src/app/rooms/mud/persist.rs @@ -16,6 +16,17 @@ use super::world::RoomId; const SCHEMA_VERSION: u32 = 1; +pub struct SavedCharacterInit { + pub class: Option, + pub xp: i64, + pub level: i32, + pub gold: i64, + pub hp: i32, + pub room: RoomId, + pub inventory: Vec, + pub equipped: Vec<(String, u32)>, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SavedCharacter { #[serde(default)] @@ -51,26 +62,17 @@ fn start_room() -> RoomId { } impl SavedCharacter { - pub fn new_for( - class: Option, - xp: i64, - level: i32, - gold: i64, - hp: i32, - room: RoomId, - inventory: Vec, - equipped: Vec<(String, u32)>, - ) -> Self { + pub fn new_for(init: SavedCharacterInit) -> Self { Self { version: SCHEMA_VERSION, - class: class.map(|c| c.as_key().to_string()), - xp, - level, - gold, - hp, - room, - inventory, - equipped, + class: init.class.map(|c| c.as_key().to_string()), + xp: init.xp, + level: init.level, + gold: init.gold, + hp: init.hp, + room: init.room, + inventory: init.inventory, + equipped: init.equipped, } } @@ -98,16 +100,16 @@ mod tests { #[test] fn round_trips_through_json() { - let c = SavedCharacter::new_for( - Some(Class::Rogue), - 1234, - 7, - 560, - 42, - 18, - vec![1300, 1301], - vec![("weapon".to_string(), 1004)], - ); + let c = SavedCharacter::new_for(SavedCharacterInit { + class: Some(Class::Rogue), + xp: 1234, + level: 7, + gold: 560, + hp: 42, + room: 18, + inventory: vec![1300, 1301], + equipped: vec![("weapon".to_string(), 1004)], + }); let json = c.to_json(); let back = SavedCharacter::from_json(&json).expect("parses"); assert_eq!(back.class(), Some(Class::Rogue)); diff --git a/late-ssh/src/app/rooms/mud/state.rs b/late-ssh/src/app/rooms/mud/state.rs index a0378d54..51cb6237 100644 --- a/late-ssh/src/app/rooms/mud/state.rs +++ b/late-ssh/src/app/rooms/mud/state.rs @@ -167,10 +167,10 @@ impl State { } } Panel::Shop => { - if let Some(shop) = self.view().shop { - if let Some(entry) = shop.entries.get(self.cursor) { - self.svc.buy_task(self.user_id, entry.item_id); - } + if let Some(shop) = self.view().shop + && let Some(entry) = shop.entries.get(self.cursor) + { + self.svc.buy_task(self.user_id, entry.item_id); } } _ => {} diff --git a/late-ssh/src/app/rooms/mud/svc.rs b/late-ssh/src/app/rooms/mud/svc.rs index f6836040..b568bf8e 100644 --- a/late-ssh/src/app/rooms/mud/svc.rs +++ b/late-ssh/src/app/rooms/mud/svc.rs @@ -28,9 +28,9 @@ use crate::app::{ use super::abilities::{Ability, AbilityEffect, learned_at, unlocked_for}; use super::classes::{Class, level_for_xp, xp_for_level}; -use super::damage::{Defense, DamageType}; +use super::damage::{DamageType, Defense}; use super::items::{ItemKind, Slot, item, shop_at}; -use super::persist::SavedCharacter; +use super::persist::{SavedCharacter, SavedCharacterInit}; use super::world::{Dir, MobSpawn, RoomId, World, seed_world}; /// World heartbeat. One combat round resolves per tick. @@ -730,16 +730,16 @@ impl WorldState { .iter() .map(|(slot, id)| (slot.label().to_string(), *id)) .collect(); - Some(SavedCharacter::new_for( - p.class, - p.xp, - p.level, - p.gold, - p.hp.max(1), - p.room, - p.inventory.clone(), + Some(SavedCharacter::new_for(SavedCharacterInit { + class: p.class, + xp: p.xp, + level: p.level, + gold: p.gold, + hp: p.hp.max(1), + room: p.room, + inventory: p.inventory.clone(), equipped, - )) + })) } fn export_all_saved(&self) -> Vec<(Uuid, SavedCharacter)> { @@ -833,7 +833,10 @@ impl WorldState { self.log_to( user_id, LogKind::Loot, - format!("{} tends {} here. Press b to browse.", shop.npc_name, shop.shop_name), + format!( + "{} tends {} here. Press b to browse.", + shop.npc_name, shop.shop_name + ), ); } for mob in mob_names { @@ -877,7 +880,11 @@ impl WorldState { // Opportunist: the Rogue's first strike of a fight always crits. player.opening_strike = player.class == Some(Class::Rogue); } - self.log_to(user_id, LogKind::Combat, format!("You close with {mob_name}!")); + self.log_to( + user_id, + LogKind::Combat, + format!("You close with {mob_name}!"), + ); } None => { self.log_to( @@ -902,25 +909,32 @@ impl WorldState { } let known = unlocked_for(class, player.level); let Some(ability) = known.get(slot.saturating_sub(1) as usize).copied() else { - self.log_to(user_id, LogKind::System, "No ability in that slot.".to_string()); + self.log_to( + user_id, + LogKind::System, + "No ability in that slot.".to_string(), + ); return; }; // Validate cost + cooldown against the truth. - let on_cd = player - .cooldowns - .get(&ability.id) - .copied() - .unwrap_or(0) - > 0; + let on_cd = player.cooldowns.get(&ability.id).copied().unwrap_or(0) > 0; if on_cd { - self.log_to(user_id, LogKind::System, format!("{} is not ready.", ability.name)); + self.log_to( + user_id, + LogKind::System, + format!("{} is not ready.", ability.name), + ); return; } if player.resource < ability.cost { self.log_to( user_id, LogKind::System, - format!("Not enough {} for {}.", class.resource().label(), ability.name), + format!( + "Not enough {} for {}.", + class.resource().label(), + ability.name + ), ); return; } @@ -963,25 +977,43 @@ impl WorldState { remaining: ability.duration, }); } - self.log_to(user_id, LogKind::Combat, format!("{} begins to mend you.", ability.name)); + self.log_to( + user_id, + LogKind::Combat, + format!("{} begins to mend you.", ability.name), + ); } AbilityEffect::Empower => { if let Some(p) = self.players.get_mut(&user_id) { p.empower = ability.magnitude; p.empower_ticks = ability.duration; } - self.log_to(user_id, LogKind::Combat, format!("{} surges through you (+{} damage).", ability.name, ability.magnitude)); + self.log_to( + user_id, + LogKind::Combat, + format!( + "{} surges through you (+{} damage).", + ability.name, ability.magnitude + ), + ); } AbilityEffect::Ward => { if let Some(p) = self.players.get_mut(&user_id) { p.shield = ability.magnitude; p.shield_ticks = ability.duration; } - self.log_to(user_id, LogKind::Combat, format!("{} shields you ({} absorb).", ability.name, ability.magnitude)); + self.log_to( + user_id, + LogKind::Combat, + format!( + "{} shields you ({} absorb).", + ability.name, ability.magnitude + ), + ); } AbilityEffect::Strike => { let dmg = self.spell_damage(class, ability.magnitude, user_id); - self.damage_target(user_id, dmg, ability.damage_type, &ability.name); + self.damage_target(user_id, dmg, ability.damage_type, ability.name); } AbilityEffect::Finisher => { let dmg = self.spell_damage(class, ability.magnitude, user_id); @@ -989,16 +1021,22 @@ impl WorldState { p.empower = p.empower.max(ability.magnitude / 8); p.empower_ticks = p.empower_ticks.max(ability.duration); } - self.damage_target(user_id, dmg, ability.damage_type, &ability.name); + self.damage_target(user_id, dmg, ability.damage_type, ability.name); } AbilityEffect::DamageOverTime => { let tick = self.spell_damage(class, ability.magnitude, user_id); - self.seed_mob_dot(user_id, tick, ability.damage_type, ability.duration, &ability.name); + self.seed_mob_dot( + user_id, + tick, + ability.damage_type, + ability.duration, + ability.name, + ); } AbilityEffect::Stun => { let target = self.players.get(&user_id).and_then(|p| p.target); let dmg = self.spell_damage(class, ability.magnitude, user_id); - self.damage_target(user_id, dmg, ability.damage_type, &ability.name); + self.damage_target(user_id, dmg, ability.damage_type, ability.name); // Only stun if the target survived the hit. if let Some(mob_id) = target && self.mobs.get(&mob_id).is_some_and(|m| m.alive) @@ -1029,12 +1067,11 @@ impl WorldState { } if class == Class::Ranger { // Hunter's Instinct: more vs wounded foe. - if let Some(mob_id) = self.players.get(&user_id).and_then(|p| p.target) { - if let Some(mob) = self.mobs.get(&mob_id) { - if mob.hp * 2 < mob.spawn.max_hp { - dmg += dmg / 4; - } - } + if let Some(mob_id) = self.players.get(&user_id).and_then(|p| p.target) + && let Some(mob) = self.mobs.get(&mob_id) + && mob.hp * 2 < mob.spawn.max_hp + { + dmg += dmg / 4; } } dmg @@ -1068,7 +1105,11 @@ impl WorldState { self.log_to( user_id, LogKind::Combat, - format!("{source} hits {mob_name} for {dmg} {}{}.", dtype.label(), tag), + format!( + "{source} hits {mob_name} for {dmg} {}{}.", + dtype.label(), + tag + ), ); if dead { self.kill_mob(user_id, mob_id); @@ -1096,7 +1137,11 @@ impl WorldState { .entry(mob_id) .or_default() .push((user_id, scaled, duration)); - self.log_to(user_id, LogKind::Combat, format!("{source} festers in the foe ({} damage).", dtype.label())); + self.log_to( + user_id, + LogKind::Combat, + format!("{source} festers in the foe ({} damage).", dtype.label()), + ); self.dirty = true; } @@ -1117,7 +1162,11 @@ impl WorldState { None => return, }; let gold = 3 + xp / 4; - self.log_to(user_id, LogKind::Loot, format!("You have slain {mob_name}! (+{xp} xp, +{gold} gold)")); + self.log_to( + user_id, + LogKind::Loot, + format!("You have slain {mob_name}! (+{xp} xp, +{gold} gold)"), + ); if let Some(p) = self.players.get_mut(&user_id) { p.target = None; p.xp += xp as i64; @@ -1149,10 +1198,18 @@ impl WorldState { self.log_to( user_id, LogKind::Loot, - format!("{mob_name} drops {} ({})! It falls into your pack.", it.name, it.rarity.label()), + format!( + "{mob_name} drops {} ({})! It falls into your pack.", + it.name, + it.rarity.label() + ), ); } else { - self.log_to(user_id, LogKind::Loot, format!("You loot {} from the corpse.", it.name)); + self.log_to( + user_id, + LogKind::Loot, + format!("You loot {} from the corpse.", it.name), + ); } } @@ -1176,7 +1233,11 @@ impl WorldState { p.hp = p.max_hp(); p.resource = p.max_resource; } - self.log_to(user_id, LogKind::System, format!("You reach level {new_level}!")); + self.log_to( + user_id, + LogKind::System, + format!("You reach level {new_level}!"), + ); // Announce any abilities gained between old and new level. for lvl in (old_level + 1)..=new_level { if let Some(a) = learned_at(class, lvl) { @@ -1194,7 +1255,11 @@ impl WorldState { return; }; if player.target.is_none() { - self.log_to(user_id, LogKind::Normal, "You're not fighting anything.".to_string()); + self.log_to( + user_id, + LogKind::Normal, + "You're not fighting anything.".to_string(), + ); return; } let room_id = player.room; @@ -1210,11 +1275,19 @@ impl WorldState { if let Some(player) = self.players.get_mut(&user_id) { player.room = dest; } - self.log_to(user_id, LogKind::Combat, format!("You flee {}!", dir.label())); + self.log_to( + user_id, + LogKind::Combat, + format!("You flee {}!", dir.label()), + ); self.describe_room(user_id); } None => { - self.log_to(user_id, LogKind::Combat, "You break off the fight.".to_string()); + self.log_to( + user_id, + LogKind::Combat, + "You break off the fight.".to_string(), + ); } } } @@ -1249,7 +1322,11 @@ impl WorldState { fn equip(&mut self, user_id: Uuid, item_id: u32) { let Some(it) = item(item_id) else { return }; let Some(slot) = it.slot() else { - self.log_to(user_id, LogKind::System, format!("{} cannot be equipped.", it.name)); + self.log_to( + user_id, + LogKind::System, + format!("{} cannot be equipped.", it.name), + ); return; }; let has = self @@ -1271,13 +1348,21 @@ impl WorldState { let max = p.max_hp(); p.hp = p.hp.min(max); } - self.log_to(user_id, LogKind::Loot, format!("You equip {} ({}).", it.name, slot.label())); + self.log_to( + user_id, + LogKind::Loot, + format!("You equip {} ({}).", it.name, slot.label()), + ); } fn use_item(&mut self, user_id: Uuid, item_id: u32) { let Some(it) = item(item_id) else { return }; let ItemKind::Consumable { heal, restore } = it.kind else { - self.log_to(user_id, LogKind::System, format!("You can't use {}.", it.name)); + self.log_to( + user_id, + LogKind::System, + format!("You can't use {}.", it.name), + ); return; }; let has = self @@ -1306,7 +1391,11 @@ impl WorldState { None => return, }; let Some(shop) = shop_at(room_id) else { - self.log_to(user_id, LogKind::System, "There is no shop here.".to_string()); + self.log_to( + user_id, + LogKind::System, + "There is no shop here.".to_string(), + ); return; }; if !shop.stock.contains(&item_id) { @@ -1315,19 +1404,31 @@ impl WorldState { let Some(it) = item(item_id) else { return }; let gold = self.players.get(&user_id).map(|p| p.gold).unwrap_or(0); if gold < it.price { - self.log_to(user_id, LogKind::System, format!("You can't afford {} ({}g).", it.name, it.price)); + self.log_to( + user_id, + LogKind::System, + format!("You can't afford {} ({}g).", it.name, it.price), + ); return; } if let Some(p) = self.players.get_mut(&user_id) { p.gold -= it.price; p.inventory.push(item_id); } - self.log_to(user_id, LogKind::Loot, format!("You buy {} for {}g.", it.name, it.price)); + self.log_to( + user_id, + LogKind::Loot, + format!("You buy {} for {}g.", it.name, it.price), + ); } fn sell(&mut self, user_id: Uuid, item_id: u32) { if shop_at(self.players.get(&user_id).map(|p| p.room).unwrap_or(0)).is_none() { - self.log_to(user_id, LogKind::System, "You need a merchant to sell.".to_string()); + self.log_to( + user_id, + LogKind::System, + "You need a merchant to sell.".to_string(), + ); return; } let Some(it) = item(item_id) else { return }; @@ -1340,7 +1441,11 @@ impl WorldState { return; } } - self.log_to(user_id, LogKind::Loot, format!("You sell {} for {}g.", it.name, price)); + self.log_to( + user_id, + LogKind::Loot, + format!("You sell {} for {}g.", it.name, price), + ); } // ---- Tick ----------------------------------------------------------- @@ -1379,17 +1484,16 @@ impl WorldState { self.mob_dots.remove(&mob_id); } } - if total > 0 { - if let Some(mob) = self.mobs.get_mut(&mob_id) { - if mob.alive { - mob.hp -= total; - self.dirty = true; - if mob.hp <= 0 { - if let Some(uid) = owner { - self.kill_mob(uid, mob_id); - } - } - } + if total > 0 + && let Some(mob) = self.mobs.get_mut(&mob_id) + && mob.alive + { + mob.hp -= total; + self.dirty = true; + if mob.hp <= 0 + && let Some(uid) = owner + { + self.kill_mob(uid, mob_id); } } } @@ -1412,7 +1516,11 @@ impl WorldState { player.shield = 0; player.empower = 0; } - self.log_to(user_id, LogKind::System, "You wake at the Temple of the Dawn, restored.".to_string()); + self.log_to( + user_id, + LogKind::System, + "You wake at the Temple of the Dawn, restored.".to_string(), + ); self.describe_room(user_id); self.dirty = true; } @@ -1485,7 +1593,11 @@ impl WorldState { if let Some(p) = self.players.get_mut(&user_id) { p.opening_strike = false; } - self.log_to(user_id, LogKind::Combat, "Opportunist! Your opening strike lands true.".to_string()); + self.log_to( + user_id, + LogKind::Combat, + "Opportunist! Your opening strike lands true.".to_string(), + ); } // Auto-attack is physical and runs through the mob's resistances, // so a physical-resistant foe rewards switching to spells. @@ -1504,13 +1616,17 @@ impl WorldState { } // Mob strikes back unless stunned. let stunned = self.mob_stuns.get(&mob_id).copied().unwrap_or(0) > 0; - if let Some(v) = self.mob_stuns.get_mut(&mob_id) { - if *v > 0 { - *v -= 1; - } + if let Some(v) = self.mob_stuns.get_mut(&mob_id) + && *v > 0 + { + *v -= 1; } if stunned { - self.log_to(user_id, LogKind::Combat, "The foe is stunned and cannot strike.".to_string()); + self.log_to( + user_id, + LogKind::Combat, + "The foe is stunned and cannot strike.".to_string(), + ); continue; } let (mob_damage, mob_dtype, mob_name) = self @@ -1531,7 +1647,9 @@ impl WorldState { let idle: Vec = self .players .iter() - .filter(|(_, p)| p.last_activity.elapsed() >= Duration::from_secs(PLAYER_IDLE_TIMEOUT_SECS)) + .filter(|(_, p)| { + p.last_activity.elapsed() >= Duration::from_secs(PLAYER_IDLE_TIMEOUT_SECS) + }) .map(|(id, _)| *id) .collect(); for user_id in idle { @@ -1572,16 +1690,32 @@ impl WorldState { if p.class == Some(Class::Warrior) && !p.death_save_used { p.death_save_used = true; p.hp = 1; - self.log_to(user_id, LogKind::System, "Unbreakable! You refuse to fall.".to_string()); - self.log_to(user_id, LogKind::Combat, format!("{mob_name} {verb} you to the brink.")); + self.log_to( + user_id, + LogKind::System, + "Unbreakable! You refuse to fall.".to_string(), + ); + self.log_to( + user_id, + LogKind::Combat, + format!("{mob_name} {verb} you to the brink."), + ); return; } p.hp = 0; p.target = None; p.respawn_at = Some(now + Duration::from_secs(PLAYER_RESPAWN_SECS)); - self.log_to(user_id, LogKind::System, "You have fallen! Darkness takes you...".to_string()); + self.log_to( + user_id, + LogKind::System, + "You have fallen! Darkness takes you...".to_string(), + ); } else { - self.log_to(user_id, LogKind::Combat, format!("{mob_name} {verb} you for {dmg}.")); + self.log_to( + user_id, + LogKind::Combat, + format!("{mob_name} {verb} you for {dmg}."), + ); } } @@ -1612,7 +1746,13 @@ impl WorldState { exits, ) } - None => (String::new(), String::new(), String::new(), true, Vec::new()), + None => ( + String::new(), + String::new(), + String::new(), + true, + Vec::new(), + ), }; let mobs: Vec = self .mobs @@ -1650,7 +1790,13 @@ impl WorldState { c.trait_desc().to_string(), c.resource().label().to_string(), ), - None => (false, String::new(), String::new(), String::new(), String::new()), + None => ( + false, + String::new(), + String::new(), + String::new(), + String::new(), + ), }; let abilities: Vec = match player.class { @@ -1681,16 +1827,20 @@ impl WorldState { equipped: false, sell_price: it.sell_price(), }) - .chain(player.equipped.values().filter_map(|id| item(*id)).map(|it| { - InvView { - item_id: it.id, - name: it.name.to_string(), - rarity: it.rarity.label().to_string(), - slot: it.slot().map(|s| s.label().to_string()), - equipped: true, - sell_price: it.sell_price(), - } - })) + .chain( + player + .equipped + .values() + .filter_map(|id| item(*id)) + .map(|it| InvView { + item_id: it.id, + name: it.name.to_string(), + rarity: it.rarity.label().to_string(), + slot: it.slot().map(|s| s.label().to_string()), + equipped: true, + sell_price: it.sell_price(), + }), + ) .collect(); let shop = shop_at(player.room).map(|shop| ShopView { @@ -1864,7 +2014,10 @@ mod tests { s.move_player(uid(1), Dir::South); s.move_player(uid(1), Dir::South); s.engage(uid(1)); - assert!(!s.players[&uid(1)].opening_strike, "only rogues get the crit"); + assert!( + !s.players[&uid(1)].opening_strike, + "only rogues get the crit" + ); } #[test] @@ -1872,9 +2025,13 @@ mod tests { let mut s = world(); s.join(uid(1)); s.choose_class(uid(1), Class::Warrior); - s.strike_player(uid(1), 9999, "a test foe"); - assert_eq!(s.players[&uid(1)].hp, 1, "Unbreakable should save the warrior"); - s.strike_player(uid(1), 9999, "a test foe"); + s.strike_player(uid(1), 9999, DamageType::Physical, "a test foe"); + assert_eq!( + s.players[&uid(1)].hp, + 1, + "Unbreakable should save the warrior" + ); + s.strike_player(uid(1), 9999, DamageType::Physical, "a test foe"); assert!(s.players[&uid(1)].respawn_at.is_some(), "second blow falls"); } } diff --git a/late-ssh/src/app/rooms/mud/ui.rs b/late-ssh/src/app/rooms/mud/ui.rs index 42e948ee..d9a731e2 100644 --- a/late-ssh/src/app/rooms/mud/ui.rs +++ b/late-ssh/src/app/rooms/mud/ui.rs @@ -48,7 +48,11 @@ pub fn draw_game(frame: &mut Frame, area: Rect, state: &State, usernames: &Usern return; } - let side_w = if area.width >= 84 { SIDE_WIDE } else { SIDE_NARROW }; + let side_w = if area.width >= 84 { + SIDE_WIDE + } else { + SIDE_NARROW + }; let cols = Layout::horizontal([Constraint::Min(26), Constraint::Length(side_w)]).split(area); draw_log(frame, cols[0], &view); draw_side(frame, cols[1], state, &view, usernames); @@ -58,7 +62,9 @@ fn draw_class_select(frame: &mut Frame, area: Rect, _view: &PlayerView) { let mut lines = vec![ Line::from(Span::styled( "~ LATEANIA ~", - Style::default().fg(theme::AMBER_GLOW()).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( "Choose your calling. Press its number.", @@ -70,11 +76,16 @@ fn draw_class_select(frame: &mut Frame, area: Rect, _view: &PlayerView) { lines.push(Line::from(vec![ Span::styled( format!(" {} ", i + 1), - Style::default().fg(theme::BG_CANVAS()).bg(theme::AMBER()).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme::BG_CANVAS()) + .bg(theme::AMBER()) + .add_modifier(Modifier::BOLD), ), Span::styled( format!(" {} ", class.name()), - Style::default().fg(theme::TEXT_BRIGHT()).add_modifier(Modifier::BOLD), + Style::default() + .fg(theme::TEXT_BRIGHT()) + .add_modifier(Modifier::BOLD), ), Span::styled( class.tagline().to_string(), @@ -82,7 +93,11 @@ fn draw_class_select(frame: &mut Frame, area: Rect, _view: &PlayerView) { ), ])); lines.push(Line::from(Span::styled( - format!(" trait: {} - {}", class.trait_name(), class.trait_desc()), + format!( + " trait: {} - {}", + class.trait_name(), + class.trait_desc() + ), Style::default().fg(theme::TEXT_DIM()), ))); } @@ -96,8 +111,16 @@ fn draw_class_select(frame: &mut Frame, area: Rect, _view: &PlayerView) { fn draw_compact(frame: &mut Frame, area: Rect, view: &PlayerView) { let mut lines = vec![Line::from(vec![ - Span::styled(view.room_name.clone(), Style::default().fg(theme::AMBER()).add_modifier(Modifier::BOLD)), - Span::styled(format!(" {}/{}hp", view.hp, view.max_hp), Style::default().fg(hp_color(view.hp, view.max_hp))), + Span::styled( + view.room_name.clone(), + Style::default() + .fg(theme::AMBER()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}/{}hp", view.hp, view.max_hp), + Style::default().fg(hp_color(view.hp, view.max_hp)), + ), ])]; for (kind, text) in log_tail(view, area.height.saturating_sub(1) as usize) { lines.push(log_line(kind, text)); @@ -131,20 +154,42 @@ fn draw_side( fn vitals(view: &PlayerView) -> Vec> { vec![ Line::from(vec![ - Span::styled(format!("{} ", view.class_name), Style::default().fg(theme::AMBER()).add_modifier(Modifier::BOLD)), - Span::styled(format!("lvl {}", view.level), Style::default().fg(theme::TEXT_BRIGHT())), + Span::styled( + format!("{} ", view.class_name), + Style::default() + .fg(theme::AMBER()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("lvl {}", view.level), + Style::default().fg(theme::TEXT_BRIGHT()), + ), ]), Line::from(vec![ Span::styled("HP ", Style::default().fg(theme::TEXT_DIM())), - Span::styled(format!("{}/{}", view.hp, view.max_hp), Style::default().fg(hp_color(view.hp, view.max_hp)).add_modifier(Modifier::BOLD)), + Span::styled( + format!("{}/{}", view.hp, view.max_hp), + Style::default() + .fg(hp_color(view.hp, view.max_hp)) + .add_modifier(Modifier::BOLD), + ), ]), Line::from(vec![ - Span::styled(format!("{:<4}", short_res(&view.resource_name)), Style::default().fg(theme::TEXT_DIM())), - Span::styled(format!("{}/{}", view.resource, view.max_resource), Style::default().fg(theme::MENTION())), + Span::styled( + format!("{:<4}", short_res(&view.resource_name)), + Style::default().fg(theme::TEXT_DIM()), + ), + Span::styled( + format!("{}/{}", view.resource, view.max_resource), + Style::default().fg(theme::MENTION()), + ), ]), Line::from(vec![ Span::styled("gold ", Style::default().fg(theme::TEXT_DIM())), - Span::styled(format!("{}", view.gold), Style::default().fg(theme::BADGE_GOLD())), + Span::styled( + format!("{}", view.gold), + Style::default().fg(theme::BADGE_GOLD()), + ), ]), ] } @@ -153,11 +198,18 @@ fn room_panel(view: &PlayerView, usernames: &UsernameLookup<'_>) -> Vec>().join(", ") + view.exits + .iter() + .map(|(_, n)| n.as_str()) + .collect::>() + .join(", ") }; lines.push(Line::from(vec![ Span::styled(" exits ", Style::default().fg(theme::TEXT_DIM())), @@ -167,17 +219,29 @@ fn room_panel(view: &PlayerView, usernames: &UsernameLookup<'_>) -> Vec Vec> { lines.push(stat("armor", view.armor.to_string())); lines.push(Line::raw("")); lines.push(section("Trait")); - lines.push(Line::from(Span::styled(format!(" {}", view.trait_name), Style::default().fg(theme::AMBER()).add_modifier(Modifier::BOLD)))); + lines.push(Line::from(Span::styled( + format!(" {}", view.trait_name), + Style::default() + .fg(theme::AMBER()) + .add_modifier(Modifier::BOLD), + ))); lines.extend(wrap(&view.trait_desc, 30)); lines.push(Line::raw("")); lines.push(section("Experience")); @@ -203,7 +272,10 @@ fn character_panel(view: &PlayerView) -> Vec> { Style::default().fg(theme::TEXT()), ))); } else { - lines.push(Line::from(Span::styled(" max level reached", Style::default().fg(theme::BADGE_GOLD())))); + lines.push(Line::from(Span::styled( + " max level reached", + Style::default().fg(theme::BADGE_GOLD()), + ))); } lines.push(Line::raw("")); lines.push(hint("c", "close v abilities t bag")); @@ -213,14 +285,34 @@ fn character_panel(view: &PlayerView) -> Vec> { fn abilities_panel(view: &PlayerView) -> Vec> { let mut lines = vec![section("Abilities")]; if view.abilities.is_empty() { - lines.push(Line::from(Span::styled(" none yet", Style::default().fg(theme::TEXT_DIM())))); + lines.push(Line::from(Span::styled( + " none yet", + Style::default().fg(theme::TEXT_DIM()), + ))); } for a in &view.abilities { - let color = if a.ready { theme::TEXT_BRIGHT() } else { theme::TEXT_FAINT() }; + let color = if a.ready { + theme::TEXT_BRIGHT() + } else { + theme::TEXT_FAINT() + }; lines.push(Line::from(vec![ - Span::styled(format!(" {} ", a.slot), Style::default().fg(theme::BG_CANVAS()).bg(if a.ready { theme::AMBER() } else { theme::BORDER_DIM() })), - Span::styled(format!(" {}", a.name), Style::default().fg(color).add_modifier(Modifier::BOLD)), - Span::styled(format!(" {}c {}", a.cost, a.effect), Style::default().fg(theme::TEXT_DIM())), + Span::styled( + format!(" {} ", a.slot), + Style::default().fg(theme::BG_CANVAS()).bg(if a.ready { + theme::AMBER() + } else { + theme::BORDER_DIM() + }), + ), + Span::styled( + format!(" {}", a.name), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" {}c {}", a.cost, a.effect), + Style::default().fg(theme::TEXT_DIM()), + ), ])); } lines.push(Line::raw("")); @@ -232,10 +324,16 @@ fn abilities_panel(view: &PlayerView) -> Vec> { fn inventory_panel(view: &PlayerView, cursor: usize) -> Vec> { let mut lines = vec![ section("Inventory"), - Line::from(Span::styled(format!(" {} gold", view.gold), Style::default().fg(theme::BADGE_GOLD()))), + Line::from(Span::styled( + format!(" {} gold", view.gold), + Style::default().fg(theme::BADGE_GOLD()), + )), ]; if view.inventory.is_empty() { - lines.push(Line::from(Span::styled(" (empty)", Style::default().fg(theme::TEXT_DIM())))); + lines.push(Line::from(Span::styled( + " (empty)", + Style::default().fg(theme::TEXT_DIM()), + ))); } for (i, it) in view.inventory.iter().enumerate() { let selected = i == cursor; @@ -248,11 +346,17 @@ fn inventory_panel(view: &PlayerView, cursor: usize) -> Vec> { String::new() }; let style = if selected { - Style::default().fg(theme::TEXT_BRIGHT()).bg(theme::BG_SELECTION()).add_modifier(Modifier::BOLD) + Style::default() + .fg(theme::TEXT_BRIGHT()) + .bg(theme::BG_SELECTION()) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(rarity_color(&it.rarity)) }; - lines.push(Line::from(Span::styled(format!("{marker} {}{}", it.name, tag), style))); + lines.push(Line::from(Span::styled( + format!("{marker} {}{}", it.name, tag), + style, + ))); } lines.push(Line::raw("")); lines.push(hint("w/s", "select Enter equip/use")); @@ -262,19 +366,37 @@ fn inventory_panel(view: &PlayerView, cursor: usize) -> Vec> { fn shop_panel(view: &PlayerView, cursor: usize) -> Vec> { let Some(shop) = &view.shop else { - return vec![Line::from(Span::styled("No shop here.", Style::default().fg(theme::TEXT_DIM())))]; + return vec![Line::from(Span::styled( + "No shop here.", + Style::default().fg(theme::TEXT_DIM()), + ))]; }; let mut lines = vec![ - Line::from(Span::styled(shop.shop_name.clone(), Style::default().fg(theme::AMBER_GLOW()).add_modifier(Modifier::BOLD))), - Line::from(Span::styled(format!("{} - your gold: {}", shop.npc_name, view.gold), Style::default().fg(theme::TEXT_DIM()))), + Line::from(Span::styled( + shop.shop_name.clone(), + Style::default() + .fg(theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + format!("{} - your gold: {}", shop.npc_name, view.gold), + Style::default().fg(theme::TEXT_DIM()), + )), Line::raw(""), ]; for (i, e) in shop.entries.iter().enumerate() { let selected = i == cursor; let marker = if selected { ">" } else { " " }; - let price_color = if e.affordable { theme::BADGE_GOLD() } else { theme::ERROR() }; + let price_color = if e.affordable { + theme::BADGE_GOLD() + } else { + theme::ERROR() + }; let name_style = if selected { - Style::default().fg(theme::TEXT_BRIGHT()).bg(theme::BG_SELECTION()).add_modifier(Modifier::BOLD) + Style::default() + .fg(theme::TEXT_BRIGHT()) + .bg(theme::BG_SELECTION()) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(rarity_color(&e.rarity)) }; @@ -292,7 +414,10 @@ fn shop_panel(view: &PlayerView, cursor: usize) -> Vec> { fn footer_hints(view: &PlayerView) -> Vec> { let mut lines = vec![section("Commands")]; if view.respawning { - lines.push(Line::from(Span::styled(" recovering...", Style::default().fg(theme::TEXT_DIM())))); + lines.push(Line::from(Span::styled( + " recovering...", + Style::default().fg(theme::TEXT_DIM()), + ))); return lines; } if view.in_combat_with.is_some() { @@ -319,7 +444,10 @@ fn log_tail(view: &PlayerView, capacity: usize) -> Vec<(LogKind, String)> { return Vec::new(); } let start = view.log.len().saturating_sub(capacity); - view.log[start..].iter().map(|l| (l.kind, l.text.clone())).collect() + view.log[start..] + .iter() + .map(|l| (l.kind, l.text.clone())) + .collect() } fn log_line(kind: LogKind, text: String) -> Line<'static> { @@ -336,13 +464,21 @@ fn log_line(kind: LogKind, text: String) -> Line<'static> { fn section(title: &str) -> Line<'static> { Line::from(vec![ Span::styled(" - ", Style::default().fg(theme::BORDER())), - Span::styled(title.to_string(), Style::default().fg(theme::AMBER()).add_modifier(Modifier::BOLD)), + Span::styled( + title.to_string(), + Style::default() + .fg(theme::AMBER()) + .add_modifier(Modifier::BOLD), + ), ]) } fn stat(label: &str, value: String) -> Line<'static> { Line::from(vec![ - Span::styled(format!(" {label:<7}"), Style::default().fg(theme::TEXT_DIM())), + Span::styled( + format!(" {label:<7}"), + Style::default().fg(theme::TEXT_DIM()), + ), Span::styled(value, Style::default().fg(theme::TEXT_BRIGHT())), ]) } @@ -358,15 +494,21 @@ fn wrap(text: &str, width: usize) -> Vec> { let mut out = Vec::new(); let mut line = String::from(" "); for word in text.split_whitespace() { - if line.len() + word.len() + 1 > width && line.trim().len() > 0 { - out.push(Line::from(Span::styled(line.clone(), Style::default().fg(theme::TEXT_DIM())))); + if line.len() + word.len() + 1 > width && !line.trim().is_empty() { + out.push(Line::from(Span::styled( + line.clone(), + Style::default().fg(theme::TEXT_DIM()), + ))); line = String::from(" "); } line.push_str(word); line.push(' '); } if !line.trim().is_empty() { - out.push(Line::from(Span::styled(line, Style::default().fg(theme::TEXT_DIM())))); + out.push(Line::from(Span::styled( + line, + Style::default().fg(theme::TEXT_DIM()), + ))); } out } diff --git a/late-ssh/src/app/rooms/mud/world.rs b/late-ssh/src/app/rooms/mud/world.rs index f111ed10..16d5b186 100644 --- a/late-ssh/src/app/rooms/mud/world.rs +++ b/late-ssh/src/app/rooms/mud/world.rs @@ -15,8 +15,8 @@ // // Content is deliberately data, not code: `seed_world` hardcodes the world, but // the shape (rooms keyed by id, exits as a direction map) is exactly what a -// future TOML/RON loader will produce. The full design target is 200 rooms; the -// remaining zones widen the existing spines. +// future TOML/RON loader will produce. The current authored world has 198 rooms; +// the planned full design target remains 200. use std::collections::HashMap; @@ -166,7 +166,12 @@ pub fn seed_world() -> World { board leans by the well, thick with bounties and lost-cat pleas alike. \ The Gilded Flagon glows north, the temple west, Market Row east, and the \ South Gate and open road lie south.", - &[(Dir::North, 2), (Dir::East, 3), (Dir::West, 4), (Dir::South, 5)], + &[ + (Dir::North, 2), + (Dir::East, 3), + (Dir::West, 4), + (Dir::South, 5), + ], ), room( 2, @@ -1454,7 +1459,11 @@ pub fn seed_world() -> World { respawn_secs: 50, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Shadow, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, // Boss: Whisperwood MobSpawn { @@ -1467,7 +1476,11 @@ pub fn seed_world() -> World { respawn_secs: 300, loot: &[1006, 1201, 1301], boss: true, - profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Physical), Some(DamageType::Fire)), + profile: DamageProfile::new( + DamageType::Physical, + Some(DamageType::Physical), + Some(DamageType::Fire), + ), }, // ---- Duskhollow Caverns (tier 3-4) ------------------------------ MobSpawn { @@ -1480,7 +1493,11 @@ pub fn seed_world() -> World { respawn_secs: 55, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Physical, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, MobSpawn { id: 21, @@ -1504,7 +1521,11 @@ pub fn seed_world() -> World { respawn_secs: 60, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Shadow, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, // Boss: Duskhollow Caverns MobSpawn { @@ -1517,7 +1538,11 @@ pub fn seed_world() -> World { respawn_secs: 300, loot: &[1105, 1202, 1302], boss: true, - profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Shadow, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, // ---- Drowned Crypts (tier 4-5) ---------------------------------- MobSpawn { @@ -1530,7 +1555,11 @@ pub fn seed_world() -> World { respawn_secs: 60, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Shadow, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, MobSpawn { id: 31, @@ -1542,7 +1571,11 @@ pub fn seed_world() -> World { respawn_secs: 60, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Physical, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, MobSpawn { id: 32, @@ -1554,7 +1587,11 @@ pub fn seed_world() -> World { respawn_secs: 65, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Frost, Some(DamageType::Frost), Some(DamageType::Fire)), + profile: DamageProfile::new( + DamageType::Frost, + Some(DamageType::Frost), + Some(DamageType::Fire), + ), }, // Boss: Drowned Crypts MobSpawn { @@ -1567,7 +1604,11 @@ pub fn seed_world() -> World { respawn_secs: 360, loot: &[1008, 1204, 1302], boss: true, - profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Shadow, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, // ---- Emberpeak Mines (tier 5-6) --------------------------------- MobSpawn { @@ -1580,7 +1621,11 @@ pub fn seed_world() -> World { respawn_secs: 65, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Frost)), + profile: DamageProfile::new( + DamageType::Fire, + Some(DamageType::Fire), + Some(DamageType::Frost), + ), }, MobSpawn { id: 41, @@ -1592,7 +1637,11 @@ pub fn seed_world() -> World { respawn_secs: 70, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Fire), Some(DamageType::Frost)), + profile: DamageProfile::new( + DamageType::Physical, + Some(DamageType::Fire), + Some(DamageType::Frost), + ), }, MobSpawn { id: 42, @@ -1604,7 +1653,11 @@ pub fn seed_world() -> World { respawn_secs: 70, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Frost)), + profile: DamageProfile::new( + DamageType::Fire, + Some(DamageType::Fire), + Some(DamageType::Frost), + ), }, // Boss: Emberpeak Mines MobSpawn { @@ -1617,7 +1670,11 @@ pub fn seed_world() -> World { respawn_secs: 360, loot: &[1009, 1205, 1304], boss: true, - profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Frost)), + profile: DamageProfile::new( + DamageType::Fire, + Some(DamageType::Fire), + Some(DamageType::Frost), + ), }, // ---- Frostspire Ascent (tier 6-7) ------------------------------- MobSpawn { @@ -1630,7 +1687,11 @@ pub fn seed_world() -> World { respawn_secs: 70, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Frost, Some(DamageType::Frost), Some(DamageType::Fire)), + profile: DamageProfile::new( + DamageType::Frost, + Some(DamageType::Frost), + Some(DamageType::Fire), + ), }, MobSpawn { id: 51, @@ -1642,7 +1703,11 @@ pub fn seed_world() -> World { respawn_secs: 75, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Frost), Some(DamageType::Fire)), + profile: DamageProfile::new( + DamageType::Physical, + Some(DamageType::Frost), + Some(DamageType::Fire), + ), }, MobSpawn { id: 52, @@ -1654,7 +1719,11 @@ pub fn seed_world() -> World { respawn_secs: 75, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Frost, Some(DamageType::Frost), Some(DamageType::Fire)), + profile: DamageProfile::new( + DamageType::Frost, + Some(DamageType::Frost), + Some(DamageType::Fire), + ), }, // Boss: Frostspire Ascent MobSpawn { @@ -1667,7 +1736,11 @@ pub fn seed_world() -> World { respawn_secs: 420, loot: &[1007, 1205, 1304], boss: true, - profile: DamageProfile::new(DamageType::Frost, Some(DamageType::Frost), Some(DamageType::Fire)), + profile: DamageProfile::new( + DamageType::Frost, + Some(DamageType::Frost), + Some(DamageType::Fire), + ), }, // ---- The Sunken Citadel (tier 7-8) ------------------------------ MobSpawn { @@ -1680,7 +1753,11 @@ pub fn seed_world() -> World { respawn_secs: 80, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Physical), Some(DamageType::Arcane)), + profile: DamageProfile::new( + DamageType::Physical, + Some(DamageType::Physical), + Some(DamageType::Arcane), + ), }, MobSpawn { id: 61, @@ -1692,7 +1769,11 @@ pub fn seed_world() -> World { respawn_secs: 80, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Physical, Some(DamageType::Physical), Some(DamageType::Arcane)), + profile: DamageProfile::new( + DamageType::Physical, + Some(DamageType::Physical), + Some(DamageType::Arcane), + ), }, MobSpawn { id: 62, @@ -1704,7 +1785,11 @@ pub fn seed_world() -> World { respawn_secs: 85, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Shadow, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, // Boss: The Sunken Citadel MobSpawn { @@ -1717,7 +1802,11 @@ pub fn seed_world() -> World { respawn_secs: 420, loot: &[1109, 1202, 1304], boss: true, - profile: DamageProfile::new(DamageType::Holy, Some(DamageType::Physical), Some(DamageType::Shadow)), + profile: DamageProfile::new( + DamageType::Holy, + Some(DamageType::Physical), + Some(DamageType::Shadow), + ), }, // ---- The Obsidian Throne (tier 9-10) ---------------------------- MobSpawn { @@ -1730,7 +1819,11 @@ pub fn seed_world() -> World { respawn_secs: 90, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Fire, + Some(DamageType::Fire), + Some(DamageType::Holy), + ), }, MobSpawn { id: 71, @@ -1742,7 +1835,11 @@ pub fn seed_world() -> World { respawn_secs: 90, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Fire, Some(DamageType::Fire), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Fire, + Some(DamageType::Fire), + Some(DamageType::Holy), + ), }, MobSpawn { id: 72, @@ -1754,7 +1851,11 @@ pub fn seed_world() -> World { respawn_secs: 95, loot: &[1000, 1100, 1103, 1300], boss: false, - profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Shadow, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, // Final boss MobSpawn { @@ -1767,7 +1868,11 @@ pub fn seed_world() -> World { respawn_secs: 600, loot: &[1009, 1205, 1401], boss: true, - profile: DamageProfile::new(DamageType::Shadow, Some(DamageType::Shadow), Some(DamageType::Holy)), + profile: DamageProfile::new( + DamageType::Shadow, + Some(DamageType::Shadow), + Some(DamageType::Holy), + ), }, ]; @@ -1888,162 +1993,918 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { // ---- Whisperwood: The Sunken Glade (12 rooms) ----------------------- let start = 300; - let last = add_wing(rooms, "Whisperwood", false, 14, Dir::North, start, &[ - wr("Whisperwood - The Mushroom Stair", "Shelves of bracket-fungus climb a slope like a giant's staircase, soft and cold underfoot, spores drifting in the lanternlight. North; the ring lies south.", Dir::North), - wr("Whisperwood - The Glowcap Grotto", "A hollow beneath an upturned root glimmers with luminous caps in blue and green, a drowned dreamlike light over soft loam. North.", Dir::North), - wr("Whisperwood - The Toadstool Court", "Rings within rings of fungus carpet a clearing, and the longer you stand the more you feel watched by things at ankle height. North.", Dir::North), - wr("Whisperwood - The Weeping Willow", "A willow vast as a tower trails its branches to the ground, and the wind in them makes a sound exactly like a woman crying. North.", Dir::North), - wr("Whisperwood - The Bog Causeway", "A path of half-sunk logs crosses a black bog that breathes bubbles and worse. Stepping wrong here is a quiet way to vanish. North.", Dir::North), - wr("Whisperwood - The Drowned Oak", "An oak has fallen full-length into the bog and rotted into a hollow tunnel; you walk through the inside of a dead giant. North.", Dir::North), - wr("Whisperwood - The Witch's Hut", "A crooked hut leans on chicken-scratch foundations, windows dark, door ajar on a single creaking hinge. North.", Dir::North), - wr("Whisperwood - The Hag's Garden", "Behind the hut a garden grows things no garden should: pale gourds with faces, vines that flinch from the light. North.", Dir::North), - wr("Whisperwood - The Bone Orchard", "Trees here have grown around old bones until trunk and skeleton are one, and the fruit they bear is best left unpicked. North.", Dir::North), - wr("Whisperwood - The Moonwell", "A perfectly round well brims with water that glows faintly silver, reflecting a moon not in tonight's sky. North.", Dir::North), - wr("Whisperwood - The Whispering Stones", "A ring of leaning stones mutters among themselves, falling silent the instant you turn to listen. North.", Dir::North), - wr("Whisperwood - The Sunken Glade", "The trees draw back from a circle of green where a single shaft of moonlight falls, beautiful and far too quiet, where something has waited a very long time. The way back is south.", Dir::North), - ]); - mob(spawns, "a will-o'-wisp", start + 1, 26, 6, 24, false, COMMON_LOOT, p(D::Fire, None, Some(D::Frost))); - mob(spawns, "a giant glowcap spider", start + 5, 34, 7, 30, false, COMMON_LOOT, p(D::Poison, None, Some(D::Fire))); - mob(spawns, "a bog-mire lurker", start + 8, 40, 8, 36, false, COMMON_LOOT, p(D::Poison, Some(D::Poison), Some(D::Fire))); - mob(spawns, "the Hexcrone of the Glade", last, 130, 13, 165, true, &[1006, 1201, 1302], p(D::Shadow, Some(D::Shadow), Some(D::Holy))); + let last = add_wing( + rooms, + "Whisperwood", + false, + 14, + Dir::North, + start, + &[ + wr( + "Whisperwood - The Mushroom Stair", + "Shelves of bracket-fungus climb a slope like a giant's staircase, soft and cold underfoot, spores drifting in the lanternlight. North; the ring lies south.", + Dir::North, + ), + wr( + "Whisperwood - The Glowcap Grotto", + "A hollow beneath an upturned root glimmers with luminous caps in blue and green, a drowned dreamlike light over soft loam. North.", + Dir::North, + ), + wr( + "Whisperwood - The Toadstool Court", + "Rings within rings of fungus carpet a clearing, and the longer you stand the more you feel watched by things at ankle height. North.", + Dir::North, + ), + wr( + "Whisperwood - The Weeping Willow", + "A willow vast as a tower trails its branches to the ground, and the wind in them makes a sound exactly like a woman crying. North.", + Dir::North, + ), + wr( + "Whisperwood - The Bog Causeway", + "A path of half-sunk logs crosses a black bog that breathes bubbles and worse. Stepping wrong here is a quiet way to vanish. North.", + Dir::North, + ), + wr( + "Whisperwood - The Drowned Oak", + "An oak has fallen full-length into the bog and rotted into a hollow tunnel; you walk through the inside of a dead giant. North.", + Dir::North, + ), + wr( + "Whisperwood - The Witch's Hut", + "A crooked hut leans on chicken-scratch foundations, windows dark, door ajar on a single creaking hinge. North.", + Dir::North, + ), + wr( + "Whisperwood - The Hag's Garden", + "Behind the hut a garden grows things no garden should: pale gourds with faces, vines that flinch from the light. North.", + Dir::North, + ), + wr( + "Whisperwood - The Bone Orchard", + "Trees here have grown around old bones until trunk and skeleton are one, and the fruit they bear is best left unpicked. North.", + Dir::North, + ), + wr( + "Whisperwood - The Moonwell", + "A perfectly round well brims with water that glows faintly silver, reflecting a moon not in tonight's sky. North.", + Dir::North, + ), + wr( + "Whisperwood - The Whispering Stones", + "A ring of leaning stones mutters among themselves, falling silent the instant you turn to listen. North.", + Dir::North, + ), + wr( + "Whisperwood - The Sunken Glade", + "The trees draw back from a circle of green where a single shaft of moonlight falls, beautiful and far too quiet, where something has waited a very long time. The way back is south.", + Dir::North, + ), + ], + ); + mob( + spawns, + "a will-o'-wisp", + start + 1, + 26, + 6, + 24, + false, + COMMON_LOOT, + p(D::Fire, None, Some(D::Frost)), + ); + mob( + spawns, + "a giant glowcap spider", + start + 5, + 34, + 7, + 30, + false, + COMMON_LOOT, + p(D::Poison, None, Some(D::Fire)), + ); + mob( + spawns, + "a bog-mire lurker", + start + 8, + 40, + 8, + 36, + false, + COMMON_LOOT, + p(D::Poison, Some(D::Poison), Some(D::Fire)), + ); + mob( + spawns, + "the Hexcrone of the Glade", + last, + 130, + 13, + 165, + true, + &[1006, 1201, 1302], + p(D::Shadow, Some(D::Shadow), Some(D::Holy)), + ); // ---- Duskhollow: The Barrow Deep (11 rooms) ------------------------- let start = 330; - let last = add_wing(rooms, "Duskhollow Caverns", false, 37, Dir::West, start, &[ - wr("Duskhollow - Behind the Sealed Door", "The chained door gives onto a passage no light has touched in centuries, the air dead and close and faintly sweet with old decay. West.", Dir::West), - wr("Duskhollow - The Gravewater Pool", "Black water fills a basin to the brim, pale shapes drifting just beneath its skin, neither sunk nor surfaced. West.", Dir::West), - wr("Duskhollow - The Creeping Dark", "The lantern seems to shrink here, the dark pressing in close enough to feel, patient and almost fond. West.", Dir::West), - wr("Duskhollow - The Hall of Urns", "Thousands of clay urns line shelves to the unseen ceiling, each holding forgotten ash. Many are broken, their contents not where they should be. West.", Dir::West), - wr("Duskhollow - The Mourner's Stair", "Steps worn into a smooth trough by centuries of grieving feet descend into a deeper cold. West.", Dir::West), - wr("Duskhollow - The Catacomb Maze", "Passages branch and rejoin among walls of stacked bone until direction loses meaning; only the draught from ahead keeps you true. West.", Dir::West), - wr("Duskhollow - The Lamentation Hall", "A vast chamber where the slightest sound returns as a chorus of weeping, until you cannot tell the echo from the dead. West.", Dir::West), - wr("Duskhollow - The Gilded Tomb", "A single tomb of beaten gold gleams untouched by the rot, its lid carved with a sleeping king who is no longer inside. West.", Dir::West), - wr("Duskhollow - The Guardian's Rest", "Stone sentinels line the final approach, each with a real sword rusted into its carved hands, each having taken one step from its plinth. West.", Dir::West), - wr("Duskhollow - The Barrow King's Vault", "A burial chamber fit for a king who refused the grave: gold heaped in the dark, and at its center a throne where a crowned and withered thing turns its head. The way out is east.", Dir::West), - ]); - mob(spawns, "a tomb-rat swarm", start + 1, 38, 7, 30, false, COMMON_LOOT, p(D::Physical, None, Some(D::Fire))); - mob(spawns, "a grave moth cloud", start + 3, 44, 8, 38, false, COMMON_LOOT, p(D::Poison, None, Some(D::Holy))); - mob(spawns, "a shambling barrow-guard", start + 6, 52, 9, 48, false, COMMON_LOOT, p(D::Physical, Some(D::Shadow), Some(D::Holy))); - mob(spawns, "a clutch of bonepickers", start + 8, 56, 10, 54, false, COMMON_LOOT, p(D::Physical, Some(D::Shadow), Some(D::Holy))); - mob(spawns, "the Barrow King", last, 190, 17, 235, true, &[1105, 1202, 1302], p(D::Shadow, Some(D::Shadow), Some(D::Holy))); + let last = add_wing( + rooms, + "Duskhollow Caverns", + false, + 37, + Dir::West, + start, + &[ + wr( + "Duskhollow - Behind the Sealed Door", + "The chained door gives onto a passage no light has touched in centuries, the air dead and close and faintly sweet with old decay. West.", + Dir::West, + ), + wr( + "Duskhollow - The Gravewater Pool", + "Black water fills a basin to the brim, pale shapes drifting just beneath its skin, neither sunk nor surfaced. West.", + Dir::West, + ), + wr( + "Duskhollow - The Creeping Dark", + "The lantern seems to shrink here, the dark pressing in close enough to feel, patient and almost fond. West.", + Dir::West, + ), + wr( + "Duskhollow - The Hall of Urns", + "Thousands of clay urns line shelves to the unseen ceiling, each holding forgotten ash. Many are broken, their contents not where they should be. West.", + Dir::West, + ), + wr( + "Duskhollow - The Mourner's Stair", + "Steps worn into a smooth trough by centuries of grieving feet descend into a deeper cold. West.", + Dir::West, + ), + wr( + "Duskhollow - The Catacomb Maze", + "Passages branch and rejoin among walls of stacked bone until direction loses meaning; only the draught from ahead keeps you true. West.", + Dir::West, + ), + wr( + "Duskhollow - The Lamentation Hall", + "A vast chamber where the slightest sound returns as a chorus of weeping, until you cannot tell the echo from the dead. West.", + Dir::West, + ), + wr( + "Duskhollow - The Gilded Tomb", + "A single tomb of beaten gold gleams untouched by the rot, its lid carved with a sleeping king who is no longer inside. West.", + Dir::West, + ), + wr( + "Duskhollow - The Guardian's Rest", + "Stone sentinels line the final approach, each with a real sword rusted into its carved hands, each having taken one step from its plinth. West.", + Dir::West, + ), + wr( + "Duskhollow - The Barrow King's Vault", + "A burial chamber fit for a king who refused the grave: gold heaped in the dark, and at its center a throne where a crowned and withered thing turns its head. The way out is east.", + Dir::West, + ), + ], + ); + mob( + spawns, + "a tomb-rat swarm", + start + 1, + 38, + 7, + 30, + false, + COMMON_LOOT, + p(D::Physical, None, Some(D::Fire)), + ); + mob( + spawns, + "a grave moth cloud", + start + 3, + 44, + 8, + 38, + false, + COMMON_LOOT, + p(D::Poison, None, Some(D::Holy)), + ); + mob( + spawns, + "a shambling barrow-guard", + start + 6, + 52, + 9, + 48, + false, + COMMON_LOOT, + p(D::Physical, Some(D::Shadow), Some(D::Holy)), + ); + mob( + spawns, + "a clutch of bonepickers", + start + 8, + 56, + 10, + 54, + false, + COMMON_LOOT, + p(D::Physical, Some(D::Shadow), Some(D::Holy)), + ); + mob( + spawns, + "the Barrow King", + last, + 190, + 17, + 235, + true, + &[1105, 1202, 1302], + p(D::Shadow, Some(D::Shadow), Some(D::Holy)), + ); // ---- Drowned Crypts: The Tidal Catacombs (11 rooms) ----------------- let start = 360; - let last = add_wing(rooms, "Drowned Crypts", false, 54, Dir::South, start, &[ - wr("Drowned Crypts - The Brine Stair", "Salt-crusted steps spiral down into water that rises to meet you, cold as a drowned bell. South.", Dir::South), - wr("Drowned Crypts - The Coral Ossuary", "Bone and pale coral have grown into one another until you cannot tell which the dead were and which the sea made. South.", Dir::South), - wr("Drowned Crypts - The Kelp Forest", "Ropes of black kelp rise from the flooded dark and sway though there is no current, parting reluctantly as you wade. South.", Dir::South), - wr("Drowned Crypts - The Sunken Chapel", "A chapel stands fully submerged, pews in drowned rows, its altar candle somehow trailing a thread of smoke up through the water. South.", Dir::South), - wr("Drowned Crypts - The Pearl Vault", "Drowned treasure spills from broken chests, every coin and pearl furred with the same pale rot. South.", Dir::South), - wr("Drowned Crypts - The Anemone Garden", "Things that might be flowers and might be mouths carpet the walls, opening and closing in slow patient unison. South.", Dir::South), - wr("Drowned Crypts - The Siren's Landing", "A dry shelf above the flood holds a single carved seat facing the water, where something once sat to sing ships down. South.", Dir::South), - wr("Drowned Crypts - The Black Trench", "The floor falls away into a trench whose bottom the lantern never finds, and from which a slow cold current breathes. South.", Dir::South), - wr("Drowned Crypts - The Bone Reef", "A reef built entirely of the bones of the drowned rises in pale ramparts, and things nest in its hollows. South.", Dir::South), - wr("Drowned Crypts - The Leviathan's Maw", "A vast flooded cavern dominated by the rib-cage of something that should not fit in any sea, and in its shadow a drowned horror stirs. The way back is north.", Dir::South), - ]); - mob(spawns, "a drowned acolyte", start + 1, 58, 11, 60, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Lightning))); - mob(spawns, "a kelp-strangler", start + 3, 64, 12, 66, false, COMMON_LOOT, p(D::Poison, Some(D::Frost), Some(D::Fire))); - mob(spawns, "a reef-thing", start + 6, 70, 13, 72, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Lightning))); - mob(spawns, "a brine-bloated drowned", start + 8, 74, 13, 76, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Lightning))); - mob(spawns, "the Tide-Drowned Leviathan", last, 260, 21, 340, true, &[1008, 1204, 1302], p(D::Frost, Some(D::Frost), Some(D::Lightning))); + let last = add_wing( + rooms, + "Drowned Crypts", + false, + 54, + Dir::South, + start, + &[ + wr( + "Drowned Crypts - The Brine Stair", + "Salt-crusted steps spiral down into water that rises to meet you, cold as a drowned bell. South.", + Dir::South, + ), + wr( + "Drowned Crypts - The Coral Ossuary", + "Bone and pale coral have grown into one another until you cannot tell which the dead were and which the sea made. South.", + Dir::South, + ), + wr( + "Drowned Crypts - The Kelp Forest", + "Ropes of black kelp rise from the flooded dark and sway though there is no current, parting reluctantly as you wade. South.", + Dir::South, + ), + wr( + "Drowned Crypts - The Sunken Chapel", + "A chapel stands fully submerged, pews in drowned rows, its altar candle somehow trailing a thread of smoke up through the water. South.", + Dir::South, + ), + wr( + "Drowned Crypts - The Pearl Vault", + "Drowned treasure spills from broken chests, every coin and pearl furred with the same pale rot. South.", + Dir::South, + ), + wr( + "Drowned Crypts - The Anemone Garden", + "Things that might be flowers and might be mouths carpet the walls, opening and closing in slow patient unison. South.", + Dir::South, + ), + wr( + "Drowned Crypts - The Siren's Landing", + "A dry shelf above the flood holds a single carved seat facing the water, where something once sat to sing ships down. South.", + Dir::South, + ), + wr( + "Drowned Crypts - The Black Trench", + "The floor falls away into a trench whose bottom the lantern never finds, and from which a slow cold current breathes. South.", + Dir::South, + ), + wr( + "Drowned Crypts - The Bone Reef", + "A reef built entirely of the bones of the drowned rises in pale ramparts, and things nest in its hollows. South.", + Dir::South, + ), + wr( + "Drowned Crypts - The Leviathan's Maw", + "A vast flooded cavern dominated by the rib-cage of something that should not fit in any sea, and in its shadow a drowned horror stirs. The way back is north.", + Dir::South, + ), + ], + ); + mob( + spawns, + "a drowned acolyte", + start + 1, + 58, + 11, + 60, + false, + COMMON_LOOT, + p(D::Frost, Some(D::Frost), Some(D::Lightning)), + ); + mob( + spawns, + "a kelp-strangler", + start + 3, + 64, + 12, + 66, + false, + COMMON_LOOT, + p(D::Poison, Some(D::Frost), Some(D::Fire)), + ); + mob( + spawns, + "a reef-thing", + start + 6, + 70, + 13, + 72, + false, + COMMON_LOOT, + p(D::Frost, Some(D::Frost), Some(D::Lightning)), + ); + mob( + spawns, + "a brine-bloated drowned", + start + 8, + 74, + 13, + 76, + false, + COMMON_LOOT, + p(D::Frost, Some(D::Frost), Some(D::Lightning)), + ); + mob( + spawns, + "the Tide-Drowned Leviathan", + last, + 260, + 21, + 340, + true, + &[1008, 1204, 1302], + p(D::Frost, Some(D::Frost), Some(D::Lightning)), + ); // ---- Emberpeak: The Deep Forge (11 rooms) --------------------------- let start = 390; - let last = add_wing(rooms, "Emberpeak Mines", false, 69, Dir::North, start, &[ - wr("Emberpeak - The Cleared Drift", "Fresh rubble dragged aside; beyond it the dwarven tunnels run on, hot and red-lit. North.", Dir::North), - wr("Emberpeak - The Ore Sorters", "Conveyor troughs of cold black iron still hold their last sorted heaps of glittering ore, untouched for an age. North.", Dir::North), - wr("Emberpeak - The Gem Cutters' Hall", "Workbenches stand abandoned mid-task, half-cut gems clamped in vices, catching the forge-light like trapped sparks. North.", Dir::North), - wr("Emberpeak - The Molten Channel", "A river of slow magma crosses the hall in a stone trough, and the air above it shimmers hard enough to bend the sight. North.", Dir::North), - wr("Emberpeak - The Bellows Engine", "A vast machine of leather and iron still wheezes faintly, breathing furnace-air into tunnels no one tends. North.", Dir::North), - wr("Emberpeak - The Slag Cathedral", "Waste glass and slag have been stacked into soaring buttresses, a cathedral built by accident over a thousand years of work. North.", Dir::North), - wr("Emberpeak - The Runesmith's Sanctum", "Walls of dwarven runes pulse with banked heat, and a forge of black iron broods at the heart, never gone cold. North.", Dir::North), - wr("Emberpeak - The Ash Vault", "Knee-deep grey ash fills a sealed vault, and something has been writing in it, over and over, the same dwarven word for sorry. North.", Dir::North), - wr("Emberpeak - The Firewalk", "A narrow bridge crosses a lake of fire, the stone underfoot warm enough to feel through boots. North.", Dir::North), - wr("Emberpeak - The Heart of the Forge", "The deepest forge of all, open to a vein of living magma, where a guardian of fused slag and fire heaves itself upright. The way out is south.", Dir::North), - ]); - mob(spawns, "a coal-wretch", start + 1, 80, 14, 84, false, COMMON_LOOT, p(D::Fire, Some(D::Fire), Some(D::Frost))); - mob(spawns, "a cinder-imp", start + 3, 84, 14, 86, false, COMMON_LOOT, p(D::Fire, Some(D::Fire), Some(D::Frost))); - mob(spawns, "a runeforged sentry", start + 6, 88, 15, 90, false, COMMON_LOOT, p(D::Fire, Some(D::Physical), Some(D::Frost))); - mob(spawns, "a slag golem", start + 8, 94, 16, 96, false, COMMON_LOOT, p(D::Physical, Some(D::Fire), Some(D::Frost))); - mob(spawns, "the Forgeheart Guardian", last, 340, 27, 460, true, &[1009, 1205, 1304], p(D::Fire, Some(D::Fire), Some(D::Frost))); + let last = add_wing( + rooms, + "Emberpeak Mines", + false, + 69, + Dir::North, + start, + &[ + wr( + "Emberpeak - The Cleared Drift", + "Fresh rubble dragged aside; beyond it the dwarven tunnels run on, hot and red-lit. North.", + Dir::North, + ), + wr( + "Emberpeak - The Ore Sorters", + "Conveyor troughs of cold black iron still hold their last sorted heaps of glittering ore, untouched for an age. North.", + Dir::North, + ), + wr( + "Emberpeak - The Gem Cutters' Hall", + "Workbenches stand abandoned mid-task, half-cut gems clamped in vices, catching the forge-light like trapped sparks. North.", + Dir::North, + ), + wr( + "Emberpeak - The Molten Channel", + "A river of slow magma crosses the hall in a stone trough, and the air above it shimmers hard enough to bend the sight. North.", + Dir::North, + ), + wr( + "Emberpeak - The Bellows Engine", + "A vast machine of leather and iron still wheezes faintly, breathing furnace-air into tunnels no one tends. North.", + Dir::North, + ), + wr( + "Emberpeak - The Slag Cathedral", + "Waste glass and slag have been stacked into soaring buttresses, a cathedral built by accident over a thousand years of work. North.", + Dir::North, + ), + wr( + "Emberpeak - The Runesmith's Sanctum", + "Walls of dwarven runes pulse with banked heat, and a forge of black iron broods at the heart, never gone cold. North.", + Dir::North, + ), + wr( + "Emberpeak - The Ash Vault", + "Knee-deep grey ash fills a sealed vault, and something has been writing in it, over and over, the same dwarven word for sorry. North.", + Dir::North, + ), + wr( + "Emberpeak - The Firewalk", + "A narrow bridge crosses a lake of fire, the stone underfoot warm enough to feel through boots. North.", + Dir::North, + ), + wr( + "Emberpeak - The Heart of the Forge", + "The deepest forge of all, open to a vein of living magma, where a guardian of fused slag and fire heaves itself upright. The way out is south.", + Dir::North, + ), + ], + ); + mob( + spawns, + "a coal-wretch", + start + 1, + 80, + 14, + 84, + false, + COMMON_LOOT, + p(D::Fire, Some(D::Fire), Some(D::Frost)), + ); + mob( + spawns, + "a cinder-imp", + start + 3, + 84, + 14, + 86, + false, + COMMON_LOOT, + p(D::Fire, Some(D::Fire), Some(D::Frost)), + ); + mob( + spawns, + "a runeforged sentry", + start + 6, + 88, + 15, + 90, + false, + COMMON_LOOT, + p(D::Fire, Some(D::Physical), Some(D::Frost)), + ); + mob( + spawns, + "a slag golem", + start + 8, + 94, + 16, + 96, + false, + COMMON_LOOT, + p(D::Physical, Some(D::Fire), Some(D::Frost)), + ); + mob( + spawns, + "the Forgeheart Guardian", + last, + 340, + 27, + 460, + true, + &[1009, 1205, 1304], + p(D::Fire, Some(D::Fire), Some(D::Frost)), + ); // ---- Frostspire: The Glacier's Heart (11 rooms) --------------------- let start = 420; - let last = add_wing(rooms, "Frostspire Ascent", false, 84, Dir::North, start, &[ - wr("Frostspire - The Blue Descent", "A stair carved into the glacier itself plunges into translucent blue depths, the cold deepening with every step. North.", Dir::North), - wr("Frostspire - The Frozen Falls", "A waterfall caught mid-plunge forms a curtain of clear ice three storeys high, and behind it, dimly, something moves. North.", Dir::North), - wr("Frostspire - The Rime Galleries", "Halls of ice branch in every direction, their walls so clear you see the frozen dark of the glacier's interior pressing close. North.", Dir::North), - wr("Frostspire - The Mammoth Graveyard", "Tusked giants lie where the ice took them an age ago, perfectly kept, their great frozen eyes still open. North.", Dir::North), - wr("Frostspire - The Aurora Cavern", "Light from the surface filters down through fathoms of ice and breaks into slow drifting color across the cavern floor. North.", Dir::North), - wr("Frostspire - The Frostbound Hoard", "A dragon's hoard sheathed entirely in clear ice, every coin and crown visible and utterly unreachable. North.", Dir::North), - wr("Frostspire - The Silent Crevasse", "A crack in the glacier so deep the cold pouring from it stops your breath, and the silence is total enough to hear your own heart. North.", Dir::North), - wr("Frostspire - The Wyrm's Spine", "You walk the frozen length of some titanic serpent locked in the ice, scale after scale underfoot for a hundred paces. North.", Dir::North), - wr("Frostspire - The Last Warmth", "A geothermal vent has kept one small chamber bearable, and the bones around the dead fire say others found it too late. North.", Dir::North), - wr("Frostspire - The Glacier's Heart", "At the glacier's frozen core, a chamber of impossible blue holds an elder ice-wyrm coiled in eternal sleep, waking now, slow and vast and furious. The way back is south.", Dir::North), - ]); - mob(spawns, "a frost-bound wretch", start + 1, 100, 17, 106, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Fire))); - mob(spawns, "an ice-stalker", start + 3, 104, 18, 110, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Fire))); - mob(spawns, "a glacial revenant", start + 6, 110, 18, 116, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Fire))); - mob(spawns, "a hoarfrost wraith", start + 8, 114, 19, 120, false, COMMON_LOOT, p(D::Frost, Some(D::Physical), Some(D::Fire))); - mob(spawns, "the Heart-of-Winter Wyrm", last, 440, 33, 620, true, &[1007, 1205, 1304], p(D::Frost, Some(D::Frost), Some(D::Fire))); + let last = add_wing( + rooms, + "Frostspire Ascent", + false, + 84, + Dir::North, + start, + &[ + wr( + "Frostspire - The Blue Descent", + "A stair carved into the glacier itself plunges into translucent blue depths, the cold deepening with every step. North.", + Dir::North, + ), + wr( + "Frostspire - The Frozen Falls", + "A waterfall caught mid-plunge forms a curtain of clear ice three storeys high, and behind it, dimly, something moves. North.", + Dir::North, + ), + wr( + "Frostspire - The Rime Galleries", + "Halls of ice branch in every direction, their walls so clear you see the frozen dark of the glacier's interior pressing close. North.", + Dir::North, + ), + wr( + "Frostspire - The Mammoth Graveyard", + "Tusked giants lie where the ice took them an age ago, perfectly kept, their great frozen eyes still open. North.", + Dir::North, + ), + wr( + "Frostspire - The Aurora Cavern", + "Light from the surface filters down through fathoms of ice and breaks into slow drifting color across the cavern floor. North.", + Dir::North, + ), + wr( + "Frostspire - The Frostbound Hoard", + "A dragon's hoard sheathed entirely in clear ice, every coin and crown visible and utterly unreachable. North.", + Dir::North, + ), + wr( + "Frostspire - The Silent Crevasse", + "A crack in the glacier so deep the cold pouring from it stops your breath, and the silence is total enough to hear your own heart. North.", + Dir::North, + ), + wr( + "Frostspire - The Wyrm's Spine", + "You walk the frozen length of some titanic serpent locked in the ice, scale after scale underfoot for a hundred paces. North.", + Dir::North, + ), + wr( + "Frostspire - The Last Warmth", + "A geothermal vent has kept one small chamber bearable, and the bones around the dead fire say others found it too late. North.", + Dir::North, + ), + wr( + "Frostspire - The Glacier's Heart", + "At the glacier's frozen core, a chamber of impossible blue holds an elder ice-wyrm coiled in eternal sleep, waking now, slow and vast and furious. The way back is south.", + Dir::North, + ), + ], + ); + mob( + spawns, + "a frost-bound wretch", + start + 1, + 100, + 17, + 106, + false, + COMMON_LOOT, + p(D::Frost, Some(D::Frost), Some(D::Fire)), + ); + mob( + spawns, + "an ice-stalker", + start + 3, + 104, + 18, + 110, + false, + COMMON_LOOT, + p(D::Frost, Some(D::Frost), Some(D::Fire)), + ); + mob( + spawns, + "a glacial revenant", + start + 6, + 110, + 18, + 116, + false, + COMMON_LOOT, + p(D::Frost, Some(D::Frost), Some(D::Fire)), + ); + mob( + spawns, + "a hoarfrost wraith", + start + 8, + 114, + 19, + 120, + false, + COMMON_LOOT, + p(D::Frost, Some(D::Physical), Some(D::Fire)), + ); + mob( + spawns, + "the Heart-of-Winter Wyrm", + last, + 440, + 33, + 620, + true, + &[1007, 1205, 1304], + p(D::Frost, Some(D::Frost), Some(D::Fire)), + ); // ---- Sunken Citadel: The Forbidden Wing (10 rooms) ------------------ let start = 450; - let last = add_wing(rooms, "The Sunken Citadel", false, 99, Dir::North, start, &[ - wr("Citadel - The Sealed Wing", "A wing the citadel tried to wall away from itself, the bricks bulging outward as though something pushed from within. North.", Dir::North), - wr("Citadel - The Mirror Gallery", "Black mirrors line a hall, and your reflection is always a half-second late and, you slowly realize, not always copying what you do. North.", Dir::North), - wr("Citadel - The Forgotten Archive", "Shelves of iron books stand toppled and burned, and the ash still holds the shape of words that hurt to almost-read. North.", Dir::North), - wr("Citadel - The Astronomer's Tower", "A ruined observatory open to a sky full of wrong stars, its brass telescope aimed at a darkness that seems to aim back. North.", Dir::North), - wr("Citadel - The Hall of Hands", "Ten thousand carved stone hands reach from the walls, and as you pass, the nearest ones slowly, gently, turn to follow. North.", Dir::North), - wr("Citadel - The Drowned Laboratory", "Flooded benches hold apparatus of glass and bone, and things in jars track you with eyes that should not still be wet. North.", Dir::North), - wr("Citadel - The Whispering Crypt", "The carved mouths of the citadel reach their loudest here, all speaking the last word of the long sentence at once. North.", Dir::North), - wr("Citadel - The Throne of Echoes", "An empty throne faces a hall built to carry a single voice forever; the air still trembles faintly with the last command given. North.", Dir::North), - wr("Citadel - The Vault of Saints", "Sarcophagi of the citadel's holy dead stand cracked open from within, their occupants risen to a sanctity gone sour. North.", Dir::North), - wr("Citadel - The Antechamber of the Heart", "The black stone turns warm and almost soft here, and the lantern dims as though something ahead is drinking the light. North.", Dir::North), - wr("Citadel - The Sealed Heart", "The forbidden room at the citadel's core, where a being of folded shadow and starlight unfurls from the dark it was bound in. The way out is south.", Dir::North), - ]); - mob(spawns, "a hollow archivist", start + 2, 122, 22, 144, false, COMMON_LOOT, p(D::Shadow, Some(D::Physical), Some(D::Holy))); - mob(spawns, "a mirror-wraith", start + 4, 128, 23, 150, false, COMMON_LOOT, p(D::Shadow, Some(D::Physical), Some(D::Holy))); - mob(spawns, "a grasping hand-swarm", start + 6, 132, 24, 156, false, COMMON_LOOT, p(D::Physical, Some(D::Physical), Some(D::Arcane))); - mob(spawns, "the Warden of the Sealed Heart", last, 540, 39, 840, true, &[1109, 1202, 1304], p(D::Shadow, Some(D::Shadow), Some(D::Holy))); + let last = add_wing( + rooms, + "The Sunken Citadel", + false, + 99, + Dir::North, + start, + &[ + wr( + "Citadel - The Sealed Wing", + "A wing the citadel tried to wall away from itself, the bricks bulging outward as though something pushed from within. North.", + Dir::North, + ), + wr( + "Citadel - The Mirror Gallery", + "Black mirrors line a hall, and your reflection is always a half-second late and, you slowly realize, not always copying what you do. North.", + Dir::North, + ), + wr( + "Citadel - The Forgotten Archive", + "Shelves of iron books stand toppled and burned, and the ash still holds the shape of words that hurt to almost-read. North.", + Dir::North, + ), + wr( + "Citadel - The Astronomer's Tower", + "A ruined observatory open to a sky full of wrong stars, its brass telescope aimed at a darkness that seems to aim back. North.", + Dir::North, + ), + wr( + "Citadel - The Hall of Hands", + "Ten thousand carved stone hands reach from the walls, and as you pass, the nearest ones slowly, gently, turn to follow. North.", + Dir::North, + ), + wr( + "Citadel - The Drowned Laboratory", + "Flooded benches hold apparatus of glass and bone, and things in jars track you with eyes that should not still be wet. North.", + Dir::North, + ), + wr( + "Citadel - The Whispering Crypt", + "The carved mouths of the citadel reach their loudest here, all speaking the last word of the long sentence at once. North.", + Dir::North, + ), + wr( + "Citadel - The Throne of Echoes", + "An empty throne faces a hall built to carry a single voice forever; the air still trembles faintly with the last command given. North.", + Dir::North, + ), + wr( + "Citadel - The Vault of Saints", + "Sarcophagi of the citadel's holy dead stand cracked open from within, their occupants risen to a sanctity gone sour. North.", + Dir::North, + ), + wr( + "Citadel - The Antechamber of the Heart", + "The black stone turns warm and almost soft here, and the lantern dims as though something ahead is drinking the light. North.", + Dir::North, + ), + wr( + "Citadel - The Sealed Heart", + "The forbidden room at the citadel's core, where a being of folded shadow and starlight unfurls from the dark it was bound in. The way out is south.", + Dir::North, + ), + ], + ); + mob( + spawns, + "a hollow archivist", + start + 2, + 122, + 22, + 144, + false, + COMMON_LOOT, + p(D::Shadow, Some(D::Physical), Some(D::Holy)), + ); + mob( + spawns, + "a mirror-wraith", + start + 4, + 128, + 23, + 150, + false, + COMMON_LOOT, + p(D::Shadow, Some(D::Physical), Some(D::Holy)), + ); + mob( + spawns, + "a grasping hand-swarm", + start + 6, + 132, + 24, + 156, + false, + COMMON_LOOT, + p(D::Physical, Some(D::Physical), Some(D::Arcane)), + ); + mob( + spawns, + "the Warden of the Sealed Heart", + last, + 540, + 39, + 840, + true, + &[1109, 1202, 1304], + p(D::Shadow, Some(D::Shadow), Some(D::Holy)), + ); // ---- Obsidian Throne: The Infernal Depths (10 rooms) ---------------- let start = 480; - let last = add_wing(rooms, "The Obsidian Throne", false, 109, Dir::South, start, &[ - wr("Obsidian Throne - The Burning Descent", "A stair of cooling lava leads down into a heat that is almost a sound, a low roar at the edge of hearing. South.", Dir::South), - wr("Obsidian Throne - The Furnace of Sins", "Vast furnaces line a hall where the damned are unmade and remade, screaming on a loop ten thousand years long. South.", Dir::South), - wr("Obsidian Throne - The Chained Legion", "Rank upon rank of bound demons stand frozen at attention, and ten thousand burning eyes track you down the length of the hall. South.", Dir::South), - wr("Obsidian Throne - The Pact Chamber", "A round room of black glass where bargains were struck with the throne itself; the contracts still hang in the air, written in light, waiting. South.", Dir::South), - wr("Obsidian Throne - The River of Fire", "A true river of flame crosses the dark, and a ferryman of ash waits at its bank with an open, expectant hand. South.", Dir::South), - wr("Obsidian Throne - The Gallery of Torments", "Each alcove holds a single damned soul in eternal, inventive agony, and each turns its head to beg you for an end. South.", Dir::South), - wr("Obsidian Throne - The Brimstone Bridge", "A bridge of fused bone arches over an abyss that glows the deep red of a banked forge, exhaling sulphur. South.", Dir::South), - wr("Obsidian Throne - The Hall of Broken Oaths", "Shattered contracts litter the floor, and the air is thick with the ghosts of promises the throne was glad to see broken. South.", Dir::South), - wr("Obsidian Throne - The Weeping Pits", "Pits of black tar bubble and sigh, and each rising bubble briefly wears a face that mouths a name before it bursts. South.", Dir::South), - wr("Obsidian Throne - The Antechamber of the Abyss", "The realm thins toward something worse, the black glass going translucent on a void that has no bottom and no patience. South.", Dir::South), - wr("Obsidian Throne - The Abyssal Gate", "The realm bottoms out at a gate into pure abyss, guarded by a herald of Mal'gareth who will not let a soul pass either way. The way back is north.", Dir::South), - ]); - mob(spawns, "a chained tormentor", start + 2, 168, 30, 206, false, COMMON_LOOT, p(D::Fire, Some(D::Fire), Some(D::Holy))); - mob(spawns, "a tormented soul-husk", start + 4, 174, 31, 212, false, COMMON_LOOT, p(D::Shadow, Some(D::Fire), Some(D::Holy))); - mob(spawns, "an ash ferryman", start + 6, 182, 32, 222, false, COMMON_LOOT, p(D::Fire, Some(D::Fire), Some(D::Holy))); - mob(spawns, "the Herald of Mal'gareth", last, 620, 43, 1100, true, &[1009, 1205, 1401], p(D::Shadow, Some(D::Fire), Some(D::Holy))); + let last = add_wing( + rooms, + "The Obsidian Throne", + false, + 109, + Dir::South, + start, + &[ + wr( + "Obsidian Throne - The Burning Descent", + "A stair of cooling lava leads down into a heat that is almost a sound, a low roar at the edge of hearing. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The Furnace of Sins", + "Vast furnaces line a hall where the damned are unmade and remade, screaming on a loop ten thousand years long. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The Chained Legion", + "Rank upon rank of bound demons stand frozen at attention, and ten thousand burning eyes track you down the length of the hall. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The Pact Chamber", + "A round room of black glass where bargains were struck with the throne itself; the contracts still hang in the air, written in light, waiting. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The River of Fire", + "A true river of flame crosses the dark, and a ferryman of ash waits at its bank with an open, expectant hand. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The Gallery of Torments", + "Each alcove holds a single damned soul in eternal, inventive agony, and each turns its head to beg you for an end. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The Brimstone Bridge", + "A bridge of fused bone arches over an abyss that glows the deep red of a banked forge, exhaling sulphur. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The Hall of Broken Oaths", + "Shattered contracts litter the floor, and the air is thick with the ghosts of promises the throne was glad to see broken. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The Weeping Pits", + "Pits of black tar bubble and sigh, and each rising bubble briefly wears a face that mouths a name before it bursts. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The Antechamber of the Abyss", + "The realm thins toward something worse, the black glass going translucent on a void that has no bottom and no patience. South.", + Dir::South, + ), + wr( + "Obsidian Throne - The Abyssal Gate", + "The realm bottoms out at a gate into pure abyss, guarded by a herald of Mal'gareth who will not let a soul pass either way. The way back is north.", + Dir::South, + ), + ], + ); + mob( + spawns, + "a chained tormentor", + start + 2, + 168, + 30, + 206, + false, + COMMON_LOOT, + p(D::Fire, Some(D::Fire), Some(D::Holy)), + ); + mob( + spawns, + "a tormented soul-husk", + start + 4, + 174, + 31, + 212, + false, + COMMON_LOOT, + p(D::Shadow, Some(D::Fire), Some(D::Holy)), + ); + mob( + spawns, + "an ash ferryman", + start + 6, + 182, + 32, + 222, + false, + COMMON_LOOT, + p(D::Fire, Some(D::Fire), Some(D::Holy)), + ); + mob( + spawns, + "the Herald of Mal'gareth", + last, + 620, + 43, + 1100, + true, + &[1009, 1205, 1401], + p(D::Shadow, Some(D::Fire), Some(D::Holy)), + ); // ---- King's Road: The Bandit Trail (9 rooms, low-level detour) ------ let start = 510; - let last = add_wing(rooms, "King's Road", false, 8, Dir::East, start, &[ - wr("King's Road - The Poacher's Trail", "A narrow trail worn by furtive feet winds east through the brush, snares glinting in the undergrowth. East.", Dir::East), - wr("King's Road - The Hollow Tree", "A hollow oak big enough to shelter in has been used as exactly that; a cold campfire and gnawed bones say by whom. East.", Dir::East), - wr("King's Road - The Abandoned Farmstead", "A burned-out farm slumps in a clearing, its fields gone to weed, its well gone to black water. East.", Dir::East), - wr("King's Road - The Scarecrow Field", "Rags on crossed sticks lean at wrong angles across a dead field, and you count one more of them on the way out than on the way in. East.", Dir::East), - wr("King's Road - The Crossroads Gibbet", "An iron gibbet creaks at a forgotten crossroads, its occupant long since flown to bone. East.", Dir::East), - wr("King's Road - The Smuggler's Cellar", "A trapdoor in the ruin of an inn drops to a cellar of stolen goods, half of it spoiled, all of it watched. East.", Dir::East), - wr("King's Road - The Watchpost", "A half-built bandit watchpost overlooks the trail, its lookout's stool still warm, its lookout suddenly not in sight. East.", Dir::East), - wr("King's Road - The Camp Approach", "The trees thin toward firelight and rough laughter; you are clearly expected, and clearly not welcome. East.", Dir::East), - wr("King's Road - The Bandit Camp", "A ring of tattered tents around a guttering fire marks the lair of the road's bandit crew, and their chief rises, hand on hilt, to greet the fool who found them. The way back is west.", Dir::East), - ]); - mob(spawns, "a feral poacher's hound", start + 1, 26, 5, 22, false, COMMON_LOOT, DamageProfile::physical()); - mob(spawns, "a road cutthroat", start + 4, 30, 6, 24, false, COMMON_LOOT, DamageProfile::physical()); - mob(spawns, "a crossbow bandit", start + 6, 32, 7, 28, false, COMMON_LOOT, DamageProfile::physical()); - mob(spawns, "the Bandit Chief Garrote", last, 110, 12, 130, true, &[1006, 1201, 1301], DamageProfile::physical()); + let last = add_wing( + rooms, + "King's Road", + false, + 8, + Dir::East, + start, + &[ + wr( + "King's Road - The Poacher's Trail", + "A narrow trail worn by furtive feet winds east through the brush, snares glinting in the undergrowth. East.", + Dir::East, + ), + wr( + "King's Road - The Hollow Tree", + "A hollow oak big enough to shelter in has been used as exactly that; a cold campfire and gnawed bones say by whom. East.", + Dir::East, + ), + wr( + "King's Road - The Abandoned Farmstead", + "A burned-out farm slumps in a clearing, its fields gone to weed, its well gone to black water. East.", + Dir::East, + ), + wr( + "King's Road - The Scarecrow Field", + "Rags on crossed sticks lean at wrong angles across a dead field, and you count one more of them on the way out than on the way in. East.", + Dir::East, + ), + wr( + "King's Road - The Crossroads Gibbet", + "An iron gibbet creaks at a forgotten crossroads, its occupant long since flown to bone. East.", + Dir::East, + ), + wr( + "King's Road - The Smuggler's Cellar", + "A trapdoor in the ruin of an inn drops to a cellar of stolen goods, half of it spoiled, all of it watched. East.", + Dir::East, + ), + wr( + "King's Road - The Watchpost", + "A half-built bandit watchpost overlooks the trail, its lookout's stool still warm, its lookout suddenly not in sight. East.", + Dir::East, + ), + wr( + "King's Road - The Camp Approach", + "The trees thin toward firelight and rough laughter; you are clearly expected, and clearly not welcome. East.", + Dir::East, + ), + wr( + "King's Road - The Bandit Camp", + "A ring of tattered tents around a guttering fire marks the lair of the road's bandit crew, and their chief rises, hand on hilt, to greet the fool who found them. The way back is west.", + Dir::East, + ), + ], + ); + mob( + spawns, + "a feral poacher's hound", + start + 1, + 26, + 5, + 22, + false, + COMMON_LOOT, + DamageProfile::physical(), + ); + mob( + spawns, + "a road cutthroat", + start + 4, + 30, + 6, + 24, + false, + COMMON_LOOT, + DamageProfile::physical(), + ); + mob( + spawns, + "a crossbow bandit", + start + 6, + 32, + 7, + 28, + false, + COMMON_LOOT, + DamageProfile::physical(), + ); + mob( + spawns, + "the Bandit Chief Garrote", + last, + 110, + 12, + 130, + true, + &[1006, 1201, 1301], + DamageProfile::physical(), + ); } /// Common low-tier drop pool shared by wandering wing mobs. @@ -2090,7 +2951,7 @@ mod tests { #[test] fn world_has_expected_size_and_every_mob_homes_to_a_real_room() { let world = seed_world(); - assert_eq!(world.rooms.len(), 200, "expected 200 authored rooms"); + assert_eq!(world.rooms.len(), 198, "expected 198 authored rooms"); for spawn in &world.spawns { assert!( world.rooms.contains_key(&spawn.home), diff --git a/late-ssh/tests/helpers/mod.rs b/late-ssh/tests/helpers/mod.rs index e498067e..40a9a15e 100644 --- a/late-ssh/tests/helpers/mod.rs +++ b/late-ssh/tests/helpers/mod.rs @@ -30,6 +30,7 @@ use late_ssh::app::rooms::asterion::manager::AsterionRoomManager; use late_ssh::app::rooms::blackjack::manager::BlackjackTableManager; use late_ssh::app::rooms::blackjack::player::BlackjackPlayerDirectory; use late_ssh::app::rooms::chess::manager::ChessTableManager; +use late_ssh::app::rooms::mud::manager::MudTableManager; use late_ssh::app::rooms::poker::manager::PokerTableManager; use late_ssh::app::rooms::registry::RoomGameRegistry; use late_ssh::app::rooms::svc::RoomsService; @@ -76,7 +77,7 @@ fn test_room_game_registry(db: Db) -> RoomGameRegistry { ); let blackjack_table_manager = BlackjackTableManager::new( chip_service.clone(), - BlackjackPlayerDirectory::new(db), + BlackjackPlayerDirectory::new(db.clone()), activity_publisher.clone(), ); RoomGameRegistry::new( @@ -87,6 +88,7 @@ fn test_room_game_registry(db: Db) -> RoomGameRegistry { activity_publisher.clone(), rooms_service, ), + MudTableManager::new(activity_publisher.clone(), db.clone()), PokerTableManager::new(chip_service.clone(), activity_publisher.clone()), TicTacToeTableManager::new(activity_publisher.clone()), TronTableManager::new(chip_service, activity_publisher.clone()), @@ -250,6 +252,7 @@ pub fn test_app_state(db: Db, config: Config) -> State { activity_publisher.clone(), rooms_service.clone(), ), + MudTableManager::new(activity_publisher.clone(), db.clone()), PokerTableManager::new(chip_service.clone(), activity_publisher.clone()), TicTacToeTableManager::new(activity_publisher.clone()), TronTableManager::new(chip_service.clone(), activity_publisher.clone()), diff --git a/vendor/potatis/mos6502/src/mos6502.rs b/vendor/potatis/mos6502/src/mos6502.rs index da1cb4a0..6d0aa1f2 100644 --- a/vendor/potatis/mos6502/src/mos6502.rs +++ b/vendor/potatis/mos6502/src/mos6502.rs @@ -34,7 +34,7 @@ impl Mos6502 { } #[cfg(feature = "debugger")] - pub fn debugger(&mut self) -> AttachedDebugger { + pub fn debugger(&mut self) -> AttachedDebugger<'_, B> { self.debugger.attach(&mut self.cpu) } diff --git a/vendor/potatis/nes/src/nes.rs b/vendor/potatis/nes/src/nes.rs index e3052617..1a0efa79 100644 --- a/vendor/potatis/nes/src/nes.rs +++ b/vendor/potatis/nes/src/nes.rs @@ -221,7 +221,7 @@ impl Nes { } #[cfg(feature = "debugger")] - pub fn debugger(&mut self) -> AttachedDebugger { + pub fn debugger(&mut self) -> AttachedDebugger<'_, NesBus> { self.machine.debugger() } From e2c2c93dec9da153a6d4576afa9c4c4214ef77cb Mon Sep 17 00:00:00 2001 From: Mike Clark Date: Tue, 2 Jun 2026 20:27:27 -0600 Subject: [PATCH 08/20] Split PR CI build scopes --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ .github/workflows/pr.yml | 27 +++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f008ca54..8a380490 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,21 @@ on: required: false type: string default: "" + exclude_package: + description: "Cargo package to exclude when checking the workspace" + required: false + type: string + default: "" source_ref: description: "Git ref to check out" required: false type: string default: "" + run_fmt: + description: "Run rustfmt before clippy + test" + required: false + type: boolean + default: true permissions: contents: read @@ -20,6 +30,7 @@ permissions: jobs: fmt: name: rustfmt + if: ${{ inputs.run_fmt }} runs-on: ubuntu-latest steps: - name: checkout @@ -34,7 +45,12 @@ jobs: clippy-test: name: clippy + test needs: [fmt] + if: ${{ !cancelled() && (!inputs.run_fmt || needs.fmt.result == 'success') }} runs-on: ubuntu-latest + env: + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" services: postgres: image: postgres:18-alpine @@ -68,10 +84,13 @@ jobs: - name: cargo_clippy env: CARGO_PACKAGE: ${{ inputs.package }} + CARGO_EXCLUDE_PACKAGE: ${{ inputs.exclude_package }} run: | package_args=(--workspace) if [ -n "$CARGO_PACKAGE" ]; then package_args=(-p "$CARGO_PACKAGE") + elif [ -n "$CARGO_EXCLUDE_PACKAGE" ]; then + package_args+=(--exclude "$CARGO_EXCLUDE_PACKAGE") fi cargo clippy "${package_args[@]}" --all-targets --features otel -- -D warnings @@ -81,10 +100,13 @@ jobs: env: TEST_DATABASE_URL: "host=127.0.0.1 port=5432 user=test password=test dbname=postgres" CARGO_PACKAGE: ${{ inputs.package }} + CARGO_EXCLUDE_PACKAGE: ${{ inputs.exclude_package }} run: | package_args=(--workspace) if [ -n "$CARGO_PACKAGE" ]; then package_args=(-p "$CARGO_PACKAGE") + elif [ -n "$CARGO_EXCLUDE_PACKAGE" ]; then + package_args+=(--exclude "$CARGO_EXCLUDE_PACKAGE") fi cargo nextest run "${package_args[@]}" --all-targets diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index fc311481..96c3f876 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -8,6 +8,29 @@ concurrency: cancel-in-progress: true jobs: - ci: - name: ci + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v5 + - name: install_rustfmt + run: rustup component add rustfmt + - name: cargo_fmt_check + run: cargo fmt --all -- --check + + late-cli: + name: late-cli + needs: [fmt] + uses: ./.github/workflows/ci.yml + with: + package: late-cli + run_fmt: false + + not-late-cli: + name: not-late-cli + needs: [fmt] uses: ./.github/workflows/ci.yml + with: + exclude_package: late-cli + run_fmt: false From 0cc96c107c1062ea7920d90aa54b61f0c9749e53 Mon Sep 17 00:00:00 2001 From: Mike Clark Date: Tue, 2 Jun 2026 20:48:28 -0600 Subject: [PATCH 09/20] Run reusable fmt checks in PR CI --- .github/workflows/pr.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 96c3f876..72b831a7 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -25,7 +25,6 @@ jobs: uses: ./.github/workflows/ci.yml with: package: late-cli - run_fmt: false not-late-cli: name: not-late-cli @@ -33,4 +32,3 @@ jobs: uses: ./.github/workflows/ci.yml with: exclude_package: late-cli - run_fmt: false From dc10a13913ac55420e4a30ce234b2ae4aef8d69b Mon Sep 17 00:00:00 2001 From: mateuszpiorowski Date: Thu, 4 Jun 2026 21:37:39 +0200 Subject: [PATCH 10/20] update --- late-core/src/models/game_room.rs | 6 +- late-ssh/src/app/common/primitives.rs | 16 +- late-ssh/src/app/dashboard/input.rs | 2 +- .../{rooms/mud => door/lateania}/abilities.rs | 2 +- .../{rooms/mud => door/lateania}/classes.rs | 0 .../{rooms/mud => door/lateania}/damage.rs | 0 .../app/{rooms/mud => door/lateania}/input.rs | 23 +- .../app/{rooms/mud => door/lateania}/items.rs | 0 .../app/{rooms/mud => door/lateania}/mod.rs | 2 - .../{rooms/mud => door/lateania}/persist.rs | 0 .../app/{rooms/mud => door/lateania}/state.rs | 20 +- .../app/{rooms/mud => door/lateania}/svc.rs | 48 +--- .../app/{rooms/mud => door/lateania}/ui.rs | 48 +++- .../app/{rooms/mud => door/lateania}/world.rs | 4 +- late-ssh/src/app/door/mod.rs | 3 + late-ssh/src/app/help_modal/data.rs | 101 ++++---- late-ssh/src/app/help_modal/hub_guide.rs | 22 +- late-ssh/src/app/input.rs | 51 +++- late-ssh/src/app/mod.rs | 1 + late-ssh/src/app/render.rs | 25 +- late-ssh/src/app/rooms/filter.rs | 1 - late-ssh/src/app/rooms/mod.rs | 1 - late-ssh/src/app/rooms/mud/create_modal.rs | 236 ------------------ late-ssh/src/app/rooms/mud/manager.rs | 199 --------------- late-ssh/src/app/rooms/registry.rs | 5 - late-ssh/src/app/rooms/ui.rs | 10 +- late-ssh/src/app/settings_modal/ui.rs | 2 +- late-ssh/src/app/state.rs | 30 +++ late-ssh/src/app/tick.rs | 3 + late-ssh/src/main.rs | 4 +- late-ssh/src/session_bootstrap.rs | 1 + late-ssh/src/ssh.rs | 1 + late-ssh/src/state.rs | 1 + late-ssh/tests/app_input_flow.rs | 33 ++- late-ssh/tests/app_smoke.rs | 5 +- late-ssh/tests/helpers/mod.rs | 15 +- 36 files changed, 302 insertions(+), 619 deletions(-) rename late-ssh/src/app/{rooms/mud => door/lateania}/abilities.rs (99%) rename late-ssh/src/app/{rooms/mud => door/lateania}/classes.rs (100%) rename late-ssh/src/app/{rooms/mud => door/lateania}/damage.rs (100%) rename late-ssh/src/app/{rooms/mud => door/lateania}/input.rs (92%) rename late-ssh/src/app/{rooms/mud => door/lateania}/items.rs (100%) rename late-ssh/src/app/{rooms/mud => door/lateania}/mod.rs (91%) rename late-ssh/src/app/{rooms/mud => door/lateania}/persist.rs (100%) rename late-ssh/src/app/{rooms/mud => door/lateania}/state.rs (94%) rename late-ssh/src/app/{rooms/mud => door/lateania}/svc.rs (98%) rename late-ssh/src/app/{rooms/mud => door/lateania}/ui.rs (93%) rename late-ssh/src/app/{rooms/mud => door/lateania}/world.rs (99%) create mode 100644 late-ssh/src/app/door/mod.rs delete mode 100644 late-ssh/src/app/rooms/mud/create_modal.rs delete mode 100644 late-ssh/src/app/rooms/mud/manager.rs diff --git a/late-core/src/models/game_room.rs b/late-core/src/models/game_room.rs index 9aebc5cd..703194c8 100644 --- a/late-core/src/models/game_room.rs +++ b/late-core/src/models/game_room.rs @@ -10,7 +10,6 @@ pub enum GameKind { Asterion, Blackjack, Chess, - Mud, Poker, Sshattrick, TicTacToe, @@ -18,11 +17,10 @@ pub enum GameKind { } impl GameKind { - pub const ALL: [Self; 8] = [ + pub const ALL: [Self; 7] = [ Self::Asterion, Self::Blackjack, Self::Chess, - Self::Mud, Self::Poker, Self::Sshattrick, Self::TicTacToe, @@ -34,7 +32,6 @@ impl GameKind { Self::Asterion => "asterion", Self::Blackjack => "blackjack", Self::Chess => "chess", - Self::Mud => "mud", Self::Poker => "poker", Self::Sshattrick => "sshattrick", Self::TicTacToe => "tictactoe", @@ -57,7 +54,6 @@ impl TryFrom<&str> for GameKind { "asterion" => Ok(Self::Asterion), "blackjack" => Ok(Self::Blackjack), "chess" => Ok(Self::Chess), - "mud" => Ok(Self::Mud), "poker" => Ok(Self::Poker), "sshattrick" => Ok(Self::Sshattrick), "tictactoe" => Ok(Self::TicTacToe), diff --git a/late-ssh/src/app/common/primitives.rs b/late-ssh/src/app/common/primitives.rs index 742b7019..793d149c 100644 --- a/late-ssh/src/app/common/primitives.rs +++ b/late-ssh/src/app/common/primitives.rs @@ -51,6 +51,7 @@ pub enum Screen { Dashboard, Arcade, Rooms, + DoorGames, Artboard, Pinstar, } @@ -60,7 +61,8 @@ impl Screen { match self { Screen::Dashboard => Screen::Arcade, Screen::Arcade => Screen::Rooms, - Screen::Rooms => Screen::Artboard, + Screen::Rooms => Screen::DoorGames, + Screen::DoorGames => Screen::Artboard, Screen::Artboard => Screen::Pinstar, Screen::Pinstar => Screen::Dashboard, } @@ -71,7 +73,8 @@ impl Screen { Screen::Dashboard => Screen::Pinstar, Screen::Arcade => Screen::Dashboard, Screen::Rooms => Screen::Arcade, - Screen::Artboard => Screen::Rooms, + Screen::DoorGames => Screen::Rooms, + Screen::Artboard => Screen::DoorGames, Screen::Pinstar => Screen::Artboard, } } @@ -96,8 +99,9 @@ pub fn format_duration_mmss(duration: Duration) -> String { pub fn draw_tabs(frame: &mut Frame, area: Rect, current: Screen) { let label = match current { Screen::Dashboard => "Dashboard", + Screen::DoorGames => "Door Games", Screen::Arcade => "Arcade", - Screen::Rooms => "Rooms", + Screen::Rooms => "Tables", Screen::Artboard => "Artboard", Screen::Pinstar => "Directory", }; @@ -180,7 +184,8 @@ mod tests { fn screen_next_cycles_all_screens() { assert_eq!(Screen::Dashboard.next(), Screen::Arcade); assert_eq!(Screen::Arcade.next(), Screen::Rooms); - assert_eq!(Screen::Rooms.next(), Screen::Artboard); + assert_eq!(Screen::Rooms.next(), Screen::DoorGames); + assert_eq!(Screen::DoorGames.next(), Screen::Artboard); assert_eq!(Screen::Artboard.next(), Screen::Pinstar); assert_eq!(Screen::Pinstar.next(), Screen::Dashboard); } @@ -190,7 +195,8 @@ mod tests { assert_eq!(Screen::Dashboard.prev(), Screen::Pinstar); assert_eq!(Screen::Arcade.prev(), Screen::Dashboard); assert_eq!(Screen::Rooms.prev(), Screen::Arcade); - assert_eq!(Screen::Artboard.prev(), Screen::Rooms); + assert_eq!(Screen::DoorGames.prev(), Screen::Rooms); + assert_eq!(Screen::Artboard.prev(), Screen::DoorGames); assert_eq!(Screen::Pinstar.prev(), Screen::Artboard); } diff --git a/late-ssh/src/app/dashboard/input.rs b/late-ssh/src/app/dashboard/input.rs index 55b7da35..fa3ed259 100644 --- a/late-ssh/src/app/dashboard/input.rs +++ b/late-ssh/src/app/dashboard/input.rs @@ -52,7 +52,7 @@ fn enter_first_game_workspace(app: &mut App) -> bool { app.dashboard_game_toggle_target = Some(DashboardGameToggleTarget::Arcade); app.set_screen(Screen::Arcade); } else { - app.banner = Some(Banner::error("No seated game rooms.")); + app.banner = Some(Banner::error("No seated tables.")); } true } diff --git a/late-ssh/src/app/rooms/mud/abilities.rs b/late-ssh/src/app/door/lateania/abilities.rs similarity index 99% rename from late-ssh/src/app/rooms/mud/abilities.rs rename to late-ssh/src/app/door/lateania/abilities.rs index 7cc00340..7402cc36 100644 --- a/late-ssh/src/app/rooms/mud/abilities.rs +++ b/late-ssh/src/app/door/lateania/abilities.rs @@ -869,7 +869,7 @@ pub fn learned_at(class: Class, level: i32) -> Option<&'static Ability> { #[cfg(test)] mod tests { use super::*; - use crate::app::rooms::mud::classes::Class; + use crate::app::door::lateania::classes::Class; #[test] fn every_class_has_a_level_one_ability() { diff --git a/late-ssh/src/app/rooms/mud/classes.rs b/late-ssh/src/app/door/lateania/classes.rs similarity index 100% rename from late-ssh/src/app/rooms/mud/classes.rs rename to late-ssh/src/app/door/lateania/classes.rs diff --git a/late-ssh/src/app/rooms/mud/damage.rs b/late-ssh/src/app/door/lateania/damage.rs similarity index 100% rename from late-ssh/src/app/rooms/mud/damage.rs rename to late-ssh/src/app/door/lateania/damage.rs diff --git a/late-ssh/src/app/rooms/mud/input.rs b/late-ssh/src/app/door/lateania/input.rs similarity index 92% rename from late-ssh/src/app/rooms/mud/input.rs rename to late-ssh/src/app/door/lateania/input.rs index 8b19202a..74597677 100644 --- a/late-ssh/src/app/rooms/mud/input.rs +++ b/late-ssh/src/app/door/lateania/input.rs @@ -1,11 +1,5 @@ // Key routing for Lateania. // -// The rooms layer routes several keys to the embedded chat before a game ever -// sees them - notably `i`, `j`, `k` (and `d`/`r`/`e`/`p`/`c`/`f`/`g` while a -// chat message is selected). See `rooms/input.rs::should_route_active_room_chat_key`. -// Number keys 1-9 are NOT intercepted (TicTacToe uses them), so the action bar -// and list selection live there. -// // Key scheme: // - Before choosing a class: 1-5 pick Warrior/Mage/Cleric/Rogue/Ranger. // - Movement: w/a/s/d and arrows (N/S/E/W); y/u/b/n diagonals; < > up/down. @@ -17,17 +11,22 @@ // // A full typed command prompt needs an input-capture mode; deferred. -use crate::app::rooms::{ - backend::InputAction, - mud::classes::Class, - mud::state::{Panel, State}, - mud::world::Dir, +use super::{ + classes::Class, + state::{Panel, State}, + world::Dir, }; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputAction { + Ignored, + Handled, + Leave, +} + pub fn handle_key(state: &mut State, byte: u8) -> InputAction { // Quit is always available. if matches!(byte, 0x1B | b'q' | b'Q') { - state.leave_world(); return InputAction::Leave; } diff --git a/late-ssh/src/app/rooms/mud/items.rs b/late-ssh/src/app/door/lateania/items.rs similarity index 100% rename from late-ssh/src/app/rooms/mud/items.rs rename to late-ssh/src/app/door/lateania/items.rs diff --git a/late-ssh/src/app/rooms/mud/mod.rs b/late-ssh/src/app/door/lateania/mod.rs similarity index 91% rename from late-ssh/src/app/rooms/mud/mod.rs rename to late-ssh/src/app/door/lateania/mod.rs index 78d8c13c..4a37406e 100644 --- a/late-ssh/src/app/rooms/mud/mod.rs +++ b/late-ssh/src/app/door/lateania/mod.rs @@ -5,11 +5,9 @@ // contributes to it. This world stands on the foundation you built. pub mod abilities; pub mod classes; -pub mod create_modal; pub mod damage; pub mod input; pub mod items; -pub mod manager; pub mod persist; pub mod state; pub mod svc; diff --git a/late-ssh/src/app/rooms/mud/persist.rs b/late-ssh/src/app/door/lateania/persist.rs similarity index 100% rename from late-ssh/src/app/rooms/mud/persist.rs rename to late-ssh/src/app/door/lateania/persist.rs diff --git a/late-ssh/src/app/rooms/mud/state.rs b/late-ssh/src/app/door/lateania/state.rs similarity index 94% rename from late-ssh/src/app/rooms/mud/state.rs rename to late-ssh/src/app/door/lateania/state.rs index 51cb6237..62741bba 100644 --- a/late-ssh/src/app/rooms/mud/state.rs +++ b/late-ssh/src/app/door/lateania/state.rs @@ -10,7 +10,7 @@ use tokio::sync::watch; use uuid::Uuid; use super::classes::Class; -use super::svc::{MudService, MudSnapshot, PlayerView, empty_player_view}; +use super::svc::{LateaniaService, MudSnapshot, PlayerView, empty_player_view}; use super::world::Dir; /// Which side panel the session is looking at. @@ -26,7 +26,7 @@ pub enum Panel { pub struct State { user_id: Uuid, snapshot: MudSnapshot, - svc: MudService, + svc: LateaniaService, snapshot_rx: watch::Receiver, panel: Panel, /// Selection cursor for the inventory/shop list panels. @@ -34,7 +34,7 @@ pub struct State { } impl State { - pub fn new(svc: MudService, user_id: Uuid) -> Self { + pub fn new(svc: LateaniaService, user_id: Uuid) -> Self { let snapshot_rx = svc.subscribe_state(); let snapshot = snapshot_rx.borrow().clone(); let state = Self { @@ -49,14 +49,6 @@ impl State { state } - pub fn room_id(&self) -> Uuid { - self.svc.room_id() - } - - pub fn is_self(&self, user_id: Uuid) -> bool { - self.user_id == user_id - } - pub fn tick(&mut self) { if self.snapshot_rx.has_changed().unwrap_or(false) { self.snapshot = self.snapshot_rx.borrow_and_update().clone(); @@ -187,3 +179,9 @@ impl State { } } } + +impl Drop for State { + fn drop(&mut self) { + self.svc.leave_task(self.user_id); + } +} diff --git a/late-ssh/src/app/rooms/mud/svc.rs b/late-ssh/src/app/door/lateania/svc.rs similarity index 98% rename from late-ssh/src/app/rooms/mud/svc.rs rename to late-ssh/src/app/door/lateania/svc.rs index b568bf8e..ecf33d47 100644 --- a/late-ssh/src/app/rooms/mud/svc.rs +++ b/late-ssh/src/app/door/lateania/svc.rs @@ -1,7 +1,8 @@ -// Lateania world runtime: the authoritative, in-memory truth for one MUD world. +// Lateania world runtime: the authoritative, in-memory truth for the server-wide +// MUD world. // -// One service per game room (the late "room" is the whole world). Many sessions -// share it via the manager's HashMap; each has its own `state::State`. Mutations +// One service is shared by the process. Sessions join it only while the +// dedicated Lateania page is open; each has its own `state::State`. Mutations // serialize through `Arc>`; reads are lock-free against each // session's cached snapshot. A background tick loop advances combat rounds, // effects, resource regen, and respawns, then publishes a fresh snapshot. @@ -18,13 +19,10 @@ use std::{ use late_core::{db::Db, models::mud_character::MudCharacter}; use rand::Rng; -use tokio::sync::{Mutex, broadcast, watch}; +use tokio::sync::{Mutex, watch}; use uuid::Uuid; -use crate::app::{ - activity::{event::ActivityGame, publisher::ActivityPublisher}, - rooms::backend::RoomGameEvent, -}; +use crate::app::activity::{event::ActivityGame, publisher::ActivityPublisher}; use super::abilities::{Ability, AbilityEffect, learned_at, unlocked_for}; use super::classes::{Class, level_for_xp, xp_for_level}; @@ -46,11 +44,9 @@ const STARTING_GOLD: i64 = 120; const AUTOSAVE_SECS: u64 = 60; #[derive(Clone)] -pub struct MudService { - room_id: Uuid, +pub struct LateaniaService { activity: ActivityPublisher, db: Db, - room_event_tx: broadcast::Sender, snapshot_tx: watch::Sender, snapshot_rx: watch::Receiver, state: Arc>, @@ -213,26 +209,15 @@ pub fn empty_player_view() -> PlayerView { PlayerView::empty() } -impl MudService { - pub fn new(room_id: Uuid, activity: ActivityPublisher, db: Db) -> Self { - let (room_event_tx, _) = broadcast::channel::(16); - Self::new_with_events(room_id, activity, db, room_event_tx) - } - - pub fn new_with_events( - room_id: Uuid, - activity: ActivityPublisher, - db: Db, - room_event_tx: broadcast::Sender, - ) -> Self { +impl LateaniaService { + pub fn new(activity: ActivityPublisher, db: Db) -> Self { + let room_id = Uuid::from_u128(0x4c41_5445_414e_4941_0000_0000_0000_0001); let state = WorldState::new(room_id, seed_world()); let initial = state.snapshot(); let (snapshot_tx, snapshot_rx) = watch::channel(initial); let svc = Self { - room_id, activity, db, - room_event_tx, snapshot_tx, snapshot_rx, state: Arc::new(Mutex::new(state)), @@ -242,10 +227,6 @@ impl MudService { svc } - pub fn room_id(&self) -> Uuid { - self.room_id - } - pub fn subscribe_state(&self) -> watch::Receiver { self.snapshot_rx.clone() } @@ -301,7 +282,7 @@ impl MudService { None } }; - let joined = { + { let mut state = svc.state.lock().await; let joined = state.join(user_id); if joined && let Some(saved) = saved { @@ -309,13 +290,6 @@ impl MudService { } state.touch(user_id); svc.publish(&state); - joined - }; - if joined { - let _ = svc.room_event_tx.send(RoomGameEvent::SeatJoined { - room_id: svc.room_id, - user_id, - }); } }); } diff --git a/late-ssh/src/app/rooms/mud/ui.rs b/late-ssh/src/app/door/lateania/ui.rs similarity index 93% rename from late-ssh/src/app/rooms/mud/ui.rs rename to late-ssh/src/app/door/lateania/ui.rs index d9a731e2..4e512bc6 100644 --- a/late-ssh/src/app/rooms/mud/ui.rs +++ b/late-ssh/src/app/door/lateania/ui.rs @@ -11,16 +11,15 @@ use ratatui::{ widgets::Paragraph, }; -use crate::app::{ - common::theme, - rooms::mud::{ - classes::Class, - state::{Panel, State}, - svc::{LogKind, PlayerView}, - }, -}; +use crate::app::common::theme; use crate::usernames::UsernameLookup; +use super::{ + classes::Class, + state::{Panel, State}, + svc::{LogKind, PlayerView}, +}; + const SIDE_WIDE: u16 = 34; const SIDE_NARROW: u16 = 28; @@ -58,6 +57,39 @@ pub fn draw_game(frame: &mut Frame, area: Rect, state: &State, usernames: &Usern draw_side(frame, cols[1], state, &view, usernames); } +pub fn draw_page(frame: &mut Frame, area: Rect, state: &State, usernames: &UsernameLookup<'_>) { + if area.height < 4 { + draw_game(frame, area, state, usernames); + return; + } + + let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(area); + let view = state.view(); + let title = if view.classed { + format!( + "LATEANIA BBS DOOR | {} lvl {} | {} adventurers online", + view.class_name, + view.level, + state.player_count() + ) + } else { + format!( + "LATEANIA BBS DOOR | persistent server world | {} online", + state.player_count() + ) + }; + frame.render_widget( + Paragraph::new(vec![Line::from(vec![Span::styled( + title, + Style::default() + .fg(theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + )])]), + rows[0], + ); + draw_game(frame, rows[1], state, usernames); +} + fn draw_class_select(frame: &mut Frame, area: Rect, _view: &PlayerView) { let mut lines = vec![ Line::from(Span::styled( diff --git a/late-ssh/src/app/rooms/mud/world.rs b/late-ssh/src/app/door/lateania/world.rs similarity index 99% rename from late-ssh/src/app/rooms/mud/world.rs rename to late-ssh/src/app/door/lateania/world.rs index 16d5b186..b48ab049 100644 --- a/late-ssh/src/app/rooms/mud/world.rs +++ b/late-ssh/src/app/door/lateania/world.rs @@ -2995,7 +2995,7 @@ mod tests { assert!(!boss.loot.is_empty(), "boss {} has no loot", boss.name); for id in boss.loot { assert!( - crate::app::rooms::mud::items::item(*id).is_some(), + crate::app::door::lateania::items::item(*id).is_some(), "boss {} drops missing item {}", boss.name, id @@ -3010,7 +3010,7 @@ mod tests { for spawn in &world.spawns { for id in spawn.loot { assert!( - crate::app::rooms::mud::items::item(*id).is_some(), + crate::app::door::lateania::items::item(*id).is_some(), "mob {} drops missing item {}", spawn.name, id diff --git a/late-ssh/src/app/door/mod.rs b/late-ssh/src/app/door/mod.rs new file mode 100644 index 00000000..2cd0c33e --- /dev/null +++ b/late-ssh/src/app/door/mod.rs @@ -0,0 +1,3 @@ +// Door Games domain. For now this only hosts Lateania, but the namespace is +// intentionally broader than the first game. +pub mod lateania; diff --git a/late-ssh/src/app/help_modal/data.rs b/late-ssh/src/app/help_modal/data.rs index 48ad17eb..e11ea1c6 100644 --- a/late-ssh/src/app/help_modal/data.rs +++ b/late-ssh/src/app/help_modal/data.rs @@ -11,7 +11,9 @@ pub enum HelpTopic { Social, Music, News, - Games, + Arcade, + Tables, + Doors, TerminalCopy, TerminalLinks, TerminalImages, @@ -24,14 +26,16 @@ pub enum HelpTopic { } impl HelpTopic { - pub const ALL: [HelpTopic; 17] = [ + pub const ALL: [HelpTopic; 19] = [ HelpTopic::Pair, HelpTopic::Overview, HelpTopic::Chat, HelpTopic::Social, HelpTopic::Music, HelpTopic::News, - HelpTopic::Games, + HelpTopic::Arcade, + HelpTopic::Tables, + HelpTopic::Doors, HelpTopic::TerminalCopy, HelpTopic::TerminalLinks, HelpTopic::TerminalImages, @@ -53,7 +57,9 @@ impl HelpTopic { HelpTopic::Social => "Social", HelpTopic::Music => "Music", HelpTopic::News => "News", - HelpTopic::Games => "Games", + HelpTopic::Arcade => "Arcade", + HelpTopic::Tables => "Tables", + HelpTopic::Doors => "Doors", HelpTopic::TerminalCopy => "Copy", HelpTopic::TerminalLinks => "Links", HelpTopic::TerminalImages => "Images", @@ -74,17 +80,19 @@ impl HelpTopic { HelpTopic::Social => 3, HelpTopic::Music => 4, HelpTopic::News => 5, - HelpTopic::Games => 6, - HelpTopic::TerminalCopy => 7, - HelpTopic::TerminalLinks => 8, - HelpTopic::TerminalImages => 9, - HelpTopic::TerminalSelection => 10, - HelpTopic::TerminalNotifications => 11, - HelpTopic::TerminalCliYoutube => 12, - HelpTopic::Economy => 13, - HelpTopic::Bonsai => 14, - HelpTopic::Settings => 15, - HelpTopic::Architecture => 16, + HelpTopic::Arcade => 6, + HelpTopic::Tables => 7, + HelpTopic::Doors => 8, + HelpTopic::TerminalCopy => 9, + HelpTopic::TerminalLinks => 10, + HelpTopic::TerminalImages => 11, + HelpTopic::TerminalSelection => 12, + HelpTopic::TerminalNotifications => 13, + HelpTopic::TerminalCliYoutube => 14, + HelpTopic::Economy => 15, + HelpTopic::Bonsai => 16, + HelpTopic::Settings => 17, + HelpTopic::Architecture => 18, } } } @@ -98,7 +106,9 @@ pub fn lines_for(topic: HelpTopic, keep_composer_focused: bool, pair_url: &str) HelpTopic::Social => social_help_lines(), HelpTopic::Music => music_help_lines(), HelpTopic::News => news_help_lines(), - HelpTopic::Games => games_help_lines(), + HelpTopic::Arcade => arcade_help_lines(), + HelpTopic::Tables => tables_help_lines(), + HelpTopic::Doors => doors_help_lines(), HelpTopic::TerminalCopy => { terminal_faq_topic_lines(crate::app::help_modal::terminal_faq::TerminalHelpTopic::Copy) } @@ -128,8 +138,8 @@ pub fn bot_app_context() -> String { "APP CONTEXT:\n\ CRITICAL FACTS:\n\ - Chat username badges render in this order: special role badges, bonsai stage, equipped badge, equipped flag, then the /brb moon.\n\ - - There is no separate top-level Chat screen. Home/Dashboard owns the chat room rail and chat center; top-level screens are Home, The Arcade, Rooms, Artboard, and Directory.\n\ - - Directory page 5 owns Profiles, Projects, and Pinstar tabs. Artboard and Pinstar have detailed page-local editing keybinds.\n", + - There is no separate top-level Chat screen. Home/Dashboard owns the chat room rail and chat center; top-level screens are Home, The Arcade, Tables, Door Games, Artboard, and Directory.\n\ + - Directory page 6 owns Profiles, Projects, and Pinstar tabs. Artboard and Pinstar have detailed page-local editing keybinds.\n", ); for topic in HelpTopic::ALL { out.push_str(&format!("## {}\n", topic.title())); @@ -361,7 +371,7 @@ pub fn chat_help_lines(keep_composer_focused: bool) -> Vec { "", "Synthetic entries", " Home room rail also contains RSS, News, Voice, Mentions, and Discover.", - " Directory page 5 contains Profiles, Projects, and Pinstar.", + " Directory page 6 contains Profiles, Projects, and Pinstar.", ] .into_iter() .map(str::to_string) @@ -385,7 +395,7 @@ fn social_help_lines() -> Vec { [ "Social surfaces", "", - "These are chat-adjacent updates and profile surfaces. RSS stays in Home; Projects and Profiles live on Directory page 5.", + "These are chat-adjacent updates and profile surfaces. RSS stays in Home; Projects and Profiles live on Directory page 6.", "", "RSS", " Private per-user RSS/Atom inbox.", @@ -470,7 +480,7 @@ fn games_help_lines() -> Vec { [ "Games", "", - "The game surfaces are The Arcade and Rooms. This page covers getting around; Economy owns per-game controls, scoring, chips, payouts, and leaderboards.", + "The game surfaces are The Arcade, Tables, and Door Games. This page covers getting around; Economy owns per-game controls, scoring, chips, payouts, and leaderboards.", "", "Arcade", " 2 open The Arcade", @@ -479,17 +489,17 @@ fn games_help_lines() -> Vec { " Esc / q leave current game", " ` return to Dashboard while a run is active", "", - "Rooms directory", - " 3 open Rooms", - " j / k or ↑ / ↓ navigate rooms", + "Tables", + " 3 open Tables", + " j / k or ↑ / ↓ navigate tables", " h / l or ← / → cycle filters", - " / search by room name", - " Enter enter selected room", - " n create a new room", - " Esc clears create/search/query/filter before leaving room state", + " / search by table name", + " Enter enter selected table", + " n create a new table", + " Esc clears create/search/query/filter before leaving table state", " Directory rows show name, game, creator, seats, pace, stakes, and status.", "", - "Room creation", + "Table creation", " n open game picker", " j / k or ↑ / ↓ choose game kind", " Enter open selected create form", @@ -497,11 +507,11 @@ fn games_help_lines() -> Vec { " Esc cancel picker/form", " Game-specific forms and limits live in the Economy tab.", "", - "Active room", + "Active table", " Layout game on top, embedded game chat below", - " ` cycle Dashboard and game rooms where you are seated", + " ` cycle Dashboard and tables where you are seated", " Esc clears selected embedded-chat message first", - " q / Esc game backend may leave the active room", + " q / Esc game backend may leave the active table", " i compose in embedded chat", " j / k embedded-chat message selection unless game claims the key", " PageUp/PageDown scroll embedded chat", @@ -510,11 +520,11 @@ fn games_help_lines() -> Vec { " Arrows game gets first chance; otherwise embedded chat handles them", "", "Home shortcuts", - " 3 open Rooms", - " b then 1-4 enter one of the recent room shortcuts in lounge", + " 3 open Tables", + " b then 1-4 enter one of the recent table shortcuts in lounge", "", "Economy", - " Economy tab Arcade game list, Arcade controls, room-game controls, chips, scoring, and leaderboards.", + " Economy tab Arcade game list, Arcade controls, table-game controls, chips, scoring, and leaderboards.", ] .into_iter() .map(str::to_string) @@ -528,11 +538,12 @@ fn overview_lines() -> Vec { "late.sh is a terminal clubhouse over SSH: chat, music, news, games, settings, and shared presence in one session.", "", "Primary screens", - " 1 Home chat, rooms, music, and live activity", + " 1 Home chat, tables, music, and live activity", " 2 The Arcade daily puzzles, endless games, leaderboard", - " 3 Rooms persistent table-game rooms", - " 4 Artboard shared persistent ASCII canvas", - " 5 Directory Profiles, Projects, and Pinstar", + " 3 Tables persistent table games", + " 4 Door Games BBS-style persistent worlds", + " 5 Artboard shared persistent ASCII canvas", + " 6 Directory Profiles, Projects, and Pinstar", "", "Artboard and Directory/Pinstar have their own page-local controls; this guide keeps detailed editing keys out.", "There is also a dedicated Architecture slide if you need system-level context.", @@ -586,8 +597,8 @@ fn overview_lines() -> Vec { " Esc close", "", "Home room shortcuts", - " 3 open Rooms", - " b then 1-4 enter one of the recent room shortcuts in lounge", + " 3 open Tables", + " b then 1-4 enter one of the recent table shortcuts in lounge", "", "This modal", " Tab / Shift+Tab next / previous tab", @@ -624,10 +635,10 @@ fn architecture_lines() -> Vec { " paired browser or CLI clients handle actual audio output and visualizer data", "", "User-facing areas", - " Home/Dashboard with chat rail, The Arcade, Rooms, Artboard, Directory, and the persistent bonsai sidebar", + " Home/Dashboard with chat rail, The Arcade, Tables, Door Games, Artboard, Directory, and the persistent bonsai sidebar", " Home chat includes synthetic entries: RSS, News, Voice, Mentions, Discover; Directory owns Profiles, Projects, and Pinstar", - " Rooms are persistent DB rows with paired chat_rooms(kind='game')", - " Room game runtime state is process-local and can reset on SSH server restart", + " Tables are persistent DB rows with paired chat_rooms(kind='game')", + " Table game runtime state is process-local and can reset on SSH server restart", "", "Important characteristics", " terminal-first, always-on, social, and zero-signup", @@ -1097,7 +1108,7 @@ mod tests { fn global_guide_points_to_hub_for_game_details() { let games = games_help_lines().join("\n"); assert!(games.contains("Economy tab")); - assert!(games.contains("Rooms directory")); + assert!(games.contains("Tables")); assert!(!games.contains("Tetris")); assert!(!games.contains("Sudoku")); assert!(!games.contains("Room stack")); diff --git a/late-ssh/src/app/help_modal/hub_guide.rs b/late-ssh/src/app/help_modal/hub_guide.rs index 871d70f8..aaa5ac1f 100644 --- a/late-ssh/src/app/help_modal/hub_guide.rs +++ b/late-ssh/src/app/help_modal/hub_guide.rs @@ -224,23 +224,23 @@ fn arcade_sections() -> Vec { fn room_game_sections() -> Vec { vec![ GuideSection { - title: "Room Games", + title: "Table Games", body: vec![ - "Open Rooms with 3.".to_string(), + "Open Tables with 3.".to_string(), "Directory filters: All, Asterion, Blackjack, Chess, Poker, Tic-Tac-Toe, Tron." .to_string(), - "j/k or arrows navigate rooms.".to_string(), + "j/k or arrows navigate tables.".to_string(), "h/l or left/right cycles filters.".to_string(), - "/ searches by room name.".to_string(), - "Enter enters the selected room.".to_string(), - "n creates a new room when the selected game supports creation.".to_string(), - "Esc clears create/search/query/filter before leaving room state.".to_string(), + "/ searches by table name.".to_string(), + "Enter enters the selected table.".to_string(), + "n creates a new table when the selected game supports creation.".to_string(), + "Esc clears create/search/query/filter before leaving table state.".to_string(), ], }, GuideSection { - title: "Create Room Forms", + title: "Create Table Forms", body: vec![ - "Room name maxes at 48 chars; search query maxes at 32 chars.".to_string(), + "Table name maxes at 48 chars; search query maxes at 32 chars.".to_string(), "A user can have up to 10 open tables per game kind.".to_string(), "Asterion form: name.".to_string(), "Blackjack form: name, pace, stake.".to_string(), @@ -249,10 +249,10 @@ fn room_game_sections() -> Vec { ], }, GuideSection { - title: "Active Room", + title: "Active Table", body: vec![ "Game is on top; embedded game chat is below.".to_string(), - "` cycles Dashboard and game rooms where you are seated.".to_string(), + "` cycles Dashboard and tables where you are seated.".to_string(), "i composes in embedded chat.".to_string(), "Esc clears selected embedded-chat message first.".to_string(), "j/k selects embedded-chat messages unless the game claims the key.".to_string(), diff --git a/late-ssh/src/app/input.rs b/late-ssh/src/app/input.rs index b8c58b21..31a576d6 100644 --- a/late-ssh/src/app/input.rs +++ b/late-ssh/src/app/input.rs @@ -1200,6 +1200,32 @@ fn handle_parsed_input(app: &mut App, event: ParsedInput) { } fn handle_dedicated_screen_input(app: &mut App, ctx: InputContext, event: &ParsedInput) -> bool { + if ctx.screen == Screen::DoorGames { + app.enter_lateania(); + let Some(state) = app.lateania_state.as_mut() else { + return true; + }; + match event { + ParsedInput::Byte(byte) => { + let action = crate::app::door::lateania::input::handle_key(state, *byte); + if action == crate::app::door::lateania::input::InputAction::Leave { + app.set_screen(Screen::Dashboard); + } + } + ParsedInput::Char(ch) if ch.is_ascii() => { + let action = crate::app::door::lateania::input::handle_key(state, *ch as u8); + if action == crate::app::door::lateania::input::InputAction::Leave { + app.set_screen(Screen::Dashboard); + } + } + ParsedInput::Arrow(key) => { + let _ = crate::app::door::lateania::input::handle_arrow(state, *key); + } + _ => {} + } + return true; + } + if ctx.screen == Screen::Arcade && app.is_playing_game { match event { ParsedInput::Byte(byte) => { @@ -2067,12 +2093,13 @@ fn topbar_screen_hit_test(x: u16, y: u16) -> Option { match x { // Top title text starts immediately after the left border. The digit - // cells in " late.sh | 1 2 3 4 5 | ..." land on these columns. + // cells in " late.sh | 1 2 3 4 5 6 | ..." land on these columns. 12 => Some(Screen::Dashboard), 14 => Some(Screen::Arcade), 16 => Some(Screen::Rooms), - 18 => Some(Screen::Artboard), - 20 => Some(Screen::Pinstar), + 18 => Some(Screen::DoorGames), + 20 => Some(Screen::Artboard), + 22 => Some(Screen::Pinstar), _ => None, } } @@ -2586,6 +2613,7 @@ fn handle_arrow_for_screen(app: &mut App, screen: Screen, key: u8) -> bool { match screen { Screen::Dashboard => dashboard::input::handle_arrow(app, key), + Screen::DoorGames => false, Screen::Arcade => crate::app::arcade::input::handle_arrow(app, key), Screen::Rooms => crate::app::rooms::input::handle_arrow(app, key), Screen::Artboard => crate::app::artboard::page::handle_arrow(app, key), @@ -3077,10 +3105,15 @@ fn handle_global_key(app: &mut App, ctx: InputContext, byte: u8) -> bool { } b'4' if !artboard_blocks_page_switch => { reset_composers_for_page_change(app); - app.set_screen(Screen::Artboard); + app.set_screen(Screen::DoorGames); true } b'5' if !artboard_blocks_page_switch => { + reset_composers_for_page_change(app); + app.set_screen(Screen::Artboard); + true + } + b'6' if !artboard_blocks_page_switch => { reset_composers_for_page_change(app); app.set_screen(Screen::Pinstar); true @@ -3131,6 +3164,9 @@ fn dispatch_screen_key(app: &mut App, screen: Screen, byte: u8) { Screen::Dashboard => { dashboard::input::handle_key(app, byte); } + Screen::DoorGames => { + // Door Games key dispatch is handled via handle_dedicated_screen_input. + } Screen::Arcade => { crate::app::arcade::input::handle_key(app, byte); } @@ -3937,9 +3973,10 @@ mod tests { assert_eq!(topbar_screen_hit_test(12, 0), Some(Screen::Dashboard)); assert_eq!(topbar_screen_hit_test(14, 0), Some(Screen::Arcade)); assert_eq!(topbar_screen_hit_test(16, 0), Some(Screen::Rooms)); - assert_eq!(topbar_screen_hit_test(18, 0), Some(Screen::Artboard)); - assert_eq!(topbar_screen_hit_test(20, 0), Some(Screen::Pinstar)); - assert_eq!(topbar_screen_hit_test(22, 0), None); + assert_eq!(topbar_screen_hit_test(18, 0), Some(Screen::DoorGames)); + assert_eq!(topbar_screen_hit_test(20, 0), Some(Screen::Artboard)); + assert_eq!(topbar_screen_hit_test(22, 0), Some(Screen::Pinstar)); + assert_eq!(topbar_screen_hit_test(24, 0), None); assert_eq!(topbar_screen_hit_test(13, 0), None); assert_eq!(topbar_screen_hit_test(12, 1), None); } diff --git a/late-ssh/src/app/mod.rs b/late-ssh/src/app/mod.rs index f39cb40d..42019317 100644 --- a/late-ssh/src/app/mod.rs +++ b/late-ssh/src/app/mod.rs @@ -9,6 +9,7 @@ pub mod chat; pub mod common; pub mod dashboard; pub(crate) mod directory; +pub mod door; pub mod files; pub mod games; pub(crate) mod help_modal; diff --git a/late-ssh/src/app/render.rs b/late-ssh/src/app/render.rs index 965b1ecf..edf2ff41 100644 --- a/late-ssh/src/app/render.rs +++ b/late-ssh/src/app/render.rs @@ -78,8 +78,9 @@ pub(crate) fn screen_number(screen: Screen) -> u8 { Screen::Dashboard => 1, Screen::Arcade => 2, Screen::Rooms => 3, - Screen::Artboard => 4, - Screen::Pinstar => 5, + Screen::DoorGames => 4, + Screen::Artboard => 5, + Screen::Pinstar => 6, } } @@ -190,6 +191,7 @@ struct DrawContext<'a> { room_game_registry: &'a crate::app::rooms::registry::RoomGameRegistry, active_room_game: Option<&'a dyn crate::app::rooms::backend::ActiveRoomBackend>, rooms_chat_view: Option>, + lateania_state: Option<&'a crate::app::door::lateania::state::State>, /// Detected terminal-image protocol for the current session. /// `None` -> no native images supported; capable terminals get /// pixel polish on top of the existing text rendering. @@ -716,6 +718,7 @@ impl App { room_game_registry: &self.room_game_registry, active_room_game: self.active_room_game.as_deref(), rooms_chat_view, + lateania_state: self.lateania_state.as_ref(), terminal_image_protocol: self.terminal_image_protocol, twenty_forty_eight_state: &self.twenty_forty_eight_state, tetris_state: &self.tetris_state, @@ -1035,6 +1038,16 @@ impl App { artboard::ui::draw_game(frame, content_area, state, ctx.artboard_interacting); } } + Screen::DoorGames => { + if let Some(state) = ctx.lateania_state { + crate::app::door::lateania::ui::draw_page( + frame, + content_area, + state, + ctx.rooms_usernames, + ); + } + } Screen::Pinstar => { crate::app::directory::ui::draw_directory_page( frame, @@ -1282,8 +1295,9 @@ fn app_frame_title(screen: Screen, ctx: &DrawContext<'_>) -> Line<'static> { (Screen::Dashboard, "1"), (Screen::Arcade, "2"), (Screen::Rooms, "3"), - (Screen::Artboard, "4"), - (Screen::Pinstar, "5"), + (Screen::DoorGames, "4"), + (Screen::Artboard, "5"), + (Screen::Pinstar, "6"), ]; for (idx, (tab_screen, key)) in tabs.iter().enumerate() { if idx > 0 { @@ -1302,9 +1316,10 @@ fn app_frame_title(screen: Screen, ctx: &DrawContext<'_>) -> Line<'static> { let page_title = match screen { Screen::Dashboard => "Home", + Screen::DoorGames => "Door Games", Screen::Arcade => "The Arcade", Screen::Artboard => "Artboard", - Screen::Rooms => "Rooms", + Screen::Rooms => "Tables", Screen::Pinstar => "Directory", }; spans.push(Span::styled( diff --git a/late-ssh/src/app/rooms/filter.rs b/late-ssh/src/app/rooms/filter.rs index 1890139d..2aee0ffe 100644 --- a/late-ssh/src/app/rooms/filter.rs +++ b/late-ssh/src/app/rooms/filter.rs @@ -14,7 +14,6 @@ impl RoomsFilter { Self::Kind(GameKind::Asterion) => "Asterion", Self::Kind(GameKind::Blackjack) => "Blackjack", Self::Kind(GameKind::Chess) => "Chess", - Self::Kind(GameKind::Mud) => "Lateania", Self::Kind(GameKind::Poker) => "Poker", Self::Kind(GameKind::Sshattrick) => "ssHattrick", Self::Kind(GameKind::TicTacToe) => "Tic-Tac-Toe", diff --git a/late-ssh/src/app/rooms/mod.rs b/late-ssh/src/app/rooms/mod.rs index 0841413c..b2e3e898 100644 --- a/late-ssh/src/app/rooms/mod.rs +++ b/late-ssh/src/app/rooms/mod.rs @@ -7,7 +7,6 @@ pub mod filter; pub mod game_ui; pub mod image_render; pub mod input; -pub mod mud; pub mod poker; pub mod registry; pub mod sshattrick; diff --git a/late-ssh/src/app/rooms/mud/create_modal.rs b/late-ssh/src/app/rooms/mud/create_modal.rs deleted file mode 100644 index 7bd507b7..00000000 --- a/late-ssh/src/app/rooms/mud/create_modal.rs +++ /dev/null @@ -1,236 +0,0 @@ -// Create-room modal for Lateania. The world has no configurable options in the -// slice, so this is a single name field, mirroring the Tic-Tac-Toe modal. - -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - style::{Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, -}; - -use crate::app::{ - common::theme, - input::{ParsedInput, sanitize_paste_markers}, - rooms::backend::{CreateModalAction, CreateRoomModal}, -}; - -const DISPLAY_NAME_MAX_LEN: usize = 48; -const MODAL_WIDTH: u16 = 60; -const MODAL_HEIGHT: u16 = 12; -const LABEL_WIDTH: usize = 10; - -pub struct MudCreateModal { - display_name: String, - error: Option, -} - -impl MudCreateModal { - pub fn new(default_name: impl Into) -> Self { - Self { - display_name: default_name.into(), - error: None, - } - } - - fn push_name_char(&mut self, ch: char) { - if ch.is_control() || self.display_name.chars().count() >= DISPLAY_NAME_MAX_LEN { - return; - } - self.error = None; - self.display_name.push(ch); - } - - fn submit(&mut self) -> CreateModalAction { - let display_name = self.display_name.trim().to_string(); - if display_name.is_empty() { - self.error = Some("World name is required.".to_string()); - return CreateModalAction::Continue; - } - - CreateModalAction::Submit { - display_name, - settings: serde_json::json!({}), - } - } -} - -impl CreateRoomModal for MudCreateModal { - fn draw(&self, frame: &mut Frame, area: Rect) { - let modal_area = centered_rect( - area, - MODAL_WIDTH.min(area.width), - MODAL_HEIGHT.min(area.height), - ); - frame.render_widget(Clear, modal_area); - - let block = Block::default() - .title(" New Lateania World ") - .title_style( - Style::default() - .fg(theme::AMBER_GLOW()) - .add_modifier(Modifier::BOLD), - ) - .borders(Borders::ALL) - .border_style(Style::default().fg(theme::BORDER_ACTIVE())); - let inner = block.inner(modal_area); - frame.render_widget(block, modal_area); - - let layout = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - Constraint::Length(1), - ]) - .split(inner); - - let width = inner.width as usize; - frame.render_widget(Paragraph::new(section_heading("World")), layout[1]); - frame.render_widget( - Paragraph::new(field_row( - true, - "Name", - name_value_span(&self.display_name), - width, - )), - layout[3], - ); - - let footer = self - .error - .as_ref() - .map(|message| { - Line::from(vec![ - Span::raw(" "), - Span::styled(message.clone(), Style::default().fg(theme::ERROR())), - ]) - }) - .unwrap_or_else(footer_line); - frame.render_widget(Paragraph::new(footer), layout[5]); - } - - fn handle_event(&mut self, event: &ParsedInput) -> CreateModalAction { - match event { - ParsedInput::Byte(0x1B) => CreateModalAction::Cancel, - ParsedInput::Byte(b'\r' | b'\n') => self.submit(), - ParsedInput::Byte(0x08 | 0x7F) => { - self.error = None; - self.display_name.pop(); - CreateModalAction::Continue - } - ParsedInput::Byte(0x17) => { - self.error = None; - self.display_name.clear(); - CreateModalAction::Continue - } - ParsedInput::Char(ch) => { - self.push_name_char(*ch); - CreateModalAction::Continue - } - ParsedInput::Byte(byte) => { - if byte.is_ascii_graphic() || *byte == b' ' { - self.push_name_char(*byte as char); - } - CreateModalAction::Continue - } - ParsedInput::Paste(bytes) => { - let pasted = String::from_utf8_lossy(bytes); - for ch in sanitize_paste_markers(&pasted).chars() { - self.push_name_char(ch); - } - CreateModalAction::Continue - } - _ => CreateModalAction::Continue, - } - } -} - -fn name_value_span(value: &str) -> ValueSpan { - ValueSpan { - text: format!("{value}\u{2588}"), - style: Style::default() - .fg(theme::AMBER()) - .add_modifier(Modifier::BOLD), - } -} - -fn footer_line() -> Line<'static> { - Line::from(vec![ - Span::raw(" "), - Span::styled("\u{21B5}", Style::default().fg(theme::AMBER_DIM())), - Span::styled(" create ", Style::default().fg(theme::TEXT_DIM())), - Span::styled("Esc", Style::default().fg(theme::AMBER_DIM())), - Span::styled(" cancel", Style::default().fg(theme::TEXT_DIM())), - ]) -} - -fn centered_rect(area: Rect, width: u16, height: u16) -> Rect { - Rect { - x: area.x + area.width.saturating_sub(width) / 2, - y: area.y + area.height.saturating_sub(height) / 2, - width, - height, - } -} - -fn section_heading(title: &str) -> Line<'static> { - Line::from(vec![ - Span::styled(" -- ", Style::default().fg(theme::BORDER())), - Span::styled( - title.to_string(), - Style::default() - .fg(theme::AMBER()) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" --", Style::default().fg(theme::BORDER())), - ]) -} - -struct ValueSpan { - text: String, - style: Style, -} - -fn field_row(focused: bool, label: &str, value: ValueSpan, width: usize) -> Line<'static> { - let marker = if focused { "\u{203A}" } else { " " }; - let prefix_style = if focused { - Style::default() - .fg(theme::AMBER_GLOW()) - .bg(theme::BG_SELECTION()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme::TEXT_FAINT()) - }; - let label_style = if focused { - Style::default() - .fg(theme::TEXT_BRIGHT()) - .bg(theme::BG_SELECTION()) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(theme::TEXT_DIM()) - }; - let value_style = if focused { - value.style.bg(theme::BG_SELECTION()) - } else { - value.style - }; - let trailing_style = if focused { - Style::default().bg(theme::BG_SELECTION()) - } else { - Style::default() - }; - - let prefix = format!(" {marker} "); - let label_text = format!("{label:>>, - event_tx: broadcast::Sender, -} - -impl MudTableManager { - pub fn new(activity: ActivityPublisher, db: Db) -> Self { - let (event_tx, _) = broadcast::channel::(256); - Self { - activity, - db, - tables: Arc::new(Mutex::new(HashMap::new())), - event_tx, - } - } - - pub fn get_or_create(&self, room: &RoomListItem) -> MudService { - let mut tables = self.tables.lock_recover(); - tables - .entry(room.id) - .or_insert_with(|| { - MudService::new_with_events( - room.id, - self.activity.clone(), - self.db.clone(), - self.event_tx.clone(), - ) - }) - .clone() - } -} - -impl RoomGameManager for MudTableManager { - fn kind(&self) -> GameKind { - GameKind::Mud - } - - fn label(&self) -> &'static str { - "Lateania" - } - - fn slug_prefix(&self) -> &'static str { - "mud" - } - - fn default_room_name(&self) -> &'static str { - "Lateania" - } - - fn default_settings(&self) -> serde_json::Value { - serde_json::json!({}) - } - - fn open_create_modal(&self) -> Box { - Box::new(MudCreateModal::new(self.default_room_name())) - } - - fn directory_meta(&self, _room: &RoomListItem) -> DirectoryMeta { - DirectoryMeta { - seats: WORLD_CAPACITY_HINT as u8, - pace: "real-time".to_string(), - stakes: "swords & sorcery".to_string(), - } - } - - fn directory_hints(&self, room_id: Uuid) -> Option { - let occupied = self.tables.lock_recover().get(&room_id)?.player_count(); - Some(DirectoryHints { - occupied, - total: WORLD_CAPACITY_HINT, - }) - } - - fn is_user_seated(&self, room_id: Uuid, user_id: Uuid) -> bool { - self.tables - .lock_recover() - .get(&room_id) - .is_some_and(|svc| svc.is_user_present(user_id)) - } - - fn subscribe_room_events(&self) -> broadcast::Receiver { - self.event_tx.subscribe() - } - - fn seat_join_ascii(&self) -> &'static [&'static str] { - &[r" /\ ", r" |==| ", r" | | "] - } - - fn enter( - &self, - room: &RoomListItem, - user_id: Uuid, - _chip_balance: i64, - ) -> Box { - Box::new(State::new(self.get_or_create(room), user_id)) - } -} - -impl ActiveRoomBackend for State { - fn room_id(&self) -> Uuid { - self.room_id() - } - - fn tick(&mut self) { - State::tick(self); - } - - fn touch_activity(&self) { - State::touch_activity(self); - } - - fn handle_key(&mut self, byte: u8) -> crate::app::rooms::backend::InputAction { - crate::app::rooms::mud::input::handle_key(self, byte) - } - - fn handle_arrow(&mut self, key: u8) -> bool { - crate::app::rooms::mud::input::handle_arrow(self, key) - } - - fn preferred_game_height(&self, area: ratatui::layout::Rect) -> u16 { - // The adventure log wants vertical room; ask for most of the pane. - let scaled = area.height.saturating_mul(13) / 20; - scaled.clamp(8, 24) - } - - fn draw( - &self, - frame: &mut ratatui::Frame, - area: ratatui::layout::Rect, - ctx: crate::app::rooms::backend::GameDrawCtx<'_>, - ) { - crate::app::rooms::mud::ui::draw_game(frame, area, self, ctx.usernames); - } - - fn title_details(&self) -> Option { - let view = self.view(); - if !view.joined { - return Some(RoomTitleDetails { - seated: Some("entering".to_string()), - role: None, - balance: None, - }); - } - let here = format!("{} online", self.player_count()); - let role = if view.respawning { - "recovering".to_string() - } else if let Some(foe) = view.in_combat_with.as_ref() { - format!("fighting {foe}") - } else { - format!("lvl {} - {}", view.level, view.room_name) - }; - Some(RoomTitleDetails { - seated: Some(here), - role: Some(role), - balance: None, - }) - } - - fn drop_on_leave(&self) -> bool { - // The per-session wrapper owns the player's presence; dropping it should - // remove the adventurer from the world. - true - } -} diff --git a/late-ssh/src/app/rooms/registry.rs b/late-ssh/src/app/rooms/registry.rs index 23b86720..32bb9962 100644 --- a/late-ssh/src/app/rooms/registry.rs +++ b/late-ssh/src/app/rooms/registry.rs @@ -9,7 +9,6 @@ use super::{ }, blackjack::manager::BlackjackTableManager, chess::manager::ChessTableManager, - mud::manager::MudTableManager, poker::manager::PokerTableManager, sshattrick::manager::SshattrickRoomManager, svc::{GameKind, RoomListItem}, @@ -31,7 +30,6 @@ pub struct RoomGameRegistry { asterion: AsterionRoomManager, blackjack: BlackjackTableManager, chess: ChessTableManager, - mud: MudTableManager, poker: PokerTableManager, sshattrick: SshattrickRoomManager, tictactoe: TicTacToeTableManager, @@ -43,7 +41,6 @@ impl RoomGameRegistry { asterion: AsterionRoomManager, blackjack: BlackjackTableManager, chess: ChessTableManager, - mud: MudTableManager, poker: PokerTableManager, sshattrick: SshattrickRoomManager, tictactoe: TicTacToeTableManager, @@ -53,7 +50,6 @@ impl RoomGameRegistry { asterion, blackjack, chess, - mud, poker, sshattrick, tictactoe, @@ -66,7 +62,6 @@ impl RoomGameRegistry { GameKind::Asterion => &self.asterion, GameKind::Blackjack => &self.blackjack, GameKind::Chess => &self.chess, - GameKind::Mud => &self.mud, GameKind::Poker => &self.poker, GameKind::Sshattrick => &self.sshattrick, GameKind::TicTacToe => &self.tictactoe, diff --git a/late-ssh/src/app/rooms/ui.rs b/late-ssh/src/app/rooms/ui.rs index 00f90d3b..5bfc93ea 100644 --- a/late-ssh/src/app/rooms/ui.rs +++ b/late-ssh/src/app/rooms/ui.rs @@ -52,7 +52,7 @@ pub fn draw_rooms_page( image_protocol: Option, ) { if area.height < 8 || area.width < 36 { - frame.render_widget(Paragraph::new("Terminal too small for Rooms"), area); + frame.render_widget(Paragraph::new("Terminal too small for Tables"), area); return; } @@ -201,7 +201,7 @@ fn draw_create_picker_modal( frame.render_widget(Clear, modal_area); let block = Block::default() - .title(" New Room ") + .title(" New Table ") .title_style( Style::default() .fg(theme::AMBER_GLOW()) @@ -611,11 +611,11 @@ fn draw_empty_state(frame: &mut Frame, area: Rect, view: &RoomsPageView<'_>) { let mut lines: Vec = Vec::new(); let q_active = !view.search_query.is_empty(); let primary = if q_active { - format!("No rooms match \"{}\".", view.search_query) + format!("No tables match \"{}\".", view.search_query) } else if view.filter == RoomsFilter::All { - "No rooms yet.".to_string() + "No tables yet.".to_string() } else { - format!("No {} rooms yet.", view.filter.label()) + format!("No {} tables yet.", view.filter.label()) }; lines.push(Line::from(Span::styled( primary, diff --git a/late-ssh/src/app/settings_modal/ui.rs b/late-ssh/src/app/settings_modal/ui.rs index 31e618da..355230d0 100644 --- a/late-ssh/src/app/settings_modal/ui.rs +++ b/late-ssh/src/app/settings_modal/ui.rs @@ -1539,7 +1539,7 @@ fn draw_right_sidebar_custom_dialog(frame: &mut Frame, area: Rect, state: &Setti let layout = Layout::vertical(constraints).split(inner); const SCREEN_LABELS: [&str; RIGHT_SIDEBAR_SCREEN_COUNT as usize] = - ["Home", "Arcade", "Rooms", "Artboard"]; + ["Home", "Arcade", "Tables", "Artboard"]; let width = inner.width as usize; for screen_idx in 0..RIGHT_SIDEBAR_SCREEN_COUNT as usize { diff --git a/late-ssh/src/app/state.rs b/late-ssh/src/app/state.rs index fef0e1d7..d679fed9 100644 --- a/late-ssh/src/app/state.rs +++ b/late-ssh/src/app/state.rs @@ -204,6 +204,7 @@ pub struct SessionConfig { pub initial_solitaire_games: Vec, pub minesweeper_service: crate::app::arcade::minesweeper::svc::MinesweeperService, pub initial_minesweeper_games: Vec, + pub lateania_service: crate::app::door::lateania::svc::LateaniaService, pub rooms_service: crate::app::rooms::svc::RoomsService, pub room_game_registry: crate::app::rooms::registry::RoomGameRegistry, /// Shared in-proc dartboard server handle. Each session only connects — consuming a @@ -398,6 +399,8 @@ pub struct App { pub(crate) game_selection: usize, pub(crate) is_playing_game: bool, pub(crate) dashboard_game_toggle_target: Option, + pub(crate) lateania_service: crate::app::door::lateania::svc::LateaniaService, + pub(crate) lateania_state: Option, pub(crate) rooms_service: crate::app::rooms::svc::RoomsService, pub(crate) room_game_registry: crate::app::rooms::registry::RoomGameRegistry, pub(crate) rooms_selected_index: usize, @@ -906,6 +909,8 @@ impl App { game_selection: DEFAULT_GAME_SELECTION, is_playing_game: false, dashboard_game_toggle_target: None, + lateania_service: config.lateania_service, + lateania_state: None, rooms_service: config.rooms_service, room_game_registry: config.room_game_registry, rooms_selected_index: 0, @@ -1000,6 +1005,20 @@ impl App { self.set_cursor_shape(CURSOR_SHAPE_STEADY_BLOCK); } + pub(crate) fn enter_lateania(&mut self) { + if self.lateania_state.is_some() { + return; + } + self.lateania_state = Some(crate::app::door::lateania::state::State::new( + self.lateania_service.clone(), + self.user_id, + )); + } + + fn leave_lateania(&mut self) { + self.lateania_state = None; + } + pub(crate) fn activate_artboard_interaction(&mut self) -> bool { self.expire_artboard_ban_if_needed(); if self.artboard_banned { @@ -1192,6 +1211,9 @@ impl App { pub(crate) fn set_screen(&mut self, screen: Screen) { if self.screen == screen { + if screen == Screen::DoorGames { + self.enter_lateania(); + } if screen == Screen::Artboard { self.enter_dartboard(); } @@ -1218,6 +1240,11 @@ impl App { self.force_full_repaint(); } + if self.screen == Screen::DoorGames { + self.leave_lateania(); + self.force_full_repaint(); + } + if self.screen == Screen::Pinstar { self.leave_pinstar(); self.force_full_repaint(); @@ -1233,6 +1260,9 @@ impl App { if self.screen == Screen::Artboard { self.enter_dartboard(); } + if self.screen == Screen::DoorGames { + self.enter_lateania(); + } if self.screen == Screen::Pinstar { self.enter_directory(); } diff --git a/late-ssh/src/app/tick.rs b/late-ssh/src/app/tick.rs index c5100113..28d1c46c 100644 --- a/late-ssh/src/app/tick.rs +++ b/late-ssh/src/app/tick.rs @@ -248,6 +248,9 @@ impl App { if let Some(state) = self.dartboard_state.as_mut() { state.tick(); } + if let Some(state) = self.lateania_state.as_mut() { + state.tick(); + } // Pinstar Browser Actions if let Some(action) = self.pinstar_browser.pending_action.take() { use crate::app::pinstar::browser::BrowserActionResult; diff --git a/late-ssh/src/main.rs b/late-ssh/src/main.rs index 1549abb0..30bd743a 100644 --- a/late-ssh/src/main.rs +++ b/late-ssh/src/main.rs @@ -220,7 +220,7 @@ async fn main() -> anyhow::Result<()> { chip_service.clone(), activity_publisher.clone(), ); - let mud_table_manager = late_ssh::app::rooms::mud::manager::MudTableManager::new( + let lateania_service = late_ssh::app::door::lateania::svc::LateaniaService::new( activity_publisher.clone(), db.clone(), ); @@ -235,7 +235,6 @@ async fn main() -> anyhow::Result<()> { asterion_room_manager, blackjack_table_manager.clone(), chess_table_manager, - mud_table_manager, poker_table_manager, sshattrick_room_manager, tictactoe_table_manager, @@ -338,6 +337,7 @@ async fn main() -> anyhow::Result<()> { nonogram_service, solitaire_service, minesweeper_service, + lateania_service, bonsai_service, pet_service, nonogram_library, diff --git a/late-ssh/src/session_bootstrap.rs b/late-ssh/src/session_bootstrap.rs index 6eb46f64..ce26736f 100644 --- a/late-ssh/src/session_bootstrap.rs +++ b/late-ssh/src/session_bootstrap.rs @@ -227,6 +227,7 @@ pub async fn build_session_config(state: &State, inputs: SessionBootstrapInputs) initial_solitaire_games, minesweeper_service: state.minesweeper_service.clone(), initial_minesweeper_games, + lateania_service: state.lateania_service.clone(), rooms_service: state.rooms_service.clone(), room_game_registry: state.room_game_registry.clone(), dartboard_server: state.dartboard_server.clone(), diff --git a/late-ssh/src/ssh.rs b/late-ssh/src/ssh.rs index 65639ed2..ed8e4dbd 100644 --- a/late-ssh/src/ssh.rs +++ b/late-ssh/src/ssh.rs @@ -916,6 +916,7 @@ impl russh::server::Handler for ClientHandler { initial_solitaire_games, minesweeper_service: self.state.minesweeper_service.clone(), initial_minesweeper_games, + lateania_service: self.state.lateania_service.clone(), rooms_service: self.state.rooms_service.clone(), room_game_registry: self.state.room_game_registry.clone(), dartboard_server: self.state.dartboard_server.clone(), diff --git a/late-ssh/src/state.rs b/late-ssh/src/state.rs index 0c9a1b2c..4f8a6844 100644 --- a/late-ssh/src/state.rs +++ b/late-ssh/src/state.rs @@ -117,6 +117,7 @@ pub struct State { pub pet_service: PetService, pub nonogram_library: NonogramLibrary, pub chip_service: ChipService, + pub lateania_service: crate::app::door::lateania::svc::LateaniaService, pub rooms_service: RoomsService, pub blackjack_table_manager: BlackjackTableManager, pub room_game_registry: RoomGameRegistry, diff --git a/late-ssh/tests/app_input_flow.rs b/late-ssh/tests/app_input_flow.rs index 8eb589c4..f2fd1da9 100644 --- a/late-ssh/tests/app_input_flow.rs +++ b/late-ssh/tests/app_input_flow.rs @@ -134,12 +134,15 @@ async fn screen_number_keys_switch_between_pages_including_pinstar() { wait_for_render_contains(&mut app, " The Arcade ").await; app.handle_input(b"3"); - wait_for_render_contains(&mut app, " Rooms ").await; + wait_for_render_contains(&mut app, " Tables ").await; app.handle_input(b"4"); - wait_for_render_contains(&mut app, "Mode view").await; + wait_for_render_contains(&mut app, " Door Games ").await; app.handle_input(b"5"); + wait_for_render_contains(&mut app, "Mode view").await; + + app.handle_input(b"6"); wait_for_render_contains(&mut app, " Directory ").await; app.handle_input(b"1"); @@ -166,7 +169,10 @@ async fn shift_tab_cycles_screens_backwards() { wait_for_render_contains(&mut app, "Mode view").await; app.handle_input(b"\x1b[Z"); - wait_for_render_contains(&mut app, " Rooms ").await; + wait_for_render_contains(&mut app, " Door Games ").await; + + app.handle_input(b"\x1b[Z"); + wait_for_render_contains(&mut app, " Tables ").await; app.handle_input(b"\x1b[Z"); wait_for_render_contains(&mut app, " The Arcade ").await; @@ -192,7 +198,10 @@ async fn tab_cycles_screens_forward_through_all_including_pinstar() { wait_for_render_contains(&mut app, " The Arcade ").await; app.handle_input(b"\t"); - wait_for_render_contains(&mut app, " Rooms ").await; + wait_for_render_contains(&mut app, " Tables ").await; + + app.handle_input(b"\t"); + wait_for_render_contains(&mut app, " Door Games ").await; app.handle_input(b"\t"); wait_for_render_contains(&mut app, "Mode view").await; @@ -375,7 +384,7 @@ async fn artboard_view_mode_allows_cursor_movement_and_screen_hotkeys() { let user = create_test_user(&test_db.db, "artboard-view-it").await; let mut app = make_app(test_db.db.clone(), user.id, "artboard-view-flow-it"); - app.handle_input(b"4"); + app.handle_input(b"5"); wait_for_render_contains(&mut app, "Mode view").await; wait_for_render_contains(&mut app, "Cursor 0,0").await; @@ -392,7 +401,7 @@ async fn artboard_view_mode_click_enters_active_mode_at_clicked_canvas_cell() { let user = create_test_user(&test_db.db, "artboard-click-enter-it").await; let mut app = make_app(test_db.db.clone(), user.id, "artboard-click-enter-flow-it"); - app.handle_input(b"4"); + app.handle_input(b"5"); wait_for_render_contains(&mut app, "Mode view").await; wait_for_render_contains(&mut app, "Cursor 0,0").await; @@ -407,7 +416,7 @@ async fn artboard_ban_locks_user_in_view_mode() { let user = create_test_user(&test_db.db, "artboard-banned-it").await; let mut app = make_app(test_db.db.clone(), user.id, "artboard-banned-flow-it"); - app.handle_input(b"4"); + app.handle_input(b"5"); wait_for_render_contains(&mut app, "Mode view").await; app.set_artboard_banned_for_tests(true); @@ -438,7 +447,7 @@ async fn active_artboard_blocks_screen_number_hotkeys_until_escape() { let user = create_test_user(&test_db.db, "artboard-active-it").await; let mut app = make_app(test_db.db.clone(), user.id, "artboard-active-flow-it"); - app.handle_input(b"4"); + app.handle_input(b"5"); wait_for_render_contains(&mut app, "Mode view").await; app.handle_input(b"i"); @@ -469,7 +478,7 @@ async fn active_artboard_ctrl_c_copies_without_quitting() { let user = create_test_user(&test_db.db, "artboard-ctrl-c-it").await; let mut app = make_app(test_db.db.clone(), user.id, "artboard-ctrl-c-flow-it"); - app.handle_input(b"4"); + app.handle_input(b"5"); wait_for_render_contains(&mut app, "Mode view").await; app.handle_input(b"i"); @@ -494,7 +503,7 @@ async fn artboard_help_modal_tab_switches_help_tabs_instead_of_pages() { let user = create_test_user(&test_db.db, "artboard-help-tab-it").await; let mut app = make_app(test_db.db.clone(), user.id, "artboard-help-tab-flow-it"); - app.handle_input(b"4"); + app.handle_input(b"5"); wait_for_render_contains(&mut app, "Mode view").await; app.handle_input(b"\x10"); @@ -516,7 +525,7 @@ async fn artboard_view_mode_question_mark_opens_local_help() { let user = create_test_user(&test_db.db, "artboard-view-help-it").await; let mut app = make_app(test_db.db.clone(), user.id, "artboard-view-help-flow-it"); - app.handle_input(b"4"); + app.handle_input(b"5"); wait_for_render_contains(&mut app, "Mode view").await; app.handle_input(b"?"); @@ -535,7 +544,7 @@ async fn active_artboard_question_mark_types_into_canvas_instead_of_opening_help let user = create_test_user(&test_db.db, "artboard-questionmark-it").await; let mut app = make_app(test_db.db.clone(), user.id, "artboard-questionmark-flow-it"); - app.handle_input(b"4"); + app.handle_input(b"5"); wait_for_render_contains(&mut app, "Mode view").await; wait_for_render_contains(&mut app, "Cursor 0,0").await; diff --git a/late-ssh/tests/app_smoke.rs b/late-ssh/tests/app_smoke.rs index 0ef528d9..1ee097e5 100644 --- a/late-ssh/tests/app_smoke.rs +++ b/late-ssh/tests/app_smoke.rs @@ -10,9 +10,10 @@ async fn renders_non_empty_frames_when_input_and_ticks_are_processed() { let user_id = Uuid::now_v7(); let mut app = make_app(db, user_id, "smoke-token"); - app.handle_input(b"2"); - app.handle_input(b"ihello\r"); + app.handle_input(b"4"); + app.handle_input(b"q"); app.handle_input(b"3"); + app.handle_input(b"ihello\r"); app.handle_input(b"n"); app.tick(); diff --git a/late-ssh/tests/helpers/mod.rs b/late-ssh/tests/helpers/mod.rs index 795dbf32..35efb845 100644 --- a/late-ssh/tests/helpers/mod.rs +++ b/late-ssh/tests/helpers/mod.rs @@ -30,7 +30,6 @@ use late_ssh::app::rooms::asterion::manager::AsterionRoomManager; use late_ssh::app::rooms::blackjack::manager::BlackjackTableManager; use late_ssh::app::rooms::blackjack::player::BlackjackPlayerDirectory; use late_ssh::app::rooms::chess::manager::ChessTableManager; -use late_ssh::app::rooms::mud::manager::MudTableManager; use late_ssh::app::rooms::poker::manager::PokerTableManager; use late_ssh::app::rooms::registry::RoomGameRegistry; use late_ssh::app::rooms::sshattrick::manager::SshattrickRoomManager; @@ -89,7 +88,6 @@ fn test_room_game_registry(db: Db) -> RoomGameRegistry { activity_publisher.clone(), rooms_service.clone(), ), - MudTableManager::new(activity_publisher.clone(), db.clone()), PokerTableManager::new(chip_service.clone(), activity_publisher.clone()), SshattrickRoomManager::new( rooms_service, @@ -249,6 +247,10 @@ pub fn test_app_state(db: Db, config: Config) -> State { pet_service, nonogram_library: NonogramLibrary::default(), chip_service: chip_service.clone(), + lateania_service: late_ssh::app::door::lateania::svc::LateaniaService::new( + activity_publisher.clone(), + db.clone(), + ), rooms_service: rooms_service.clone(), blackjack_table_manager: blackjack_table_manager.clone(), room_game_registry: RoomGameRegistry::new( @@ -259,7 +261,6 @@ pub fn test_app_state(db: Db, config: Config) -> State { activity_publisher.clone(), rooms_service.clone(), ), - MudTableManager::new(activity_publisher.clone(), db.clone()), PokerTableManager::new(chip_service.clone(), activity_publisher.clone()), SshattrickRoomManager::new( rooms_service.clone(), @@ -380,6 +381,10 @@ fn make_app_with_chat_service_and_permissions( broadcast::channel::(64).0, ), initial_minesweeper_games: Vec::new(), + lateania_service: late_ssh::app::door::lateania::svc::LateaniaService::new( + ActivityPublisher::new(db.clone(), broadcast::channel::(64).0), + db.clone(), + ), rooms_service: RoomsService::new(db.clone()), room_game_registry: test_room_game_registry(db.clone()), dartboard_server: test_dartboard_server(), @@ -510,6 +515,10 @@ pub fn make_app_with_paired_client( broadcast::channel::(64).0, ), initial_minesweeper_games: Vec::new(), + lateania_service: late_ssh::app::door::lateania::svc::LateaniaService::new( + ActivityPublisher::new(db.clone(), broadcast::channel::(64).0), + db.clone(), + ), rooms_service: RoomsService::new(db.clone()), room_game_registry: test_room_game_registry(db.clone()), dartboard_server: test_dartboard_server(), From 851f7848199f4ffc8527fccb285f0d93f6b22a57 Mon Sep 17 00:00:00 2001 From: mateuszpiorowski Date: Thu, 4 Jun 2026 22:03:22 +0200 Subject: [PATCH 11/20] update --- late-ssh/src/app/door/lateania/ui.rs | 92 ++++++++++++++++++++++++---- late-ssh/src/app/help_modal/data.rs | 88 ++++++++++++++++++++------ 2 files changed, 149 insertions(+), 31 deletions(-) diff --git a/late-ssh/src/app/door/lateania/ui.rs b/late-ssh/src/app/door/lateania/ui.rs index 4e512bc6..7b80dd33 100644 --- a/late-ssh/src/app/door/lateania/ui.rs +++ b/late-ssh/src/app/door/lateania/ui.rs @@ -10,6 +10,7 @@ use ratatui::{ text::{Line, Span}, widgets::Paragraph, }; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::app::common::theme; use crate::usernames::UsernameLookup; @@ -154,15 +155,16 @@ fn draw_compact(frame: &mut Frame, area: Rect, view: &PlayerView) { Style::default().fg(hp_color(view.hp, view.max_hp)), ), ])]; - for (kind, text) in log_tail(view, area.height.saturating_sub(1) as usize) { - lines.push(log_line(kind, text)); - } + lines.extend(wrapped_log_tail( + view, + area.width as usize, + area.height.saturating_sub(1) as usize, + )); frame.render_widget(Paragraph::new(lines), area); } fn draw_log(frame: &mut Frame, area: Rect, view: &PlayerView) { - let tail = log_tail(view, area.height as usize); - let lines: Vec = tail.into_iter().map(|(k, t)| log_line(k, t)).collect(); + let lines = wrapped_log_tail(view, area.width as usize, area.height as usize); frame.render_widget(Paragraph::new(lines), area); } @@ -471,18 +473,21 @@ fn footer_hints(view: &PlayerView) -> Vec> { // ---- helpers ------------------------------------------------------------- -fn log_tail(view: &PlayerView, capacity: usize) -> Vec<(LogKind, String)> { - if capacity == 0 { +fn wrapped_log_tail(view: &PlayerView, width: usize, height: usize) -> Vec> { + if width == 0 || height == 0 { return Vec::new(); } - let start = view.log.len().saturating_sub(capacity); - view.log[start..] + + let mut lines: Vec> = view + .log .iter() - .map(|l| (l.kind, l.text.clone())) - .collect() + .flat_map(|line| wrapped_log_line(line.kind, &line.text, width)) + .collect(); + let start = lines.len().saturating_sub(height); + lines.split_off(start) } -fn log_line(kind: LogKind, text: String) -> Line<'static> { +fn wrapped_log_line(kind: LogKind, text: &str, width: usize) -> Vec> { let color = match kind { LogKind::Normal => theme::TEXT(), LogKind::Combat => theme::ERROR(), @@ -490,7 +495,68 @@ fn log_line(kind: LogKind, text: String) -> Line<'static> { LogKind::Say => theme::CHAT_BODY(), LogKind::Loot => theme::SUCCESS(), }; - Line::from(Span::styled(text, Style::default().fg(color))) + wrap_log_text(text, width) + .into_iter() + .map(|line| Line::from(Span::styled(line, Style::default().fg(color)))) + .collect() +} + +fn wrap_log_text(text: &str, width: usize) -> Vec { + let width = width.max(1); + let continuation = if width > 2 { " " } else { "" }; + let mut out = Vec::new(); + let mut line = String::new(); + + for word in text.split_whitespace() { + let prefix_only = !continuation.is_empty() && line == continuation; + let pending_width = UnicodeWidthStr::width(line.as_str()); + let word_width = UnicodeWidthStr::width(word); + let sep_width = usize::from(!line.is_empty() && !prefix_only); + if pending_width > 0 && pending_width + sep_width + word_width > width { + out.push(line); + line = continuation.to_string(); + } + if word_width > width { + append_long_word(&mut out, &mut line, word, width, continuation); + continue; + } + if !line.is_empty() && line != continuation && !line.ends_with(' ') { + line.push(' '); + } + line.push_str(word); + } + + if line.is_empty() { + out.push(String::new()); + } else { + out.push(line); + } + out +} + +fn append_long_word( + out: &mut Vec, + line: &mut String, + word: &str, + width: usize, + continuation: &str, +) { + if !line.is_empty() && line != continuation { + out.push(std::mem::take(line)); + } + if line.is_empty() { + line.push_str(continuation); + } + + for ch in word.chars() { + let ch_width = ch.width().unwrap_or(0); + let line_width = UnicodeWidthStr::width(line.as_str()); + if line_width > UnicodeWidthStr::width(continuation) && line_width + ch_width > width { + out.push(std::mem::take(line)); + line.push_str(continuation); + } + line.push(ch); + } } fn section(title: &str) -> Line<'static> { diff --git a/late-ssh/src/app/help_modal/data.rs b/late-ssh/src/app/help_modal/data.rs index e11ea1c6..5cef894b 100644 --- a/late-ssh/src/app/help_modal/data.rs +++ b/late-ssh/src/app/help_modal/data.rs @@ -476,20 +476,31 @@ fn social_help_lines() -> Vec { .collect() } -fn games_help_lines() -> Vec { +fn arcade_help_lines() -> Vec { [ - "Games", - "", - "The game surfaces are The Arcade, Tables, and Door Games. This page covers getting around; Economy owns per-game controls, scoring, chips, payouts, and leaderboards.", - "", "Arcade", + "", + "The Arcade is for single-player terminal games, daily puzzles, endless runs, and leaderboard play.", " 2 open The Arcade", " j / k or ↑ / ↓ browse games", " Enter play selected game", " Esc / q leave current game", " ` return to Dashboard while a run is active", "", + "Notes", + " Game-specific controls appear inside the Arcade page.", + " Daily puzzle completions, run scores, chips, payouts, and leaderboards are covered in Economy.", + ] + .into_iter() + .map(str::to_string) + .collect() +} + +fn tables_help_lines() -> Vec { + [ "Tables", + "", + "Tables are persistent multiplayer sessions for table-style games with paired embedded chat.", " 3 open Tables", " j / k or ↑ / ↓ navigate tables", " h / l or ← / → cycle filters", @@ -531,6 +542,41 @@ fn games_help_lines() -> Vec { .collect() } +fn doors_help_lines() -> Vec { + [ + "Doors", + "", + "Door Games are BBS-style persistent worlds. Lateania is the first door.", + " 4 open Door Games", + " Esc / q leave the door and return Home", + "", + "Lateania", + " 1-5 choose class before your first adventure", + " w/a/s/d or arrows move north/west/south/east", + " y/u/n/m diagonal movement", + " < / > move up / down where exits exist", + " o look around", + " Space / Enter / x attack", + " 1-9 use ability slots after choosing a class", + " z flee combat", + "", + "Panels", + " c character", + " v abilities", + " t inventory", + " b shop, when a merchant is present", + " Enter activate selected inventory/shop row", + " x sell selected inventory item at a shop", + "", + "Persistence", + " Your Lateania character is saved when you leave and periodically while present.", + " Reset/restart is not exposed in the UI yet.", + ] + .into_iter() + .map(str::to_string) + .collect() +} + fn overview_lines() -> Vec { [ "late.sh in one pass", @@ -583,7 +629,7 @@ fn overview_lines() -> Vec { " Tab / Shift+Tab switch Hub tabs", " 1-4 jump to Hub tab", " Shop j/k select, [/] subtab, Enter buy with Late Chips", - " Economy tab chips, payouts, leaderboards, Arcade, room games", + " Economy tab chips, payouts, leaderboards, Arcade, table games", "", "Jump search", " Ctrl+/ open / close jump modal", @@ -1019,11 +1065,15 @@ mod tests { } #[test] - fn all_purpose_guide_merges_game_topics() { - assert!(HelpTopic::ALL.iter().any(|topic| topic.title() == "Games")); - assert!(!bot_app_context().contains("## Arcade\n")); - assert!(!bot_app_context().contains("## Rooms\n")); - assert!(bot_app_context().contains("## Games\n")); + fn all_purpose_guide_splits_game_topics() { + assert!(HelpTopic::ALL.iter().any(|topic| topic.title() == "Arcade")); + assert!(HelpTopic::ALL.iter().any(|topic| topic.title() == "Tables")); + assert!(HelpTopic::ALL.iter().any(|topic| topic.title() == "Doors")); + assert!(!HelpTopic::ALL.iter().any(|topic| topic.title() == "Games")); + assert!(bot_app_context().contains("## Arcade\n")); + assert!(bot_app_context().contains("## Tables\n")); + assert!(bot_app_context().contains("## Doors\n")); + assert!(!bot_app_context().contains("## Games\n")); } #[test] @@ -1106,12 +1156,14 @@ mod tests { #[test] fn global_guide_points_to_hub_for_game_details() { - let games = games_help_lines().join("\n"); - assert!(games.contains("Economy tab")); - assert!(games.contains("Tables")); - assert!(!games.contains("Tetris")); - assert!(!games.contains("Sudoku")); - assert!(!games.contains("Room stack")); - assert!(!games.contains("Clock presets")); + let arcade = arcade_help_lines().join("\n"); + let tables = tables_help_lines().join("\n"); + let doors = doors_help_lines().join("\n"); + assert!(arcade.contains("Economy")); + assert!(tables.contains("Economy tab")); + assert!(doors.contains("Lateania")); + assert!(!arcade.contains("Tetris")); + assert!(!tables.contains("Sudoku")); + assert!(!doors.contains("Clock presets")); } } From 03c40de184d1dc47df7a86f60ddf20a44ac3a9e3 Mon Sep 17 00:00:00 2001 From: mateuszpiorowski Date: Thu, 4 Jun 2026 23:13:56 +0200 Subject: [PATCH 12/20] update --- .github/workflows/ci.yml | 22 ------- .github/workflows/pr.yml | 25 +------- late-core/src/models/user.rs | 7 ++- late-ssh/src/app/door/lateania/state.rs | 20 +++++-- late-ssh/src/app/door/lateania/svc.rs | 78 +++++++++++++++++++++---- late-ssh/src/app/render.rs | 35 ++++++++++- late-ssh/src/app/settings_modal/ui.rs | 2 +- 7 files changed, 124 insertions(+), 65 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a380490..f008ca54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,21 +8,11 @@ on: required: false type: string default: "" - exclude_package: - description: "Cargo package to exclude when checking the workspace" - required: false - type: string - default: "" source_ref: description: "Git ref to check out" required: false type: string default: "" - run_fmt: - description: "Run rustfmt before clippy + test" - required: false - type: boolean - default: true permissions: contents: read @@ -30,7 +20,6 @@ permissions: jobs: fmt: name: rustfmt - if: ${{ inputs.run_fmt }} runs-on: ubuntu-latest steps: - name: checkout @@ -45,12 +34,7 @@ jobs: clippy-test: name: clippy + test needs: [fmt] - if: ${{ !cancelled() && (!inputs.run_fmt || needs.fmt.result == 'success') }} runs-on: ubuntu-latest - env: - CARGO_INCREMENTAL: "0" - CARGO_PROFILE_DEV_DEBUG: "0" - CARGO_PROFILE_TEST_DEBUG: "0" services: postgres: image: postgres:18-alpine @@ -84,13 +68,10 @@ jobs: - name: cargo_clippy env: CARGO_PACKAGE: ${{ inputs.package }} - CARGO_EXCLUDE_PACKAGE: ${{ inputs.exclude_package }} run: | package_args=(--workspace) if [ -n "$CARGO_PACKAGE" ]; then package_args=(-p "$CARGO_PACKAGE") - elif [ -n "$CARGO_EXCLUDE_PACKAGE" ]; then - package_args+=(--exclude "$CARGO_EXCLUDE_PACKAGE") fi cargo clippy "${package_args[@]}" --all-targets --features otel -- -D warnings @@ -100,13 +81,10 @@ jobs: env: TEST_DATABASE_URL: "host=127.0.0.1 port=5432 user=test password=test dbname=postgres" CARGO_PACKAGE: ${{ inputs.package }} - CARGO_EXCLUDE_PACKAGE: ${{ inputs.exclude_package }} run: | package_args=(--workspace) if [ -n "$CARGO_PACKAGE" ]; then package_args=(-p "$CARGO_PACKAGE") - elif [ -n "$CARGO_EXCLUDE_PACKAGE" ]; then - package_args+=(--exclude "$CARGO_EXCLUDE_PACKAGE") fi cargo nextest run "${package_args[@]}" --all-targets diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 72b831a7..fc311481 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -8,27 +8,6 @@ concurrency: cancel-in-progress: true jobs: - fmt: - name: rustfmt - runs-on: ubuntu-latest - steps: - - name: checkout - uses: actions/checkout@v5 - - name: install_rustfmt - run: rustup component add rustfmt - - name: cargo_fmt_check - run: cargo fmt --all -- --check - - late-cli: - name: late-cli - needs: [fmt] - uses: ./.github/workflows/ci.yml - with: - package: late-cli - - not-late-cli: - name: not-late-cli - needs: [fmt] + ci: + name: ci uses: ./.github/workflows/ci.yml - with: - exclude_package: late-cli diff --git a/late-core/src/models/user.rs b/late-core/src/models/user.rs index 8c611871..5c5afb80 100644 --- a/late-core/src/models/user.rs +++ b/late-core/src/models/user.rs @@ -53,8 +53,11 @@ crate::model! { pub const USERNAME_MAX_LEN: usize = 32; -/// Number of top-level screens (Dashboard, Arcade, Rooms, Artboard). -pub const RIGHT_SIDEBAR_SCREEN_COUNT: u8 = 4; +/// Number of screens exposed in the custom right-sidebar picker. +/// +/// Directory/Pinstar is intentionally not configurable here; "on" mode still +/// shows the sidebar everywhere. +pub const RIGHT_SIDEBAR_SCREEN_COUNT: u8 = 5; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum RightSidebarMode { diff --git a/late-ssh/src/app/door/lateania/state.rs b/late-ssh/src/app/door/lateania/state.rs index 62741bba..7688929a 100644 --- a/late-ssh/src/app/door/lateania/state.rs +++ b/late-ssh/src/app/door/lateania/state.rs @@ -25,27 +25,32 @@ pub enum Panel { pub struct State { user_id: Uuid, + session_id: Uuid, snapshot: MudSnapshot, svc: LateaniaService, snapshot_rx: watch::Receiver, panel: Panel, /// Selection cursor for the inventory/shop list panels. cursor: usize, + joined: bool, } impl State { pub fn new(svc: LateaniaService, user_id: Uuid) -> Self { + let session_id = Uuid::now_v7(); let snapshot_rx = svc.subscribe_state(); let snapshot = snapshot_rx.borrow().clone(); let state = Self { user_id, + session_id, snapshot, svc, snapshot_rx, panel: Panel::Room, cursor: 0, + joined: true, }; - state.svc.join_task(user_id); + state.svc.join_task(user_id, session_id); state } @@ -141,8 +146,15 @@ impl State { self.svc.flee_task(self.user_id); } - pub fn leave_world(&self) { - self.svc.leave_task(self.user_id); + pub fn leave_world(&mut self) { + self.close_session(); + } + + fn close_session(&mut self) { + if self.joined { + self.joined = false; + self.svc.leave_task(self.user_id, self.session_id); + } } /// Context action on the selected list row (equip/use in inventory, buy in shop). @@ -182,6 +194,6 @@ impl State { impl Drop for State { fn drop(&mut self) { - self.svc.leave_task(self.user_id); + self.close_session(); } } diff --git a/late-ssh/src/app/door/lateania/svc.rs b/late-ssh/src/app/door/lateania/svc.rs index ecf33d47..b0bffb0b 100644 --- a/late-ssh/src/app/door/lateania/svc.rs +++ b/late-ssh/src/app/door/lateania/svc.rs @@ -12,12 +12,12 @@ // (abilities.rs), and an inventory / equipment / gold / shop economy (items.rs). use std::{ - collections::HashMap, - sync::Arc, + collections::{HashMap, HashSet}, + sync::{Arc, Mutex as StdMutex}, time::{Duration, Instant}, }; -use late_core::{db::Db, models::mud_character::MudCharacter}; +use late_core::{MutexRecover, db::Db, models::mud_character::MudCharacter}; use rand::Rng; use tokio::sync::{Mutex, watch}; use uuid::Uuid; @@ -50,6 +50,7 @@ pub struct LateaniaService { snapshot_tx: watch::Sender, snapshot_rx: watch::Receiver, state: Arc>, + active_sessions: Arc>>>, } // ---- Snapshot (what sessions render) ------------------------------------- @@ -221,6 +222,7 @@ impl LateaniaService { snapshot_tx, snapshot_rx, state: Arc::new(Mutex::new(state)), + active_sessions: Arc::new(StdMutex::new(HashMap::new())), }; svc.start_tick_loop(); svc.start_autosave_loop(); @@ -264,9 +266,25 @@ impl LateaniaService { }); } - pub fn join_task(&self, user_id: Uuid) { + pub fn join_task(&self, user_id: Uuid, session_id: Uuid) { + self.mark_session_joined(user_id, session_id); let svc = self.clone(); tokio::spawn(async move { + let should_load_saved = { + let mut state = svc.state.lock().await; + if !svc.has_active_session(user_id) { + return; + } + let joined = state.join(user_id); + state.touch(user_id); + svc.publish(&state); + joined + }; + + if !should_load_saved { + return; + } + // Load any saved character BEFORE taking the world lock (DB is async). let saved = match svc.db.get().await { Ok(client) => match MudCharacter::load(&client, user_id).await { @@ -282,24 +300,33 @@ impl LateaniaService { None } }; - { + + if let Some(saved) = saved { let mut state = svc.state.lock().await; - let joined = state.join(user_id); - if joined && let Some(saved) = saved { + if svc.has_active_session(user_id) && state.players.contains_key(&user_id) { state.hydrate(user_id, &saved); + state.touch(user_id); + svc.publish(&state); } - state.touch(user_id); - svc.publish(&state); } }); } - pub fn leave_task(&self, user_id: Uuid) { + pub fn leave_task(&self, user_id: Uuid, session_id: Uuid) { + if !self.mark_session_left(user_id, session_id) { + return; + } let svc = self.clone(); tokio::spawn(async move { + if svc.has_active_session(user_id) { + return; + } // Capture the durable character under the lock, then remove the player. let saved = { let mut state = svc.state.lock().await; + if svc.has_active_session(user_id) { + return; + } let saved = state.export_saved(user_id); state.leave(user_id); svc.publish(&state); @@ -311,6 +338,37 @@ impl LateaniaService { }); } + fn mark_session_joined(&self, user_id: Uuid, session_id: Uuid) { + self.active_sessions + .lock_recover() + .entry(user_id) + .or_default() + .insert(session_id); + } + + /// Mark one session closed. Returns true only when no sessions remain for + /// that user, meaning the world player can be removed after re-checking. + fn mark_session_left(&self, user_id: Uuid, session_id: Uuid) -> bool { + let mut active_sessions = self.active_sessions.lock_recover(); + let Some(user_sessions) = active_sessions.get_mut(&user_id) else { + return true; + }; + user_sessions.remove(&session_id); + if user_sessions.is_empty() { + active_sessions.remove(&user_id); + true + } else { + false + } + } + + fn has_active_session(&self, user_id: Uuid) -> bool { + self.active_sessions + .lock_recover() + .get(&user_id) + .is_some_and(|sessions| !sessions.is_empty()) + } + /// Write one character blob to the database (best-effort). async fn persist(&self, user_id: Uuid, saved: SavedCharacter) { match self.db.get().await { diff --git a/late-ssh/src/app/render.rs b/late-ssh/src/app/render.rs index edf2ff41..c28ca91d 100644 --- a/late-ssh/src/app/render.rs +++ b/late-ssh/src/app/render.rs @@ -78,8 +78,8 @@ pub(crate) fn screen_number(screen: Screen) -> u8 { Screen::Dashboard => 1, Screen::Arcade => 2, Screen::Rooms => 3, - Screen::DoorGames => 4, - Screen::Artboard => 5, + Screen::Artboard => 4, + Screen::DoorGames => 5, Screen::Pinstar => 6, } } @@ -1624,8 +1624,10 @@ mod tests { HelpHintStyle, NotificationMode, app_frame_bottom_titles, app_frame_help_hint_title, app_frame_sponsor_title, dashboard_home_selected, desktop_notification_bytes, line_width, mentions_hud_title, room_list_sidebar_enabled, room_top_boxes_enabled, sidebar_enabled, - sponsor_line, + resolve_right_sidebar_enabled, screen_number, sponsor_line, }; + use crate::app::common::primitives::Screen; + use late_core::models::user::RightSidebarMode; use uuid::Uuid; fn line_text(line: &ratatui::text::Line<'_>) -> String { @@ -1698,6 +1700,33 @@ mod tests { assert!(!sidebar_enabled(false, true, false)); } + #[test] + fn right_sidebar_custom_slots_keep_artboard_and_add_door_games() { + assert_eq!(screen_number(Screen::Artboard), 4); + assert_eq!(screen_number(Screen::DoorGames), 5); + + assert!(resolve_right_sidebar_enabled( + RightSidebarMode::Custom, + &[4], + Screen::Artboard, + )); + assert!(!resolve_right_sidebar_enabled( + RightSidebarMode::Custom, + &[4], + Screen::DoorGames, + )); + assert!(resolve_right_sidebar_enabled( + RightSidebarMode::Custom, + &[5], + Screen::DoorGames, + )); + assert!(!resolve_right_sidebar_enabled( + RightSidebarMode::Custom, + &[5], + Screen::Pinstar, + )); + } + #[test] fn room_list_sidebar_enabled_prefers_settings_draft_while_modal_is_open() { assert!(!room_list_sidebar_enabled(true, false, true)); diff --git a/late-ssh/src/app/settings_modal/ui.rs b/late-ssh/src/app/settings_modal/ui.rs index 355230d0..4e06c09e 100644 --- a/late-ssh/src/app/settings_modal/ui.rs +++ b/late-ssh/src/app/settings_modal/ui.rs @@ -1539,7 +1539,7 @@ fn draw_right_sidebar_custom_dialog(frame: &mut Frame, area: Rect, state: &Setti let layout = Layout::vertical(constraints).split(inner); const SCREEN_LABELS: [&str; RIGHT_SIDEBAR_SCREEN_COUNT as usize] = - ["Home", "Arcade", "Tables", "Artboard"]; + ["Home", "Arcade", "Tables", "Artboard", "Door Games"]; let width = inner.width as usize; for screen_idx in 0..RIGHT_SIDEBAR_SCREEN_COUNT as usize { From bed07de8da113574a9f0ba2309f736dd1c781f8a Mon Sep 17 00:00:00 2001 From: mateuszpiorowski Date: Thu, 4 Jun 2026 23:38:22 +0200 Subject: [PATCH 13/20] update --- late-cli/CONTEXT.md | 2 +- late-cli/src/webview/mod.rs | 9 +- late-ssh/src/app/door/lateania/input.rs | 6 +- late-ssh/src/app/door/lateania/state.rs | 82 ++++++++--- late-ssh/src/app/door/lateania/svc.rs | 180 ++++++++++++++++++------ 5 files changed, 218 insertions(+), 61 deletions(-) diff --git a/late-cli/CONTEXT.md b/late-cli/CONTEXT.md index 4cd4aeff..88031ada 100644 --- a/late-cli/CONTEXT.md +++ b/late-cli/CONTEXT.md @@ -287,7 +287,7 @@ Embedded YouTube helper window: - By default the parent redirects helper stderr to the webview log path. For a single combined debug capture, run `LATE_WEBVIEW_DEBUG_STDERR=1 late -v 2>late-debug.log`; this captures both parent CLI tracing and helper GTK/WebKit/GStreamer output. - The normal helper spawn sets `NO_AT_BRIDGE=1` and, on Linux, sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set it. If `late webview-spike ...` is run directly during debugging and crashes in `libatk-bridge-2.0.so` after `dbind-WARNING`, retry as `NO_AT_BRIDGE=1 late webview-spike ` or restart stale `at-spi-bus-launcher` processes. - If the embedded helper exits or fails to start 3 times within 60 seconds, the parent disables embedded YouTube fallback for 5 minutes and logs the helper log path. This prevents the repeated open/close loop when a host WebKit/GStreamer install is broken; a real browser connect page can still take over YouTube playback. -- On Linux/Wayland the app id/class is `sh.late.youtube`; Hyprland users should route it with window rules. Use a special workspace/scratchpad to hide it from the active workspace instead of relying on fully off-screen placement. +- The helper requests no initial focus and always-on-bottom placement; on Linux it also skips the taskbar. These are best-effort window-manager hints, not a hidden/background player. On Linux/Wayland the app id/class is `sh.late.youtube`; Hyprland may ignore always-on-bottom, so users who need stronger routing should use a special workspace/scratchpad instead of relying on fully off-screen placement. - On initial helper open only, `webview-pair` uses the first `queue_update.current.started_at_ms` snapshot to apply one `startSeconds` value to the first matching `load_video`. If a `load_video` arrives before that first snapshot, the relay buffers it and flushes it when the snapshot decision is known. After that first load is dispatched, heartbeats and later track switches do not receive a seek offset and continue through the normal `loadVideoById({ videoId })` path. - The helper page suppresses transient YouTube IFrame `unstarted`/`cued` states and only reports `ended` after the current item has reached `playing`; the server still owns queue advancement through its playback timer. - If YouTube rejects the embedded iframe with `101`, `150`, or `153`, the helper logs the rejection and stays on its controlled bridge page. It does not navigate to the normal `youtube.com/watch` page because that would leave the local player bridge and make source switching/state harder to reason about. diff --git a/late-cli/src/webview/mod.rs b/late-cli/src/webview/mod.rs index bc5b7dc8..54e06726 100644 --- a/late-cli/src/webview/mod.rs +++ b/late-cli/src/webview/mod.rs @@ -28,7 +28,7 @@ use tracing::{info, warn}; use wry::{WebView, WebViewBuilder}; #[cfg(target_os = "linux")] -use tao::platform::unix::{EventLoopBuilderExtUnix, WindowExtUnix}; +use tao::platform::unix::{EventLoopBuilderExtUnix, WindowBuilderExtUnix, WindowExtUnix}; #[cfg(target_os = "linux")] use wry::WebViewBuilderExtUnix; @@ -69,11 +69,16 @@ where let proxy = event_loop.create_proxy(); let (ipc_tx, ipc_rx) = mpsc::unbounded_channel::(); - let window = WindowBuilder::new() + let window_builder = WindowBuilder::new() .with_title("late.sh — YouTube") .with_inner_size(LogicalSize::new(480.0, 320.0)) .with_resizable(false) .with_decorations(false) + .with_focused(false) + .with_always_on_bottom(true); + #[cfg(target_os = "linux")] + let window_builder = window_builder.with_skip_taskbar(true); + let window = window_builder .build(&event_loop) .context("failed to build webview window")?; diff --git a/late-ssh/src/app/door/lateania/input.rs b/late-ssh/src/app/door/lateania/input.rs index 74597677..9470ba9c 100644 --- a/late-ssh/src/app/door/lateania/input.rs +++ b/late-ssh/src/app/door/lateania/input.rs @@ -31,9 +31,13 @@ pub fn handle_key(state: &mut State, byte: u8) -> InputAction { } let view = state.view(); + if !view.joined { + state.ensure_player_present(); + return InputAction::Handled; + } // Class selection gate: until a class is chosen, 1-5 pick it and nothing else acts. - if view.joined && !view.classed { + if !view.classed { match byte { b'1' => state.choose_class(Class::Warrior), b'2' => state.choose_class(Class::Mage), diff --git a/late-ssh/src/app/door/lateania/state.rs b/late-ssh/src/app/door/lateania/state.rs index 7688929a..0fd3e33e 100644 --- a/late-ssh/src/app/door/lateania/state.rs +++ b/late-ssh/src/app/door/lateania/state.rs @@ -6,6 +6,8 @@ // list panels. All real actions delegate to the service's *_task methods; this // struct never blocks and never mutates world truth. +use std::time::{Duration, Instant}; + use tokio::sync::watch; use uuid::Uuid; @@ -33,11 +35,14 @@ pub struct State { /// Selection cursor for the inventory/shop list panels. cursor: usize, joined: bool, + join_pending: bool, + join_requested_at: Instant, } impl State { pub fn new(svc: LateaniaService, user_id: Uuid) -> Self { let session_id = Uuid::now_v7(); + let join_requested_at = Instant::now(); let snapshot_rx = svc.subscribe_state(); let snapshot = snapshot_rx.borrow().clone(); let state = Self { @@ -49,6 +54,8 @@ impl State { panel: Panel::Room, cursor: 0, joined: true, + join_pending: true, + join_requested_at, }; state.svc.join_task(user_id, session_id); state @@ -58,10 +65,35 @@ impl State { if self.snapshot_rx.has_changed().unwrap_or(false) { self.snapshot = self.snapshot_rx.borrow_and_update().clone(); } + if self.snapshot.players.contains_key(&self.user_id) { + self.join_pending = false; + } + } + + pub fn touch_activity(&mut self) { + if self.ensure_player_present() { + self.svc.touch_activity_task(self.user_id); + } } - pub fn touch_activity(&self) { - self.svc.touch_activity_task(self.user_id); + pub fn ensure_player_present(&mut self) -> bool { + if !self.joined { + return false; + } + if self.snapshot.players.contains_key(&self.user_id) { + self.join_pending = false; + return true; + } + if !self.join_pending { + self.join_requested_at = Instant::now(); + self.join_pending = true; + self.svc.join_task(self.user_id, self.session_id); + } else if self.join_requested_at.elapsed() >= Duration::from_secs(2) { + self.join_requested_at = Instant::now(); + self.join_pending = true; + self.svc.join_task(self.user_id, self.session_id); + } + false } pub fn view(&self) -> PlayerView { @@ -122,28 +154,40 @@ impl State { // ---- Actions -------------------------------------------------------- - pub fn choose_class(&self, class: Class) { - self.svc.choose_class_task(self.user_id, class); + pub fn choose_class(&mut self, class: Class) { + if self.ensure_player_present() { + self.svc.choose_class_task(self.user_id, class); + } } - pub fn go(&self, dir: Dir) { - self.svc.move_task(self.user_id, dir); + pub fn go(&mut self, dir: Dir) { + if self.ensure_player_present() { + self.svc.move_task(self.user_id, dir); + } } - pub fn look(&self) { - self.svc.look_task(self.user_id); + pub fn look(&mut self) { + if self.ensure_player_present() { + self.svc.look_task(self.user_id); + } } - pub fn attack(&self) { - self.svc.attack_task(self.user_id); + pub fn attack(&mut self) { + if self.ensure_player_present() { + self.svc.attack_task(self.user_id); + } } - pub fn use_ability(&self, slot: u8) { - self.svc.ability_task(self.user_id, slot); + pub fn use_ability(&mut self, slot: u8) { + if self.ensure_player_present() { + self.svc.ability_task(self.user_id, slot); + } } - pub fn flee(&self) { - self.svc.flee_task(self.user_id); + pub fn flee(&mut self) { + if self.ensure_player_present() { + self.svc.flee_task(self.user_id); + } } pub fn leave_world(&mut self) { @@ -158,7 +202,10 @@ impl State { } /// Context action on the selected list row (equip/use in inventory, buy in shop). - pub fn activate_selection(&self) { + pub fn activate_selection(&mut self) { + if !self.ensure_player_present() { + return; + } match self.panel { Panel::Inventory => { let view = self.view(); @@ -182,7 +229,10 @@ impl State { } /// Secondary action: sell the selected inventory row at a shop. - pub fn sell_selection(&self) { + pub fn sell_selection(&mut self) { + if !self.ensure_player_present() { + return; + } if self.panel == Panel::Inventory { let view = self.view(); if let Some(row) = view.inventory.get(self.cursor) { diff --git a/late-ssh/src/app/door/lateania/svc.rs b/late-ssh/src/app/door/lateania/svc.rs index b0bffb0b..ef4c8252 100644 --- a/late-ssh/src/app/door/lateania/svc.rs +++ b/late-ssh/src/app/door/lateania/svc.rs @@ -51,6 +51,9 @@ pub struct LateaniaService { snapshot_rx: watch::Receiver, state: Arc>, active_sessions: Arc>>>, + persist_versions: Arc>>, + persist_locks: Arc>>>>, + prepared_saves: Arc>>, } // ---- Snapshot (what sessions render) ------------------------------------- @@ -223,6 +226,9 @@ impl LateaniaService { snapshot_rx, state: Arc::new(Mutex::new(state)), active_sessions: Arc::new(StdMutex::new(HashMap::new())), + persist_versions: Arc::new(StdMutex::new(HashMap::new())), + persist_locks: Arc::new(StdMutex::new(HashMap::new())), + prepared_saves: Arc::new(StdMutex::new(HashMap::new())), }; svc.start_tick_loop(); svc.start_autosave_loop(); @@ -270,45 +276,44 @@ impl LateaniaService { self.mark_session_joined(user_id, session_id); let svc = self.clone(); tokio::spawn(async move { - let should_load_saved = { - let mut state = svc.state.lock().await; - if !svc.has_active_session(user_id) { - return; - } - let joined = state.join(user_id); - state.touch(user_id); - svc.publish(&state); - joined - }; - - if !should_load_saved { + if !svc.has_active_session(user_id) { return; } - // Load any saved character BEFORE taking the world lock (DB is async). - let saved = match svc.db.get().await { - Ok(client) => match MudCharacter::load(&client, user_id).await { - Ok(Some(blob)) => SavedCharacter::from_json(&blob), - Ok(None) => None, + // Load any saved character before exposing a fresh player. A DB + // failure must not become "no save", otherwise later autosave or + // logout can overwrite an existing character with a starter one. + let saved = if let Some(saved) = svc.prepared_saved(user_id) { + Some(saved) + } else { + match svc.db.get().await { + Ok(client) => match MudCharacter::load(&client, user_id).await { + Ok(Some(blob)) => SavedCharacter::from_json(&blob), + Ok(None) => None, + Err(error) => { + tracing::warn!(%user_id, ?error, "failed to load mud character"); + return; + } + }, Err(error) => { - tracing::warn!(%user_id, ?error, "failed to load mud character"); - None + tracing::warn!(%user_id, ?error, "no db client for mud character load"); + return; } - }, - Err(error) => { - tracing::warn!(%user_id, ?error, "no db client for mud character load"); - None } }; - if let Some(saved) = saved { - let mut state = svc.state.lock().await; - if svc.has_active_session(user_id) && state.players.contains_key(&user_id) { + let mut state = svc.state.lock().await; + if !svc.has_active_session(user_id) { + return; + } + if !state.players.contains_key(&user_id) { + state.join(user_id); + if let Some(saved) = saved { state.hydrate(user_id, &saved); - state.touch(user_id); - svc.publish(&state); } } + state.touch(user_id); + svc.publish(&state); }); } @@ -327,13 +332,15 @@ impl LateaniaService { if svc.has_active_session(user_id) { return; } - let saved = state.export_saved(user_id); + let saved = state + .export_saved(user_id) + .map(|saved| svc.prepare_persist(user_id, saved)); state.leave(user_id); svc.publish(&state); saved }; if let Some(saved) = saved { - svc.persist(user_id, saved).await; + svc.persist(saved).await; } }); } @@ -369,16 +376,76 @@ impl LateaniaService { .is_some_and(|sessions| !sessions.is_empty()) } + fn clear_sessions(&self, user_id: Uuid) { + self.active_sessions.lock_recover().remove(&user_id); + } + + fn prepare_persist(&self, user_id: Uuid, saved: SavedCharacter) -> PendingSave { + let mut versions = self.persist_versions.lock_recover(); + let version = versions.entry(user_id).and_modify(|v| *v += 1).or_insert(1); + self.prepared_saves + .lock_recover() + .insert(user_id, (*version, saved.clone())); + PendingSave { + user_id, + version: *version, + saved, + } + } + + fn prepared_saved(&self, user_id: Uuid) -> Option { + self.prepared_saves + .lock_recover() + .get(&user_id) + .map(|(_, saved)| saved.clone()) + } + + fn clear_prepared_save(&self, save: &PendingSave) { + let mut prepared_saves = self.prepared_saves.lock_recover(); + if prepared_saves + .get(&save.user_id) + .is_some_and(|(version, _)| *version == save.version) + { + prepared_saves.remove(&save.user_id); + } + } + + fn is_latest_persist(&self, save: &PendingSave) -> bool { + self.persist_versions + .lock_recover() + .get(&save.user_id) + .is_some_and(|version| *version == save.version) + } + + fn persist_lock(&self, user_id: Uuid) -> Arc> { + self.persist_locks + .lock_recover() + .entry(user_id) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + /// Write one character blob to the database (best-effort). - async fn persist(&self, user_id: Uuid, saved: SavedCharacter) { + async fn persist(&self, save: PendingSave) { + if !self.is_latest_persist(&save) { + return; + } + let lock = self.persist_lock(save.user_id); + let _guard = lock.lock().await; + if !self.is_latest_persist(&save) { + return; + } match self.db.get().await { Ok(client) => { - if let Err(error) = MudCharacter::save(&client, user_id, saved.to_json()).await { - tracing::warn!(%user_id, ?error, "failed to save mud character"); + match MudCharacter::save(&client, save.user_id, save.saved.to_json()).await { + Ok(()) => self.clear_prepared_save(&save), + Err(error) => { + tracing::warn!(user_id = %save.user_id, ?error, "failed to save mud character"); + } } } Err(error) => { - tracing::warn!(%user_id, ?error, "no db client for mud character save"); + tracing::warn!(user_id = %save.user_id, ?error, "no db client for mud character save"); } } } @@ -390,12 +457,15 @@ impl LateaniaService { ticker.tick().await; // skip the immediate first tick loop { ticker.tick().await; - let saves: Vec<(Uuid, SavedCharacter)> = { + let saves: Vec = { let state = svc.state.lock().await; state.export_all_saved() + .into_iter() + .map(|(user_id, saved)| svc.prepare_persist(user_id, saved)) + .collect() }; - for (user_id, saved) in saves { - svc.persist(user_id, saved).await; + for save in saves { + svc.persist(save).await; } } }); @@ -460,13 +530,22 @@ impl LateaniaService { loop { ticker.tick().await; let mut state = svc.state.lock().await; - let outcomes = state.tick(); + let tick = state.tick(); + let idle_saves: Vec = tick + .idle_saves + .into_iter() + .map(|(user_id, saved)| svc.prepare_persist(user_id, saved)) + .collect(); if state.dirty { svc.publish(&state); state.dirty = false; } drop(state); - for outcome in outcomes { + for save in idle_saves { + svc.clear_sessions(save.user_id); + svc.persist(save).await; + } + for outcome in tick.kills { svc.activity.game_won_task( outcome.user_id, ActivityGame::Mud, @@ -488,6 +567,18 @@ struct KillOutcome { mob_name: String, } +#[derive(Default)] +struct TickOutput { + kills: Vec, + idle_saves: Vec<(Uuid, SavedCharacter)>, +} + +struct PendingSave { + user_id: Uuid, + version: u64, + saved: SavedCharacter, +} + // ---- Active effects (spells, poisons, buffs unified) --------------------- #[derive(Clone, Copy)] @@ -1482,7 +1573,7 @@ impl WorldState { // ---- Tick ----------------------------------------------------------- - fn tick(&mut self) -> Vec { + fn tick(&mut self) -> TickOutput { self.pending_kills.clear(); let now = Instant::now(); @@ -1684,7 +1775,11 @@ impl WorldState { }) .map(|(id, _)| *id) .collect(); + let mut idle_saves = Vec::new(); for user_id in idle { + if let Some(saved) = self.export_saved(user_id) { + idle_saves.push((user_id, saved)); + } self.players.remove(&user_id); self.dirty = true; } @@ -1692,7 +1787,10 @@ impl WorldState { if self.dirty { self.generation = self.generation.wrapping_add(1); } - std::mem::take(&mut self.pending_kills) + TickOutput { + kills: std::mem::take(&mut self.pending_kills), + idle_saves, + } } fn strike_player(&mut self, user_id: Uuid, raw: i32, dtype: DamageType, mob_name: &str) { From 2912f028b274ea1e1747efa515058610578b7c30 Mon Sep 17 00:00:00 2001 From: mateuszpiorowski Date: Thu, 4 Jun 2026 23:51:13 +0200 Subject: [PATCH 14/20] update --- late-ssh/src/app/door/lateania/state.rs | 6 +----- late-ssh/src/app/door/lateania/svc.rs | 3 ++- late-ssh/src/app/render.rs | 20 ++++++++++---------- late-ssh/src/app/settings_modal/ui.rs | 2 +- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/late-ssh/src/app/door/lateania/state.rs b/late-ssh/src/app/door/lateania/state.rs index 0fd3e33e..8008186f 100644 --- a/late-ssh/src/app/door/lateania/state.rs +++ b/late-ssh/src/app/door/lateania/state.rs @@ -84,11 +84,7 @@ impl State { self.join_pending = false; return true; } - if !self.join_pending { - self.join_requested_at = Instant::now(); - self.join_pending = true; - self.svc.join_task(self.user_id, self.session_id); - } else if self.join_requested_at.elapsed() >= Duration::from_secs(2) { + if !self.join_pending || self.join_requested_at.elapsed() >= Duration::from_secs(2) { self.join_requested_at = Instant::now(); self.join_pending = true; self.svc.join_task(self.user_id, self.session_id); diff --git a/late-ssh/src/app/door/lateania/svc.rs b/late-ssh/src/app/door/lateania/svc.rs index ef4c8252..ee987510 100644 --- a/late-ssh/src/app/door/lateania/svc.rs +++ b/late-ssh/src/app/door/lateania/svc.rs @@ -459,7 +459,8 @@ impl LateaniaService { ticker.tick().await; let saves: Vec = { let state = svc.state.lock().await; - state.export_all_saved() + state + .export_all_saved() .into_iter() .map(|(user_id, saved)| svc.prepare_persist(user_id, saved)) .collect() diff --git a/late-ssh/src/app/render.rs b/late-ssh/src/app/render.rs index c28ca91d..e323d783 100644 --- a/late-ssh/src/app/render.rs +++ b/late-ssh/src/app/render.rs @@ -78,8 +78,8 @@ pub(crate) fn screen_number(screen: Screen) -> u8 { Screen::Dashboard => 1, Screen::Arcade => 2, Screen::Rooms => 3, - Screen::Artboard => 4, - Screen::DoorGames => 5, + Screen::DoorGames => 4, + Screen::Artboard => 5, Screen::Pinstar => 6, } } @@ -1623,8 +1623,8 @@ mod tests { use super::{ HelpHintStyle, NotificationMode, app_frame_bottom_titles, app_frame_help_hint_title, app_frame_sponsor_title, dashboard_home_selected, desktop_notification_bytes, line_width, - mentions_hud_title, room_list_sidebar_enabled, room_top_boxes_enabled, sidebar_enabled, - resolve_right_sidebar_enabled, screen_number, sponsor_line, + mentions_hud_title, resolve_right_sidebar_enabled, room_list_sidebar_enabled, + room_top_boxes_enabled, screen_number, sidebar_enabled, sponsor_line, }; use crate::app::common::primitives::Screen; use late_core::models::user::RightSidebarMode; @@ -1701,24 +1701,24 @@ mod tests { } #[test] - fn right_sidebar_custom_slots_keep_artboard_and_add_door_games() { - assert_eq!(screen_number(Screen::Artboard), 4); - assert_eq!(screen_number(Screen::DoorGames), 5); + fn right_sidebar_custom_slots_follow_page_order() { + assert_eq!(screen_number(Screen::DoorGames), 4); + assert_eq!(screen_number(Screen::Artboard), 5); assert!(resolve_right_sidebar_enabled( RightSidebarMode::Custom, &[4], - Screen::Artboard, + Screen::DoorGames, )); assert!(!resolve_right_sidebar_enabled( RightSidebarMode::Custom, &[4], - Screen::DoorGames, + Screen::Artboard, )); assert!(resolve_right_sidebar_enabled( RightSidebarMode::Custom, &[5], - Screen::DoorGames, + Screen::Artboard, )); assert!(!resolve_right_sidebar_enabled( RightSidebarMode::Custom, diff --git a/late-ssh/src/app/settings_modal/ui.rs b/late-ssh/src/app/settings_modal/ui.rs index 4e06c09e..bc26b1d8 100644 --- a/late-ssh/src/app/settings_modal/ui.rs +++ b/late-ssh/src/app/settings_modal/ui.rs @@ -1539,7 +1539,7 @@ fn draw_right_sidebar_custom_dialog(frame: &mut Frame, area: Rect, state: &Setti let layout = Layout::vertical(constraints).split(inner); const SCREEN_LABELS: [&str; RIGHT_SIDEBAR_SCREEN_COUNT as usize] = - ["Home", "Arcade", "Tables", "Artboard", "Door Games"]; + ["Home", "Arcade", "Tables", "Door Games", "Artboard"]; let width = inner.width as usize; for screen_idx in 0..RIGHT_SIDEBAR_SCREEN_COUNT as usize { From 1fc69243ca202ed66ab2f84df1ae1514316e1080 Mon Sep 17 00:00:00 2001 From: mateuszpiorowski Date: Fri, 5 Jun 2026 00:03:32 +0200 Subject: [PATCH 15/20] update --- late-ssh/src/app/input.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/late-ssh/src/app/input.rs b/late-ssh/src/app/input.rs index 31a576d6..ee19f222 100644 --- a/late-ssh/src/app/input.rs +++ b/late-ssh/src/app/input.rs @@ -1201,6 +1201,9 @@ fn handle_parsed_input(app: &mut App, event: ParsedInput) { fn handle_dedicated_screen_input(app: &mut App, ctx: InputContext, event: &ParsedInput) -> bool { if ctx.screen == Screen::DoorGames { + if door_games_allows_global_navigation(event) { + return false; + } app.enter_lateania(); let Some(state) = app.lateania_state.as_mut() else { return true; @@ -1550,6 +1553,15 @@ fn handle_dedicated_screen_input(app: &mut App, ctx: InputContext, event: &Parse false } +fn door_games_allows_global_navigation(event: &ParsedInput) -> bool { + match event { + ParsedInput::BackTab => true, + ParsedInput::Byte(b'\t' | b'1'..=b'6') => true, + ParsedInput::Char('1'..='6') => true, + _ => false, + } +} + fn handle_directory_catalog_input(app: &mut App, ctx: InputContext, event: &ParsedInput) -> bool { match event { ParsedInput::AltEnter => { From eec57d2b02f05d994d7940c41e38c4aca5c5482e Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Fri, 5 Jun 2026 21:39:12 +1000 Subject: [PATCH 16/20] feat(rooms): Lateania overworld, D&D stats, fountains, titles & veteran revives Major expansion of the Lateania MUD: - 100 new rooms across new biomes (the Greatroad, Sapphire Coast, Verdant Highlands, Mistfen, Fungal Hollow, Sahra Wastes, Amber Savanna, Skyreach Mesas) reachable from Embergate's South Gate. Three capital cities - Tasmania, Melvanala, Matlatesh - each a safe haven with a healing fountain and a dedication plaque. Built on the reciprocal add_wing spine, so reachability and exit-reciprocity hold by construction. - D&D ability scores (4d6 drop lowest) rolled at creation and rerollable (r) on the selection screen until a class is chosen. Constitution adds max HP and each class's key score adds attack; scores persist in the character blob (schema v2, serde-defaulted for old saves). - A look/examine layer: room features (fountains, plaques, vistas) whose descriptions are revealed only when looked at (press o). A capital fountain restores HP/mana and refreshes resurrection charges. - Titles earned by slaying foes (e.g. Wretchbane, Bane of the Barrow King), persisted and shown on the character sheet. - Veteran resurrections: accounts older than 20 days rise in place twice per adventure instead of respawning at the temple; refreshed at a fountain. - Every room now carries a paragraph-length description, enforced by a new test; the 86 terse base/extension rooms were expanded to match. - Fixed the pre-existing stale strike_player unit test (3 vs 4 args). All 45 mud unit tests pass; the library builds clean. Signed-off-by: Tony Hosaroygard --- late-ssh/src/app/door/lateania/classes.rs | 12 + late-ssh/src/app/door/lateania/input.rs | 10 +- late-ssh/src/app/door/lateania/mod.rs | 1 + late-ssh/src/app/door/lateania/persist.rs | 19 +- late-ssh/src/app/door/lateania/state.rs | 19 + late-ssh/src/app/door/lateania/stats.rs | 166 ++++++ late-ssh/src/app/door/lateania/svc.rs | 338 +++++++++++- late-ssh/src/app/door/lateania/ui.rs | 87 +++- late-ssh/src/app/door/lateania/world.rs | 603 ++++++++++++++++++---- 9 files changed, 1151 insertions(+), 104 deletions(-) create mode 100644 late-ssh/src/app/door/lateania/stats.rs diff --git a/late-ssh/src/app/door/lateania/classes.rs b/late-ssh/src/app/door/lateania/classes.rs index 3b3542ad..07a7664f 100644 --- a/late-ssh/src/app/door/lateania/classes.rs +++ b/late-ssh/src/app/door/lateania/classes.rs @@ -67,6 +67,18 @@ impl Class { } } + /// The ability score that sharpens this class's strikes (its attack key). + pub fn primary_score(self) -> super::stats::Score { + use super::stats::Score; + match self { + Self::Warrior => Score::Strength, + Self::Mage => Score::Intelligence, + Self::Cleric => Score::Wisdom, + Self::Rogue => Score::Dexterity, + Self::Ranger => Score::Dexterity, + } + } + pub fn resource(self) -> Resource { match self { Self::Warrior => Resource::Rage, diff --git a/late-ssh/src/app/door/lateania/input.rs b/late-ssh/src/app/door/lateania/input.rs index 9470ba9c..09ff2024 100644 --- a/late-ssh/src/app/door/lateania/input.rs +++ b/late-ssh/src/app/door/lateania/input.rs @@ -36,7 +36,8 @@ pub fn handle_key(state: &mut State, byte: u8) -> InputAction { return InputAction::Handled; } - // Class selection gate: until a class is chosen, 1-5 pick it and nothing else acts. + // Class selection gate: until a class is chosen, 1-5 pick it, r rerolls the + // ability scores, and nothing else acts. if !view.classed { match byte { b'1' => state.choose_class(Class::Warrior), @@ -44,13 +45,14 @@ pub fn handle_key(state: &mut State, byte: u8) -> InputAction { b'3' => state.choose_class(Class::Cleric), b'4' => state.choose_class(Class::Rogue), b'5' => state.choose_class(Class::Ranger), + b'r' | b'R' => state.reroll(), _ => return InputAction::Ignored, } return InputAction::Handled; } let panel = state.panel(); - let in_list = matches!(panel, Panel::Inventory | Panel::Shop); + let in_list = matches!(panel, Panel::Inventory | Panel::Shop | Panel::Examine); // Number keys: select a list row when a list panel is open, else use an ability. if (b'1'..=b'9').contains(&byte) { @@ -88,7 +90,9 @@ pub fn handle_key(state: &mut State, byte: u8) -> InputAction { InputAction::Handled } b'o' | b'O' => { - state.set_panel(Panel::Room); + // Open the Examine list (the "look at things" panel) and refresh the + // room description in the log. + state.toggle_panel(Panel::Examine); state.look(); InputAction::Handled } diff --git a/late-ssh/src/app/door/lateania/mod.rs b/late-ssh/src/app/door/lateania/mod.rs index 4a37406e..0f2dc6cd 100644 --- a/late-ssh/src/app/door/lateania/mod.rs +++ b/late-ssh/src/app/door/lateania/mod.rs @@ -10,6 +10,7 @@ pub mod input; pub mod items; pub mod persist; pub mod state; +pub mod stats; pub mod svc; pub mod ui; pub mod world; diff --git a/late-ssh/src/app/door/lateania/persist.rs b/late-ssh/src/app/door/lateania/persist.rs index fc4cff72..a1c153a0 100644 --- a/late-ssh/src/app/door/lateania/persist.rs +++ b/late-ssh/src/app/door/lateania/persist.rs @@ -12,9 +12,10 @@ use serde::{Deserialize, Serialize}; use super::classes::Class; +use super::stats::AbilityScores; use super::world::RoomId; -const SCHEMA_VERSION: u32 = 1; +const SCHEMA_VERSION: u32 = 2; pub struct SavedCharacterInit { pub class: Option, @@ -25,6 +26,8 @@ pub struct SavedCharacterInit { pub room: RoomId, pub inventory: Vec, pub equipped: Vec<(String, u32)>, + pub scores: AbilityScores, + pub titles: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -51,6 +54,12 @@ pub struct SavedCharacter { /// Equipped items as (slot-key, item-id) pairs. #[serde(default)] pub equipped: Vec<(String, u32)>, + /// Rolled D&D ability scores; default (all 10s) for pre-v2 saves. + #[serde(default)] + pub scores: AbilityScores, + /// Titles earned by slaying notable foes (most recent last). + #[serde(default)] + pub titles: Vec, } fn one() -> i32 { @@ -73,6 +82,8 @@ impl SavedCharacter { room: init.room, inventory: init.inventory, equipped: init.equipped, + scores: init.scores, + titles: init.titles, } } @@ -100,6 +111,8 @@ mod tests { #[test] fn round_trips_through_json() { + let mut scores = AbilityScores::default(); + scores.dexterity = 16; let c = SavedCharacter::new_for(SavedCharacterInit { class: Some(Class::Rogue), xp: 1234, @@ -109,6 +122,8 @@ mod tests { room: 18, inventory: vec![1300, 1301], equipped: vec![("weapon".to_string(), 1004)], + scores, + titles: vec!["Wyrmbane".to_string()], }); let json = c.to_json(); let back = SavedCharacter::from_json(&json).expect("parses"); @@ -118,6 +133,8 @@ mod tests { assert_eq!(back.gold, 560); assert_eq!(back.inventory, vec![1300, 1301]); assert_eq!(back.equipped, vec![("weapon".to_string(), 1004)]); + assert_eq!(back.scores.dexterity, 16); + assert_eq!(back.titles, vec!["Wyrmbane".to_string()]); } #[test] diff --git a/late-ssh/src/app/door/lateania/state.rs b/late-ssh/src/app/door/lateania/state.rs index 8008186f..0326460f 100644 --- a/late-ssh/src/app/door/lateania/state.rs +++ b/late-ssh/src/app/door/lateania/state.rs @@ -23,6 +23,9 @@ pub enum Panel { Abilities, Inventory, Shop, + /// Lookable things in the room: select one and press Enter to examine it + /// (and use it, for a fountain). + Examine, } pub struct State { @@ -133,6 +136,7 @@ impl State { match self.panel { Panel::Inventory => self.view().inventory.len(), Panel::Shop => self.view().shop.map(|s| s.entries.len()).unwrap_or(0), + Panel::Examine => self.view().features.len(), _ => 0, } } @@ -168,6 +172,20 @@ impl State { } } + /// Re-roll ability scores on the selection screen (before choosing a class). + pub fn reroll(&mut self) { + if self.ensure_player_present() { + self.svc.reroll_task(self.user_id); + } + } + + /// Examine the selected lookable feature in the room. + pub fn examine_selection(&mut self) { + if self.panel == Panel::Examine && self.ensure_player_present() { + self.svc.interact_task(self.user_id, self.cursor); + } + } + pub fn attack(&mut self) { if self.ensure_player_present() { self.svc.attack_task(self.user_id); @@ -220,6 +238,7 @@ impl State { self.svc.buy_task(self.user_id, entry.item_id); } } + Panel::Examine => self.svc.interact_task(self.user_id, self.cursor), _ => {} } } diff --git a/late-ssh/src/app/door/lateania/stats.rs b/late-ssh/src/app/door/lateania/stats.rs new file mode 100644 index 00000000..2363d345 --- /dev/null +++ b/late-ssh/src/app/door/lateania/stats.rs @@ -0,0 +1,166 @@ +// D&D-style ability scores for Lateania characters. +// +// Six classic ability scores, rolled with 4d6-drop-lowest at character creation +// and rerollable on the selection screen until a class is chosen. Scores feed +// real mechanics through their D&D modifiers: Constitution hardens the body +// (bonus max HP) and each class's key ability sharpens its strikes (bonus +// attack). The struct serde-serializes into the saved-character blob and +// defaults every score to 10 (a +0 modifier), so characters saved before this +// system existed load unchanged. + +use rand::Rng; +use serde::{Deserialize, Serialize}; + +use super::classes::Class; + +fn ten() -> i32 { + 10 +} + +/// The six classic ability scores. A score of 10 is the unremarkable human +/// average and yields a +0 modifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AbilityScores { + #[serde(default = "ten")] + pub strength: i32, + #[serde(default = "ten")] + pub dexterity: i32, + #[serde(default = "ten")] + pub constitution: i32, + #[serde(default = "ten")] + pub intelligence: i32, + #[serde(default = "ten")] + pub wisdom: i32, + #[serde(default = "ten")] + pub charisma: i32, +} + +impl Default for AbilityScores { + fn default() -> Self { + Self { + strength: 10, + dexterity: 10, + constitution: 10, + intelligence: 10, + wisdom: 10, + charisma: 10, + } + } +} + +/// Which of the six scores. Used to ask a class for its key ability. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Score { + Strength, + Dexterity, + Constitution, + Intelligence, + Wisdom, + Charisma, +} + +/// The D&D ability modifier for a score: floor((score - 10) / 2). div_euclid +/// floors toward negative infinity, so a score of 7 correctly yields -2. +pub fn modifier(score: i32) -> i32 { + (score - 10).div_euclid(2) +} + +/// Roll one ability score as 4d6, dropping the lowest die - the classic heroic +/// roll, which centers a touch above the flat 3d6 average. +fn roll_one(rng: &mut impl Rng) -> i32 { + let mut dice = [ + rng.gen_range(1..=6), + rng.gen_range(1..=6), + rng.gen_range(1..=6), + rng.gen_range(1..=6), + ]; + dice.sort_unstable(); + dice[1] + dice[2] + dice[3] // sum the top three; drop dice[0], the lowest +} + +impl AbilityScores { + /// Roll a fresh set of six scores, 4d6-drop-lowest each. + pub fn roll() -> Self { + let mut rng = rand::thread_rng(); + Self { + strength: roll_one(&mut rng), + dexterity: roll_one(&mut rng), + constitution: roll_one(&mut rng), + intelligence: roll_one(&mut rng), + wisdom: roll_one(&mut rng), + charisma: roll_one(&mut rng), + } + } + + pub fn score(&self, which: Score) -> i32 { + match which { + Score::Strength => self.strength, + Score::Dexterity => self.dexterity, + Score::Constitution => self.constitution, + Score::Intelligence => self.intelligence, + Score::Wisdom => self.wisdom, + Score::Charisma => self.charisma, + } + } + + /// Bonus max HP granted by Constitution. Scales gently with level so a hardy + /// (or frail) build matters more as the journey goes on - and never so much + /// that it eclipses the class HP curve. + pub fn hp_bonus(&self, level: i32) -> i32 { + let lvl = level.clamp(1, Class::MAX_LEVEL); + modifier(self.constitution) * (2 + lvl / 4) + } + + /// Bonus attack granted by the class's key ability score. + pub fn attack_bonus(&self, class: Class) -> i32 { + modifier(self.score(class.primary_score())) + } + + /// The six scores in display order: (short label, value, modifier). + pub fn rows(&self) -> [(&'static str, i32, i32); 6] { + [ + ("STR", self.strength, modifier(self.strength)), + ("DEX", self.dexterity, modifier(self.dexterity)), + ("CON", self.constitution, modifier(self.constitution)), + ("INT", self.intelligence, modifier(self.intelligence)), + ("WIS", self.wisdom, modifier(self.wisdom)), + ("CHA", self.charisma, modifier(self.charisma)), + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn modifier_follows_the_dnd_rule() { + assert_eq!(modifier(10), 0); + assert_eq!(modifier(11), 0); + assert_eq!(modifier(12), 1); + assert_eq!(modifier(8), -1); + assert_eq!(modifier(7), -2); + assert_eq!(modifier(18), 4); + assert_eq!(modifier(3), -4); + } + + #[test] + fn rolls_are_in_the_4d6_drop_lowest_range() { + // Top three of 4d6 can range 3..=18; check many rolls stay in-band. + for _ in 0..2000 { + let s = AbilityScores::roll(); + for (_, value, _) in s.rows() { + assert!((3..=18).contains(&value), "score {value} out of 4d6 range"); + } + } + } + + #[test] + fn defaults_are_neutral() { + let s = AbilityScores::default(); + for (_, value, modifier) in s.rows() { + assert_eq!(value, 10); + assert_eq!(modifier, 0); + } + } +} diff --git a/late-ssh/src/app/door/lateania/svc.rs b/late-ssh/src/app/door/lateania/svc.rs index ee987510..7a0faa20 100644 --- a/late-ssh/src/app/door/lateania/svc.rs +++ b/late-ssh/src/app/door/lateania/svc.rs @@ -17,7 +17,12 @@ use std::{ time::{Duration, Instant}, }; -use late_core::{MutexRecover, db::Db, models::mud_character::MudCharacter}; +use chrono::Utc; +use late_core::{ + MutexRecover, + db::Db, + models::{mud_character::MudCharacter, user::User}, +}; use rand::Rng; use tokio::sync::{Mutex, watch}; use uuid::Uuid; @@ -29,7 +34,8 @@ use super::classes::{Class, level_for_xp, xp_for_level}; use super::damage::{DamageType, Defense}; use super::items::{ItemKind, Slot, item, shop_at}; use super::persist::{SavedCharacter, SavedCharacterInit}; -use super::world::{Dir, MobSpawn, RoomId, World, seed_world}; +use super::stats::AbilityScores; +use super::world::{Dir, FeatureKind, MobSpawn, RoomId, World, features_at, seed_world}; /// World heartbeat. One combat round resolves per tick. const TICK_SECS: u64 = 2; @@ -43,6 +49,13 @@ const STARTING_GOLD: i64 = 120; /// How often the world autosaves every present character's progress. const AUTOSAVE_SECS: u64 = 60; +/// Account age (in days) at which an adventurer is a "citizen" of Lateania and +/// earns extra resurrections. +const VETERAN_DAYS: i64 = 20; +/// In-place resurrections a veteran gets per adventure (refreshed at a capital +/// fountain). Newer accounts get none and respawn at the temple as before. +const VETERAN_RESURRECTIONS: u8 = 2; + #[derive(Clone)] pub struct LateaniaService { activity: ActivityPublisher, @@ -88,6 +101,14 @@ pub struct OccupantView { pub in_combat: bool, } +/// One lookable thing in the current room, as shown in the Examine panel. +#[derive(Clone, Debug)] +pub struct FeatureView { + pub name: String, + /// Short kind tag ("fountain", "plaque", "vista", or "" for plain scenery). + pub kind: String, +} + /// One known ability as shown on the action bar. #[derive(Clone, Debug)] pub struct AbilityView { @@ -169,6 +190,15 @@ pub struct PlayerView { pub shop: Option, pub log: Vec, pub respawning: bool, + /// Rolled D&D ability scores (shown on the select screen and sheet). + pub scores: AbilityScores, + /// Titles earned by slaying notable foes. + pub titles: Vec, + /// Veteran in-place resurrections remaining / total this adventure. + pub resurrections_left: u8, + pub resurrection_cap: u8, + /// Lookable things in the current room (Examine panel). + pub features: Vec, } impl PlayerView { @@ -205,6 +235,11 @@ impl PlayerView { shop: None, log: Vec::new(), respawning: false, + scores: AbilityScores::default(), + titles: Vec::new(), + resurrections_left: 0, + resurrection_cap: 0, + features: Vec::new(), } } } @@ -302,12 +337,23 @@ impl LateaniaService { } }; + // Accounts older than VETERAN_DAYS earn extra resurrections. Best + // effort: any DB failure simply means "not a veteran". + let veteran = match svc.db.get().await { + Ok(client) => match User::get(&client, user_id).await { + Ok(Some(user)) => (Utc::now() - user.created).num_days() >= VETERAN_DAYS, + _ => false, + }, + Err(_) => false, + }; + let mut state = svc.state.lock().await; if !svc.has_active_session(user_id) { return; } if !state.players.contains_key(&user_id) { state.join(user_id); + state.set_veteran(user_id, veteran); if let Some(saved) = saved { state.hydrate(user_id, &saved); } @@ -484,6 +530,17 @@ impl LateaniaService { self.mutate(user_id, move |s| s.look(user_id)); } + /// Re-roll ability scores on the selection screen (before a class is chosen). + pub fn reroll_task(&self, user_id: Uuid) { + self.mutate(user_id, move |s| s.reroll(user_id)); + } + + /// Examine the indexed lookable feature in the current room (and use it, + /// for fountains). + pub fn interact_task(&self, user_id: Uuid, idx: usize) { + self.mutate(user_id, move |s| s.interact(user_id, idx)); + } + pub fn attack_task(&self, user_id: Uuid) { self.mutate(user_id, move |s| s.engage(user_id)); } @@ -623,6 +680,13 @@ struct PlayerState { equipped: HashMap, /// True once the class trait's death-save has been spent this life (Warrior). death_save_used: bool, + /// Rolled D&D ability scores; feed bonus HP (CON) and attack (class key). + scores: AbilityScores, + /// Titles earned by slaying notable foes. + titles: Vec, + /// Veteran in-place resurrections: total this adventure and how many remain. + resurrection_cap: u8, + resurrections_left: u8, last_activity: Instant, respawn_at: Option, log: Vec, @@ -645,12 +709,13 @@ impl PlayerState { fn max_hp(&self) -> i32 { let (_, hp, _) = self.equipment_mods(); - self.base_max_hp + hp + (self.base_max_hp + hp + self.scores.hp_bonus(self.level)).max(1) } fn attack(&self) -> i32 { let (atk, _, _) = self.equipment_mods(); - self.base_attack + atk + self.empower + let stat = self.class.map(|c| self.scores.attack_bonus(c)).unwrap_or(0); + (self.base_attack + atk + self.empower + stat).max(1) } fn armor(&self) -> i32 { @@ -744,6 +809,10 @@ impl WorldState { inventory: vec![1000, 1300, 1300], // a rusty sword and two minor draughts equipped: HashMap::new(), death_save_used: false, + scores: AbilityScores::roll(), + titles: Vec::new(), + resurrection_cap: 0, + resurrections_left: 0, last_activity: Instant::now(), respawn_at: None, log: Vec::new(), @@ -751,7 +820,8 @@ impl WorldState { push_log( &mut player.log, LogKind::System, - "Welcome to Lateania. Choose your calling to begin.".to_string(), + "Welcome to Lateania. Your fate is rolled - reroll it (r) if you dare, then choose your calling." + .to_string(), ); self.players.insert(user_id, player); true @@ -786,6 +856,47 @@ impl WorldState { self.describe_room(user_id); } + /// Grant (or clear) the veteran resurrection allowance for this adventure. + /// Called once on join from the account-age check; a fresh adventure starts + /// with a full set of charges. + fn set_veteran(&mut self, user_id: Uuid, veteran: bool) { + let cap = if veteran { VETERAN_RESURRECTIONS } else { 0 }; + if let Some(p) = self.players.get_mut(&user_id) { + p.resurrection_cap = cap; + p.resurrections_left = cap; + } + if veteran { + self.log_to( + user_id, + LogKind::System, + format!( + "Twenty days a citizen of Lateania - the world grants you {cap} resurrections this adventure." + ), + ); + } + } + + /// Re-roll ability scores. Only allowed before a class is chosen, so a build + /// is locked the moment you commit to a calling. + fn reroll(&mut self, user_id: Uuid) { + let unclassed = self + .players + .get(&user_id) + .map(|p| p.class.is_none()) + .unwrap_or(false); + if !unclassed { + return; + } + if let Some(p) = self.players.get_mut(&user_id) { + p.scores = AbilityScores::roll(); + } + self.log_to( + user_id, + LogKind::System, + "You cast the bones of fate anew. Fresh scores settle into place.".to_string(), + ); + } + fn leave(&mut self, user_id: Uuid) { self.players.remove(&user_id); } @@ -831,7 +942,10 @@ impl WorldState { p.equipped.insert(slot, *id); } } - // Restore vitals last so equipment max-hp is already in effect. + // Rolled scores and earned titles persist across sessions. + p.scores = saved.scores; + p.titles = saved.titles.clone(); + // Restore vitals last so equipment and CON max-hp are already in effect. let max = p.max_hp(); p.hp = if saved.hp > 0 { saved.hp.min(max) } else { max }; } @@ -863,6 +977,8 @@ impl WorldState { room: p.room, inventory: p.inventory.clone(), equipped, + scores: p.scores, + titles: p.titles.clone(), })) } @@ -966,6 +1082,54 @@ impl WorldState { for mob in mob_names { self.log_to(user_id, LogKind::Combat, format!("{mob} is here.")); } + // Note lookable things without revealing them - you must look (o) to see + // their description. + let features = features_at(room_id); + if !features.is_empty() { + let names: Vec<&str> = features.iter().map(|f| f.name).collect(); + self.log_to( + user_id, + LogKind::System, + format!( + "You notice {} here. Press o to look closer.", + join_with_and(&names) + ), + ); + } + } + + /// Examine the indexed lookable feature in the current room. The feature's + /// description is revealed only here (the "look at things" rule); fountains + /// in a safe capital also restore vitals and refresh resurrection charges. + fn interact(&mut self, user_id: Uuid, idx: usize) { + let room_id = match self.players.get(&user_id) { + Some(p) => p.room, + None => return, + }; + let features = features_at(room_id); + let Some(feat) = features.get(idx) else { + return; + }; + self.log_to(user_id, LogKind::Normal, format!("You look at {}.", feat.name)); + self.log_to(user_id, LogKind::Normal, feat.desc.to_string()); + if feat.kind == FeatureKind::Fountain { + let safe = self.world.room(room_id).is_some_and(|r| r.safe); + if safe { + if let Some(p) = self.players.get_mut(&user_id) { + let max = p.max_hp(); + p.hp = max; + p.resource = p.max_resource; + p.resurrections_left = p.resurrection_cap; + } + self.log_to( + user_id, + LogKind::Loot, + "The fountain's clear waters wash through you. Health and power are restored, and your strength to rise again renews." + .to_string(), + ); + } + } + self.dirty = true; } fn engage(&mut self, user_id: Uuid) { @@ -1297,11 +1461,30 @@ impl WorldState { p.gold += gold as i64; } self.roll_loot(user_id, &mob_name, loot, boss); + self.grant_title(user_id, &mob_name, boss); self.check_level_up(user_id); self.pending_kills.push(KillOutcome { user_id, mob_name }); self.dirty = true; } + /// Award a title themed on a slain foe, the first time that foe is felled. + /// Bosses confer a "Bane of ..." honorific; lesser foes a "...bane" epithet. + fn grant_title(&mut self, user_id: Uuid, mob_name: &str, boss: bool) { + let title = title_for(mob_name, boss); + let is_new = self + .players + .get(&user_id) + .map(|p| !p.titles.contains(&title)) + .unwrap_or(false); + if !is_new { + return; + } + if let Some(p) = self.players.get_mut(&user_id) { + p.titles.push(title.clone()); + } + self.log_to(user_id, LogKind::Loot, format!("A new title is yours: {title}.")); + } + /// Award loot from a slain mob. Bosses always drop one item from their table; /// regular mobs have a modest chance at a common drop. fn roll_loot(&mut self, user_id: Uuid, mob_name: &str, loot: &'static [u32], boss: bool) { @@ -1833,6 +2016,28 @@ impl WorldState { ); return; } + // Veteran resurrection: a citizen of twenty days rises where they fell + // instead of waking back at the temple. Refreshes at a capital fountain. + if p.resurrections_left > 0 { + p.resurrections_left -= 1; + let left = p.resurrections_left; + let max = p.max_hp(); + p.hp = max; + p.resource = p.max_resource; + p.target = None; + p.shield = 0; + p.empower = 0; + p.death_save_used = false; + let plural = if left == 1 { "" } else { "s" }; + self.log_to( + user_id, + LogKind::System, + format!( + "{mob_name} {verb} you down - but Lateania will not have you yet. You rise where you stand. ({left} resurrection{plural} left this adventure.)" + ), + ); + return; + } p.hp = 0; p.target = None; p.respawn_at = Some(now + Duration::from_secs(PLAYER_RESPAWN_SECS)); @@ -1999,6 +2204,14 @@ impl WorldState { xp_for_level(player.level + 1) - xp_for_level(player.level) }; + let features: Vec = features_at(player.room) + .iter() + .map(|f| FeatureView { + name: f.name.to_string(), + kind: f.kind.tag().to_string(), + }) + .collect(); + players.insert( *user_id, PlayerView { @@ -2033,6 +2246,11 @@ impl WorldState { shop, log: player.log.clone(), respawning: player.respawn_at.is_some(), + scores: player.scores, + titles: player.titles.clone(), + resurrections_left: player.resurrections_left, + resurrection_cap: player.resurrection_cap, + features, }, ); } @@ -2053,6 +2271,41 @@ fn defense_tag(defense: Defense, _dtype: DamageType) -> &'static str { } } +/// Derive a title from a slain foe. Bosses already read as proper names ("the +/// Barrow King") and become "Bane of ..."; lesser foes ("a frost-bound wretch") +/// lend their creature word to a "...bane" epithet ("Wretchbane"). +fn title_for(mob_name: &str, boss: bool) -> String { + let trimmed = mob_name.trim(); + let core = trimmed + .strip_prefix("a ") + .or_else(|| trimmed.strip_prefix("an ")) + .unwrap_or(trimmed); + if boss { + return format!("Bane of {core}"); + } + let last = core + .rsplit([' ', '-']) + .find(|w| !w.is_empty()) + .unwrap_or("Foe"); + let mut chars = last.chars(); + let capitalized = match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => "Foe".to_string(), + }; + format!("{capitalized}bane") +} + +/// Join a short list into prose: "the fountain", "the fountain and the plaque", +/// "the fountain, the plaque, and the vista". +fn join_with_and(items: &[&str]) -> String { + match items { + [] => String::new(), + [only] => only.to_string(), + [a, b] => format!("{a} and {b}"), + [rest @ .., last] => format!("{}, and {last}", rest.join(", ")), + } +} + fn push_log(log: &mut Vec, kind: LogKind, text: String) { log.push(LogLine { text, kind }); if log.len() > LOG_CAP { @@ -2165,4 +2418,77 @@ mod tests { s.strike_player(uid(1), 9999, DamageType::Physical, "a test foe"); assert!(s.players[&uid(1)].respawn_at.is_some(), "second blow falls"); } + + #[test] + fn slaying_a_foe_grants_a_themed_title() { + let mut s = world(); + s.join(uid(1)); + s.choose_class(uid(1), Class::Mage); + s.grant_title(uid(1), "a frost-bound wretch", false); + s.grant_title(uid(1), "the Barrow King", true); + // Re-slaying the same foe must not duplicate its title. + s.grant_title(uid(1), "a frost-bound wretch", false); + let titles = s.players[&uid(1)].titles.clone(); + assert!(titles.iter().any(|t| t == "Wretchbane"), "lesser foe -> ...bane"); + assert!( + titles.iter().any(|t| t == "Bane of the Barrow King"), + "boss -> Bane of ..." + ); + assert_eq!(titles.iter().filter(|t| *t == "Wretchbane").count(), 1); + } + + #[test] + fn veteran_resurrects_in_place_then_falls_when_spent() { + let mut s = world(); + s.join(uid(1)); + s.set_veteran(uid(1), true); + s.choose_class(uid(1), Class::Mage); // mage has no Warrior death-save + assert_eq!(s.players[&uid(1)].resurrection_cap, VETERAN_RESURRECTIONS); + for expected_left in (0..VETERAN_RESURRECTIONS).rev() { + s.strike_player(uid(1), 9999, DamageType::Physical, "a test foe"); + let p = &s.players[&uid(1)]; + assert!(p.respawn_at.is_none(), "veteran rises where they fall"); + assert_eq!(p.hp, p.max_hp(), "revived at full health"); + assert_eq!(p.resurrections_left, expected_left); + } + s.strike_player(uid(1), 9999, DamageType::Physical, "a test foe"); + assert!(s.players[&uid(1)].respawn_at.is_some(), "out of charges, falls"); + } + + #[test] + fn a_capital_fountain_restores_vitals_and_revives() { + let mut s = world(); + s.join(uid(1)); + s.set_veteran(uid(1), true); + s.choose_class(uid(1), Class::Mage); + if let Some(p) = s.players.get_mut(&uid(1)) { + p.room = 620; // Tasmania's Harborgate Square (safe capital) + p.hp = 1; + p.resource = 0; + p.resurrections_left = 0; + } + s.interact(uid(1), 0); // feature 0 in the square is the fountain + let p = &s.players[&uid(1)]; + assert_eq!(p.hp, p.max_hp(), "fountain heals to full"); + assert_eq!(p.resource, p.max_resource, "fountain restores resource"); + assert_eq!( + p.resurrections_left, p.resurrection_cap, + "fountain refreshes resurrection charges" + ); + } + + #[test] + fn ability_scores_change_derived_stats() { + let mut s = world(); + s.join(uid(1)); + s.choose_class(uid(1), Class::Warrior); // STR is the warrior's key score + let base_attack = s.players[&uid(1)].attack(); + let base_hp = s.players[&uid(1)].max_hp(); + if let Some(p) = s.players.get_mut(&uid(1)) { + p.scores.strength = 18; // +4 + p.scores.constitution = 18; // +4 + } + assert!(s.players[&uid(1)].attack() > base_attack, "STR raises attack"); + assert!(s.players[&uid(1)].max_hp() > base_hp, "CON raises max HP"); + } } diff --git a/late-ssh/src/app/door/lateania/ui.rs b/late-ssh/src/app/door/lateania/ui.rs index 7b80dd33..e8189361 100644 --- a/late-ssh/src/app/door/lateania/ui.rs +++ b/late-ssh/src/app/door/lateania/ui.rs @@ -91,7 +91,7 @@ pub fn draw_page(frame: &mut Frame, area: Rect, state: &State, usernames: &Usern draw_game(frame, rows[1], state, usernames); } -fn draw_class_select(frame: &mut Frame, area: Rect, _view: &PlayerView) { +fn draw_class_select(frame: &mut Frame, area: Rect, view: &PlayerView) { let mut lines = vec![ Line::from(Span::styled( "~ LATEANIA ~", @@ -104,6 +104,16 @@ fn draw_class_select(frame: &mut Frame, area: Rect, _view: &PlayerView) { Style::default().fg(theme::TEXT_DIM()), )), Line::raw(""), + Line::from(Span::styled( + "Your rolled fate (4d6, drop lowest):", + Style::default().fg(theme::AMBER()), + )), + score_row(view), + Line::from(Span::styled( + "Press r to reroll - your scores lock the moment you choose a class.", + Style::default().fg(theme::TEXT_DIM()), + )), + Line::raw(""), ]; for (i, class) in Class::ALL.iter().enumerate() { lines.push(Line::from(vec![ @@ -181,6 +191,7 @@ fn draw_side( Panel::Abilities => abilities_panel(view), Panel::Inventory => inventory_panel(view, state.cursor()), Panel::Shop => shop_panel(view, state.cursor()), + Panel::Examine => examine_panel(view, state.cursor()), }; frame.render_widget(Paragraph::new(lines), area); } @@ -290,6 +301,15 @@ fn character_panel(view: &PlayerView) -> Vec> { lines.push(stat("attack", view.attack.to_string())); lines.push(stat("armor", view.armor.to_string())); lines.push(Line::raw("")); + lines.push(section("Scores")); + lines.push(score_row(view)); + if view.resurrection_cap > 0 { + lines.push(stat( + "revives", + format!("{}/{}", view.resurrections_left, view.resurrection_cap), + )); + } + lines.push(Line::raw("")); lines.push(section("Trait")); lines.push(Line::from(Span::styled( format!(" {}", view.trait_name), @@ -298,6 +318,16 @@ fn character_panel(view: &PlayerView) -> Vec> { .add_modifier(Modifier::BOLD), ))); lines.extend(wrap(&view.trait_desc, 30)); + if !view.titles.is_empty() { + lines.push(Line::raw("")); + lines.push(section("Titles")); + for title in &view.titles { + lines.push(Line::from(Span::styled( + format!(" {title}"), + Style::default().fg(theme::BADGE_GOLD()), + ))); + } + } lines.push(Line::raw("")); lines.push(section("Experience")); if view.xp_for_next > 0 { @@ -316,6 +346,59 @@ fn character_panel(view: &PlayerView) -> Vec> { lines } +/// Examine panel: the lookable things in the current room. +fn examine_panel(view: &PlayerView, cursor: usize) -> Vec> { + let mut lines = vec![section("Look at")]; + if view.features.is_empty() { + lines.push(Line::from(Span::styled( + " nothing of note here", + Style::default().fg(theme::TEXT_DIM()), + ))); + } + for (i, feat) in view.features.iter().enumerate() { + let selected = i == cursor; + let marker = if selected { ">" } else { " " }; + let tag = if feat.kind.is_empty() { + String::new() + } else { + format!(" [{}]", feat.kind) + }; + let style = if selected { + Style::default() + .fg(theme::TEXT_BRIGHT()) + .bg(theme::BG_SELECTION()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme::TEXT()) + }; + lines.push(Line::from(Span::styled( + format!("{marker} {}{}", feat.name, tag), + style, + ))); + } + lines.push(Line::raw("")); + lines.push(hint("w/s", "select Enter look")); + lines.push(hint("o", "close")); + lines +} + +/// One compact line of the six ability scores with their modifiers. +fn score_row(view: &PlayerView) -> Line<'static> { + let mut spans = vec![Span::raw(" ")]; + for (label, value, modifier) in view.scores.rows() { + let sign = if modifier >= 0 { "+" } else { "" }; + spans.push(Span::styled( + format!("{label} "), + Style::default().fg(theme::TEXT_DIM()), + )); + spans.push(Span::styled( + format!("{value}({sign}{modifier}) "), + Style::default().fg(theme::TEXT_BRIGHT()), + )); + } + Line::from(spans) +} + fn abilities_panel(view: &PlayerView) -> Vec> { let mut lines = vec![section("Abilities")]; if view.abilities.is_empty() { @@ -461,7 +544,7 @@ fn footer_hints(view: &PlayerView) -> Vec> { } else { lines.push(hint("wasd/arrows", "move")); lines.push(hint("yunm", "diagonals")); - lines.push(hint("space", "attack o look")); + lines.push(hint("space", "attack o look at things")); } lines.push(hint("c v t", "sheet abilities bag")); if view.shop.is_some() { diff --git a/late-ssh/src/app/door/lateania/world.rs b/late-ssh/src/app/door/lateania/world.rs index b48ab049..6ec82b8b 100644 --- a/late-ssh/src/app/door/lateania/world.rs +++ b/late-ssh/src/app/door/lateania/world.rs @@ -132,6 +132,122 @@ impl World { } } +// ---- Lookable room features (the "look at things" layer) ------------------ +// +// A Feature is a thing in a room a player must LOOK at to read its description - +// fountains, plaques, distant vistas, scenery. Features are keyed to a room id +// exactly like shops (see items::shop_at), so adding them never disturbs the +// room table or its authored entries. + +/// The town squares of the three capitals, each home to a healing fountain and +/// the builder's dedication plaque. These ids are the first (square) room of +/// each capital wing built in `extend_overworld`. +pub const TASMANIA_SQUARE: RoomId = 620; +pub const MELVANALA_SQUARE: RoomId = 660; +pub const MATLATESH_SQUARE: RoomId = 720; + +/// What kind of lookable thing a feature is. Fountains restore vitals in a safe +/// capital; the rest are pure description revealed on look. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FeatureKind { + Scenery, + Fountain, + Plaque, + Vista, +} + +impl FeatureKind { + /// Short tag shown beside the feature in the Examine panel. + pub fn tag(self) -> &'static str { + match self { + Self::Scenery => "", + Self::Fountain => "fountain", + Self::Plaque => "plaque", + Self::Vista => "vista", + } + } +} + +/// A lookable thing in a room. +#[derive(Clone, Copy, Debug)] +pub struct Feature { + pub room: RoomId, + pub name: &'static str, + pub desc: &'static str, + pub kind: FeatureKind, +} + +const fn feat(room: RoomId, name: &'static str, kind: FeatureKind, desc: &'static str) -> Feature { + Feature { + room, + name, + desc, + kind, + } +} + +/// The builder's dedication, engraved on a plaque in every capital. A player +/// only ever reads it by choosing to look at the plaque. +const DEDICATION: &str = "A broad bronze plaque, gone green with the years and polished \ + bright only where countless hands have brushed it in passing. The engraving reads: \ + \"LATEANIA - this world was dreamed, designed, and built by Tasmania of \ + hardlygospel.github.io, raised upon late.sh and the labor of all who tend it. It was \ + made slowly and gladly, as a labor of love, so that strangers far apart might meet \ + here and find adventure together. Look long, traveller, and be welcome.\""; + +/// Healing fountains share one description; the runtime restores vitals when one +/// is examined in a safe capital. +const FOUNTAIN_DESC: &str = "A broad fountain of pale, sea-worn stone stands at the heart \ + of the square, its tiers brimming with water so clear it seems to hold its own quiet \ + light. Travellers kneel here to wash the road from their faces, and rise with their \ + hurts closed over and their weariness gone. The old folk say the spring beneath was \ + blessed in the city's founding, and that while its waters run, no wound you carry need \ + be the end of you."; + +/// Every lookable feature in the world, keyed to the room it stands in. +pub const FEATURES: &[Feature] = &[ + // ---- Tasmania (harbor capital) -------------------------------------- + feat(TASMANIA_SQUARE, "the harbor fountain", FeatureKind::Fountain, FOUNTAIN_DESC), + feat(TASMANIA_SQUARE, "the bronze plaque", FeatureKind::Plaque, DEDICATION), + feat( + TASMANIA_SQUARE, + "the harbor", + FeatureKind::Vista, + "Past the rooftops the harbor opens wide and silver, crowded with the masts of \ + fishing dhows and far-trading caravels, and beyond the breakwater the Sapphire \ + Coast curves away east into haze. A good road leads down to the water; whatever \ + you can see from here, your feet can reach.", + ), + // ---- Melvanala (highland lake capital) ------------------------------ + feat(MELVANALA_SQUARE, "the mountain fountain", FeatureKind::Fountain, FOUNTAIN_DESC), + feat(MELVANALA_SQUARE, "the bronze plaque", FeatureKind::Plaque, DEDICATION), + feat( + MELVANALA_SQUARE, + "the high lake", + FeatureKind::Vista, + "From the terraced square the land falls away to a vast mountain lake, so still it \ + holds the snow-capped peaks upside down upon its face. Switchback paths thread down \ + to its shore and on toward the Verdant Highlands; nothing you see from this height \ + is beyond a day's honest walking.", + ), + // ---- Matlatesh (desert capital) ------------------------------------- + feat(MATLATESH_SQUARE, "the oasis fountain", FeatureKind::Fountain, FOUNTAIN_DESC), + feat(MATLATESH_SQUARE, "the bronze plaque", FeatureKind::Plaque, DEDICATION), + feat( + MATLATESH_SQUARE, + "the desert horizon", + FeatureKind::Vista, + "Beyond the mud-brick walls the Sahra Wastes run gold to the edge of the world, and \ + far off a lone mesa stands against the sky like a tombstone for a giant. A caravan \ + road leaves the gate and dwindles toward it; the desert is wide, but every dune you \ + can see has a path across it.", + ), +]; + +pub fn features_at(room: RoomId) -> Vec<&'static Feature> { + FEATURES.iter().filter(|f| f.room == room).collect() +} + fn room( id: RoomId, name: &'static str, @@ -218,8 +334,13 @@ pub fn seed_world() -> World { "Embergate - South Gate", "Embergate", true, - "A heavy iron portcullis stands raised. Beyond it the King's Road \ - stretches into open country. The square is north.", + "A heavy iron portcullis stands raised on chains thick as a man's arm, \ + and beneath its teeth the last of Embergate's lanternlight gives way to \ + the open dark. Beyond the gate the King's Road unspools into rolling \ + country, pale under the moon and loud with crickets, and a bored \ + gate-guard leans on his halberd and warns every passing adventurer that \ + the road is safe only as far as he can see it. The square lies north; \ + the open road runs south.", &[(Dir::North, 1), (Dir::South, 6)], ), // ---- Embergate shop district (safe) ----------------------------- @@ -290,8 +411,11 @@ pub fn seed_world() -> World { "The King's Road - Open Country", "King's Road", false, - "The cobbles give way to packed earth. Tall grass whispers on either \ - side and the town wall recedes behind you to the north.", + "The cobbles give way to packed earth rutted by cart-wheels, and the \ + ordered safety of the town falls away with them. Tall grass whispers \ + and bows on either side of the road, full of the small rustlings of \ + night creatures, and the town wall recedes behind you into the dark to \ + the north. Ahead the road runs on south into open, unguarded country.", &[(Dir::North, 5), (Dir::South, 7)], ), room( @@ -299,8 +423,12 @@ pub fn seed_world() -> World { "The King's Road - The Old Milestone", "King's Road", false, - "A mossy milestone marks the leagues to far cities. A thin trail forks \ - east into a thicket; the road runs on south.", + "A mossy old milestone leans at the verge, its carved leagues to far \ + cities worn nearly smooth by weather and the idle hands of resting \ + travellers. A thin trail forks away east into a dark bramble thicket, \ + the grass beside it beaten down by something that left no clear track, \ + while the King's Road itself runs on south. The way back to the gate is \ + north.", &[(Dir::North, 6), (Dir::East, 8), (Dir::South, 9)], ), room( @@ -308,8 +436,11 @@ pub fn seed_world() -> World { "The King's Road - Bramble Thicket", "King's Road", false, - "Thorns crowd a dead-end clearing. Something has trampled the grass \ - here recently. The trail back is west.", + "The trail chokes to a dead end in a clearing walled on every side by \ + thorns grown high as a horse, their black branches hung with tufts of \ + snagged wool and worse. Something heavy has trampled the grass flat here \ + quite recently, and the air carries a rank animal musk that prickles the \ + back of the neck. The only way out is back west the way you came.", &[(Dir::West, 7)], ), room( @@ -317,8 +448,12 @@ pub fn seed_world() -> World { "The King's Road - Ruined Watchtower", "King's Road", false, - "A toppled watchtower slumps against the hillside, its stones scorched. \ - The road continues south into a shadowed defile; the way back is north.", + "A toppled watchtower slumps against the hillside, its stones black and \ + scorched and its timbers long since fallen to charcoal, a relic of some \ + border war no living song remembers. Crows have made the ruin their own, \ + and they watch your passing with a patience that feels less than \ + natural. The road continues south into a shadowed defile, and the way \ + back to safer ground is north.", &[(Dir::North, 7), (Dir::South, 10)], ), room( @@ -1882,6 +2017,10 @@ pub fn seed_world() -> World { // Append the deeper-exploration wings (rooms 300+), reciprocal by construction. extend_world(&mut rooms, &mut spawns); + // Append the overworld: 100 rooms of new biomes and the three capital cities + // (rooms 600+), reachable from Embergate's South Gate. + extend_overworld(&mut rooms, &mut spawns); + World { rooms, spawns, @@ -2003,57 +2142,57 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { &[ wr( "Whisperwood - The Mushroom Stair", - "Shelves of bracket-fungus climb a slope like a giant's staircase, soft and cold underfoot, spores drifting in the lanternlight. North; the ring lies south.", + "Shelves of bracket-fungus climb a steep slope like a giant's staircase, soft and cold and faintly yielding underfoot, and a slow rain of spores drifts down through the lanternlight to settle on your shoulders. The deeper air tastes of loam and rot and something sweeter beneath. The stair leads north, and the standing-stone ring lies back south.", Dir::North, ), wr( "Whisperwood - The Glowcap Grotto", - "A hollow beneath an upturned root glimmers with luminous caps in blue and green, a drowned dreamlike light over soft loam. North.", + "A hollow beneath a vast upturned root glimmers with luminous mushroom-caps in blue and green and palest gold, casting a drowned and dreamlike light across the soft loam. Moths the size of your hand drift between them on silent wings, and the silence has the held quality of a place that does not often see the living. The way leads on north.", Dir::North, ), wr( "Whisperwood - The Toadstool Court", - "Rings within rings of fungus carpet a clearing, and the longer you stand the more you feel watched by things at ankle height. North.", + "Rings within rings of pale fungus carpet a still clearing, the old faerie-circles of song, and the longer you stand among them the more keenly you feel yourself watched by small patient things at ankle height. To step inside a ring is reckoned very bad luck, and the toadstools seem to lean inward as you pass. The path continues north.", Dir::North, ), wr( "Whisperwood - The Weeping Willow", - "A willow vast as a tower trails its branches to the ground, and the wind in them makes a sound exactly like a woman crying. North.", + "A willow vast as a temple tower trails its long branches all the way to the wet ground, curtaining a hollow at its heart, and the wind moving through them makes a sound exactly and unmistakably like a woman weeping. You catch yourself listening for words in it, and almost find them. The way out lies north.", Dir::North, ), wr( "Whisperwood - The Bog Causeway", - "A path of half-sunk logs crosses a black bog that breathes bubbles and worse. Stepping wrong here is a quiet way to vanish. North.", + "A path of half-sunk, slime-furred logs crosses a black bog that breathes slow bubbles of marsh-gas and a stench of rot and old death. The water between the logs is depthless and patient, and stepping wrong here would be a very quiet way indeed to vanish from the world. The treacherous causeway leads north.", Dir::North, ), wr( "Whisperwood - The Drowned Oak", - "An oak has fallen full-length into the bog and rotted into a hollow tunnel; you walk through the inside of a dead giant. North.", + "A mighty oak has fallen full-length into the bog and rotted from within into a hollow tunnel, and the path runs straight through it, so that for a dozen paces you walk inside the dark damp ribcage of a dead green giant. Pale grubs the length of fingers glisten in the punky wood overhead. The tunnel lets out to the north.", Dir::North, ), wr( "Whisperwood - The Witch's Hut", - "A crooked hut leans on chicken-scratch foundations, windows dark, door ajar on a single creaking hinge. North.", + "A crooked hut leans at an impossible angle on foundations that look, in the wrong light, like the scaled feet of an enormous bird, its windows dark and its door standing ajar on a single slowly creaking hinge. Bundles of dried herbs and less wholesome things twist in the doorway, and nothing inside makes a sound. The path goes on north.", Dir::North, ), wr( "Whisperwood - The Hag's Garden", - "Behind the hut a garden grows things no garden should: pale gourds with faces, vines that flinch from the light. North.", + "Behind the hut a walled garden grows things no honest garden ever should: pale swollen gourds with the half-formed suggestion of faces, vines that visibly flinch and recoil from your lantern's light, and beds of black flowers that turn to follow you. The soil here is too rich, and too dark, and you would rather not wonder why. The path leads north.", Dir::North, ), wr( "Whisperwood - The Bone Orchard", - "Trees here have grown around old bones until trunk and skeleton are one, and the fruit they bear is best left unpicked. North.", + "The trees of this orchard have grown around old bones over long slow years until trunk and skeleton are grown wholly into one, ribs and root indistinguishable in the gloom. The dark fruit they bear hangs heavy and glistening, and every instinct you own insists it is best left unpicked and untasted. The way on lies north.", Dir::North, ), wr( "Whisperwood - The Moonwell", - "A perfectly round well brims with water that glows faintly silver, reflecting a moon not in tonight's sky. North.", + "A perfectly round well of old mortared stone brims to its very lip with water that glows a faint cold silver, and its surface reflects a full and brilliant moon that hangs nowhere in tonight's actual sky. To look too long into it is to feel the strong and dangerous urge to lean closer. The path continues north.", Dir::North, ), wr( "Whisperwood - The Whispering Stones", - "A ring of leaning stones mutters among themselves, falling silent the instant you turn to listen. North.", + "A ring of tall leaning stones, lichen-grey and older than the forest around them, mutters and murmurs softly among themselves in a language just below understanding, and falls utterly silent the very instant you turn your head to listen. The grass within the circle has never once been cut, yet grows no higher than your ankle. The glade lies on north.", Dir::North, ), wr( @@ -2120,52 +2259,52 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { &[ wr( "Duskhollow - Behind the Sealed Door", - "The chained door gives onto a passage no light has touched in centuries, the air dead and close and faintly sweet with old decay. West.", + "The great chained door gives at last onto a passage that no light has touched in centuries, the air beyond it dead and close and faintly, sickly sweet with the perfume of old decay. Dust lies undisturbed and ankle-deep, and your footprints are the first to mark it since the door was sealed. The passage runs west into the dark.", Dir::West, ), wr( "Duskhollow - The Gravewater Pool", - "Black water fills a basin to the brim, pale shapes drifting just beneath its skin, neither sunk nor surfaced. West.", + "Black water fills a wide stone basin clear to the brim, utterly still, and pale shapes drift just beneath its skin, neither sunk nor surfaced, turning with a slowness that has nothing to do with any current. One of them, you are nearly certain, was facing the other way a moment ago. The passage continues west.", Dir::West, ), wr( "Duskhollow - The Creeping Dark", - "The lantern seems to shrink here, the dark pressing in close enough to feel, patient and almost fond. West.", + "Your lantern-flame seems to shrink and gutter here for no draught you can find, and the dark presses in close enough to feel against the skin, a weight on the shoulders that is patient and almost, horribly, fond. It does not want to hurt you. It only wants you to stay. The way on lies west.", Dir::West, ), wr( "Duskhollow - The Hall of Urns", - "Thousands of clay urns line shelves to the unseen ceiling, each holding forgotten ash. Many are broken, their contents not where they should be. West.", + "Thousands upon thousands of clay funerary urns line shelves that climb to an unseen ceiling, each one holding the forgotten ash of a forgotten life. Many have been broken open, and their grey contents lie scattered across the floor in trails that lead off into the dark, as though something went looking through them. The hall runs west.", Dir::West, ), wr( "Duskhollow - The Mourner's Stair", - "Steps worn into a smooth trough by centuries of grieving feet descend into a deeper cold. West.", + "A long stair descends, its steps worn into a smooth central trough by the passage of countless centuries of grieving feet, down toward a cold that deepens with every footfall until your breath smokes white before you. Somewhere far below, water drips with the patience of an age. The stair leads down and on to the west.", Dir::West, ), wr( "Duskhollow - The Catacomb Maze", - "Passages branch and rejoin among walls of stacked bone until direction loses meaning; only the draught from ahead keeps you true. West.", + "Passages branch and rejoin and double back among high walls of neatly stacked human bone, skull set upon skull, until direction itself loses all meaning and the maze seems to rearrange behind you. Only the faint cold draught breathing from somewhere ahead keeps your feet pointed true. Follow it west.", Dir::West, ), wr( "Duskhollow - The Lamentation Hall", - "A vast chamber where the slightest sound returns as a chorus of weeping, until you cannot tell the echo from the dead. West.", + "A vast vaulted chamber catches the slightest sound you make and returns it warped and multiplied as a soft chorus of weeping, so that a single cleared throat becomes a hundred mourners, and you slowly lose the ability to tell your own echo from the grief of the listening dead. Best to move quietly. The chamber opens west.", Dir::West, ), wr( "Duskhollow - The Gilded Tomb", - "A single tomb of beaten gold gleams untouched by the rot, its lid carved with a sleeping king who is no longer inside. West.", + "A single great tomb of beaten gold gleams warm and untouched amid all the surrounding rot, its heavy lid carved with the serene effigy of a sleeping king. The lid has been pushed askew from the inside, and the king it portrays is very plainly no longer at home within. The way on lies west.", Dir::West, ), wr( "Duskhollow - The Guardian's Rest", - "Stone sentinels line the final approach, each with a real sword rusted into its carved hands, each having taken one step from its plinth. West.", + "Stone sentinels line the final approach in two grim ranks, each clutching a real and rusted sword in its carved granite hands, and each, you slowly realize with a cold drop in the stomach, has taken exactly one heavy step down from its plinth toward the path. They wait now with the stillness of things that can afford to. The vault lies west.", Dir::West, ), wr( "Duskhollow - The Barrow King's Vault", - "A burial chamber fit for a king who refused the grave: gold heaped in the dark, and at its center a throne where a crowned and withered thing turns its head. The way out is east.", + "A burial chamber fit for a king who refused the grave: drifts of gold and grave-goods heaped glittering in the dark, weapons and crowns and the bones of buried servants. At its center stands a black throne, and upon it a crowned and withered thing, dry as old leather, slowly turns its head on a creaking neck to mark that someone has finally come. The only way out is back east.", Dir::West, ), ], @@ -2238,52 +2377,52 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { &[ wr( "Drowned Crypts - The Brine Stair", - "Salt-crusted steps spiral down into water that rises to meet you, cold as a drowned bell. South.", + "Salt-crusted steps spiral steeply down into dark water that rises to meet you, cold as a drowned bell and tasting of deep brine and older death. The walls run with weeping rivulets, and far below the stair the black water waits without a ripple. The way down leads south.", Dir::South, ), wr( "Drowned Crypts - The Coral Ossuary", - "Bone and pale coral have grown into one another until you cannot tell which the dead were and which the sea made. South.", + "Bone and pale coral have grown into one another over drowned centuries until you cannot tell which parts were once the dead and which the patient sea made afterward. Skulls flower with coral horns, and ribcages cradle anemones that flinch closed as your light sweeps past. The flooded passage runs south.", Dir::South, ), wr( "Drowned Crypts - The Kelp Forest", - "Ropes of black kelp rise from the flooded dark and sway though there is no current, parting reluctantly as you wade. South.", + "Thick ropes of black kelp rise from the flooded dark and sway in slow unison though there is no current to move them, parting only reluctantly as you wade waist-deep through the cold. Now and then a strand brushes your leg and seems, for an instant, to tighten. The drowned forest gives way south.", Dir::South, ), wr( "Drowned Crypts - The Sunken Chapel", - "A chapel stands fully submerged, pews in drowned rows, its altar candle somehow trailing a thread of smoke up through the water. South.", + "A small chapel stands fully submerged, its pews still ranked in drowned and silent rows beneath the surface, and upon the altar a single candle burns impossibly underwater, trailing a thin grey thread of smoke up through the green water to the unseen ceiling. Someone, or something, still keeps the vigil here. The flooded nave opens south.", Dir::South, ), wr( "Drowned Crypts - The Pearl Vault", - "Drowned treasure spills from broken chests, every coin and pearl furred with the same pale rot. South.", + "Drowned treasure spills in glittering drifts from broken iron-bound chests, gold and pearl and gem heaped enough to ransom a kingdom, and every last piece of it is furred over with the same soft pale rot that fuzzes the bones between. To fill your pockets here would be to carry the grave home with you. The way leads south.", Dir::South, ), wr( "Drowned Crypts - The Anemone Garden", - "Things that might be flowers and might be mouths carpet the walls, opening and closing in slow patient unison. South.", + "Things that might be flowers and might equally be mouths carpet the dripping walls from floor to ceiling, opening and closing in a slow, patient, breathing unison that follows you as you pass. A sweet rotten scent rises from them, and the nearest ones lean and turn to track your warmth. The chamber empties south.", Dir::South, ), wr( "Drowned Crypts - The Siren's Landing", - "A dry shelf above the flood holds a single carved seat facing the water, where something once sat to sing ships down. South.", + "A dry stone shelf lifts above the flood, and upon it stands a single weather-worn carved seat facing out over the black water, where something once sat through the long nights to sing passing ships down to their drowning. The seat is smooth with long use, and not quite cold. The shelf-path continues south.", Dir::South, ), wr( "Drowned Crypts - The Black Trench", - "The floor falls away into a trench whose bottom the lantern never finds, and from which a slow cold current breathes. South.", + "The floor falls away without warning into a vast trench whose bottom the lantern-light never finds, only deepening blue going down to black, and from its depths a slow cold current breathes steadily up into your face like the exhalation of something enormous and asleep. A narrow ledge skirts the void. Follow it south.", Dir::South, ), wr( "Drowned Crypts - The Bone Reef", - "A reef built entirely of the bones of the drowned rises in pale ramparts, and things nest in its hollows. South.", + "A reef built entirely from the bones of the drowned rises in pale ramparts and arches across the flooded cavern, the accumulated dead of a thousand wrecks knit together by coral and time. Pale eyeless things nest deep in its hollows, and they shift and click as your light crosses them. The way through lies south.", Dir::South, ), wr( "Drowned Crypts - The Leviathan's Maw", - "A vast flooded cavern dominated by the rib-cage of something that should not fit in any sea, and in its shadow a drowned horror stirs. The way back is north.", + "A vast flooded cavern opens at the catacomb's end, dominated by the bleached rib-cage of something so enormous it should not fit in any sea the maps record, each rib an arch you could sail a boat beneath. In the green shadow beneath that cage of bone, a drowned horror uncoils and stirs toward the warmth of your coming. The only way back is north.", Dir::South, ), ], @@ -2356,52 +2495,52 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { &[ wr( "Emberpeak - The Cleared Drift", - "Fresh rubble dragged aside; beyond it the dwarven tunnels run on, hot and red-lit. North.", + "Fresh rubble has been dragged aside to clear a way, the pick-marks still bright in the broken stone, and beyond it the old dwarven tunnels run on into a dry heat lit by a deep red glow from somewhere far ahead. The air smells of hot iron and char. The drift continues north.", Dir::North, ), wr( "Emberpeak - The Ore Sorters", - "Conveyor troughs of cold black iron still hold their last sorted heaps of glittering ore, untouched for an age. North.", + "Long conveyor troughs of cold black iron run the length of the hall, still holding their last sorted heaps of glittering raw ore exactly where the dwarven crews left them when they fled, untouched for an age. A single tin cup sits on the edge of a trough, as if its owner stepped away a moment ago. The tunnels run on north.", Dir::North, ), wr( "Emberpeak - The Gem Cutters' Hall", - "Workbenches stand abandoned mid-task, half-cut gems clamped in vices, catching the forge-light like trapped sparks. North.", + "Rows of jewellers' workbenches stand abandoned mid-task, half-cut gems still clamped in their tiny vices, catching the distant forge-light and throwing it back like trapped and frightened sparks. Fine tools lie scattered as though dropped in a single shared instant of alarm. The hall opens north.", Dir::North, ), wr( "Emberpeak - The Molten Channel", - "A river of slow magma crosses the hall in a stone trough, and the air above it shimmers hard enough to bend the sight. North.", + "A river of slow molten magma crosses the hall in a great hewn stone trough, glowing sullen orange and gold, and the air above it shimmers and warps hard enough to bend the very sight, so the far wall seems to swim and melt. The heat is a hand pressed flat against your face. A narrow span crosses it to the north.", Dir::North, ), wr( "Emberpeak - The Bellows Engine", - "A vast machine of leather and iron still wheezes faintly, breathing furnace-air into tunnels no one tends. North.", + "A vast machine of cracked leather bellows and pitted iron fills the chamber and still wheezes faintly on, all on its own, breathing hot furnace-air into tunnels that no living hand has tended for centuries. Its slow rasping breath sounds disquietingly like that of a great sleeping beast. The way on lies north.", Dir::North, ), wr( "Emberpeak - The Slag Cathedral", - "Waste glass and slag have been stacked into soaring buttresses, a cathedral built by accident over a thousand years of work. North.", + "Over a thousand years of discarded waste glass and cooled slag have been heaped and fused into soaring buttresses and arches, a vast cathedral built entirely by accident, its translucent walls catching the red glow and scattering it in a thousand sullen colors. It is grand, and unintended, and somehow holy. The nave runs north.", Dir::North, ), wr( "Emberpeak - The Runesmith's Sanctum", - "Walls of dwarven runes pulse with banked heat, and a forge of black iron broods at the heart, never gone cold. North.", + "Walls dense with carved dwarven runes pulse and glow with a banked inner heat, the old work-songs and wardings of a vanished people, and at the heart of the sanctum a great forge of black iron broods over coals that have never once gone cold in all the centuries since its makers died. Something keeps it fed. The passage continues north.", Dir::North, ), wr( "Emberpeak - The Ash Vault", - "Knee-deep grey ash fills a sealed vault, and something has been writing in it, over and over, the same dwarven word for sorry. North.", + "Knee-deep grey ash fills a sealed vault to which there is no other door, soft and undisturbed but for one thing: across its whole surface something has been writing, over and over and over in a child's clumsy hand, the same single dwarven word, which means sorry. The fresh strokes are still sharp. The way out lies north.", Dir::North, ), wr( "Emberpeak - The Firewalk", - "A narrow bridge crosses a lake of fire, the stone underfoot warm enough to feel through boots. North.", + "A narrow railless bridge of fire-blackened stone arches across a wide lake of slow-churning fire, and the span underfoot is warm enough to feel clearly through the soles of your boots, growing hotter toward the middle. Updrafts of furnace-air pluck at your clothes with every step. The bridge leads north.", Dir::North, ), wr( "Emberpeak - The Heart of the Forge", - "The deepest forge of all, open to a vein of living magma, where a guardian of fused slag and fire heaves itself upright. The way out is south.", + "The deepest forge of all opens here, hewn straight into a vein of living magma that lights the whole cavern the color of a wound and fills it with a roar of heat. As your shadow falls across the coals, a guardian of fused slag and molten fire, raised to keep this place against all comers, heaves itself ponderously upright to do exactly that. The only way out is south.", Dir::North, ), ], @@ -2474,52 +2613,52 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { &[ wr( "Frostspire - The Blue Descent", - "A stair carved into the glacier itself plunges into translucent blue depths, the cold deepening with every step. North.", + "A stair carved into the living glacier itself plunges down into translucent blue depths, the steps slick and glassy, the cold deepening with every careful footfall until it burns in the lungs. Shapes are frozen deep in the ice on either hand, too dim to name. The descent leads north.", Dir::North, ), wr( "Frostspire - The Frozen Falls", - "A waterfall caught mid-plunge forms a curtain of clear ice three storeys high, and behind it, dimly, something moves. North.", + "A waterfall caught and frozen mid-plunge forms a vast curtain of clear ice three storeys high, glittering and motionless, and behind its warped glass something dim and slow shifts its weight from one side to the other. You tell yourself it is only the light. The way leads on north.", Dir::North, ), wr( "Frostspire - The Rime Galleries", - "Halls of ice branch in every direction, their walls so clear you see the frozen dark of the glacier's interior pressing close. North.", + "Glittering halls of rime-frost branch away in every direction, their walls so impossibly clear that you see straight into the frozen blue-black dark of the glacier's deep interior pressing close on all sides. The galleries echo your every breath back as a brittle whisper. The true way lies north.", Dir::North, ), wr( "Frostspire - The Mammoth Graveyard", - "Tusked giants lie where the ice took them an age ago, perfectly kept, their great frozen eyes still open. North.", + "Tusked giants lie sprawled where the ice took them an age ago, mammoths and worse, each one perfectly kept and unblemished beneath the clear glacier, their great frozen eyes still open and somehow still seeming to follow your slow progress past. The cold here is the cold of held time. The way on lies north.", Dir::North, ), wr( "Frostspire - The Aurora Cavern", - "Light from the surface filters down through fathoms of ice and breaks into slow drifting color across the cavern floor. North.", + "Light from the unreachable surface filters down through uncounted fathoms of blue ice and breaks, somewhere far above, into slow drifting curtains of green and rose and violet that wash silently across the cavern floor like a captive aurora. It is the most beautiful thing you have seen in days, and the coldest. The way continues north.", Dir::North, ), wr( "Frostspire - The Frostbound Hoard", - "A dragon's hoard sheathed entirely in clear ice, every coin and crown visible and utterly unreachable. North.", + "A dragon's whole hoard lies sheathed entirely in a fathom of clear ice, every coin and crown and jewelled blade perfectly visible and utterly, mockingly unreachable, a fortune you could spend a lifetime failing to chip free. The ice is scored with the claw-marks of others who tried. The way on lies north.", Dir::North, ), wr( "Frostspire - The Silent Crevasse", - "A crack in the glacier so deep the cold pouring from it stops your breath, and the silence is total enough to hear your own heart. North.", + "A crevasse splits the glacier so deep that the cold pouring up out of it stops your breath in your throat and frosts your lashes in an instant, and the silence down here is so complete that you can hear the slow heavy beat of your own labored heart. Nothing else moves. A ledge skirts the crack to the north.", Dir::North, ), wr( "Frostspire - The Wyrm's Spine", - "You walk the frozen length of some titanic serpent locked in the ice, scale after scale underfoot for a hundred paces. North.", + "The floor itself becomes the frozen length of some titanic serpent locked in the glacier, and you walk its spine scale after vast frozen scale for a full hundred paces, each one broad as a shield underfoot. You try very hard not to wonder where, ahead in the ice, its head must be. The spine leads north.", Dir::North, ), wr( "Frostspire - The Last Warmth", - "A geothermal vent has kept one small chamber bearable, and the bones around the dead fire say others found it too late. North.", + "A geothermal vent breathes warmth into one small chamber, just bearable after the killing cold of the galleries, and the huddle of frost-rimed bones around a long-dead campfire tells you that others found this refuge a little too late to be saved by it. Their packs lie unopened. The way on lies north.", Dir::North, ), wr( "Frostspire - The Glacier's Heart", - "At the glacier's frozen core, a chamber of impossible blue holds an elder ice-wyrm coiled in eternal sleep, waking now, slow and vast and furious. The way back is south.", + "At the glacier's frozen core opens a chamber of impossible, luminous blue, and coiled at its center in what was meant to be eternal sleep lies an elder ice-wyrm, vast beyond the scale of the hoard it guards. The warmth of your blood has reached it at last, and it is waking now, slow and immense and very, very furious. The only way back is south.", Dir::North, ), ], @@ -2592,57 +2731,57 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { &[ wr( "Citadel - The Sealed Wing", - "A wing the citadel tried to wall away from itself, the bricks bulging outward as though something pushed from within. North.", + "This is a wing the citadel once tried to wall away from itself, the great brickwork seal still standing but bulging slowly outward, course by course, as though something on the far side has been pushing against it with infinite patience for a very long time. A draught of cold dead air leaks through the cracks. The wing runs north.", Dir::North, ), wr( "Citadel - The Mirror Gallery", - "Black mirrors line a hall, and your reflection is always a half-second late and, you slowly realize, not always copying what you do. North.", + "Tall black mirrors line both walls of a long hall, and your reflection in them runs always a half-second late, lagging your steps, until you slowly come to understand with a crawling dread that it is not always troubling to copy what you do at all. Best not to stop and watch. The hall leads north.", Dir::North, ), wr( "Citadel - The Forgotten Archive", - "Shelves of iron books stand toppled and burned, and the ash still holds the shape of words that hurt to almost-read. North.", + "Shelves of iron-bound books stand toppled and burned the length of a great archive, and the drifts of ash on the floor still hold, impossibly intact, the shapes of words and diagrams that hurt the eye to almost-read and leave an ache behind them. Some knowledge was meant to burn. The archive opens north.", Dir::North, ), wr( "Citadel - The Astronomer's Tower", - "A ruined observatory open to a sky full of wrong stars, its brass telescope aimed at a darkness that seems to aim back. North.", + "A ruined observatory stands open to a sky full of wrong and unfamiliar stars wheeling in patterns no living astronomer charted, and its great brass telescope sits aimed at one particular patch of starless darkness that seems, the longer you look, to be patiently aiming itself back at you. The dome groans in the wind. The way on lies north.", Dir::North, ), wr( "Citadel - The Hall of Hands", - "Ten thousand carved stone hands reach from the walls, and as you pass, the nearest ones slowly, gently, turn to follow. North.", + "Ten thousand carved stone hands reach out from the walls of this hall, open and supplicant, and as you pass between them the nearest ones turn slowly, gently, almost tenderly, to follow your movement and reach a little further toward your warmth. None of them quite touches you. Not yet. The hall continues north.", Dir::North, ), wr( "Citadel - The Drowned Laboratory", - "Flooded benches hold apparatus of glass and bone, and things in jars track you with eyes that should not still be wet. North.", + "Flooded laboratory benches hold the dust-furred apparatus of some forbidden study, retorts and coils of glass and bone, and the specimens floating in the rows of cloudy jars turn to track you as you wade past, watching with eyes that have no business still being wet and bright after all these centuries. The water laps at your knees. The way on lies north.", Dir::North, ), wr( "Citadel - The Whispering Crypt", - "The carved mouths of the citadel reach their loudest here, all speaking the last word of the long sentence at once. North.", + "The carved stone mouths that mutter throughout the citadel reach their loudest and most insistent here in this crypt, scores of them, all at last speaking the final word of the same enormous sentence the whole fortress has been pronouncing for an age. You feel the word in your teeth before you hear it. The crypt opens north.", Dir::North, ), wr( "Citadel - The Throne of Echoes", - "An empty throne faces a hall built to carry a single voice forever; the air still trembles faintly with the last command given. North.", + "An empty black throne faces down a long hall built by clever ancient acoustics to carry a single seated voice forever and unfading to its furthest corner, and the still air here trembles faintly yet with the residue of the last command ever given from that seat. It has not finished echoing. The hall runs north.", Dir::North, ), wr( "Citadel - The Vault of Saints", - "Sarcophagi of the citadel's holy dead stand cracked open from within, their occupants risen to a sanctity gone sour. North.", + "The sarcophagi of the citadel's holy dead stand ranked in this vault, and every last one has been cracked open from within, the heavy lids shouldered aside by their occupants, who rose long ago to a sanctity gone sour and strange in the dark. The air is thick with cold incense and something fouler beneath. The vault leads north.", Dir::North, ), wr( "Citadel - The Antechamber of the Heart", - "The black stone turns warm and almost soft here, and the lantern dims as though something ahead is drinking the light. North.", + "The black stone of the walls turns subtly warm and almost soft to the touch here, yielding like cooling wax, and your lantern dims and shrinks against the dark as though something just ahead has begun, slowly and steadily, to drink the very light out of the air. Each step forward costs more will than the last. The way on lies north.", Dir::North, ), wr( "Citadel - The Sealed Heart", - "The forbidden room at the citadel's core, where a being of folded shadow and starlight unfurls from the dark it was bound in. The way out is south.", + "This is the forbidden room at the citadel's very core, the thing the whole fortress was raised to cage, and as the last of your light gutters a being of folded shadow and cold starlight unfurls itself from the bound dark, dimension by impossible dimension, turning what passes for its attention upon the small warm intruder who unsealed its prison. The only way out is back south.", Dir::North, ), ], @@ -2704,57 +2843,57 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { &[ wr( "Obsidian Throne - The Burning Descent", - "A stair of cooling lava leads down into a heat that is almost a sound, a low roar at the edge of hearing. South.", + "A stair of black cooling lava, its treads still cracked with veins of dull orange fire, leads down into a heat so total it becomes almost a sound, a low and ceaseless roar that sits forever just at the edge of hearing. Sweat dries before it can fall. The descent leads south.", Dir::South, ), wr( "Obsidian Throne - The Furnace of Sins", - "Vast furnaces line a hall where the damned are unmade and remade, screaming on a loop ten thousand years long. South.", + "Vast furnaces line a hall longer than a cathedral, and in each the damned are unmade and patiently remade, over and over, screaming on a single seamless loop ten thousand years long and showing no sign of nearing its end. The heat-haze bends their writhing shapes. The hall runs south.", Dir::South, ), wr( "Obsidian Throne - The Chained Legion", - "Rank upon rank of bound demons stand frozen at attention, and ten thousand burning eyes track you down the length of the hall. South.", + "Rank upon serried rank of bound demons stand frozen at rigid attention, chained and waiting for a war-horn that has not yet sounded, and as you pass between them ten thousand burning eyes swivel in their stillness to track you the whole length of the hall. Not one of them so much as breathes. The way on lies south.", Dir::South, ), wr( "Obsidian Throne - The Pact Chamber", - "A round room of black glass where bargains were struck with the throne itself; the contracts still hang in the air, written in light, waiting. South.", + "A round room of polished black glass holds the place where bargains were once struck with the throne itself, and the contracts still hang unsigned in the air, written in slow-burning light, turning gently, each one waiting with infinite patience for a desperate enough hand to take up the offered pen. You feel them sense your wants. The chamber opens south.", Dir::South, ), wr( "Obsidian Throne - The River of Fire", - "A true river of flame crosses the dark, and a ferryman of ash waits at its bank with an open, expectant hand. South.", + "A true river of liquid flame crosses the dark in a slow blinding flood, and at its near bank a tall ferryman of compacted ash stands waiting beside a boat of charred bone, one open and expectant hand held out for the toll that every soul must pay to cross. His price is rarely coin. The crossing lies south.", Dir::South, ), wr( "Obsidian Throne - The Gallery of Torments", - "Each alcove holds a single damned soul in eternal, inventive agony, and each turns its head to beg you for an end. South.", + "A long gallery of alcoves runs into the dark, and each one holds a single damned soul fixed in its own eternal and inventively tailored agony, and each lifts its head as you pass to beg you, in a voice worn to a thread, for the one mercy of an end. You cannot give it, and they know, and still they ask. The gallery continues south.", Dir::South, ), wr( "Obsidian Throne - The Brimstone Bridge", - "A bridge of fused bone arches over an abyss that glows the deep red of a banked forge, exhaling sulphur. South.", + "A slender bridge of fused and blackened bone arches high over an abyss that glows the deep sullen red of a banked forge far below, exhaling a hot reek of sulphur that sears the throat with every breath. The bone underfoot is warm and faintly, horribly springy. The bridge crosses to the south.", Dir::South, ), wr( "Obsidian Throne - The Hall of Broken Oaths", - "Shattered contracts litter the floor, and the air is thick with the ghosts of promises the throne was glad to see broken. South.", + "Shattered contracts litter the floor of this hall ankle-deep in drifts of broken light, and the air hangs thick and cold with the lingering ghosts of every promise the throne was only ever glad to watch its bargainers break. They drift against you like cobwebs, whispering the terms you never read. The hall runs south.", Dir::South, ), wr( "Obsidian Throne - The Weeping Pits", - "Pits of black tar bubble and sigh, and each rising bubble briefly wears a face that mouths a name before it bursts. South.", + "Wide pits of black boiling tar bubble and sigh across the chamber floor, and each slow rising bubble briefly wears a stretched and silent face that mouths a single name, perhaps its own, perhaps yours, before it bursts and sinks back into the churning dark. The smell is of pitch and grief. The way on lies south.", Dir::South, ), wr( "Obsidian Throne - The Antechamber of the Abyss", - "The realm thins toward something worse, the black glass going translucent on a void that has no bottom and no patience. South.", + "The very substance of the realm thins here toward something far worse, the black glass underfoot going slowly translucent, then clear, opening onto a depthless void below that has no bottom, no floor, and no patience left for the warm thing walking above it. Vertigo claws at you. The last threshold lies south.", Dir::South, ), wr( "Obsidian Throne - The Abyssal Gate", - "The realm bottoms out at a gate into pure abyss, guarded by a herald of Mal'gareth who will not let a soul pass either way. The way back is north.", + "The infernal realm bottoms out at last before a colossal gate that opens onto pure and howling abyss, and before it stands a herald of Mal'gareth, wreathed in cold fire and older than the sin it serves, who will suffer no living soul to pass through in either direction while it still holds its post. It turns to bar your way. The only road back is north.", Dir::South, ), ], @@ -2816,42 +2955,42 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { &[ wr( "King's Road - The Poacher's Trail", - "A narrow trail worn by furtive feet winds east through the brush, snares glinting in the undergrowth. East.", + "A narrow trail worn by furtive feet winds away east through the brush, and the careful eye picks out the glint of wire snares and the pale scar of deadfall triggers half-hidden in the undergrowth on either side. Someone does not want to be casually followed. The trail leads east; the road lies west.", Dir::East, ), wr( "King's Road - The Hollow Tree", - "A hollow oak big enough to shelter in has been used as exactly that; a cold campfire and gnawed bones say by whom. East.", + "A hollow oak stands big enough for a man to shelter inside, and it has plainly been used as exactly that: a ring of cold ashes, a heap of gnawed and cracked bones, and a stink of old habitation say clearly enough by whom, and how recently. The trail goes on east and back west.", Dir::East, ), wr( "King's Road - The Abandoned Farmstead", - "A burned-out farm slumps in a clearing, its fields gone to weed, its well gone to black water. East.", + "A burned-out farmstead slumps in a weed-choked clearing, its roof-beams fallen, its fields long gone to thistle and bramble, its well gone to still black water that smells of rot. Whoever worked this land did not leave it willingly. The trail continues east and west.", Dir::East, ), wr( "King's Road - The Scarecrow Field", - "Rags on crossed sticks lean at wrong angles across a dead field, and you count one more of them on the way out than on the way in. East.", + "Scarecrows of grey rags on crossed sticks lean at subtly wrong angles all across a dead and stubbled field, far more of them than any farmer would ever need, and a careful count leaves you uneasily certain there is one more of them now than there was when you first looked. None of them has a face. The trail runs east and west.", Dir::East, ), wr( "King's Road - The Crossroads Gibbet", - "An iron gibbet creaks at a forgotten crossroads, its occupant long since flown to bone. East.", + "An iron gibbet creaks slowly on its chain at a forgotten crossroads, swinging in a wind you cannot feel, its long-ago occupant flown now to a clatter of bone and a few greening rags. A weathered board names the crime, but the letters have run to rust. The ways lead east and west.", Dir::East, ), wr( "King's Road - The Smuggler's Cellar", - "A trapdoor in the ruin of an inn drops to a cellar of stolen goods, half of it spoiled, all of it watched. East.", + "A trapdoor sunk in the floor of a ruined roadside inn drops to a low cellar stacked with stolen goods, bolts of cloth and casks and crates, half of it gone to damp and mildew and all of it watched, you are quite sure, by unseen eyes from the further dark. Something down here is breathing. The trail continues east and west.", Dir::East, ), wr( "King's Road - The Watchpost", - "A half-built bandit watchpost overlooks the trail, its lookout's stool still warm, its lookout suddenly not in sight. East.", + "A half-built watchpost of lashed timber overlooks a bend in the trail, well-placed to spot anyone coming up from the road, and its lookout's three-legged stool still holds the warmth of someone who was sitting there a moment ago and is now, abruptly and ominously, nowhere in sight. The alarm has gone ahead of you. The trail runs east and west.", Dir::East, ), wr( "King's Road - The Camp Approach", - "The trees thin toward firelight and rough laughter; you are clearly expected, and clearly not welcome. East.", + "The trees thin ahead toward the flicker of a great fire and the sound of rough laughter and the scrape of whetstones on steel, and the laughter falls silent, all at once, as you draw near. You are clearly expected, and just as clearly not at all welcome. The camp lies east; the trail back is west.", Dir::East, ), wr( @@ -2907,6 +3046,222 @@ fn extend_world(rooms: &mut HashMap, spawns: &mut Vec) { ); } +/// Common low-tier drop pool shared by wandering wing mobs. +/// The overworld: 100 rooms of new biomes radiating from Embergate's South Gate +/// down the Greatroad, plus the three capital cities - Tasmania (harbor), +/// Melvanala (mountain lake), and Matlatesh (desert) - each a safe haven with a +/// healing fountain and the builder's dedication plaque (see FEATURES). Built on +/// the same reciprocal add_wing spine as extend_world, so reachability and exit +/// reciprocity hold by construction. Mob ids start at 600 to clear all earlier +/// spawns; the three capital wings are safe and carry no mobs. +fn extend_overworld(rooms: &mut HashMap, spawns: &mut Vec) { + let mut next_mob: u32 = 600; + let mut mob = |spawns: &mut Vec, + name: &'static str, + home: RoomId, + hp: i32, + dmg: i32, + xp: i32, + boss: bool, + loot: &'static [u32], + profile: DamageProfile| { + let id = next_mob; + next_mob += 1; + spawns.push(MobSpawn { + id, + name, + home, + max_hp: hp, + damage: dmg, + xp, + respawn_secs: if boss { 300 } else { 55 }, + loot, + boss, + profile, + }); + }; + fn p(at: DamageType, res: Option, weak: Option) -> DamageProfile { + DamageProfile::new(at, res, weak) + } + use DamageType as D; + + // ---- The Greatroad (9 rooms): the spine west from Embergate --------- + add_wing(rooms, "The Greatroad", false, 5, Dir::West, 600, &[ + wr("The Greatroad - The Westgate Mile", "Beyond Embergate's south gate the King's Road forks, and the Greatroad peels away west: a broad ribbon of old imperial flagstone, rutted by ten centuries of cartwheels and kept just clear enough of brigands to be called safe by optimists. Milestones march off into the haze, each chiselled with the league-count to cities you have only ever heard of in songs. The road runs on west, and Embergate lies back east.", Dir::West), + wr("The Greatroad - The Toll Bridge", "A humpbacked stone bridge vaults a slow brown river, its toll-house long abandoned and its gate-arm rotted off the hinge. Beneath the span the water slides green and patient around the piers, and a heron stands one-legged among the reeds, wholly unimpressed by your passing. The road carries on west and east.", Dir::West), + wr("The Greatroad - The Crossroads Shrine", "Here the Greatroad meets a northbound track, and at their meeting a weathered shrine to the road-god stands heaped with the small offerings of nervous travellers: copper coins, a child's shoe, a sprig of dried rosemary gone to dust. A painted board points north to the harbor-city of Tasmania, its lettering salt-faded but legible. The road runs west and east, and the northbound track climbs away toward the distant sea.", Dir::West), + wr("The Greatroad - The Poplar Avenue", "Tall poplars line the road in two unbroken ranks, planted by some forgotten governor to shade legions that no longer march, and the wind through their high leaves makes a dry, ceaseless, sea-like sighing. Their shadows fall in long bars across the worn stone, and between them the late light lies spilled like honey. The avenue runs west and east.", Dir::West), + wr("The Greatroad - The Wayfarer's Rest", "A ruined coaching inn slumps at the roadside, half its roof fallen in, but one corner has been patched with hides and someone keeps a fire there for any soul benighted on the road. Tonight it stands empty, the embers banked low, a black kettle left hopefully on its hook above the coals. The road goes on west and east.", Dir::West), + wr("The Greatroad - The Mountain Turn", "The land begins to heave upward, and a second track breaks away to the north, switchbacking toward the grey shoulders of the mountains and the lake-city of Melvanala hidden somewhere among them. The air here already tastes of cold stone and crushed pine. The Greatroad presses on west, the mountain track climbs north, and the way you came lies east.", Dir::West), + wr("The Greatroad - The Locust Fields", "The road crosses a wide plain of abandoned grainfields gone to wild oats and the endless dry sawing of locusts, the husks of farmsteads standing roofless among them like the bones of a meal long since finished. A scarecrow leans at the verge, and you are nearly past before you notice it has turned its straw face to watch you go. The road runs west and east.", Dir::West), + wr("The Greatroad - The Dust Reach", "The green drains out of the country by slow degrees until the road runs through a hard ochre land of thornscrub and heat-shimmer, the flagstones half-swallowed by blown grit. The west wind carries a fine hot sand that sings against your teeth and stings the eyes, and the horizon ahead has taken on the brassy glare of true desert. West and east.", Dir::West), + wr("The Greatroad - The Caravan Fork", "The Greatroad ends at a great fork worn into the desert's very edge, where the caravan roads diverge: one west into the gold furnace of the Sahra Wastes and the mud-walled city of Matlatesh, others scattering toward rumors of water and grass. A broken obelisk marks the place, its proud inscription scoured smooth and blank by a thousand years of sand. Tracks lead west, and the road home lies east.", Dir::West), + ]); + mob(spawns, "a road-worn brigand", 601, 30, 6, 26, false, COMMON_LOOT, p(D::Physical, None, None)); + mob(spawns, "a dust-jackal", 607, 38, 8, 34, false, COMMON_LOOT, p(D::Physical, None, Some(D::Frost))); + mob(spawns, "a scarecrow that walks", 606, 46, 9, 44, false, COMMON_LOOT, p(D::Physical, Some(D::Physical), Some(D::Fire))); + + // ---- Tasmania (7 rooms): the harbor capital (SAFE) ------------------ + add_wing(rooms, "Tasmania", true, 602, Dir::North, 620, &[ + wr("Tasmania - Harborgate Square", "The northbound track ends at the sea-gate of Tasmania, and the city opens before you all at once: white-walled and red-roofed, tumbling down its hill to a harbor crowded with masts, loud with gulls and ship-chandlers and the bargaining of a hundred tongues. At the square's heart a great tiered fountain catches the sea-light, and a bronze plaque is set into the harbor wall beside it. Streets climb north into the city, and the Greatroad lies back south.", Dir::North), + wr("Tasmania - The Chandler's Row", "A steep cobbled street of ship-chandlers and net-menders, every doorway hung with coils of tarred rope, brass lanterns, and the clean iron smell of fish-hooks sold by the gross. Cats sun themselves on the warm stone and watch the wheeling gulls with the air of professionals reviewing amateurs. The street climbs north and drops back south to the square.", Dir::North), + wr("Tasmania - The Salt Market", "Under a vast patched awning the salt market roars: pyramids of white and grey and rose-pink salt, barrels of cured fish, ropes of garlic and dried chilies, and fishwives whose voices could strip the paint from a hull at forty paces. The air is a solid wall of brine and spice and frying oil. The way runs north and south.", Dir::North), + wr("Tasmania - The Cathedral of the Tide", "A great pale cathedral rises over the rooftops, its tall windows glazed with sea-green glass so that the light within swims and ripples as though the whole soaring nave lay drowned beneath the waves. Pilgrims come here to light slow candles for sailors who never made it home. The way climbs north, and the market lies south.", Dir::North), + wr("Tasmania - The Lighthouse Stair", "A long stair climbs the seaward cliff to the foot of the great lighthouse, whose patient lamp has not failed in three hundred years. From the windy landing the whole Sapphire Coast unrolls to the east, cliff and cove and the far white line of breaking surf. The city falls away north and south, and a cliff-path leads east along the coast.", Dir::North), + wr("Tasmania - The Governor's Terrace", "The topmost terrace of the city is given over to the governor's pale colonnaded palace and its gardens of wind-bent tamarisk, where the nobility take the evening air and pretend with great effort not to watch one another. The view to the north is nothing but open, gleaming sea. The terrace runs north and south.", Dir::North), + wr("Tasmania - The Watchtower Crown", "The city ends at its very highest point, an old watchtower crowning the hill, its beacon-pan long cold but still heaped and ready. From here Tasmania lies spread out below like a thing built of coral and chalk, and beyond it the sea simply goes on forever. The only way is back south.", Dir::North), + ]); + + // ---- The Sapphire Coast (12 rooms): sea cliffs east of Tasmania ----- + let last = add_wing(rooms, "The Sapphire Coast", false, 624, Dir::East, 640, &[ + wr("The Sapphire Coast - The Cliff Path", "A narrow path clings to the chalk cliff above a sheer drop where the sea breaks white on black rocks a hundred feet below, and the wind comes off the water hard enough to lean your whole weight against. Seabirds wheel and scream from their nests in the cliff-face, loudly resentful of the company. The path runs east, and Tasmania lies west.", Dir::East), + wr("The Sapphire Coast - The Smuggler's Cove", "A hidden cove opens at the foot of a treacherous goat-track, its shingle beach littered with the grey ribs of wrecked boats and, higher up the strand, the cold ashes and stacked kegs of folk who do their trading strictly by moonlight. The tide is out, and the sea-caves gape black and dripping. East and west.", Dir::East), + wr("The Sapphire Coast - The Tidal Flats", "At low water a vast plain of rippled sand and mirror-bright pools stretches out toward a sea gone distant and small, and the cockle-pickers' baskets lie abandoned where their diggers fled from something none of them will name. The returning tide is only a rumor on the wind, for now. East and west.", Dir::East), + wr("The Sapphire Coast - The Driftwood Henge", "Someone, or something, has hauled the bone-pale trunks of drowned trees upright into a rough circle on the strand, hung with fishing-floats of green glass and the small picked skulls of seabirds that turn and clack against one another in the wind. It is far older than it has any right to be. East and west.", Dir::East), + wr("The Sapphire Coast - The Sea-Cave Mouth", "The cliff splits in a vast cave-mouth that breathes the sea in and out with a long, hollow, living groan, and far back in its dripping throat something pale shifts in water that has never once seen the sun. The whole tide-line is hung with weed like sodden green hair. East and west.", Dir::East), + wr("The Sapphire Coast - The Coral Shelf", "The path crosses a wide shelf of dead white coral, sharp as smashed crockery underfoot, pocked everywhere with rock-pools where anemones the color of fresh bruises open and close with a slow and disconcerting intent. The sea sucks and clatters in the hollows below. East and west.", Dir::East), + wr("The Sapphire Coast - The Wreck of the Cormorant", "A great galleon lies broken-backed across the rocks, her masts down and her hull stove wide open, and her gilded figurehead - a straining cormorant - still reaches seaward as though it might yet tear free and fly. Crabs the size of dinner-plates have claimed the captain's flooded cabin as their own. East and west.", Dir::East), + wr("The Sapphire Coast - The Pearl Divers' Camp", "A shantytown of stilt-huts and drying-racks clings to a sheltered inlet where the pearl-divers worked, for the camp is silent now, the diving-stones still corded and waiting by the water's edge, the cook-fires gone long and utterly cold. Nothing moves but the flies. East and west.", Dir::East), + wr("The Sapphire Coast - The Singing Sands", "A long crescent of fine white sand moans and booms underfoot with every step, a deep uncanny music that the coast-folk swear is the voices of the drowned singing up through the beach to call new company down. It raises the fine hairs on your arms. East and west.", Dir::East), + wr("The Sapphire Coast - The Drowned Causeway", "A paved causeway runs arrow-straight out into the sea and simply vanishes beneath the waves, the road to some island the water swallowed an age ago; at the lowest tide its first stones glisten just clear, leading the eye and the foolish out toward the deeps. East and west.", Dir::East), + wr("The Sapphire Coast - The Kraken's Reach", "The coast bends into a deep, still, oily bay where no birds fly and the water lies flat and black and waiting, and the rocks above the tideline are scored everywhere with great curving grooves that no storm ever cut. The air smells of cold salt and a very old fear. East and west.", Dir::East), + wr("The Sapphire Coast - The Tide-King's Grotto", "The path ends at last in a sea-grotto where the swell rushes in to fill a vast green-lit cavern, and upon a throne of barnacled rock something ancient and immense uncoils from the deep water to regard the small warm morsel that has wandered so far down its shore. The only way out is west.", Dir::East), + ]); + mob(spawns, "a cliff-nesting harpy", 641, 50, 10, 56, false, COMMON_LOOT, p(D::Physical, None, Some(D::Lightning))); + mob(spawns, "a shambling drowned sailor", 644, 58, 11, 64, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Lightning))); + mob(spawns, "a giant shore-crab", 646, 66, 12, 70, false, COMMON_LOOT, p(D::Physical, Some(D::Physical), Some(D::Lightning))); + mob(spawns, "a singing-sand wraith", 648, 60, 13, 72, false, COMMON_LOOT, p(D::Shadow, Some(D::Frost), Some(D::Holy))); + mob(spawns, "the Tide-King of the Reach", last, 300, 22, 380, true, &[1008, 1205, 1302], p(D::Frost, Some(D::Frost), Some(D::Lightning))); + + // ---- Melvanala (7 rooms): the mountain-lake capital (SAFE) ---------- + add_wing(rooms, "Melvanala", true, 605, Dir::North, 660, &[ + wr("Melvanala - The Lakeshore Square", "The mountain track climbs at last into Melvanala, a city of grey stone and blue slate terraced up the steeps above a vast and utterly still mountain lake. Woodsmoke and the sharp scent of pine-resin hang in the thin bright air, and at the heart of the lakeshore square a tiered fountain murmurs beside a bronze plaque set into the old retaining wall. Stairs climb north into the city, and the Greatroad track falls away south.", Dir::North), + wr("Melvanala - The Coppersmith's Steps", "A stepped street rings all day long with the bright hammering of the coppersmiths, whose wares - kettles, braziers, bells, and prayer-wheels - hang gleaming from every lintel and turn the slanting evening light to running flame. The steps climb north and descend south to the square.", Dir::North), + wr("Melvanala - The Pilgrim's Stair", "A broad stone stair, worn into shallow troughs by the knees of countless generations, climbs between walls hung with sun-faded prayer-flags toward the high monastery above. Brass cylinders line the way, and the mountain wind spins them so they whisper their endless blessings to no one at all. North and south.", Dir::North), + wr("Melvanala - The Hanging Gardens", "Terrace upon terrace of mountain gardens cling to the slope, thick with alpine flowers and the drowsy hum of bees, fed by a clever lattice of stone channels that catch and share the snowmelt. From up here the whole city lies laid out below like a careful model of itself. North and south.", Dir::North), + wr("Melvanala - The Monastery Gate", "The pilgrim stair ends at the iron-bound gate of the high monastery, where saffron-robed monks keep a silence so deep it seems to carry an actual weight, and from the gatehouse the Verdant Highlands roll away green and gold and endless to the east. The city lies south, and a herders' path leads off east into the hills.", Dir::North), + wr("Melvanala - The Bell Tower", "A slender tower holds the great bronze bell of Melvanala, rung only three times a year, its single deep voice said to carry to every peak that can see the lake. From the high gallery the water lies far, far below, a held breath of perfect silver. North and south.", Dir::North), + wr("Melvanala - The Sky-Burial Ledge", "The city's highest place is a windswept stone ledge thrown open to the peaks and the patiently wheeling vultures, where the dead of Melvanala are given back up to the sky they loved. It is a place of fierce, cold, absolute beauty, and an even deeper peace. The only way is back south.", Dir::North), + ]); + + // ---- The Verdant Highlands (12 rooms): green hills east of Melvanala + let last = add_wing(rooms, "The Verdant Highlands", false, 664, Dir::East, 680, &[ + wr("The Verdant Highlands - The Herders' Path", "A grassy path winds east through high rolling pasture, dotted with the small dark shapes of grazing yaks and the occasional stone cairn raised by herders to mark the way through the fog that rolls in without warning. Skylarks burst up singing from beneath your very boots. East, and Melvanala lies west.", Dir::East), + wr("The Verdant Highlands - The Gentian Meadow", "A meadow of deep-blue gentian and nodding white edelweiss spills down the hillside in a sweep of color so intense it looks painted, loud with bees and the click of grasshoppers in the warm grass. A lone shepherd's flute carries faintly from somewhere out of sight. East and west.", Dir::East), + wr("The Verdant Highlands - The Standing Stones", "A ring of moss-furred standing stones crowns a green hill, far older than any herder's memory, and the sheep will not graze within the circle no matter how rich the grass grows there. The wind drops oddly still as you step inside. East and west.", Dir::East), + wr("The Verdant Highlands - The Thundering Falls", "A river throws itself off a high green shelf in a white roar of spray, and the path crosses behind the falling water on a slick ledge where the whole world becomes noise and cold rainbow mist. The rock is treacherous and the drop is long. East and west.", Dir::East), + wr("The Verdant Highlands - The Heather Moor", "The grass gives way to a vast purple moor of springy heather and black peat-pools, stretching to every horizon under a sky full of racing cloud-shadow. Curlews call their lonely falling cry, and the wind never once stops moving over the open land. East and west.", Dir::East), + wr("The Verdant Highlands - The Shepherd's Refuge", "A round drystone hut crouches in the lee of a tor, its turf roof grown thick with the same heather as the moor, a refuge built for herders caught out by the weather. Inside, a stack of cut peat and a tinderbox wait in patient readiness. East and west.", Dir::East), + wr("The Verdant Highlands - The Eagle's Tor", "A great granite tor juts from the moor like a clenched fist, and from its summit a golden eagle launches on the updraft, while half a kingdom of green and grey and distant blue spreads out below your feet. The wind up here could carry a careless soul away. East and west.", Dir::East), + wr("The Verdant Highlands - The Sunken Lane", "The path drops into a green-roofed lane so deep and so old that its banks rise twice a man's height on either hand, laced with the roots of unseen trees and floored with soft black mud. It is cool, and close, and very quiet down here. East and west.", Dir::East), + wr("The Verdant Highlands - The Faerie Hollow", "A perfect green hollow opens in the hills, ringed with foxglove and toadstool, and the light within has a thick golden cast that makes time itself feel slow and uncertain. You have the strong sense of having interrupted something that has now gone still to watch. East and west.", Dir::East), + wr("The Verdant Highlands - The Cattle Raid Ford", "A wide shallow river chatters over a stony ford, the crossing churned to mud by hooves and old violence, and a leaning standing-stone records some forgotten cattle-raid in worn spiral carvings. The water runs clear and bitterly cold. East and west.", Dir::East), + wr("The Verdant Highlands - The Beast-Lord's Cairn", "The hills crowd close around a huge ancient burial cairn, its capstone fallen, its black mouth breathing out the smell of old fur and older blood. Bones gnawed white are scattered thick at the threshold, and not all of them are from sheep. East and west.", Dir::East), + wr("The Verdant Highlands - The Antlered Throne", "The path ends in a high green amphitheatre walled by hills, where upon a throne of interlaced antler and weathered bone sits the great Beast-Lord of the highlands, vast and shaggy and crowned, rising now to the full towering height of its long-guarded solitude. The only way out is west.", Dir::East), + ]); + mob(spawns, "a moor wolf", 681, 54, 11, 60, false, COMMON_LOOT, p(D::Physical, None, Some(D::Fire))); + mob(spawns, "a highland reaver", 684, 60, 12, 66, false, COMMON_LOOT, p(D::Physical, None, None)); + mob(spawns, "a cairn-bound revenant", 690, 70, 13, 78, false, COMMON_LOOT, p(D::Shadow, Some(D::Shadow), Some(D::Holy))); + mob(spawns, "the Beast-Lord of the Hills", last, 320, 24, 420, true, &[1007, 1202, 1304], p(D::Physical, Some(D::Frost), Some(D::Fire))); + + // ---- The Mistfen (9 rooms): drowned marsh south of the Highlands ---- + let last = add_wing(rooms, "The Mistfen", false, 686, Dir::South, 700, &[ + wr("The Mistfen - The Sinking Path", "The firm highland turf rots away southward into a treacherous fen of black water and floating sedge, where a path of half-sunk logs offers the only footing and a cold white mist drinks the sound right out of the air. Something plops into the water just out of sight. South, and the hills lie north.", Dir::South), + wr("The Mistfen - The Reed Labyrinth", "Walls of reed twice your height close in on every side, channels of still brown water branching and rejoining until the world shrinks to mud, mist, and the rustle of unseen things parting the stems ahead of you. Direction becomes a matter of faith. North and south.", Dir::South), + wr("The Mistfen - The Drowned Village", "The peaked roofs of a sunken village break the surface of the fen, their windows full of black water, a church spire leaning at a drunken angle with its bell still hung and waiting. The mist hangs a single rope of woodsmoke that has no fire to come from. North and south.", Dir::South), + wr("The Mistfen - The Will-o'-Wisp Mire", "Pale lights drift and bob across the deep mire, beautiful and patient, each one hovering just over the worst of the sucking mud, each one promising firm ground that is not there at all. They brighten, hopefully, as you draw near. North and south.", Dir::South), + wr("The Mistfen - The Bog-Body Barrow", "A low island of slightly firmer peat holds an ancient barrow, and the black bog has kept its dead so perfectly that the faces pressing up through the surface still wear their final expressions of surprise. The peat sighs and shifts as if breathing. North and south.", Dir::South), + wr("The Mistfen - The Leech-Black Pool", "The path skirts a pool so utterly black and still it might be a hole cut clean through the world, and the things that live in it - long, soft, and far too many - lift the surface in slow ripples that all turn, somehow, toward you. North and south.", Dir::South), + wr("The Mistfen - The Hag's Causeway", "A causeway of mortared skulls, white and grinning, lifts the path above the deepest fen, and at its midpoint a wicker idol leans over the water, freshly garlanded by hands that did not love what they were appeasing. A way leads down through a sinkhole here. North, south, and down.", Dir::South), + wr("The Mistfen - The Sunken Cathedral", "A vast drowned cathedral rears from the mire, three-quarters swallowed, its remaining stained glass casting drowned and broken colors across the water, and from within comes the slow drip and the slower, deliberate sound of something very large turning over. North and south.", Dir::South), + wr("The Mistfen - The Marsh-Mother's Hollow", "The fen opens into a stagnant lagoon ringed by dead willows, and from its center, draped in weed and rising water, the Marsh-Mother lifts her ancient drowned head and opens arms enough to gather in the whole foolish world. The only way back is north.", Dir::South), + ]); + mob(spawns, "a fen leech-swarm", 701, 50, 10, 54, false, COMMON_LOOT, p(D::Poison, Some(D::Poison), Some(D::Fire))); + mob(spawns, "a bog-body shambler", 704, 58, 11, 62, false, COMMON_LOOT, p(D::Poison, Some(D::Shadow), Some(D::Fire))); + mob(spawns, "a drowned bell-ringer", 707, 64, 12, 70, false, COMMON_LOOT, p(D::Frost, Some(D::Frost), Some(D::Holy))); + mob(spawns, "the Marsh-Mother", last, 300, 21, 360, true, &[1109, 1204, 1302], p(D::Poison, Some(D::Poison), Some(D::Fire))); + + // ---- The Fungal Hollow (8 rooms): underdark beneath the Mistfen ----- + let last = add_wing(rooms, "The Fungal Hollow", false, 705, Dir::Down, 800, &[ + wr("The Fungal Hollow - The Sinkhole Descent", "The Mistfen's sinkhole drops you into a warm and breathing dark, down a slope of soft pale mycelium that gives underfoot like flesh, into a world lit only by the cold blue glow of fungus. The mist and the marsh seal over far above. The hollow goes down, and the surface lies up.", Dir::Down), + wr("The Fungal Hollow - The Glowcap Forest", "A forest of luminous mushrooms taller than houses spreads in every direction, their caps shedding a soft drifting rain of spores that hangs glittering in the still air and settles cold on your skin. The silence has a texture, like standing inside a held breath. Up and down.", Dir::Down), + wr("The Fungal Hollow - The Spore Cloud Gallery", "The passage thickens with a dense floating fog of spores that catch the glow and turn the air to luminous soup, and breathing it leaves a strange sweet taste and the creeping certainty that the fungus is, very slowly, learning your shape. Up and down.", Dir::Down), + wr("The Fungal Hollow - The Myconid Ring", "A wide cavern floor is dimpled with a perfect ring of squat mushroom-folk, utterly still, their blunt faces all turned inward to a contemplation that has clearly been going on for centuries and does not welcome the interruption. Up and down.", Dir::Down), + wr("The Fungal Hollow - The Rot Pools", "Pools of bubbling digestive slime pock the cavern, hissing softly, dissolving the bones of the unlucky into a pale broth that the surrounding fungus drinks up through threadlike roots. The smell is sweet, and rich, and wrong. Up and down.", Dir::Down), + wr("The Fungal Hollow - The Crystal Vault", "The fungus thins where a vault of pale crystal takes over, every facet throwing back the blue glow until the chamber blazes like the inside of a star, and clusters of fungus-light pulse in slow patterns that almost, almost resolve into meaning. Up and down.", Dir::Down), + wr("The Fungal Hollow - The Spore-Lord's Antechamber", "The mycelium underfoot grows thick and propertarial, climbing the walls in pulsing ropes that all run inward and downward toward a single source, and the very air grows heavy with the sense of an enormous slow attention swinging round to face you. Up and down.", Dir::Down), + wr("The Fungal Hollow - The Heart-Spore", "The hollow bottoms out in a great domed chamber where the whole fungal world converges upon one vast pulsing fruiting-body, the Heart-Spore, which splits now along a hundred glowing seams to look upon the warm and breathing thing that has come down into its dark. The only way back is up.", Dir::Down), + ]); + mob(spawns, "a shrieker fungus", 801, 56, 11, 60, false, COMMON_LOOT, p(D::Poison, Some(D::Poison), Some(D::Fire))); + mob(spawns, "a spore-maddened thrall", 803, 62, 12, 66, false, COMMON_LOOT, p(D::Poison, None, Some(D::Fire))); + mob(spawns, "a myconid sovereign's guard", 806, 70, 13, 74, false, COMMON_LOOT, p(D::Poison, Some(D::Physical), Some(D::Fire))); + mob(spawns, "the Heart-Spore", last, 310, 22, 400, true, &[1008, 1205, 1304], p(D::Poison, Some(D::Poison), Some(D::Fire))); + + // ---- Matlatesh (7 rooms): the desert capital (SAFE) ----------------- + add_wing(rooms, "Matlatesh", true, 608, Dir::West, 720, &[ + wr("Matlatesh - The Oasis Square", "The caravan road climbs a last dune and Matlatesh stands revealed in the bowl of its oasis: a city of honey-colored mud-brick and palm shade, its wind-towers reaching up to catch the desert breeze, its streets cool and dim and smelling of cardamom and dust. A great tiered fountain spills at the square's heart, fed by the blessed spring, and a bronze plaque is set in the shaded wall beside it. Lanes run west into the city, and the desert road lies east.", Dir::West), + wr("Matlatesh - The Spice Souk", "A roofed bazaar runs deep into cool shadow, its stalls heaped with saffron and cumin and dried roses, with brass and carpets and caged singing-birds, and the haggling never stops nor rises above a confidential murmur. Shafts of dusty light fall from holes in the high roof. West and east.", Dir::West), + wr("Matlatesh - The Caravanserai", "A great arcaded courtyard gives rest to the desert caravans, ringed with stalls for camels and cool cells for their drivers, a fountain trickling at its center and the air thick with the patient grumble of beasts and the smell of dung-fires and mint tea. West and east.", Dir::West), + wr("Matlatesh - The Astronomer's College", "A domed college of pale stone houses the desert's famous star-readers, its courtyard floor inlaid with a vast brass map of a sky far clearer than any rain-country ever sees, its scholars arguing softly beneath an arch of mathematics. West and east.", Dir::West), + wr("Matlatesh - The Sultana's Water-Garden", "Behind high walls a miracle unfolds: a garden of running channels and quiet pools, of orange trees and jasmine and the impossible green that only the truly rich can wring from the desert, every drop of it accounted for and adored. West and east.", Dir::West), + wr("Matlatesh - The Potter's Quarter", "A warren of kilns and drying-yards where the city's red clay is thrown, fired, and painted, the lanes stacked head-high with jars and lamps and tiles, and every wall splashed with the bright glaze-spatter of a hundred years of work. West and east.", Dir::West), + wr("Matlatesh - The High Minaret", "The city's tallest minaret offers a dizzying climb to a balcony where the muezzin calls the hours, and from which the whole oasis lies green and small below while the Sahra Wastes run gold to every edge of the trembling world. The only way is back east.", Dir::West), + ]); + + // ---- The Sahra Wastes (12 rooms): the deep desert south of Matlatesh + let last = add_wing(rooms, "The Sahra Wastes", false, 724, Dir::South, 740, &[ + wr("The Sahra Wastes - The Last Well", "South of the city walls the green ends with a single brick-ringed well, the last sure water before the Sahra Wastes proper, where camel-bones and prayer-rags mark the spot at which sensible travellers turn back. The dunes roll away gold and silent and enormous. South, and Matlatesh lies north.", Dir::South), + wr("The Sahra Wastes - The Singing Dunes", "Mountainous dunes march to every horizon, and when the wind crests them they sing in a deep booming moan that you feel in your chest before you hear it, a sound like the desert mourning something vast and long-buried. Your footprints fill behind you as you walk. North and south.", Dir::South), + wr("The Sahra Wastes - The Sun-Bleached Caravan", "A whole caravan lies preserved and abandoned in the lee of a dune, camels and crates and curl-toed slippers all sandblasted to the same pale gold, the traders sitting yet around a fire that went out a hundred years ago. Nothing has decayed; it has only dried. North and south.", Dir::South), + wr("The Sahra Wastes - The Glass Crater", "A circle of desert has been fused to green glass, smooth and warm and cracked into a vast mosaic, the relic of some ancient fury fallen from the sky, and at its center the glass is darkest and the heat-shimmer hardest, hiding what lies beneath. North and south.", Dir::South), + wr("The Sahra Wastes - The Bone Oasis", "A dead oasis: a dry stone basin ringed by the petrified stumps of palms, the water long gone, the place now only a graveyard where the desert's wanderers crawled to die in the memory of shade. The wind moves the sand like slow water. North and south.", Dir::South), + wr("The Sahra Wastes - The Buried Colossus", "One vast stone hand and the crown of a serene carved face break the surface of the sand, all that shows of a buried colossus whose full size the dunes will never give up, gazing up forever at a sky that has long since forgotten it. North and south.", Dir::South), + wr("The Sahra Wastes - The Scorpion Flats", "A hard, cracked pan of baked clay stretches between the dunes, and the ground itself seems to seethe, for it is carpeted with scorpions of every size, parting reluctantly before your boots and closing again behind. The heat here is a physical weight. North and south.", Dir::South), + wr("The Sahra Wastes - The Mirage Lake", "A wide and shimmering lake lies dead ahead, blue and cool and crowded with palms, and it retreats exactly as fast as you advance, for it is no lake at all but the desert's cruelest lie told in light and heat to the thirsty. North and south.", Dir::South), + wr("The Sahra Wastes - The Sandstorm Wall", "A wall of ochre cloud towers on the southern horizon and rolls steadily nearer, a sandstorm that will flay the skin from the bone of anything caught in the open, and the only shelter is the dark slot of a canyon ahead. North and south.", Dir::South), + wr("The Sahra Wastes - The Tomb-Canyon", "A slot canyon cuts down through the bedrock, its walls honeycombed with the carved doorways of a thousand desert tombs, their seals broken, their dark mouths breathing out cool air and the dry whisper of disturbed dust. North and south.", Dir::South), + wr("The Sahra Wastes - The Hall of the Dune-Kings", "The canyon opens into a pillared hall hewn from the living rock, lined with the seated stone statues of the old dune-kings, their painted eyes somehow still bright, watching the intruder come down the long aisle toward the dark at its end. North and south.", Dir::South), + wr("The Sahra Wastes - The Sand-Wyrm's Maw", "The hall ends above a vast funnel of softly sliding sand, and as your shadow falls across it the whole pit erupts, and the Sand-Wyrm of the Sahra rears its city-swallowing bulk into the light, ringed mouth wide, very glad you came. The only way back is north.", Dir::South), + ]); + mob(spawns, "a giant desert scorpion", 746, 56, 12, 64, false, COMMON_LOOT, p(D::Poison, Some(D::Fire), Some(D::Frost))); + mob(spawns, "a sun-dried husk", 743, 60, 12, 68, false, COMMON_LOOT, p(D::Physical, Some(D::Fire), Some(D::Frost))); + mob(spawns, "a tomb-canyon ghoul", 749, 68, 13, 76, false, COMMON_LOOT, p(D::Shadow, Some(D::Fire), Some(D::Holy))); + mob(spawns, "the Sand-Wyrm of the Sahra", last, 340, 25, 460, true, &[1009, 1205, 1401], p(D::Physical, Some(D::Fire), Some(D::Frost))); + + // ---- The Amber Savanna (9 rooms): grassland east of the Sahra ------- + let last = add_wing(rooms, "The Amber Savanna", false, 746, Dir::East, 760, &[ + wr("The Amber Savanna - The Grass Sea", "East of the deep desert the dunes give way to a rolling sea of amber grass, shoulder-high and whispering, broken only by the flat green crowns of solitary acacia trees standing like sentinels on the swells. The horizon is impossibly wide. East, and the Sahra lies west.", Dir::East), + wr("The Amber Savanna - The Acacia Stand", "A loose grove of thorn-trees offers the only shade for miles, their crowns alive with weaver-birds and their trunks scored by the horns and claws of beasts that come to scratch. The grass beneath is cropped short and littered with old bones. East and west.", Dir::East), + wr("The Amber Savanna - The Watering Hole", "A muddy waterhole draws the life of the whole savanna to its banks in a wary, jostling truce, hoofprints and pawprints churned together in the mud, and just now the silence and the absolute stillness of the herd say a hunter is very close. East and west.", Dir::East), + wr("The Amber Savanna - The Migration Trail", "A broad trail beaten bare by the passage of countless hooves runs across the grassland, and the very ground trembles faintly with the memory or the approach of the great herds, the dust of their passing hanging gold and immense on the air. East and west.", Dir::East), + wr("The Amber Savanna - The Termite Cathedrals", "Spires of red mud rear twice the height of a man across the plain, the cathedrals of the termites, hard as fired brick and riddled within by a numberless industrious dark. Something larger has hollowed one out to make a lair. East and west.", Dir::East), + wr("The Amber Savanna - The Baobab of Bones", "A single colossal baobab stands alone, ancient beyond reckoning, its swollen trunk hollowed into a chamber and its branches hung with the bleached skulls of beasts and men alike, an oracle-tree, a charnel-tree, a place of old and bloody power. East and west.", Dir::East), + wr("The Amber Savanna - The Scorched Plain", "A wide swath of the savanna has burned recently to black stubble and white ash, still ticking with heat, the new green only just spearing up through the char, and the predators work the open ground here where nothing can hide. East and west.", Dir::East), + wr("The Amber Savanna - The Lion-Throne Kopje", "A pile of great sun-warmed boulders rises from the plain like a natural throne, and from its summit the savanna stretches gold to every edge of the sky, the perfect seat for the apex of all this teeming land to survey its domain. East and west.", Dir::East), + wr("The Amber Savanna - The Pride's Reckoning", "The grass opens into a trampled arena ringed by kopje-rock, and here the great Maned Terror of the savanna and its pride rise from the shade as one, unhurried and certain, to deal with the small upright thing that has walked so boldly into the open. The only way back is west.", Dir::East), + ]); + mob(spawns, "a savanna hyena", 761, 54, 12, 62, false, COMMON_LOOT, p(D::Physical, None, Some(D::Fire))); + mob(spawns, "a stampeding bull", 763, 64, 13, 70, false, COMMON_LOOT, p(D::Physical, None, None)); + mob(spawns, "a baobab oracle-shade", 765, 66, 13, 74, false, COMMON_LOOT, p(D::Shadow, Some(D::Physical), Some(D::Holy))); + mob(spawns, "the Maned Terror", last, 320, 24, 430, true, &[1007, 1202, 1304], p(D::Physical, None, Some(D::Fire))); + + // ---- The Skyreach Mesas (8 rooms): high red-rock country ------------ + let last = add_wing(rooms, "The Skyreach Mesas", false, 765, Dir::North, 780, &[ + wr("The Skyreach Mesas - The Red Ascent", "North of the savanna the land buckles upward into towering mesas of banded red rock, and a switchback trail climbs the first of them through layers of stone laid down before the world had any names, the air thinning and cooling with every turn. North, and the grass lies south.", Dir::North), + wr("The Skyreach Mesas - The Hoodoo Forest", "A forest of slender rock spires, balanced impossibly with great boulders for caps, stands carved by ten thousand years of wind, and they cast long strange shadows that seem to shift and lean when you are not looking straight at them. North and south.", Dir::North), + wr("The Skyreach Mesas - The Cliff-Dwellings", "An entire abandoned city is built into the sheer face of the mesa, room stacked on room in the cool shade of an overhang, reached by ladders long since rotted away, its grindstones and painted pots all left mid-task an age ago. North and south.", Dir::North), + wr("The Skyreach Mesas - The Wind-Bridge", "A natural arch of red stone spans a dizzying gulf between two mesas, narrow and railless and humming faintly in the perpetual wind, with a fall on either hand long enough to leave a body time for serious reflection. North and south.", Dir::North), + wr("The Skyreach Mesas - The Thunderbird Eyrie", "The trail passes beneath a ledge heaped with an enormous nest of whole tree-trunks and sun-bleached bones, and the very rock is scorched in long forking patterns, for this is the eyrie of the thunderbird, and the sky to the north growls in warning. North and south.", Dir::North), + wr("The Skyreach Mesas - The Petroglyph Gallery", "A long sheltered wall is covered floor to unreachable ceiling in spiraling petroglyphs - suns, beasts, falling stars, and figures with too many arms - a history or a warning pecked into the rock by hands no one remembers. North and south.", Dir::North), + wr("The Skyreach Mesas - The Sky-Altar Approach", "The trail narrows toward the summit along a knife-edge of red stone, the world falling away on both sides into blue distance, the wind shoving at you with real intent, and ahead the flat crown of the highest mesa waits open to the whole roaring sky. North and south.", Dir::North), + wr("The Skyreach Mesas - The Roof of the World", "The trail tops out on the flat summit of the highest mesa, an altar-stone at its center and nothing above but sky, and as your shadow falls across the altar the Thunderbird stoops from the sun itself, vast and crackling, to defend the roof of the world. The only way down is south.", Dir::North), + ]); + mob(spawns, "a cliff-stalking puma", 781, 58, 13, 66, false, COMMON_LOOT, p(D::Physical, None, Some(D::Lightning))); + mob(spawns, "a hoodoo rock-wight", 784, 66, 13, 72, false, COMMON_LOOT, p(D::Physical, Some(D::Physical), Some(D::Frost))); + mob(spawns, "a storm-touched roc", 786, 72, 14, 80, false, COMMON_LOOT, p(D::Lightning, Some(D::Lightning), Some(D::Frost))); + mob(spawns, "the Thunderbird", last, 330, 25, 450, true, &[1008, 1205, 1304], p(D::Lightning, Some(D::Lightning), Some(D::Frost))); +} + /// Common low-tier drop pool shared by wandering wing mobs. const COMMON_LOOT: &[u32] = &[1000, 1100, 1103, 1300]; @@ -2951,7 +3306,8 @@ mod tests { #[test] fn world_has_expected_size_and_every_mob_homes_to_a_real_room() { let world = seed_world(); - assert_eq!(world.rooms.len(), 198, "expected 198 authored rooms"); + // 198 base + extension rooms, plus the 100 new overworld rooms. + assert_eq!(world.rooms.len(), 298, "expected 298 authored rooms"); for spawn in &world.spawns { assert!( world.rooms.contains_key(&spawn.home), @@ -3040,4 +3396,67 @@ mod tests { "some rooms are unreachable from the start room" ); } + + #[test] + fn overworld_adds_one_hundred_new_rooms() { + let world = seed_world(); + let new_rooms = world.rooms.keys().filter(|id| **id >= 600).count(); + assert_eq!(new_rooms, 100, "expected exactly 100 new overworld rooms (600+)"); + } + + #[test] + fn every_room_has_a_paragraph_description() { + // "A paragraph of detail" - every authored room reads as real prose, not + // a stub. The bar is a minimum length plus more than one sentence. + const MIN_CHARS: usize = 180; + let world = seed_world(); + let mut short: Vec<(RoomId, usize)> = world + .rooms + .values() + .filter(|r| { + let len = r.desc.chars().count(); + let sentences = r.desc.matches(['.', '!', '?']).count(); + len < MIN_CHARS || sentences < 2 + }) + .map(|r| (r.id, r.desc.chars().count())) + .collect(); + short.sort_unstable(); + assert!( + short.is_empty(), + "{} room(s) lack a paragraph-length description: {:?}", + short.len(), + short + ); + } + + #[test] + fn every_capital_has_a_fountain_and_a_plaque() { + let world = seed_world(); + for square in [TASMANIA_SQUARE, MELVANALA_SQUARE, MATLATESH_SQUARE] { + let room = world.room(square).expect("capital square exists"); + assert!(room.safe, "capital {square} must be a safe haven"); + let feats = features_at(square); + assert!( + feats.iter().any(|f| f.kind == FeatureKind::Fountain), + "capital {square} has no healing fountain" + ); + assert!( + feats.iter().any(|f| f.kind == FeatureKind::Plaque), + "capital {square} has no dedication plaque" + ); + } + } + + #[test] + fn every_feature_lives_in_a_real_room() { + let world = seed_world(); + for feature in FEATURES { + assert!( + world.rooms.contains_key(&feature.room), + "feature {:?} references missing room {}", + feature.name, + feature.room + ); + } + } } From 5c4933809d5b185e9e85e97734f98b13a5218c71 Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Fri, 5 Jun 2026 23:01:02 +1000 Subject: [PATCH 17/20] feat(lateania): flush characters to the DB on graceful shutdown Adds LateaniaService::flush_all and calls it from the server shutdown sequence next to the artboard and pinstar flushes, so an adventure in progress survives a clean restart with no loss. Previously up to one autosave interval (60s) could be lost when the server stopped between ticks; characters were never wiped (they reload from their last save), but recent progress could be. Saves are best-effort per character. Signed-off-by: Tony Hosaroygard --- late-ssh/src/app/door/lateania/svc.rs | 21 +++++++++++++++++++++ late-ssh/src/main.rs | 14 ++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/late-ssh/src/app/door/lateania/svc.rs b/late-ssh/src/app/door/lateania/svc.rs index 7a0faa20..dffd0e0f 100644 --- a/late-ssh/src/app/door/lateania/svc.rs +++ b/late-ssh/src/app/door/lateania/svc.rs @@ -518,6 +518,27 @@ impl LateaniaService { }); } + /// Persist every present character right now. Called on graceful server + /// shutdown so an adventure in progress is not lost to the gap between + /// autosaves; mirrors the artboard/pinstar shutdown flushes in main. Saves + /// are best-effort (each logs on failure), so this always returns Ok. + pub async fn flush_all(&self) -> anyhow::Result<()> { + let saves: Vec = { + let state = self.state.lock().await; + state + .export_all_saved() + .into_iter() + .map(|(user_id, saved)| self.prepare_persist(user_id, saved)) + .collect() + }; + let count = saves.len(); + for save in saves { + self.persist(save).await; + } + tracing::info!(count, "flushed lateania characters during shutdown"); + Ok(()) + } + pub fn choose_class_task(&self, user_id: Uuid, class: Class) { self.mutate(user_id, move |s| s.choose_class(user_id, class)); } diff --git a/late-ssh/src/main.rs b/late-ssh/src/main.rs index 30bd743a..9b724e9d 100644 --- a/late-ssh/src/main.rs +++ b/late-ssh/src/main.rs @@ -102,6 +102,19 @@ async fn flush_pinstar_diagrams(state: &State, fatal_error: &mut Option) { + match state.lateania_service.flush_all().await { + Ok(()) => tracing::info!("flushed lateania characters during shutdown"), + Err(err) => { + tracing::error!(error = ?err, "failed to flush lateania characters during shutdown"); + if fatal_error.is_none() { + *fatal_error = + Some(err.context("failed to flush lateania characters during shutdown")); + } + } + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { let _telemetry = late_core::telemetry::init_telemetry("late-ssh") @@ -589,6 +602,7 @@ async fn main() -> anyhow::Result<()> { } flush_dartboard_snapshot(&state, &mut fatal_error).await; flush_pinstar_diagrams(&state, &mut fatal_error).await; + flush_lateania_characters(&state, &mut fatal_error).await; session_shutdown.cancel(); if tokio::time::timeout(Duration::from_secs(6), async { From 15bd141adf786f28f4c1c55feb94d006eca37ffd Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Sun, 7 Jun 2026 01:56:18 +1000 Subject: [PATCH 18/20] feat(lateania): overhead minimap of the explored neighbourhood Adds a small automap to the bottom of the Room side-panel showing where you've been. The current room (@), visited rooms (o), unexplored exits (.), and the corridors between them are laid onto a 7x5-room grid centred on the player; up/down exits, which a flat map can't place, are noted as text. Each character now tracks a visited room set, seeded on spawn and extended on every move. The set persists with the character (save schema bumped to v3, serde-default so older saves load and simply start the map fresh). World::minimap BFS-walks exits out from the current room so the shortest path wins the world's occasional non-Euclidean clashes. Signed-off-by: Tony Hosaroygard --- late-ssh/src/app/door/lateania/persist.rs | 11 +- late-ssh/src/app/door/lateania/svc.rs | 19 +- late-ssh/src/app/door/lateania/ui.rs | 56 ++++++ late-ssh/src/app/door/lateania/world.rs | 203 +++++++++++++++++++++- 4 files changed, 286 insertions(+), 3 deletions(-) diff --git a/late-ssh/src/app/door/lateania/persist.rs b/late-ssh/src/app/door/lateania/persist.rs index a1c153a0..c97ac486 100644 --- a/late-ssh/src/app/door/lateania/persist.rs +++ b/late-ssh/src/app/door/lateania/persist.rs @@ -15,7 +15,7 @@ use super::classes::Class; use super::stats::AbilityScores; use super::world::RoomId; -const SCHEMA_VERSION: u32 = 2; +const SCHEMA_VERSION: u32 = 3; pub struct SavedCharacterInit { pub class: Option, @@ -24,6 +24,7 @@ pub struct SavedCharacterInit { pub gold: i64, pub hp: i32, pub room: RoomId, + pub visited: Vec, pub inventory: Vec, pub equipped: Vec<(String, u32)>, pub scores: AbilityScores, @@ -49,6 +50,10 @@ pub struct SavedCharacter { /// Room the character logged out in; reloaded here if it still exists. #[serde(default = "start_room")] pub room: RoomId, + /// Rooms the character has visited, for the overhead map. Empty for pre-v3 + /// saves, which simply start the map from wherever they reload. + #[serde(default)] + pub visited: Vec, #[serde(default)] pub inventory: Vec, /// Equipped items as (slot-key, item-id) pairs. @@ -80,6 +85,7 @@ impl SavedCharacter { gold: init.gold, hp: init.hp, room: init.room, + visited: init.visited, inventory: init.inventory, equipped: init.equipped, scores: init.scores, @@ -120,6 +126,7 @@ mod tests { gold: 560, hp: 42, room: 18, + visited: vec![1, 5, 18], inventory: vec![1300, 1301], equipped: vec![("weapon".to_string(), 1004)], scores, @@ -131,6 +138,7 @@ mod tests { assert_eq!(back.xp, 1234); assert_eq!(back.level, 7); assert_eq!(back.gold, 560); + assert_eq!(back.visited, vec![1, 5, 18]); assert_eq!(back.inventory, vec![1300, 1301]); assert_eq!(back.equipped, vec![("weapon".to_string(), 1004)]); assert_eq!(back.scores.dexterity, 16); @@ -151,6 +159,7 @@ mod tests { assert_eq!(c.class(), Some(Class::Mage)); assert_eq!(c.level, 1); assert_eq!(c.room, 1); + assert!(c.visited.is_empty()); assert!(c.inventory.is_empty()); } } diff --git a/late-ssh/src/app/door/lateania/svc.rs b/late-ssh/src/app/door/lateania/svc.rs index dffd0e0f..cb1f2b1a 100644 --- a/late-ssh/src/app/door/lateania/svc.rs +++ b/late-ssh/src/app/door/lateania/svc.rs @@ -35,7 +35,7 @@ use super::damage::{DamageType, Defense}; use super::items::{ItemKind, Slot, item, shop_at}; use super::persist::{SavedCharacter, SavedCharacterInit}; use super::stats::AbilityScores; -use super::world::{Dir, FeatureKind, MobSpawn, RoomId, World, features_at, seed_world}; +use super::world::{Dir, FeatureKind, MiniMap, MobSpawn, RoomId, World, features_at, seed_world}; /// World heartbeat. One combat round resolves per tick. const TICK_SECS: u64 = 2; @@ -199,6 +199,8 @@ pub struct PlayerView { pub resurrection_cap: u8, /// Lookable things in the current room (Examine panel). pub features: Vec, + /// Overhead map of the explored neighbourhood around the player. + pub minimap: MiniMap, } impl PlayerView { @@ -240,6 +242,7 @@ impl PlayerView { resurrections_left: 0, resurrection_cap: 0, features: Vec::new(), + minimap: MiniMap::default(), } } } @@ -682,6 +685,8 @@ struct PlayerState { level: i32, gold: i64, room: RoomId, + /// Every room this character has stood in, for the overhead map. + visited: HashSet, target: Option, /// True from engaging until the first auto-attack lands (Rogue opening crit). opening_strike: bool, @@ -818,6 +823,7 @@ impl WorldState { level: 1, gold: STARTING_GOLD, room: start, + visited: HashSet::from([start]), target: None, opening_strike: false, empower: 0, @@ -948,6 +954,8 @@ impl WorldState { p.resource_regen = stats.resource_regen; p.base_attack = stats.attack; p.room = room; + p.visited = saved.visited.iter().copied().collect(); + p.visited.insert(room); p.inventory = saved .inventory .iter() @@ -996,6 +1004,11 @@ impl WorldState { gold: p.gold, hp: p.hp.max(1), room: p.room, + visited: { + let mut rooms: Vec = p.visited.iter().copied().collect(); + rooms.sort_unstable(); + rooms + }, inventory: p.inventory.clone(), equipped, scores: p.scores, @@ -1055,6 +1068,7 @@ impl WorldState { }; if let Some(player) = self.players.get_mut(&user_id) { player.room = dest; + player.visited.insert(dest); } self.describe_room(user_id); } @@ -2233,6 +2247,8 @@ impl WorldState { }) .collect(); + let minimap = self.world.minimap(player.room, &player.visited, 3, 2); + players.insert( *user_id, PlayerView { @@ -2272,6 +2288,7 @@ impl WorldState { resurrections_left: player.resurrections_left, resurrection_cap: player.resurrection_cap, features, + minimap, }, ); } diff --git a/late-ssh/src/app/door/lateania/ui.rs b/late-ssh/src/app/door/lateania/ui.rs index e8189361..96c8fb66 100644 --- a/late-ssh/src/app/door/lateania/ui.rs +++ b/late-ssh/src/app/door/lateania/ui.rs @@ -19,6 +19,7 @@ use super::{ classes::Class, state::{Panel, State}, svc::{LogKind, PlayerView}, + world::{MapCell, MiniMap}, }; const SIDE_WIDE: u16 = 34; @@ -291,9 +292,64 @@ fn room_panel(view: &PlayerView, usernames: &UsernameLookup<'_>) -> Vec Vec> { + if map.grid.is_empty() { + return Vec::new(); + } + let mut lines = vec![section("Map")]; + for row in &map.grid { + let mut spans = vec![Span::raw(" ")]; + spans.extend(row.iter().map(|cell| map_cell_span(*cell))); + lines.push(Line::from(spans)); + } + // Vertical exits can't sit on a flat map; note them in words instead. + let mut stairs = Vec::new(); + if map.up { + stairs.push("up"); + } + if map.down { + stairs.push("down"); + } + if !stairs.is_empty() { + lines.push(Line::from(Span::styled( + format!(" stairs: {}", stairs.join(", ")), + Style::default().fg(theme::TEXT_DIM()), + ))); + } + lines.push(Line::from(Span::styled( + " @=you o=seen .=new", + Style::default().fg(theme::TEXT_FAINT()), + ))); lines } +/// One char-cell of the minimap, styled by what it represents. +fn map_cell_span(cell: MapCell) -> Span<'static> { + let (glyph, color) = match cell { + MapCell::Empty => (' ', theme::TEXT_FAINT()), + MapCell::Current => ('@', theme::AMBER_GLOW()), + MapCell::Visited => ('o', theme::AMBER_DIM()), + MapCell::Frontier => ('.', theme::TEXT_FAINT()), + MapCell::ConnH => ('-', theme::BORDER()), + MapCell::ConnV => ('|', theme::BORDER()), + MapCell::ConnSlash => ('/', theme::BORDER()), + MapCell::ConnBack => ('\\', theme::BORDER()), + MapCell::ConnCross => ('X', theme::BORDER()), + }; + let mut style = Style::default().fg(color); + if cell == MapCell::Current { + style = style.add_modifier(Modifier::BOLD); + } + Span::styled(glyph.to_string(), style) +} + fn character_panel(view: &PlayerView) -> Vec> { let mut lines = vitals(view); lines.push(Line::raw("")); diff --git a/late-ssh/src/app/door/lateania/world.rs b/late-ssh/src/app/door/lateania/world.rs index 6ec82b8b..f3e3447b 100644 --- a/late-ssh/src/app/door/lateania/world.rs +++ b/late-ssh/src/app/door/lateania/world.rs @@ -18,7 +18,7 @@ // future TOML/RON loader will produce. The current authored world has 198 rooms; // the planned full design target remains 200. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet, VecDeque}; use super::damage::{DamageProfile, DamageType}; @@ -82,6 +82,22 @@ impl Dir { Self::Down => Self::Up, } } + + /// Offset on the overhead map, in (east+, south+) grid steps. Vertical exits + /// (up/down) have no place on a flat map and return `None`. + pub fn delta_2d(self) -> Option<(i32, i32)> { + Some(match self { + Self::North => (0, -1), + Self::South => (0, 1), + Self::East => (1, 0), + Self::West => (-1, 0), + Self::Northeast => (1, -1), + Self::Northwest => (-1, -1), + Self::Southeast => (1, 1), + Self::Southwest => (-1, 1), + Self::Up | Self::Down => return None, + }) + } } pub type RoomId = u32; @@ -130,6 +146,136 @@ impl World { pub fn room(&self, id: RoomId) -> Option<&Room> { self.rooms.get(&id) } + + /// Build an overhead minimap centred on `current`, spanning `hr` rooms east + /// and west and `vr` rooms north and south. Visited rooms are drawn solid; + /// an unvisited room one step from a drawn room becomes a faint frontier + /// marker so the player can see where there is still to explore. Up/down + /// exits can't be placed on a flat plane, so they're reported as flags. + pub fn minimap(&self, current: RoomId, visited: &HashSet, hr: i32, vr: i32) -> MiniMap { + // 1. Lay visited rooms onto an integer grid by walking exits out from the + // current room. BFS, so the shortest path to each room wins any clash + // that the world's non-Euclidean geometry might otherwise create. + let mut coords: HashMap = HashMap::new(); + coords.insert(current, (0, 0)); + let mut queue = VecDeque::from([current]); + while let Some(rid) = queue.pop_front() { + let (x, y) = coords[&rid]; + let Some(room) = self.room(rid) else { continue }; + for (dir, &dest) in &room.exits { + let Some((dx, dy)) = dir.delta_2d() else { + continue; + }; + let (nx, ny) = (x + dx, y + dy); + if nx.abs() > hr || ny.abs() > vr { + continue; + } + if !visited.contains(&dest) || coords.contains_key(&dest) { + continue; + } + coords.insert(dest, (nx, ny)); + queue.push_back(dest); + } + } + + // 2. Paint rooms, corridors, and frontier markers. The char grid + // interleaves room cells (even indices) with connector cells (odd), + // so a (2hr+1) x (2vr+1) room viewport becomes a (4hr+1) x (4vr+1) grid. + let gw = (2 * hr + 1) as usize * 2 - 1; + let gh = (2 * vr + 1) as usize * 2 - 1; + let mut grid = vec![vec![MapCell::Empty; gw]; gh]; + let to_cell = |x: i32, y: i32| (((y + vr) * 2) as usize, ((x + hr) * 2) as usize); + + for (&rid, &(x, y)) in &coords { + let (r, c) = to_cell(x, y); + grid[r][c] = if rid == current { + MapCell::Current + } else { + MapCell::Visited + }; + } + + for (&rid, &(x, y)) in &coords { + let Some(room) = self.room(rid) else { continue }; + let (r, c) = to_cell(x, y); + for (dir, &dest) in &room.exits { + let Some((dx, dy)) = dir.delta_2d() else { + continue; + }; + let (nx, ny) = (x + dx, y + dy); + if nx.abs() > hr || ny.abs() > vr { + continue; + } + let (nr, nc) = to_cell(nx, ny); + draw_connector(&mut grid[(r + nr) / 2][(c + nc) / 2], dx, dy); + // A corridor leaving the visited set points at somewhere new. + if !coords.contains_key(&dest) && grid[nr][nc] == MapCell::Empty { + grid[nr][nc] = MapCell::Frontier; + } + } + } + + let exits = self.room(current).map(|room| &room.exits); + MiniMap { + grid, + up: exits.is_some_and(|e| e.contains_key(&Dir::Up)), + down: exits.is_some_and(|e| e.contains_key(&Dir::Down)), + } + } +} + +/// What a single char-cell of the overhead minimap shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MapCell { + /// Nothing drawn here. + Empty, + /// The room the player is standing in. + Current, + /// A room the player has already visited. + Visited, + /// An unvisited room one step from somewhere visited - left to explore. + Frontier, + /// A horizontal corridor (`-`). + ConnH, + /// A vertical corridor (`|`). + ConnV, + /// A `/` corridor (northeast/southwest). + ConnSlash, + /// A `\` corridor (northwest/southeast). + ConnBack, + /// Where two diagonal corridors cross (`X`). + ConnCross, +} + +/// A small overhead map of the explored neighbourhood, ready to paint in the +/// side panel. `grid[row][col]` is a char-cell; `up`/`down` flag vertical exits +/// from the current room that a flat map cannot draw. +#[derive(Clone, Debug, Default)] +pub struct MiniMap { + pub grid: Vec>, + pub up: bool, + pub down: bool, +} + +/// Lay a corridor glyph into a connector cell, merging crossing diagonals into +/// an `X`. Room cells and matching prior corridors are left untouched. +fn draw_connector(cell: &mut MapCell, dx: i32, dy: i32) { + let drawn = if dx == 0 { + MapCell::ConnV + } else if dy == 0 { + MapCell::ConnH + } else if dx == dy { + MapCell::ConnBack + } else { + MapCell::ConnSlash + }; + *cell = match (*cell, drawn) { + (MapCell::Empty, glyph) => glyph, + (MapCell::ConnSlash, MapCell::ConnBack) | (MapCell::ConnBack, MapCell::ConnSlash) => { + MapCell::ConnCross + } + (existing, _) => existing, + }; } // ---- Lookable room features (the "look at things" layer) ------------------ @@ -3459,4 +3605,59 @@ mod tests { ); } } + + #[test] + fn minimap_centres_on_the_player_and_reveals_frontiers() { + let world = seed_world(); + let start = world.start_room; + // Only the start room is visited: it sits dead centre, and at least one + // unexplored exit shows up as a frontier marker. + let visited = HashSet::from([start]); + let map = world.minimap(start, &visited, 3, 2); + let centre = (map.grid.len() / 2, map.grid[0].len() / 2); + assert_eq!(map.grid[centre.0][centre.1], MapCell::Current); + let frontiers = map + .grid + .iter() + .flatten() + .filter(|c| **c == MapCell::Frontier) + .count(); + assert!(frontiers >= 1, "the start room should reveal somewhere to go"); + } + + #[test] + fn minimap_draws_a_corridor_between_visited_rooms() { + let world = seed_world(); + let start = world.start_room; + let neighbour = world + .room(start) + .unwrap() + .exits + .iter() + .filter(|(dir, _)| dir.delta_2d().is_some()) + .map(|(_, dest)| *dest) + .next() + .expect("start has a planar exit"); + let visited = HashSet::from([start, neighbour]); + let map = world.minimap(start, &visited, 3, 2); + let visited_cells = map + .grid + .iter() + .flatten() + .filter(|c| **c == MapCell::Visited) + .count(); + assert!(visited_cells >= 1, "the visited neighbour should be drawn"); + let corridors = map + .grid + .iter() + .flatten() + .filter(|c| { + matches!( + c, + MapCell::ConnH | MapCell::ConnV | MapCell::ConnSlash | MapCell::ConnBack + ) + }) + .count(); + assert!(corridors >= 1, "a corridor should join the two rooms"); + } } From 758bf3242a8fbf22725d6305854298083bafbe57 Mon Sep 17 00:00:00 2001 From: Tony Hosaroygard Date: Sun, 7 Jun 2026 03:34:53 +1000 Subject: [PATCH 19/20] fix(lateania): show the up/down stair keys so players can descend The world has vertical exits (15 down, 6 up) and the < > / , . keys already move through them, but the Commands panel only listed move, diagonals, and attack - so when a room had a 'down' exit players had no idea which key to press. Add a contextual hint that names the stair keys exactly when the current room has a way up or down. Also corrects the stale key-scheme comment (diagonals are y/u/n/m, not y/u/b/n - b is the shop key). Signed-off-by: Tony Hosaroygard --- late-ssh/src/app/door/lateania/input.rs | 3 ++- late-ssh/src/app/door/lateania/ui.rs | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/late-ssh/src/app/door/lateania/input.rs b/late-ssh/src/app/door/lateania/input.rs index 09ff2024..95b38750 100644 --- a/late-ssh/src/app/door/lateania/input.rs +++ b/late-ssh/src/app/door/lateania/input.rs @@ -2,7 +2,8 @@ // // Key scheme: // - Before choosing a class: 1-5 pick Warrior/Mage/Cleric/Rogue/Ranger. -// - Movement: w/a/s/d and arrows (N/S/E/W); y/u/b/n diagonals; < > up/down. +// - Movement: w/a/s/d and arrows (N/S/E/W); y/u/n/m diagonals; < or , up and +// > or . down (also shown as a hint in-game when a room has a vertical exit). // - Combat: space/x attack; 1-9 use the ability in that action-bar slot; z flee. // - Panels: c character, v abilities, o look, b shop, t inventory ("things"). // In a list panel, 1-9 select a row, Enter activates (equip/use/buy), diff --git a/late-ssh/src/app/door/lateania/ui.rs b/late-ssh/src/app/door/lateania/ui.rs index 96c8fb66..33f89602 100644 --- a/late-ssh/src/app/door/lateania/ui.rs +++ b/late-ssh/src/app/door/lateania/ui.rs @@ -19,7 +19,7 @@ use super::{ classes::Class, state::{Panel, State}, svc::{LogKind, PlayerView}, - world::{MapCell, MiniMap}, + world::{Dir, MapCell, MiniMap}, }; const SIDE_WIDE: u16 = 34; @@ -600,6 +600,17 @@ fn footer_hints(view: &PlayerView) -> Vec> { } else { lines.push(hint("wasd/arrows", "move")); lines.push(hint("yunm", "diagonals")); + // Vertical exits aren't on the wasd/diagonal keys, so spell out the + // stair keys - but only when this room actually has a way up or down, + // so the hint appears exactly when the player needs it. + let has_up = view.exits.iter().any(|(dir, _)| *dir == Dir::Up); + let has_down = view.exits.iter().any(|(dir, _)| *dir == Dir::Down); + match (has_up, has_down) { + (true, true) => lines.push(hint("< >", "climb up / go down")), + (true, false) => lines.push(hint("<", "climb up")), + (false, true) => lines.push(hint(">", "go down")), + (false, false) => {} + } lines.push(hint("space", "attack o look at things")); } lines.push(hint("c v t", "sheet abilities bag")); From 9d38d60a0edae0a0f60727c16bbe3bc9d1a153a2 Mon Sep 17 00:00:00 2001 From: mateuszpiorowski Date: Sun, 7 Jun 2026 21:24:01 +0200 Subject: [PATCH 20/20] update --- late-ssh/src/app/door/lateania/input.rs | 11 +++++++---- late-ssh/src/app/door/lateania/svc.rs | 1 + late-ssh/src/app/door/lateania/ui.rs | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/late-ssh/src/app/door/lateania/input.rs b/late-ssh/src/app/door/lateania/input.rs index 95b38750..275fecea 100644 --- a/late-ssh/src/app/door/lateania/input.rs +++ b/late-ssh/src/app/door/lateania/input.rs @@ -8,7 +8,7 @@ // - Panels: c character, v abilities, o look, b shop, t inventory ("things"). // In a list panel, 1-9 select a row, Enter activates (equip/use/buy), // w/s move the cursor, x sells (inventory). -// - Esc / q leave the world. +// - Esc leaves the world for the Door Games lobby. // // A full typed command prompt needs an input-capture mode; deferred. @@ -26,8 +26,8 @@ pub enum InputAction { } pub fn handle_key(state: &mut State, byte: u8) -> InputAction { - // Quit is always available. - if matches!(byte, 0x1B | b'q' | b'Q') { + // Active Door games reserve Esc for returning to the Door Games lobby. + if byte == 0x1B { return InputAction::Leave; } @@ -193,7 +193,10 @@ fn select_row(state: &mut State, target: usize) { } pub fn handle_arrow(state: &mut State, key: u8) -> bool { - let in_list = matches!(state.panel(), Panel::Inventory | Panel::Shop); + let in_list = matches!( + state.panel(), + Panel::Inventory | Panel::Shop | Panel::Examine + ); match key { b'A' => { if in_list { diff --git a/late-ssh/src/app/door/lateania/svc.rs b/late-ssh/src/app/door/lateania/svc.rs index 23d79b89..b8e9c0ac 100644 --- a/late-ssh/src/app/door/lateania/svc.rs +++ b/late-ssh/src/app/door/lateania/svc.rs @@ -1921,6 +1921,7 @@ impl WorldState { Some((dir, dest)) => { if let Some(player) = self.players.get_mut(&user_id) { player.room = dest; + player.visited.insert(dest); } self.log_to( user_id, diff --git a/late-ssh/src/app/door/lateania/ui.rs b/late-ssh/src/app/door/lateania/ui.rs index 33f89602..73c20b07 100644 --- a/late-ssh/src/app/door/lateania/ui.rs +++ b/late-ssh/src/app/door/lateania/ui.rs @@ -617,7 +617,7 @@ fn footer_hints(view: &PlayerView) -> Vec> { if view.shop.is_some() { lines.push(hint("b", "shop")); } - lines.push(hint("q", "leave")); + lines.push(hint("Esc", "leave")); lines }