From ea75ece19f2d8bd308da178481442fc80bd27d2f Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 12 Jul 2026 12:35:10 -0500 Subject: [PATCH 01/19] fix: gitignore the root .env file The setup script and README both create a root .env for docker-compose variable substitution (includes the DuckDNS token), but only backend/.env was ignored - the root copy showed up as untracked and could be committed by accident. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index bed2d38..105627b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ backend/data/ # Backend environment and dev config backend/.env +.env backend/nodemon.json # Large archives From ff5f0f972cda71305713320ac832693d95dd2e85 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 12 Jul 2026 18:25:59 -0500 Subject: [PATCH 02/19] feat: character sheets Phase 1 - engine, CP:R template, player window Data: - character_sheets table (one sheet per player per system; NPC sheets exempt via partial unique index) + npc_sheet_links table + saved_maps.npc_links_data - game_system global setting Backend: - routes/sheets.js: admin REST (list, full view, per-field patch) with an explicit admin role check (player tokens rejected), public game-system endpoint, PUT system emits gameSystemChanged - sheets/templates.js: server-side public/combat field metadata; the server filter is the only privacy gate - Socket events: requestMySheet (auto-creates blank sheet, carries portrait across systems), updateSheetField (identity from socket, never payload), requestQuickSheet (public fields only, spectator-allowlisted) Frontend: - Template engine (src/sheets/): types, generic template, full Cyberpunk RED template (10 stats, 65 skills with derived BASE, armor SP, health, gear) - SheetRenderer: one renderer for any template (grid/skills/list/notes layouts, collapsible sections, current/max pairs) - CharacterSheetWindow: DraggableWindow with edit-in-place and debounced per-field socket saves; re-syncs on admin edits - CHARACTER_SHEET button in TOKEN_PROTOCOLS sidebar menu - Admin panel TTRPG_SYSTEM section: system selector + sheet list Tests: 20 backend (routes, filter, constraints, role rejection) + 14 frontend (registry integrity, renderer, window socket flows) Co-Authored-By: Claude Opus 4.8 --- backend/__tests__/helpers/testDb.js | 24 ++ backend/__tests__/sheets.test.js | 238 ++++++++++++++++ backend/db.js | 28 ++ backend/routes/sheets.js | 110 ++++++++ backend/server.js | 1 + backend/sheets/templates.js | 44 +++ backend/sockets/index.js | 107 +++++++- frontend/src/App.tsx | 16 +- frontend/src/components/AdminPanel.tsx | 70 +++++ .../src/components/CharacterSheetWindow.tsx | 80 ++++++ frontend/src/components/SheetRenderer.tsx | 175 ++++++++++++ frontend/src/components/Sidebar.tsx | 18 +- .../__tests__/CharacterSheet.test.tsx | 152 +++++++++++ frontend/src/sheets/index.ts | 13 + .../src/sheets/templates/cyberpunk_red.ts | 254 ++++++++++++++++++ frontend/src/sheets/templates/generic.ts | 44 +++ frontend/src/sheets/types.ts | 58 ++++ 17 files changed, 1427 insertions(+), 5 deletions(-) create mode 100644 backend/__tests__/sheets.test.js create mode 100644 backend/routes/sheets.js create mode 100644 backend/sheets/templates.js create mode 100644 frontend/src/components/CharacterSheetWindow.tsx create mode 100644 frontend/src/components/SheetRenderer.tsx create mode 100644 frontend/src/components/__tests__/CharacterSheet.test.tsx create mode 100644 frontend/src/sheets/index.ts create mode 100644 frontend/src/sheets/templates/cyberpunk_red.ts create mode 100644 frontend/src/sheets/templates/generic.ts create mode 100644 frontend/src/sheets/types.ts diff --git a/backend/__tests__/helpers/testDb.js b/backend/__tests__/helpers/testDb.js index b4c8d49..3bfdff3 100644 --- a/backend/__tests__/helpers/testDb.js +++ b/backend/__tests__/helpers/testDb.js @@ -162,6 +162,30 @@ function makeTestDb() { timestamp DATETIME DEFAULT CURRENT_TIMESTAMP )`); + db.run(`CREATE TABLE global_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + )`); + + db.run(`CREATE TABLE character_sheets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + system TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}', + portrait_url TEXT, + is_npc INTEGER DEFAULT 0, + npc_label TEXT, + folder TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + db.run(`CREATE UNIQUE INDEX idx_player_sheet ON character_sheets(username, system) WHERE is_npc = 0`); + + db.run(`CREATE TABLE npc_sheet_links ( + location_id INTEGER NOT NULL UNIQUE, + sheet_id INTEGER NOT NULL, + FOREIGN KEY(sheet_id) REFERENCES character_sheets(id) ON DELETE CASCADE + )`); + db.run(`CREATE TABLE sqlite_sequence (name TEXT, seq INTEGER)`, () => { // ignore error — it may already exist resolve(db); diff --git a/backend/__tests__/sheets.test.js b/backend/__tests__/sheets.test.js new file mode 100644 index 0000000..d85f183 --- /dev/null +++ b/backend/__tests__/sheets.test.js @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { makeTestDb, get, all, run } from './helpers/testDb.js'; +import sheetsRouteFactory from '../routes/sheets.js'; +import { TEMPLATES, filterPublicData, isValidSystem } from '../sheets/templates.js'; + +const ADMIN_TOKEN = jwt.sign( + { id: 1, username: 'testadmin', role: 'admin', isTemporary: false }, + 'test-secret' +); +// Secure-mode player token: passes authenticate but must NOT reach admin routes +const PLAYER_TOKEN = jwt.sign( + { username: 'ghost', role: 'player' }, + 'test-secret' +); + +let db; +let app; +let emitted; + +const makeApp = (database) => { + const application = express(); + application.use(express.json()); + emitted = []; + const io = { emit: (event, payload) => emitted.push({ event, payload }) }; + application.use('/api/sheets', sheetsRouteFactory(database, io)); + return application; +}; + +const insertSheet = (database, overrides = {}) => { + const s = { username: 'GHOST', system: 'cyberpunk_red', data: '{}', is_npc: 0, npc_label: null, ...overrides }; + return run(database, + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES (?, ?, ?, ?, ?)`, + [s.username, s.system, s.data, s.is_npc, s.npc_label] + ); +}; + +const setSystem = (database, system) => + run(database, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [system]); + +beforeEach(async () => { + db = await makeTestDb(); + app = makeApp(db); +}); + +// ─── Template metadata ──────────────────────────────────────────────────────── + +describe('sheet template metadata', () => { + it('knows the shipped systems', () => { + expect(isValidSystem('generic')).toBe(true); + expect(isValidSystem('cyberpunk_red')).toBe(true); + expect(isValidSystem('dnd_hombrew_9000')).toBe(false); + }); + + it('filterPublicData keeps only public fields', () => { + const data = { handle: 'VIPER', role: 'Rogue', description: 'Ghost of the grid', int: 8, sp_body: 11 }; + const filtered = filterPublicData('cyberpunk_red', data); + expect(filtered).toEqual({ handle: 'VIPER', role: 'Rogue', description: 'Ghost of the grid' }); + }); + + it('filterPublicData never leaks combat fields even if listed public', () => { + // Defensive: combatFields wins over publicFields by construction + Object.values(TEMPLATES).forEach(meta => { + meta.combatFields.forEach(f => { + const filtered = filterPublicData( + Object.keys(TEMPLATES).find(k => TEMPLATES[k] === meta), + { [f]: 99 } + ); + expect(filtered[f]).toBeUndefined(); + }); + }); + }); + + it('filterPublicData tolerates string and object data', () => { + expect(filterPublicData('cyberpunk_red', '{"handle":"NYX"}')).toEqual({ handle: 'NYX' }); + expect(filterPublicData('cyberpunk_red', null)).toEqual({}); + }); +}); + +// ─── GET /api/sheets/system ─────────────────────────────────────────────────── + +describe('GET /api/sheets/system', () => { + it('returns the default system when unset', async () => { + const res = await request(app).get('/api/sheets/system'); + expect(res.status).toBe(200); + expect(res.body.system).toBe('generic'); + expect(res.body.systems.map(s => s.id)).toContain('cyberpunk_red'); + }); + + it('returns the stored system', async () => { + await setSystem(db, 'cyberpunk_red'); + const res = await request(app).get('/api/sheets/system'); + expect(res.body.system).toBe('cyberpunk_red'); + }); +}); + +// ─── PUT /api/sheets/system ─────────────────────────────────────────────────── + +describe('PUT /api/sheets/system', () => { + it('sets the system and emits gameSystemChanged', async () => { + const res = await request(app) + .put('/api/sheets/system') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ system: 'cyberpunk_red' }); + expect(res.status).toBe(200); + const row = await get(db, `SELECT value FROM global_settings WHERE key = 'game_system'`); + expect(row.value).toBe('cyberpunk_red'); + expect(emitted.some(e => e.event === 'gameSystemChanged' && e.payload.system === 'cyberpunk_red')).toBe(true); + }); + + it('rejects unknown systems', async () => { + const res = await request(app) + .put('/api/sheets/system') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ system: 'calvinball' }); + expect(res.status).toBe(400); + }); + + it('rejects unauthenticated calls', async () => { + const res = await request(app).put('/api/sheets/system').send({ system: 'generic' }); + expect(res.status).toBe(401); + }); + + it('rejects secure-mode player tokens', async () => { + const res = await request(app) + .put('/api/sheets/system') + .set('Authorization', `Bearer ${PLAYER_TOKEN}`) + .send({ system: 'generic' }); + expect(res.status).toBe(403); + }); +}); + +// ─── GET /api/sheets ────────────────────────────────────────────────────────── + +describe('GET /api/sheets (admin list)', () => { + it('lists player and NPC sheets', async () => { + await insertSheet(db, { username: 'GHOST' }); + await insertSheet(db, { username: 'admin', is_npc: 1, npc_label: 'Gang Member' }); + const res = await request(app) + .get('/api/sheets') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + const npc = res.body.find(r => r.is_npc === 1); + expect(npc.npc_label).toBe('Gang Member'); + }); + + it('rejects player tokens', async () => { + const res = await request(app) + .get('/api/sheets') + .set('Authorization', `Bearer ${PLAYER_TOKEN}`); + expect(res.status).toBe(403); + }); +}); + +// ─── GET /api/sheets/user/:username ────────────────────────────────────────── + +describe('GET /api/sheets/user/:username', () => { + it('returns the full sheet including combat fields for admin', async () => { + await setSystem(db, 'cyberpunk_red'); + await insertSheet(db, { username: 'GHOST', data: JSON.stringify({ handle: 'GHOST', sp_body: 11 }) }); + const res = await request(app) + .get('/api/sheets/user/GHOST') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body.data.sp_body).toBe(11); + }); + + it('404s when the player has no sheet on the active system', async () => { + await setSystem(db, 'generic'); + await insertSheet(db, { username: 'GHOST', system: 'cyberpunk_red' }); + const res = await request(app) + .get('/api/sheets/user/GHOST') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(404); + }); +}); + +// ─── PUT /api/sheets/user/:username ────────────────────────────────────────── + +describe('PUT /api/sheets/user/:username', () => { + it('merges field patches and emits sheetUpdated', async () => { + await setSystem(db, 'cyberpunk_red'); + await insertSheet(db, { username: 'GHOST', data: JSON.stringify({ handle: 'GHOST', int: 5 }) }); + + const res = await request(app) + .put('/api/sheets/user/GHOST') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ fields: { int: 8, ref: 7 } }); + expect(res.status).toBe(200); + + const row = await get(db, `SELECT data FROM character_sheets WHERE username = 'GHOST'`); + const data = JSON.parse(row.data); + expect(data).toEqual({ handle: 'GHOST', int: 8, ref: 7 }); + expect(emitted.some(e => e.event === 'sheetUpdated' && e.payload.username === 'GHOST')).toBe(true); + }); + + it('rejects a missing fields object', async () => { + const res = await request(app) + .put('/api/sheets/user/GHOST') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({}); + expect(res.status).toBe(400); + }); + + it('rejects player tokens', async () => { + const res = await request(app) + .put('/api/sheets/user/GHOST') + .set('Authorization', `Bearer ${PLAYER_TOKEN}`) + .send({ fields: { int: 10 } }); + expect(res.status).toBe(403); + }); +}); + +// ─── DB constraints ─────────────────────────────────────────────────────────── + +describe('character_sheets constraints', () => { + it('enforces one sheet per player per system', async () => { + await insertSheet(db, { username: 'GHOST', system: 'cyberpunk_red' }); + await expect(insertSheet(db, { username: 'GHOST', system: 'cyberpunk_red' })).rejects.toThrow(); + }); + + it('allows the same player on different systems', async () => { + await insertSheet(db, { username: 'GHOST', system: 'cyberpunk_red' }); + await insertSheet(db, { username: 'GHOST', system: 'generic' }); + const rows = await all(db, `SELECT * FROM character_sheets WHERE username = 'GHOST'`); + expect(rows).toHaveLength(2); + }); + + it('allows many NPC sheets under one owner', async () => { + await insertSheet(db, { username: 'admin', system: 'cyberpunk_red', is_npc: 1, npc_label: 'Gang Member' }); + await insertSheet(db, { username: 'admin', system: 'cyberpunk_red', is_npc: 1, npc_label: 'Fixer' }); + const rows = await all(db, `SELECT * FROM character_sheets WHERE is_npc = 1`); + expect(rows).toHaveLength(2); + }); +}); diff --git a/backend/db.js b/backend/db.js index 2e0cddb..d3e04d5 100644 --- a/backend/db.js +++ b/backend/db.js @@ -312,6 +312,34 @@ db.serialize(() => { db.run(`ALTER TABLE signs ADD COLUMN font_family TEXT DEFAULT 'monospace'`, () => {}); db.run(`ALTER TABLE signs ADD COLUMN lines TEXT`, () => {}); db.run(`ALTER TABLE signs ADD COLUMN filter_intensity REAL DEFAULT 1.0`, () => {}); + + // Character sheets: one sheet per player PER SYSTEM (switching game systems + // never destroys sheets - the old system's rows stay dormant until switched back) + db.run(`CREATE TABLE IF NOT EXISTS character_sheets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + system TEXT NOT NULL, + data TEXT NOT NULL DEFAULT '{}', + portrait_url TEXT, + is_npc INTEGER DEFAULT 0, + npc_label TEXT, + folder TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + // One sheet per player per system - but NPC sheets (is_npc=1) are exempt, + // the admin owns many of them + db.run(`CREATE UNIQUE INDEX IF NOT EXISTS idx_player_sheet + ON character_sheets(username, system) WHERE is_npc = 0`); + + // NPC sheets attach to rhombus tokens via links so one sheet can back many tokens + db.run(`CREATE TABLE IF NOT EXISTS npc_sheet_links ( + location_id INTEGER NOT NULL UNIQUE, + sheet_id INTEGER NOT NULL, + FOREIGN KEY(sheet_id) REFERENCES character_sheets(id) ON DELETE CASCADE + )`); + + // NPC links ride along in map snapshots (location ids are preserved on load) + db.run(`ALTER TABLE saved_maps ADD COLUMN npc_links_data TEXT`, () => {}); }); module.exports = db; diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js new file mode 100644 index 0000000..0806184 --- /dev/null +++ b/backend/routes/sheets.js @@ -0,0 +1,110 @@ +const express = require('express'); +const { authenticate } = require('../middleware/auth'); +const { TEMPLATES, DEFAULT_SYSTEM, isValidSystem } = require('../sheets/templates'); + +// Admin-facing character sheet routes. Player self-service (open/edit own +// sheet, quick-sheet lookups) goes through socket events, matching how the +// bank works - players in non-secure mode have no REST token. +// +// authenticate alone is not enough here: secure-mode player tokens also pass +// it. Full-sheet access is admin (or elevated temporary admin) only. +const requireAdmin = (req, res, next) => { + const u = req.user; + const isAdmin = u && (u.role === 'admin' || u.isTemporary); + if (!isAdmin) return res.status(403).json({ error: 'Admin only' }); + next(); +}; + +module.exports = (db, io) => { + const router = express.Router(); + + const getGameSystem = (cb) => { + db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, (err, row) => { + cb(err, row ? row.value : DEFAULT_SYSTEM); + }); + }; + + // --- Game system setting --- + + // Public: every client needs to know the active system to pick a template + router.get('/system', (req, res) => { + getGameSystem((err, system) => { + if (err) return res.status(500).json({ error: err.message }); + res.json({ system, systems: Object.entries(TEMPLATES).map(([id, t]) => ({ id, name: t.name })) }); + }); + }); + + router.put('/system', authenticate, requireAdmin, (req, res) => { + const { system } = req.body; + if (!isValidSystem(system)) return res.status(400).json({ error: 'Unknown game system' }); + db.run( + `INSERT INTO global_settings (key, value) VALUES ('game_system', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [system], + (err) => { + if (err) return res.status(500).json({ error: err.message }); + io.emit('gameSystemChanged', { system }); + res.json({ message: 'Game system updated', system }); + } + ); + }); + + // --- Admin sheet access --- + + // List all sheets (players and NPCs, every system) for the admin panel + router.get('/', authenticate, requireAdmin, (req, res) => { + db.all( + `SELECT id, username, system, is_npc, npc_label, folder, portrait_url, updated_at + FROM character_sheets ORDER BY is_npc, username`, + (err, rows) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(rows); + } + ); + }); + + // Full sheet for one player on the active system (admin view/edit) + router.get('/user/:username', authenticate, requireAdmin, (req, res) => { + getGameSystem((err, system) => { + if (err) return res.status(500).json({ error: err.message }); + db.get( + `SELECT * FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [req.params.username, system], + (err2, row) => { + if (err2) return res.status(500).json({ error: err2.message }); + if (!row) return res.status(404).json({ error: 'No sheet for this player on the active system' }); + res.json({ ...row, data: JSON.parse(row.data || '{}') }); + } + ); + }); + }); + + // Admin per-field patch of any player's active-system sheet + router.put('/user/:username', authenticate, requireAdmin, (req, res) => { + const { fields } = req.body; // { fieldId: value, ... } + if (!fields || typeof fields !== 'object') return res.status(400).json({ error: 'fields object required' }); + getGameSystem((err, system) => { + if (err) return res.status(500).json({ error: err.message }); + db.get( + `SELECT id, data FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [req.params.username, system], + (err2, row) => { + if (err2) return res.status(500).json({ error: err2.message }); + if (!row) return res.status(404).json({ error: 'No sheet for this player on the active system' }); + const data = { ...JSON.parse(row.data || '{}'), ...fields }; + db.run( + `UPDATE character_sheets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [JSON.stringify(data), row.id], + (err3) => { + if (err3) return res.status(500).json({ error: err3.message }); + io.emit('sheetUpdated', { username: req.params.username, system }); + res.json({ message: 'Sheet updated' }); + } + ); + } + ); + }); + }); + + return router; +}; diff --git a/backend/server.js b/backend/server.js index 62f6f67..6f4035e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -47,6 +47,7 @@ app.use('/uploads/fonts', express.static(path.join(__dirname, 'uploads/fonts'))) app.use('/api/player', require('./routes/player')(db, io)); app.use('/api', require('./routes/admin')(db, io, helpers)); app.use('/api/music', require('./routes/music')(db, io)); +app.use('/api/sheets', require('./routes/sheets')(db, io)); // Frontend static serving const frontendDist = path.join(__dirname, '../frontend/dist'); diff --git a/backend/sheets/templates.js b/backend/sheets/templates.js new file mode 100644 index 0000000..f471dfc --- /dev/null +++ b/backend/sheets/templates.js @@ -0,0 +1,44 @@ +// Server-side template metadata for character sheets. +// +// The full templates (sections, layouts, labels, roll formulas) live in the +// frontend at src/sheets/templates/. The backend only needs to know, per +// system, which fields are safe to expose: +// +// - publicFields: shown on the quick-sheet card to other players and +// spectators. Everything not listed here is owner+admin only. +// - combatFields: values that determine whether an attack hits (SP, AC, +// evasion bases...). NEVER exposed to non-owners regardless of any other +// flag - listed separately so a template edit can't accidentally leak them. +// +// The server filter is the only privacy gate; the client never receives +// fields it shouldn't show. + +const TEMPLATES = { + generic: { + name: 'Generic', + publicFields: ['name', 'description'], + combatFields: [], + }, + cyberpunk_red: { + name: 'Cyberpunk RED', + publicFields: ['handle', 'role', 'description'], + combatFields: ['sp_head', 'sp_head_max', 'sp_body', 'sp_body_max', 'sp_shield', 'sp_shield_max'], + }, +}; + +const DEFAULT_SYSTEM = 'generic'; + +const isValidSystem = (system) => Object.prototype.hasOwnProperty.call(TEMPLATES, system); + +// Strip a sheet's data down to what non-owners may see. +const filterPublicData = (system, data) => { + const meta = TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]; + const parsed = typeof data === 'string' ? JSON.parse(data || '{}') : (data || {}); + const out = {}; + meta.publicFields.forEach((f) => { + if (!meta.combatFields.includes(f) && parsed[f] !== undefined) out[f] = parsed[f]; + }); + return out; +}; + +module.exports = { TEMPLATES, DEFAULT_SYSTEM, isValidSystem, filterPublicData }; diff --git a/backend/sockets/index.js b/backend/sockets/index.js index aedf33e..530e987 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -1,4 +1,5 @@ const jwt = require('jsonwebtoken'); +const sheetTemplates = require('../sheets/templates'); const SECRET = process.env.JWT_SECRET; @@ -6,7 +7,9 @@ const userSockets = new Map(); let activeNPCs = []; // Streamer mode: spectator sockets are read-only observers, invisible to the game. -const SPECTATOR_ALLOWED_EVENTS = new Set(['identify', 'requestDiceHistory']); +// requestQuickSheet is spectator-allowed by design: it only ever returns +// server-filtered public fields, so stream viewers can see who's who. +const SPECTATOR_ALLOWED_EVENTS = new Set(['identify', 'requestDiceHistory', 'requestQuickSheet']); const formatMeasurementPayload = (data, userName, socketId) => ({ owner: userName ? userName : socketId, @@ -609,6 +612,108 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { if (data && data.username) sendBankUpdate(data.username); }); + // ── Character Sheets ───────────────────────────────────────────────────── + // Player self-service goes through the socket (identity = the socket's + // registered userName, never a payload field) so a client can only ever + // fetch or edit its own full sheet. Admin access is via REST. + + const getGameSystem = (cb) => { + db.get(`SELECT value FROM global_settings WHERE key = 'game_system'`, (err, row) => { + cb(err, row ? row.value : sheetTemplates.DEFAULT_SYSTEM); + }); + }; + + socket.on('requestMySheet', () => { + const info = userSockets.get(socket.id); + if (!info || !info.userName) return; + getGameSystem((err, system) => { + if (err) return; + db.get( + `SELECT * FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [info.userName, system], + (err2, row) => { + if (err2) return; + if (row) { + socket.emit('sheetData', { ...row, data: JSON.parse(row.data || '{}') }); + } else { + // Auto-create a blank sheet on first open, carrying the portrait + // over from the player's most recent sheet on another system + db.get( + `SELECT portrait_url FROM character_sheets WHERE username = ? AND is_npc = 0 ORDER BY updated_at DESC`, + [info.userName], + (err3, prev) => { + const portrait = prev ? prev.portrait_url : null; + db.run( + `INSERT INTO character_sheets (username, system, data, portrait_url) VALUES (?, ?, '{}', ?)`, + [info.userName, system, portrait], + function (err4) { + if (err4) return; + socket.emit('sheetData', { + id: this.lastID, username: info.userName, system, + data: {}, portrait_url: portrait, is_npc: 0, + }); + } + ); + } + ); + } + } + ); + }); + }); + + socket.on('updateSheetField', (payload) => { + const info = userSockets.get(socket.id); + if (!info || !info.userName) return; + if (!payload || typeof payload.fieldId !== 'string') return; + getGameSystem((err, system) => { + if (err) return; + db.get( + `SELECT id, data FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [info.userName, system], + (err2, row) => { + if (err2 || !row) return; + const data = JSON.parse(row.data || '{}'); + data[payload.fieldId] = payload.value; + db.run( + `UPDATE character_sheets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [JSON.stringify(data), row.id], + (err3) => { + if (err3) return; + io.emit('sheetUpdated', { username: info.userName, system }); + } + ); + } + ); + }); + }); + + // Public card for another player's token. Server-filtered to the template's + // public fields - combat-sensitive values never leave the server. Safe for + // spectators (allowlisted above). + socket.on('requestQuickSheet', (data) => { + if (!data || !data.username) return; + getGameSystem((err, system) => { + if (err) return; + db.get( + `SELECT username, system, data, portrait_url FROM character_sheets + WHERE username = ? AND system = ? AND is_npc = 0`, + [data.username, system], + (err2, row) => { + if (err2) return; + if (!row) return socket.emit('quickSheetData', { username: data.username, exists: false }); + socket.emit('quickSheetData', { + username: row.username, + system: row.system, + portrait_url: row.portrait_url, + exists: true, + fields: sheetTemplates.filterPublicData(row.system, row.data), + }); + } + ); + }); + }); + socket.on('markFirstPayDone', (data) => { if (!data || !data.username) return; db.run('UPDATE player_banks SET first_pay_done = 1 WHERE username = ?', [data.username]); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19c4453..9003fd6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,7 @@ import { CityDataBaseMenu } from './components/CityDatabase'; import { AdminBankWindow, AdminPayWindow, BankWindow, formatBankValue } from './components/BankWindows'; import { ChatWindow } from './components/ChatWindow'; import { Sidebar, NavControlsMenu, GeometryMenu, SystemInfoMenu, DiceMenu, QuickAccessMenu } from './components/Sidebar'; +import { CharacterSheetWindow } from './components/CharacterSheetWindow'; import { DiceTrayWindow, DotMatrixScoreboard, DiceScene } from './components/DiceTray'; import { EnemyRhombus, FriendlyRhombus, PlayerRhombus, OverlapChecker } from './components/Rhombuses'; import { UpdateModal } from './components/UpdateModal'; @@ -210,6 +211,8 @@ function App() { const [isChatOpen, setIsChatOpen] = useState(false); const [hasUnreadChat, setHasUnreadChat] = useState(false); const [isBankOpen, setIsBankOpen] = useState(false); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const [sheetPos, setSheetPos] = useState(() => ({ x: window.innerWidth / 2 - 220, y: 60 })); const [bankData, setBankData] = useState<{ balance: number, debt: number, firstPayDone?: boolean, highRollerDone?: boolean }>({ balance: 0, debt: 0 }); const [notificationsEnabled, setNotificationsEnabled] = useState(true); @@ -1272,6 +1275,8 @@ function App() { locations={locations} isBankOpen={isBankOpen} setIsBankOpen={setIsBankOpen} + isSheetOpen={isSheetOpen} + setIsSheetOpen={setIsSheetOpen} onSelect={setSelectedLocation} onZoom={setCameraTarget} selectedLocation={selectedLocation} @@ -1550,7 +1555,16 @@ function App() { }} currencyIcon={globalSettings?.currency_icon} /> - setIsSheetOpen(false)} + socket={socketRef.current} + userName={userName} + /> + )} + setIsChatOpen(false)} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 01e0534..9eb848a 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -1020,6 +1020,9 @@ export function AdminPanel({ {/* BANK SOUNDS TEST PANEL */} + {/* TTRPG SYSTEM (character sheets) */} + +
@@ -2054,6 +2057,73 @@ const BANK_SOUND_TESTERS: Record void> = { overdraft: playWompWomp, }; +function TTRPGSystemPanel({ token }: { token: string }) { + const [open, setOpen] = useState(false); + const [system, setSystem] = useState('generic'); + const [systems, setSystems] = useState<{ id: string; name: string }[]>([]); + const [sheets, setSheets] = useState([]); + + const refresh = () => { + fetch('/api/sheets/system').then(r => r.json()).then(d => { + if (d.system) setSystem(d.system); + if (d.systems) setSystems(d.systems); + }).catch(() => {}); + fetch('/api/sheets', { headers: { Authorization: `Bearer ${token}` } }) + .then(r => r.ok ? r.json() : []) + .then(rows => setSheets(Array.isArray(rows) ? rows : [])) + .catch(() => {}); + }; + + useEffect(() => { if (open) refresh(); }, [open]); + + const selectSystem = (id: string) => { + fetch('/api/sheets/system', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ system: id }), + }).then(r => { if (r.ok) setSystem(id); }); + }; + + return ( +
+ + {open && ( +
+ +
+ {systems.map(s => ( + + ))} +
+

+ Player sheets for the current system are kept and restored if you switch back. +

+ {sheets.length > 0 && ( +
+ + {sheets.map(s => ( +
+ {s.is_npc ? `[NPC] ${s.npc_label || s.username}` : s.username} + {s.system} +
+ ))} +
+ )} +
+ )} +
+ ); +} + function BankSoundsPanel({ token, globalSettings, fetchGlobalSettings }: { token: string; globalSettings: any; fetchGlobalSettings: () => void }) { const [open, setOpen] = useState(false); const [volumes, setVolumes] = useState>({ diff --git a/frontend/src/components/CharacterSheetWindow.tsx b/frontend/src/components/CharacterSheetWindow.tsx new file mode 100644 index 0000000..b08e726 --- /dev/null +++ b/frontend/src/components/CharacterSheetWindow.tsx @@ -0,0 +1,80 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { DraggableWindow } from './DraggableWindow'; +import { SheetRenderer } from './SheetRenderer'; +import { getTemplate, type CharacterSheet } from '../sheets'; + +// The player's own character sheet. Identity is the socket's registered +// user - the server only ever returns / edits the caller's own sheet. + +interface CharacterSheetWindowProps { + pos: { x: number; y: number }; + setPos: (pos: { x: number; y: number }) => void; + onClose: () => void; + socket: any; + userName: string; +} + +export function CharacterSheetWindow({ pos, setPos, onClose, socket, userName }: CharacterSheetWindowProps) { + const [sheet, setSheet] = useState(null); + const pendingSaves = useRef>>(new Map()); + + useEffect(() => { + if (!socket) return; + const onSheetData = (data: CharacterSheet) => { + if (data.username === userName) setSheet(data); + }; + const onSheetUpdated = (info: { username: string }) => { + // Re-sync when someone else (the admin) edits this sheet + if (info.username === userName) socket.emit('requestMySheet'); + }; + socket.on('sheetData', onSheetData); + socket.on('sheetUpdated', onSheetUpdated); + socket.emit('requestMySheet'); + return () => { + socket.off('sheetData', onSheetData); + socket.off('sheetUpdated', onSheetUpdated); + }; + }, [socket, userName]); + + // Cancel outstanding debounce timers on unmount + useEffect(() => () => { + pendingSaves.current.forEach(t => clearTimeout(t)); + pendingSaves.current.clear(); + }, []); + + const handleFieldChange = useCallback((fieldId: string, value: string | number) => { + setSheet(prev => prev ? { ...prev, data: { ...prev.data, [fieldId]: value } } : prev); + // Debounced per-field save so typing doesn't spam the socket + const timers = pendingSaves.current; + const existing = timers.get(fieldId); + if (existing) clearTimeout(existing); + timers.set(fieldId, setTimeout(() => { + timers.delete(fieldId); + socket?.emit('updateSheetField', { fieldId, value }); + }, 400)); + }, [socket]); + + const template = sheet ? getTemplate(sheet.system) : null; + + return ( + + {template.name.toUpperCase()} + + ) : undefined} + windowStyle={{ width: '440px' }} + contentStyle={{ maxHeight: '70vh', overflowY: 'auto', padding: '10px' }} + > + {sheet && template ? ( + + ) : ( +
ACCESSING RECORD...
+ )} +
+ ); +} diff --git a/frontend/src/components/SheetRenderer.tsx b/frontend/src/components/SheetRenderer.tsx new file mode 100644 index 0000000..3769256 --- /dev/null +++ b/frontend/src/components/SheetRenderer.tsx @@ -0,0 +1,175 @@ +import React, { useState } from 'react'; +import type { SheetTemplate, SheetSection, SheetField, SheetData } from '../sheets'; + +// Renders any game-system template. One renderer for every system - the +// template data decides what appears. + +interface SheetRendererProps { + template: SheetTemplate; + data: SheetData; + readOnly?: boolean; + onFieldChange: (fieldId: string, value: string | number) => void; +} + +const num = (v: unknown): number => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; + +const inputStyle: React.CSSProperties = { + background: 'rgba(0, 20, 0, 0.5)', + border: '1px solid var(--green)', + color: 'var(--green)', + fontFamily: 'inherit', + fontSize: '0.75rem', + padding: '3px 6px', + width: '100%', + boxSizing: 'border-box', +}; + +function FieldInput({ field, data, readOnly, onFieldChange }: { + field: SheetField; data: SheetData; readOnly: boolean; + onFieldChange: (fieldId: string, value: string | number) => void; +}) { + const value = data[field.id] ?? ''; + if (field.type === 'textarea') { + return ( +