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 + + + +
+

AdventureBot Web Lobby

+

Prototype web interface for creating and joining dungeon runs.

+
+
+ +
+
+

Create a Lobby

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+
+ +
+

Dungeon settings (mirrors database)

+

Difficulties and room types come from AdventureBot's database tables, not hard-coded defaults.

+
+
+
+ +
+

Lobby Rooms

+ +
+
+ +
+

Lobby Chat

+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Join a Session

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+

Session State

+
+
+ + +
+ +
+
+
+
+ + + + + + 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[];