From 5a1d4b70598d9126463aa1f11a6c100b863f030f Mon Sep 17 00:00:00 2001
From: Demi Dev Unit <131922119+jackandcarter@users.noreply.github.com>
Date: Sun, 23 Nov 2025 02:56:12 -0600
Subject: [PATCH] Mirror dungeon definitions in web lobby
---
webapp/README.md | 16 +-
webapp/public/index.html | 131 +++++++++
webapp/public/main.js | 328 ++++++++++++++++++++++
webapp/public/styles.css | 182 ++++++++++++
webapp/src/app.ts | 11 +
webapp/src/routes/lobby.ts | 7 +-
webapp/src/routes/sessions.ts | 3 +-
webapp/src/services/difficulties.ts | 66 +++++
webapp/src/services/floor-room-rules.ts | 33 +++
webapp/src/services/game-engine.ts | 204 +++++++++-----
webapp/src/services/session-serializer.ts | 44 +--
webapp/src/services/types.ts | 37 ++-
12 files changed, 962 insertions(+), 100 deletions(-)
create mode 100644 webapp/public/index.html
create mode 100644 webapp/public/main.js
create mode 100644 webapp/public/styles.css
create mode 100644 webapp/src/services/difficulties.ts
create mode 100644 webapp/src/services/floor-room-rules.ts
diff --git a/webapp/README.md b/webapp/README.md
index ec191e3..2fcf561 100644
--- a/webapp/README.md
+++ b/webapp/README.md
@@ -37,17 +37,27 @@ This service ports the Discord AdventureBot experience to a standalone web stack
- `GET /health` returns an immediate OK payload to verify the process is running.
- `GET /ready` performs a lightweight database ping against the configured MariaDB instance.
-## Game session prototype
+## In-browser lobby prototype
+You can now try the lobby and session flows directly from the server without extra tooling. Start the dev server (`npm run dev` from `webapp/`) and open [http://localhost:3000](http://localhost:3000) to load a static UI that exercises the APIs:
+
+- Create new lobbies with difficulty, join rules, optional passwords, and max player counts.
+- Difficulty options are loaded from the database seed values (Easy, Medium, Hard, Crazy Catto) instead of hard-coded lists.
+- View the list of active rooms, copy session IDs, and prefill the join inspector.
+- Post lobby chat messages (optionally tagged to a session) and see the chat feed update.
+- Join rooms, then load a session to inspect players, recent log entries, and grid size.
+
+## Game session prototype APIs
Early web game APIs are exposed under `/api` and use in-memory state to start experimenting with browser gameplay flows:
-- `POST /api/sessions` — create a new dungeon run with an owner name and optional difficulty (`easy`, `normal`, `hard`). Returns the session ID and owner player ID.
+- `GET /api/difficulties` — surface the difficulty definitions mirrored from the database (Easy, Medium, Hard, Crazy Catto) with floor sizing, enemy chances, and basement tuning.
+- `POST /api/sessions` — create a new dungeon run with an owner name and optional difficulty (`easy`, `medium`, `hard`, `crazy_catto`). Returns the session ID and owner player ID.
- `POST /api/sessions/:sessionId/join` — join an existing run by session ID and player name. Responds with the joining player ID plus the updated session state.
- `GET /api/sessions/:sessionId` — fetch the latest session state (players, turn order, log, and grid size).
- `POST /api/sessions/:sessionId/actions/move` — move the active player north/south/east/west. Enforces turn order and appends a descriptive log entry for the discovered room type.
Sessions now support lobby-style controls: optional passwords, configurable maximum party size, and a toggle that allows or blocks mid-dungeon joins once a run begins. Live sessions report their status (`waiting` or `in_progress`), whether a password is required, and how many seats remain.
-## Lobby and cyber chat prototype
+## Lobby and cyber chat prototype APIs
The lobby surfaces an in-memory chat feed alongside the list of joinable rooms to help players coordinate:
- `GET /api/lobby` — fetch the current lobby snapshot (chat messages annotated with session summaries and the live room list).
diff --git a/webapp/public/index.html b/webapp/public/index.html
new file mode 100644
index 0000000..fcb7204
--- /dev/null
+++ b/webapp/public/index.html
@@ -0,0 +1,131 @@
+
+
+
+
+
+ AdventureBot Web Lobby
+
+
+
+
+
+
+
+
+
+ Dungeon settings (mirrors database)
+ Difficulties and room types come from AdventureBot's database tables, not hard-coded defaults.
+
+
+
+
+
+ Lobby Rooms
+ Refresh lobby
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Copy Session ID
+
+
+
+
+
+
diff --git a/webapp/public/main.js b/webapp/public/main.js
new file mode 100644
index 0000000..a5a64cd
--- /dev/null
+++ b/webapp/public/main.js
@@ -0,0 +1,328 @@
+const statusEl = document.querySelector('#status');
+const roomsEl = document.querySelector('#rooms');
+const chatEl = document.querySelector('#chat');
+const sessionViewEl = document.querySelector('#session');
+const createRoomResultEl = document.querySelector('#create-room-result');
+const joinResultEl = document.querySelector('#join-result');
+const difficultyCardsEl = document.querySelector('#difficulty-cards');
+const legendEl = document.querySelector('#legend');
+
+let difficultyDefinitions = [];
+
+const api = async (path, options = {}) => {
+ const response = await fetch(`/api${path}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ ...options,
+ });
+
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ const message = payload.message || `Request failed with status ${response.status}`;
+ throw new Error(message);
+ }
+
+ return payload;
+};
+
+const roomLegend = [
+ { type: 'entrance', label: 'Entrance', detail: 'Starting point for the party.' },
+ { type: 'safe', label: 'Safe', detail: 'Quiet corridors with no threats.' },
+ { type: 'monster', label: 'Monster', detail: 'Triggers combat using difficulty-scaled stats.' },
+ { type: 'boss', label: 'Boss', detail: 'Placed on the final floor as a capstone fight.' },
+ { type: 'item', label: 'Treasure', detail: 'Contains loot such as potions or keys.' },
+ { type: 'shop', label: 'Shop', detail: 'A wandering merchant appears.' },
+ { type: 'locked', label: 'Locked', detail: 'Requires a key obtained from treasure rooms.' },
+ { type: 'trap', label: 'Trap', detail: 'Deals damage on entry.' },
+ { type: 'illusion', label: 'Illusion', detail: 'Flavor rooms highlighting hidden passages.' },
+ { type: 'staircase_down', label: 'Stairs Down', detail: 'Descend to the next floor once cleared.' },
+ { type: 'staircase_up', label: 'Stairs Up', detail: 'Return toward safety.' },
+ { type: 'exit', label: 'Exit', detail: 'A future hook for leaving early.' },
+];
+
+const formatDifficulty = (key) => difficultyDefinitions.find((d) => d.key === key)?.name || key;
+
+const renderDifficultyCards = () => {
+ if (!difficultyCardsEl) return;
+ difficultyCardsEl.innerHTML = '';
+
+ difficultyDefinitions.forEach((def) => {
+ const card = document.createElement('div');
+ card.className = 'card';
+
+ card.innerHTML = `
+ ${def.name}
+ ${def.width}×${def.height} grid • ${def.minFloors}-${def.maxFloors} floors
+ Enemy chance ${Math.round(def.enemyChance * 100)}% · NPC slots ${def.npcCount}
+ Basement chance ${Math.round(def.basementChance * 100)}% (${def.basementMinRooms}-${def.basementMaxRooms} rooms)
+ `;
+
+ difficultyCardsEl.appendChild(card);
+ });
+};
+
+const renderLegend = () => {
+ if (!legendEl) return;
+ legendEl.innerHTML = '';
+ roomLegend.forEach((entry) => {
+ const node = document.createElement('div');
+ node.className = 'legend-item';
+ node.innerHTML = `${entry.label} ${entry.detail} `;
+ legendEl.appendChild(node);
+ });
+};
+
+const renderRooms = (rooms = []) => {
+ roomsEl.innerHTML = '';
+ if (!rooms.length) {
+ roomsEl.textContent = 'No rooms yet. Create one above to get started!';
+ return;
+ }
+
+ const template = document.querySelector('#room-template');
+
+ rooms.forEach((room) => {
+ const node = template.content.cloneNode(true);
+ node.querySelector('.room-owner').textContent = `${room.ownerName}'s lobby`;
+ node.querySelector('.room-meta').textContent = `${formatDifficulty(room.difficulty)} · ${room.playerCount}/${room.maxPlayers} players`;
+
+ const details = [];
+ details.push(room.status === 'waiting' ? 'Waiting to start' : 'In progress');
+ if (room.passwordProtected) {
+ details.push('Password required');
+ }
+ if (!room.allowJoinMidgame) {
+ details.push('Locks after start');
+ }
+
+ node.querySelector('.room-body').textContent = `${details.join(' • ')} (created ${new Date(
+ room.createdAt,
+ ).toLocaleTimeString()})`;
+
+ const btn = node.querySelector('.join-room');
+ btn.addEventListener('click', async () => {
+ await navigator.clipboard.writeText(room.sessionId);
+ alert(`Session ID copied: ${room.sessionId}`);
+ document.querySelector('#join-session-id').value = room.sessionId;
+ document.querySelector('#session-id').value = room.sessionId;
+ });
+
+ roomsEl.appendChild(node);
+ });
+};
+
+const renderChat = (messages = []) => {
+ chatEl.innerHTML = '';
+ messages
+ .slice()
+ .reverse()
+ .forEach((message) => {
+ const container = document.createElement('div');
+ container.className = 'message';
+ const meta = document.createElement('div');
+ meta.className = 'meta';
+ meta.textContent = `${message.author} • ${new Date(message.timestamp).toLocaleTimeString()}`;
+ if (message.sessionSummary) {
+ meta.textContent += ` • ${message.sessionSummary.ownerName}'s lobby`;
+ }
+ const body = document.createElement('div');
+ body.textContent = message.body;
+ container.append(meta, body);
+ chatEl.appendChild(container);
+ });
+};
+
+const renderSession = (state) => {
+ if (!state) {
+ sessionViewEl.textContent = 'Load a session to inspect its state.';
+ return;
+ }
+
+ const wrapper = document.createElement('div');
+ wrapper.className = 'session-summary';
+
+ const heading = document.createElement('h3');
+ heading.textContent = `${state.ownerName}'s ${formatDifficulty(state.difficulty)} run (${state.status})`;
+ wrapper.appendChild(heading);
+
+ const meta = document.createElement('div');
+ meta.className = 'muted';
+ meta.textContent = `${state.players.length}/${state.maxPlayers} players · started ${new Date(
+ state.createdAt,
+ ).toLocaleString()}`;
+ wrapper.appendChild(meta);
+
+ const players = document.createElement('div');
+ const list = document.createElement('ul');
+ state.players.forEach((player) => {
+ const li = document.createElement('li');
+ const isActive = state.turn?.currentPlayerId === player.id;
+ const prefix = isActive ? '➡️ ' : '';
+ li.textContent = `${prefix}${player.name} (${player.id.slice(0, 8)}) on floor ${player.floor + 1} at (${player.position.x}, ${
+ player.position.y
+ })`;
+ list.appendChild(li);
+ });
+ players.append('Players: ', list);
+ wrapper.appendChild(players);
+
+ const dungeonMeta = document.createElement('div');
+ const floor = state.dungeon.floors[state.dungeon.currentFloor];
+ dungeonMeta.textContent = `Floor ${state.dungeon.currentFloor + 1}/${state.dungeon.floors.length} · ${
+ floor.width
+ }×${floor.height}${floor.isBasement ? ' (basement)' : ''}`;
+ wrapper.appendChild(dungeonMeta);
+
+ const difficultyMeta = document.createElement('div');
+ difficultyMeta.className = 'muted';
+ difficultyMeta.textContent = `Grid ${state.difficultySettings.width}×${state.difficultySettings.height} · ${state.difficultySettings.minFloors}-${state.difficultySettings.maxFloors} floors`;
+ wrapper.appendChild(difficultyMeta);
+
+ const logHeading = document.createElement('h4');
+ logHeading.textContent = 'Recent events';
+ wrapper.appendChild(logHeading);
+
+ const logList = document.createElement('ul');
+ state.log
+ .slice()
+ .reverse()
+ .forEach((entry) => {
+ const li = document.createElement('li');
+ li.textContent = entry;
+ logList.appendChild(li);
+ });
+ wrapper.appendChild(logList);
+
+ const coords = document.createElement('div');
+ coords.textContent = `Grid size: ${state.gridSize} × ${state.gridSize}`;
+ wrapper.appendChild(coords);
+
+ sessionViewEl.innerHTML = '';
+ sessionViewEl.appendChild(wrapper);
+};
+
+const refreshLobby = async () => {
+ try {
+ statusEl.textContent = 'Loading lobby...';
+ const snapshot = await api('/lobby');
+ renderRooms(snapshot.rooms);
+ renderChat(snapshot.messages);
+ statusEl.textContent = `Lobby loaded at ${new Date().toLocaleTimeString()}`;
+ } catch (error) {
+ statusEl.textContent = error.message;
+ }
+};
+
+const populateDifficultySelect = () => {
+ const select = document.querySelector('#difficulty');
+ if (!select) return;
+ select.innerHTML = '';
+ difficultyDefinitions.forEach((difficulty) => {
+ const option = document.createElement('option');
+ option.value = difficulty.key;
+ option.textContent = difficulty.name;
+ select.appendChild(option);
+ });
+};
+
+const loadDifficulties = async () => {
+ try {
+ const { difficulties } = await api('/difficulties');
+ difficultyDefinitions = difficulties || [];
+ populateDifficultySelect();
+ renderDifficultyCards();
+ renderLegend();
+ } catch (error) {
+ statusEl.textContent = error.message;
+ }
+};
+
+const createRoomForm = document.querySelector('#create-room-form');
+createRoomForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const formData = new FormData(createRoomForm);
+ const payload = Object.fromEntries(formData.entries());
+ payload.allowJoinMidgame = formData.get('allowJoinMidgame') === 'on';
+ payload.maxPlayers = payload.maxPlayers ? Number(payload.maxPlayers) : undefined;
+ payload.password = payload.password || undefined;
+
+ try {
+ const result = await api('/lobby/rooms', {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ createRoomResultEl.textContent = `Lobby created! Session ID: ${result.sessionId}`;
+ document.querySelector('#join-session-id').value = result.sessionId;
+ document.querySelector('#session-id').value = result.sessionId;
+ refreshLobby();
+ } catch (error) {
+ createRoomResultEl.textContent = error.message;
+ }
+});
+
+const chatForm = document.querySelector('#chat-form');
+chatForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const formData = new FormData(chatForm);
+ const payload = {
+ author: formData.get('chat-author')?.toString() || '',
+ body: formData.get('chat-body')?.toString() || '',
+ sessionId: formData.get('chat-session')?.toString() || undefined,
+ };
+
+ try {
+ await api('/lobby/messages', { method: 'POST', body: JSON.stringify(payload) });
+ chatForm.reset();
+ refreshLobby();
+ } catch (error) {
+ statusEl.textContent = error.message;
+ }
+});
+
+const joinForm = document.querySelector('#join-form');
+joinForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const formData = new FormData(joinForm);
+ const sessionId = formData.get('join-session-id');
+ const payload = {
+ playerName: formData.get('join-name'),
+ password: formData.get('join-password') || undefined,
+ };
+
+ try {
+ const response = await api(`/sessions/${sessionId}/join`, {
+ method: 'POST',
+ body: JSON.stringify(payload),
+ });
+ joinResultEl.textContent = `Joined! Player ID: ${response.playerId}`;
+ refreshSession(sessionId);
+ refreshLobby();
+ } catch (error) {
+ joinResultEl.textContent = error.message;
+ }
+});
+
+const sessionForm = document.querySelector('#load-session-form');
+sessionForm?.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ const sessionId = new FormData(sessionForm).get('session-id');
+ refreshSession(sessionId);
+});
+
+const refreshSession = async (sessionId) => {
+ if (!sessionId) return;
+ try {
+ const response = await api(`/sessions/${sessionId}`);
+ renderSession(response.state);
+ } catch (error) {
+ sessionViewEl.textContent = error.message;
+ }
+};
+
+const refreshButton = document.querySelector('#refresh-lobby');
+refreshButton?.addEventListener('click', () => refreshLobby());
+
+loadDifficulties().then(() => refreshLobby());
+renderLegend();
+renderSession(null);
diff --git a/webapp/public/styles.css b/webapp/public/styles.css
new file mode 100644
index 0000000..a147b8f
--- /dev/null
+++ b/webapp/public/styles.css
@@ -0,0 +1,182 @@
+:root {
+ color-scheme: light dark;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+ line-height: 1.4;
+ padding: 0 1rem;
+}
+
+body {
+ max-width: 1200px;
+ margin: 0 auto 3rem;
+}
+
+header {
+ text-align: center;
+ padding: 1rem 0 0.5rem;
+}
+
+main {
+ display: grid;
+ gap: 1rem;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+}
+
+.panel {
+ border: 1px solid var(--border-color, #ccc);
+ border-radius: 10px;
+ padding: 1rem;
+ background: color-mix(in srgb, var(--panel-color, #f7f7f7), transparent 10%);
+ box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08);
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: 0.75rem;
+}
+
+.field.checkbox {
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+label {
+ font-weight: 600;
+ margin-bottom: 0.25rem;
+}
+
+input,
+select,
+button {
+ padding: 0.5rem;
+ border-radius: 6px;
+ border: 1px solid #ccc;
+ font-size: 1rem;
+}
+
+button {
+ cursor: pointer;
+ background: linear-gradient(90deg, #5e60ce, #64dfdf);
+ color: white;
+ border: none;
+ transition: transform 120ms ease, box-shadow 120ms ease;
+}
+
+button:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12);
+}
+
+.result {
+ min-height: 1.5rem;
+ margin-top: 0.5rem;
+ font-size: 0.9rem;
+}
+
+#rooms,
+#session {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.room-card {
+ border: 1px solid #ccc;
+ border-radius: 8px;
+ padding: 0.75rem;
+ background: rgba(100, 223, 223, 0.08);
+}
+
+.room-heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.room-meta {
+ font-size: 0.85rem;
+ opacity: 0.9;
+}
+
+.room-body {
+ margin: 0.5rem 0;
+ font-size: 0.95rem;
+}
+
+.scrollable {
+ max-height: 260px;
+ overflow: auto;
+ border: 1px solid #ccc;
+ border-radius: 8px;
+ padding: 0.5rem;
+ background: rgba(0, 0, 0, 0.02);
+}
+
+.message {
+ margin-bottom: 0.5rem;
+}
+
+.message .meta {
+ font-size: 0.85rem;
+ opacity: 0.8;
+}
+
+.session-summary {
+ font-size: 0.95rem;
+}
+
+.session-summary h3 {
+ margin: 0.25rem 0;
+}
+
+ul {
+ padding-left: 1.25rem;
+}
+
+.grid {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+
+.muted {
+ opacity: 0.8;
+ font-size: 0.95rem;
+}
+
+.legend {
+ margin-top: 0.75rem;
+ display: grid;
+ gap: 0.4rem;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+}
+
+.legend-item {
+ padding: 0.5rem;
+ border: 1px solid #d0d0d0;
+ border-radius: 6px;
+ background: rgba(0, 0, 0, 0.02);
+}
+
+.legend-item strong {
+ display: block;
+}
+
+.pill {
+ display: inline-block;
+ padding: 0.15rem 0.5rem;
+ border-radius: 999px;
+ font-size: 0.8rem;
+ background: rgba(94, 96, 206, 0.12);
+ border: 1px solid rgba(94, 96, 206, 0.25);
+ margin-right: 0.25rem;
+}
+
+.card {
+ border: 1px solid #d7d7d7;
+ border-radius: 8px;
+ padding: 0.75rem;
+ background: rgba(94, 96, 206, 0.05);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
+}
diff --git a/webapp/src/app.ts b/webapp/src/app.ts
index 754216f..964ec60 100644
--- a/webapp/src/app.ts
+++ b/webapp/src/app.ts
@@ -1,4 +1,6 @@
import express from 'express';
+import path from 'path';
+import { fileURLToPath } from 'url';
import pinoHttp from 'pino-http';
import { logger } from './logger.js';
import { healthRouter } from './routes/health.js';
@@ -9,13 +11,22 @@ import { HttpError } from './errors/http-error.js';
export const createApp = () => {
const app = express();
+ const __filename = fileURLToPath(import.meta.url);
+ const __dirname = path.dirname(__filename);
+ const publicDir = path.join(__dirname, '../public');
+
app.use(pinoHttp({ logger }));
app.use(express.json());
+ app.use(express.static(publicDir));
app.use(healthRouter);
app.use('/api', lobbyRouter);
app.use('/api', sessionsRouter);
+ app.get('/', (_req, res) => {
+ res.sendFile(path.join(publicDir, 'index.html'));
+ });
+
app.use((req, res) => {
res.status(404).json({ message: `Route not found: ${req.method} ${req.path}` });
});
diff --git a/webapp/src/routes/lobby.ts b/webapp/src/routes/lobby.ts
index 85cc18f..a04568c 100644
--- a/webapp/src/routes/lobby.ts
+++ b/webapp/src/routes/lobby.ts
@@ -2,6 +2,7 @@ import { Router } from 'express';
import { z } from 'zod';
import { lobbyStore } from '../services/lobby-store.js';
import { serializeSession } from '../services/session-serializer.js';
+import { difficultyDefinitions, difficultyKeys } from '../services/difficulties.js';
export const lobbyRouter = Router();
@@ -16,6 +17,10 @@ lobbyRouter.get('/lobby', (_req, res) => {
res.json(snapshot);
});
+lobbyRouter.get('/difficulties', (_req, res) => {
+ res.json({ difficulties: difficultyKeys.map((key) => difficultyDefinitions[key]) });
+});
+
lobbyRouter.post('/lobby/messages', (req, res, next) => {
try {
const payload = messageSchema.parse(req.body);
@@ -28,7 +33,7 @@ lobbyRouter.post('/lobby/messages', (req, res, next) => {
const createRoomSchema = z.object({
ownerName: z.string().min(1),
- difficulty: z.enum(['easy', 'normal', 'hard']).default('normal'),
+ difficulty: z.enum(difficultyKeys).default('easy'),
allowJoinMidgame: z.boolean().optional().default(true),
password: z.string().min(4).max(50).optional(),
maxPlayers: z.number().int().min(1).max(10).optional(),
diff --git a/webapp/src/routes/sessions.ts b/webapp/src/routes/sessions.ts
index 7156b5d..bf2cded 100644
--- a/webapp/src/routes/sessions.ts
+++ b/webapp/src/routes/sessions.ts
@@ -5,12 +5,13 @@ import { lobbyStore } from '../services/lobby-store.js';
import { sessionStore } from '../services/session-store.js';
import { Difficulty } from '../services/types.js';
import { serializeSession } from '../services/session-serializer.js';
+import { difficultyKeys } from '../services/difficulties.js';
export const sessionsRouter = Router();
const createSessionSchema = z.object({
ownerName: z.string().min(1),
- difficulty: z.enum(['easy', 'normal', 'hard']).default('normal'),
+ difficulty: z.enum(difficultyKeys).default('easy'),
allowJoinMidgame: z.boolean().optional().default(true),
password: z.string().min(4).max(50).optional(),
maxPlayers: z.number().int().min(1).max(10).optional(),
diff --git a/webapp/src/services/difficulties.ts b/webapp/src/services/difficulties.ts
new file mode 100644
index 0000000..e4e8d1d
--- /dev/null
+++ b/webapp/src/services/difficulties.ts
@@ -0,0 +1,66 @@
+import { Difficulty, DifficultyDefinition } from './types.js';
+
+export const difficultyOrder: Difficulty[] = ['easy', 'medium', 'hard', 'crazy_catto'];
+
+export const difficultyDefinitions: Record = {
+ easy: {
+ key: 'easy',
+ name: 'Easy',
+ width: 10,
+ height: 10,
+ minFloors: 1,
+ maxFloors: 1,
+ minRooms: 50,
+ enemyChance: 0.2,
+ npcCount: 2,
+ basementChance: 0.1,
+ basementMinRooms: 3,
+ basementMaxRooms: 5,
+ },
+ medium: {
+ key: 'medium',
+ name: 'Medium',
+ width: 10,
+ height: 10,
+ minFloors: 1,
+ maxFloors: 2,
+ minRooms: 75,
+ enemyChance: 0.25,
+ npcCount: 3,
+ basementChance: 0.15,
+ basementMinRooms: 4,
+ basementMaxRooms: 6,
+ },
+ hard: {
+ key: 'hard',
+ name: 'Hard',
+ width: 12,
+ height: 12,
+ minFloors: 2,
+ maxFloors: 3,
+ minRooms: 100,
+ enemyChance: 0.3,
+ npcCount: 3,
+ basementChance: 0.2,
+ basementMinRooms: 5,
+ basementMaxRooms: 8,
+ },
+ crazy_catto: {
+ key: 'crazy_catto',
+ name: 'Crazy Catto',
+ width: 12,
+ height: 12,
+ minFloors: 3,
+ maxFloors: 4,
+ minRooms: 125,
+ enemyChance: 0.4,
+ npcCount: 3,
+ basementChance: 0.25,
+ basementMinRooms: 6,
+ basementMaxRooms: 10,
+ },
+};
+
+export const getDifficultyDefinition = (key: Difficulty): DifficultyDefinition => difficultyDefinitions[key];
+
+export const difficultyKeys = Object.keys(difficultyDefinitions) as Difficulty[];
diff --git a/webapp/src/services/floor-room-rules.ts b/webapp/src/services/floor-room-rules.ts
new file mode 100644
index 0000000..c3b4b50
--- /dev/null
+++ b/webapp/src/services/floor-room-rules.ts
@@ -0,0 +1,33 @@
+import { Difficulty, RoomKind } from './types.js';
+
+export interface FloorRoomRule {
+ difficulty: Difficulty;
+ floorNumber: number | null;
+ roomType: RoomKind;
+ chance: number;
+ maxPerFloor: number;
+}
+
+// Seed data mirrors MERGED_FLOOR_ROOM_RULES in database_setup.py
+export const floorRoomRules: FloorRoomRule[] = [
+ { difficulty: 'easy', floorNumber: 1, roomType: 'safe', chance: 0.5, maxPerFloor: 20 },
+ { difficulty: 'easy', floorNumber: 1, roomType: 'monster', chance: 0.3, maxPerFloor: 10 },
+ { difficulty: 'easy', floorNumber: 1, roomType: 'item', chance: 0.1, maxPerFloor: 5 },
+ { difficulty: 'easy', floorNumber: 1, roomType: 'locked', chance: 0.05, maxPerFloor: 2 },
+ { difficulty: 'easy', floorNumber: 1, roomType: 'staircase_down', chance: 0.05, maxPerFloor: 1 },
+ { difficulty: 'easy', floorNumber: null, roomType: 'boss', chance: 0, maxPerFloor: 1 },
+ { difficulty: 'medium', floorNumber: null, roomType: 'boss', chance: 0, maxPerFloor: 1 },
+ { difficulty: 'hard', floorNumber: null, roomType: 'boss', chance: 0, maxPerFloor: 1 },
+ { difficulty: 'crazy_catto', floorNumber: null, roomType: 'boss', chance: 0, maxPerFloor: 1 },
+];
+
+export const baseRoomTypes: RoomKind[] = [
+ 'safe',
+ 'monster',
+ 'item',
+ 'shop',
+ 'trap',
+ 'illusion',
+ 'locked',
+ 'staircase_down',
+];
diff --git a/webapp/src/services/game-engine.ts b/webapp/src/services/game-engine.ts
index cd601a4..5d1814e 100644
--- a/webapp/src/services/game-engine.ts
+++ b/webapp/src/services/game-engine.ts
@@ -1,8 +1,11 @@
import crypto from 'crypto';
import { HttpError } from '../errors/http-error.js';
import { createSeededRng, pickFrom, randomInt } from './random.js';
+import { baseRoomTypes, floorRoomRules } from './floor-room-rules.js';
+import { difficultyDefinitions, getDifficultyDefinition } from './difficulties.js';
import {
CreateSessionOptions,
+ Difficulty,
DungeonState,
FloorState,
GameSession,
@@ -16,47 +19,62 @@ import {
const DEFAULT_STATS: Stats = { maxHealth: 100, health: 100, attack: 10, defense: 4 };
const MAX_LOG_ENTRIES = 50;
-const DEFAULT_GRID = 9;
-const DEFAULT_FLOORS = 3;
-const enemyTemplates: Record = {
+const enemyTemplates: Record = {
easy: { maxHealth: 24, health: 24, attack: 6, defense: 2 },
- normal: { maxHealth: 30, health: 30, attack: 8, defense: 3 },
+ medium: { maxHealth: 30, health: 30, attack: 8, defense: 3 },
hard: { maxHealth: 40, health: 40, attack: 10, defense: 4 },
+ crazy_catto: { maxHealth: 52, health: 52, attack: 12, defense: 6 },
};
-const difficultyKeys: Record = { easy: 1, normal: 2, hard: 3 };
-
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
const describeRoom = (room: RoomState) => {
switch (room.kind) {
- case 'enemy':
+ case 'monster':
return 'a hostile creature blocks the path';
- case 'treasure':
+ case 'item':
return 'a gleaming treasure chest awaits';
case 'locked':
return 'a locked door stands in your way';
- case 'stairs':
+ case 'staircase_down':
return 'a staircase descends to the next floor';
+ case 'staircase_up':
+ return 'a staircase rises back to safety';
case 'boss':
return 'a powerful foe defends this chamber';
case 'shop':
return 'a wandering merchant offers wares';
+ case 'trap':
+ return 'the floor bristles with hidden traps';
+ case 'illusion':
+ return 'shifting illusions distort the hallway';
+ case 'exit':
+ return 'a path back to the entrance glows faintly';
+ case 'safe':
+ return 'quiet stone corridors stretch forward';
+ case 'entrance':
+ return 'the party gathers here';
default:
return 'quiet stone corridors stretch forward';
}
};
-const createEmptyFloor = (size: number, floorIndex: number, seed: string): FloorState => {
+const createEmptyFloor = (
+ width: number,
+ height: number,
+ floorIndex: number,
+ seed: string,
+ isBasement = false,
+): FloorState => {
const rng = createSeededRng(`${seed}:floor:${floorIndex}`);
const rooms: RoomState[][] = [];
- for (let y = 0; y < size; y += 1) {
+ for (let y = 0; y < height; y += 1) {
const row: RoomState[] = [];
- for (let x = 0; x < size; x += 1) {
+ for (let x = 0; x < width; x += 1) {
row.push({
position: { x, y },
- kind: 'hall',
+ kind: 'safe',
discovered: false,
cleared: true,
locked: false,
@@ -66,86 +84,122 @@ const createEmptyFloor = (size: number, floorIndex: number, seed: string): Floor
rooms.push(row);
}
- const start: Position = { x: Math.floor(size / 2), y: Math.floor(size / 2) };
- const stairs: Position = { x: randomInt(rng, 0, size - 1), y: randomInt(rng, 0, size - 1) };
+ const start: Position = { x: Math.floor(width / 2), y: Math.floor(height / 2) };
+ const stairs: Position = { x: randomInt(rng, 0, width - 1), y: randomInt(rng, 0, height - 1) };
rooms[start.y][start.x].kind = 'entrance';
rooms[start.y][start.x].cleared = true;
rooms[start.y][start.x].discovered = true;
- rooms[stairs.y][stairs.x].kind = 'stairs';
+ rooms[stairs.y][stairs.x].kind = 'staircase_down';
rooms[stairs.y][stairs.x].cleared = false;
+ rooms[stairs.y][stairs.x].legend = 'Advance to the next floor';
- return { index: floorIndex, size, start, stairs, rooms };
+ return { index: floorIndex, width, height, start, stairs, rooms, isBasement };
};
-const sprinkleRooms = (floor: FloorState, difficulty: string) => {
- const rng = createSeededRng(`${floor.start.x},${floor.start.y}:${difficulty}:${floor.index}`);
- const density = clamp(6 + difficultyKeys[difficulty] * 2, 4, floor.size * 2);
-
- const pickCoords = (): Position => {
- return { x: randomInt(rng, 0, floor.size - 1), y: randomInt(rng, 0, floor.size - 1) };
- };
+const pickFloorRules = (difficulty: Difficulty, floorIndex: number) => {
+ const floorNumber = floorIndex + 1;
+ return floorRoomRules.filter(
+ (rule) => rule.difficulty === difficulty && (rule.floorNumber === null || rule.floorNumber === floorNumber),
+ );
+};
+const sprinkleRooms = (floor: FloorState, difficulty: Difficulty) => {
+ const rng = createSeededRng(`${floor.start.x},${floor.start.y}:${difficulty}:${floor.index}`);
+ const definition = difficultyDefinitions[difficulty];
+ const rules = pickFloorRules(difficulty, floor.index);
const occupied = new Set([`${floor.start.x},${floor.start.y}`, `${floor.stairs.x},${floor.stairs.y}`]);
const occupy = (pos: Position) => occupied.add(`${pos.x},${pos.y}`);
occupy(floor.stairs);
occupy(floor.start);
- for (let i = 0; i < density; i += 1) {
- const pos = pickCoords();
- const key = `${pos.x},${pos.y}`;
- if (occupied.has(key)) continue;
- occupy(pos);
- const template = pickFrom(rng, ['enemy', 'treasure', 'locked', 'enemy', 'treasure', 'hall'] as RoomKind[]);
- const room = floor.rooms[pos.y][pos.x];
- room.kind = template;
- room.discovered = false;
- room.cleared = template !== 'enemy' && template !== 'locked';
- if (template === 'enemy') {
- room.enemy = { ...enemyTemplates[difficulty] };
- }
- if (template === 'treasure') {
- room.loot = [
- {
- id: crypto.randomUUID(),
- name: pickFrom(rng, ['Potion', 'Old Coin', 'Strange Relic', 'Sturdy Key']),
- type: 'treasure',
- quantity: 1,
- },
- ];
- }
- if (template === 'locked') {
- room.locked = true;
- room.cleared = false;
+ const availableCells = floor.width * floor.height - occupied.size;
+ const baseDensity = Math.min(definition.minRooms, availableCells);
+
+ const pickCoords = (): Position => {
+ return { x: randomInt(rng, 0, floor.width - 1), y: randomInt(rng, 0, floor.height - 1) };
+ };
+
+ const applyRule = (roomType: RoomKind, count: number) => {
+ for (let i = 0; i < count; i += 1) {
+ let pos = pickCoords();
+ let attempts = 0;
+ while (occupied.has(`${pos.x},${pos.y}`) && attempts < 10) {
+ pos = pickCoords();
+ attempts += 1;
+ }
+ if (occupied.has(`${pos.x},${pos.y}`)) continue;
+ occupy(pos);
+ const room = floor.rooms[pos.y][pos.x];
+ room.kind = roomType;
+ room.discovered = false;
+ room.cleared = roomType === 'safe' || roomType === 'shop' || roomType === 'illusion';
+ if (roomType === 'monster' || roomType === 'boss') {
+ room.enemy = { ...enemyTemplates[difficulty] };
+ }
+ if (roomType === 'item') {
+ room.loot = [
+ {
+ id: crypto.randomUUID(),
+ name: pickFrom(rng, ['Potion', 'Old Coin', 'Strange Relic', 'Sturdy Key']),
+ type: 'treasure',
+ quantity: 1,
+ },
+ ];
+ }
+ if (roomType === 'locked') {
+ room.locked = true;
+ room.cleared = false;
+ }
+ if (roomType === 'trap') {
+ room.legend = 'Watch your step — traps ahead';
+ }
+ if (roomType === 'illusion') {
+ room.legend = 'Illusory walls hide secrets';
+ }
}
+ };
+
+ const estimatedCells = baseDensity || availableCells;
+ rules.forEach((rule) => {
+ const desired = Math.min(rule.maxPerFloor, Math.max(1, Math.round(rule.chance * estimatedCells)));
+ applyRule(rule.roomType, desired);
+ });
+
+ if (!rules.length) {
+ baseRoomTypes.forEach((type) => {
+ const weight = type === 'monster' ? definition.enemyChance : 0.08;
+ applyRule(type, Math.max(1, Math.floor(weight * estimatedCells)));
+ });
}
- // Ensure a key exists if we spawned locks
const hasLock = floor.rooms.some((row) => row.some((r) => r.locked));
- if (hasLock) {
- const keySpot = pickCoords();
- const room = floor.rooms[keySpot.y][keySpot.x];
- room.kind = 'treasure';
- room.locked = false;
- room.cleared = false;
- room.loot = [
- { id: crypto.randomUUID(), name: 'Iron Key', type: 'quest', quantity: 1 },
- { id: crypto.randomUUID(), name: 'Rations', type: 'consumable', quantity: 1 },
- ];
+ const hasKey = floor.rooms.some((row) => row.some((r) => r.loot?.some((l) => l.name === 'Sturdy Key')));
+ if (hasLock && !hasKey) {
+ applyRule('item', 1);
}
- // Boss on final floor guarding stairs
- if (floor.index === DEFAULT_FLOORS - 1) {
+ const isFinalFloor = !floor.isBasement && floor.index === Math.max(0, definition.maxFloors - 1);
+ if (isFinalFloor) {
const room = floor.rooms[floor.stairs.y][floor.stairs.x];
room.kind = 'boss';
room.cleared = false;
- room.enemy = { maxHealth: 60, health: 60, attack: 14, defense: 6 };
+ room.enemy = { maxHealth: 80, health: 80, attack: 16, defense: 7 };
+ room.legend = 'The final encounter awaits';
}
};
-const createDungeon = (difficulty: string, seed: string): DungeonState => {
+const createDungeon = (difficulty: Difficulty, seed: string): DungeonState => {
+ const definition = getDifficultyDefinition(difficulty);
const floors: FloorState[] = [];
- for (let i = 0; i < DEFAULT_FLOORS; i += 1) {
- const floor = createEmptyFloor(DEFAULT_GRID, i, seed);
+ const rng = createSeededRng(`${seed}:floors`);
+ const totalFloors = randomInt(rng, definition.minFloors, definition.maxFloors);
+ const includeBasement = rng() < definition.basementChance;
+ const basementFloors = includeBasement ? 1 : 0;
+ const total = totalFloors + basementFloors;
+
+ for (let i = 0; i < total; i += 1) {
+ const isBasement = includeBasement && i === total - 1 && basementFloors > 0;
+ const floor = createEmptyFloor(definition.width, definition.height, i, seed, isBasement);
sprinkleRooms(floor, difficulty);
floors.push(floor);
}
@@ -197,6 +251,7 @@ export class GameEngine {
static createSession(options: CreateSessionOptions): GameSession {
const sessionId = crypto.randomUUID();
const createdAt = new Date().toISOString();
+ const definition = getDifficultyDefinition(options.difficulty);
const dungeon = createDungeon(options.difficulty, sessionId);
const owner: Player = {
id: crypto.randomUUID(),
@@ -222,6 +277,7 @@ export class GameEngine {
maxPlayers: options.maxPlayers ?? 6,
dungeon,
version: 1,
+ difficultySettings: definition,
};
}
@@ -257,7 +313,7 @@ export class GameEngine {
const floor = session.dungeon.floors[player.floor];
const next: Position = { x: player.position.x + delta[direction].x, y: player.position.y + delta[direction].y };
- if (next.x < 0 || next.y < 0 || next.x >= floor.size || next.y >= floor.size) {
+ if (next.x < 0 || next.y < 0 || next.x >= floor.width || next.y >= floor.height) {
throw new HttpError(400, 'Cannot move beyond the dungeon walls');
}
@@ -272,18 +328,17 @@ export class GameEngine {
if (room.locked && player.inventory.some((item) => item.type === 'quest')) {
room.locked = false;
- room.kind = room.kind === 'locked' ? 'hall' : room.kind;
events.push(`${player.name} unlocks the door.`);
}
- if (room.kind === 'enemy' || room.kind === 'boss') {
+ if (room.kind === 'monster' || room.kind === 'boss') {
events.push(resolveCombat(room, player));
}
const lootLog = collectLoot(room, player);
if (lootLog) events.push(lootLog);
- if (room.kind === 'stairs' && room.cleared) {
+ if ((room.kind === 'staircase_down' || room.kind === 'staircase_up') && room.cleared) {
if (session.dungeon.currentFloor < session.dungeon.floors.length - 1) {
session.dungeon.currentFloor += 1;
const nextFloor = session.dungeon.floors[session.dungeon.currentFloor];
@@ -296,6 +351,12 @@ export class GameEngine {
}
}
+ if (room.kind === 'trap') {
+ const trapDamage = clamp(randomInt(createSeededRng(room.seed), 5, 12), 1, player.stats.health);
+ player.stats.health = clamp(player.stats.health - trapDamage, 0, player.stats.maxHealth);
+ events.push(`${player.name} is hurt by a trap for ${trapDamage} damage.`);
+ }
+
const description = `${player.name} moved ${direction} and found ${describeRoom(room)}.`;
events.unshift(description);
@@ -314,4 +375,3 @@ export class GameEngine {
return { session, room, events };
}
}
-
diff --git a/webapp/src/services/session-serializer.ts b/webapp/src/services/session-serializer.ts
index 98ea9c7..91df9fb 100644
--- a/webapp/src/services/session-serializer.ts
+++ b/webapp/src/services/session-serializer.ts
@@ -6,6 +6,7 @@ export const serializeSession = (session: GameSession) => {
return {
id: session.id,
difficulty: session.difficulty,
+ difficultySettings: session.difficultySettings,
ownerName: session.ownerName,
createdAt: session.createdAt,
players: session.players.map((player) => ({
@@ -17,26 +18,29 @@ export const serializeSession = (session: GameSession) => {
maxHealth: player.stats.maxHealth,
inventory: player.inventory,
})),
- dungeon: {
- currentFloor: session.dungeon.currentFloor,
- floors: session.dungeon.floors.map((floor) => ({
- index: floor.index,
- size: floor.size,
- start: floor.start,
- stairs: floor.stairs,
- rooms: floor.rooms.map((row) =>
- row.map((room) => ({
- position: room.position,
- kind: room.kind,
- discovered: room.discovered,
- cleared: room.cleared,
- locked: room.locked,
- enemy: room.enemy ? { ...room.enemy } : undefined,
- loot: room.loot,
- })),
- ),
- })),
- },
+ dungeon: {
+ currentFloor: session.dungeon.currentFloor,
+ floors: session.dungeon.floors.map((floor) => ({
+ index: floor.index,
+ width: floor.width,
+ height: floor.height,
+ isBasement: floor.isBasement,
+ start: floor.start,
+ stairs: floor.stairs,
+ rooms: floor.rooms.map((row) =>
+ row.map((room) => ({
+ position: room.position,
+ kind: room.kind,
+ discovered: room.discovered,
+ cleared: room.cleared,
+ locked: room.locked,
+ enemy: room.enemy ? { ...room.enemy } : undefined,
+ loot: room.loot,
+ legend: room.legend,
+ })),
+ ),
+ })),
+ },
log: session.log,
status: session.status,
allowJoinMidgame: session.allowJoinMidgame,
diff --git a/webapp/src/services/types.ts b/webapp/src/services/types.ts
index 142a242..db3f5ba 100644
--- a/webapp/src/services/types.ts
+++ b/webapp/src/services/types.ts
@@ -1,4 +1,19 @@
-export type Difficulty = 'easy' | 'normal' | 'hard';
+export type Difficulty = 'easy' | 'medium' | 'hard' | 'crazy_catto';
+
+export interface DifficultyDefinition {
+ key: Difficulty;
+ name: string;
+ width: number;
+ height: number;
+ minFloors: number;
+ maxFloors: number;
+ minRooms: number;
+ enemyChance: number;
+ npcCount: number;
+ basementChance: number;
+ basementMinRooms: number;
+ basementMaxRooms: number;
+}
export type SessionStatus = 'waiting' | 'in_progress' | 'completed';
@@ -7,7 +22,19 @@ export interface Position {
y: number;
}
-export type RoomKind = 'entrance' | 'hall' | 'enemy' | 'treasure' | 'stairs' | 'locked' | 'boss' | 'shop';
+export type RoomKind =
+ | 'entrance'
+ | 'safe'
+ | 'monster'
+ | 'item'
+ | 'shop'
+ | 'boss'
+ | 'trap'
+ | 'illusion'
+ | 'staircase_up'
+ | 'staircase_down'
+ | 'exit'
+ | 'locked';
export interface Stats {
maxHealth: number;
@@ -32,11 +59,14 @@ export interface RoomState {
seed: string;
enemy?: Stats;
loot?: InventoryItem[];
+ legend?: string;
}
export interface FloorState {
index: number;
- size: number;
+ width: number;
+ height: number;
+ isBasement: boolean;
start: Position;
stairs: Position;
rooms: RoomState[][];
@@ -60,6 +90,7 @@ export interface Player {
export interface GameSession {
id: string;
difficulty: Difficulty;
+ difficultySettings: DifficultyDefinition;
ownerName: string;
createdAt: string;
players: Player[];