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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c106c1..6980a37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,45 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.4.0] - 2026-07-13 + +Cyberpunk RED character sheets: the full Phase 3-5 sheet system, making CP:R the first feature-complete game system. + +### Added +- **Character sheet system** — template-driven sheets (one renderer, per-system templates); player window, admin view of any player/NPC sheet, standalone browser tab (`?sheet=true`), quick-sheet card, portrait upload with TV-glitch effect, segmented HP bar (green/yellow/red) +- **Server-authoritative rolls** — stat/skill rolls resolve against the stored sheet (exploding CP:R check die); results land in the dice tray and history +- **CP:R combat flow** — single ATTACK button; weapon picker from structured sheet weapon rows (name/DMG/skill/ROF); to-hit vs token DV; aimed shots (−8, head, ×2 damage through armor); damage auto-rolled, soaked by defender SP, armor ablation on penetration; damage writes through to token HP; attack animation follows the weapon type (melee/ranged) +- **SP SHIELD** — defender's shield intercepts damage first and breaks down point-for-point; overflow soaks against location SP +- **Critical injuries** — two+ max-face damage dice trigger +5 direct damage (ignores armor/shield) and prompt the GM to roll the book's injury table (table not embedded) +- **Death saves** — MORTALLY WOUNDED banner at 0 HP with DEATH SAVE button; 1d10 + escalating penalty vs BODY (natural 10 always fails); penalty resets on healing above 0 +- **Seriously Wounded** — banner at ≤ threshold HP; −2 to all checks applied server-side (−4 while mortally wounded) +- **Armor penalty** — heavy-armor stat penalty applied to all REF/DEX-keyed checks and attacks +- **LUCK on rolls** — arm pips on the sheet (declared before the roll, per RAW) for a flat bonus on the next roll; spend is capped/decremented server-side; attack panel has its own LUCK selector +- **House rules panel** (ADMIN → TTRPG_SYSTEM, staged APPLY/REVERT) — `MELEE_DV TAKE-10` (10 + DEX + Evasion instead of 6 +) and `LUCK BONUS ALSO NEGATES NAT-1` (also unlocks a dedicated 1-LUCK fumble shield); rules apply live via settingsUpdated +- **LUCK pips + admin reset** — hexagonal pips on the sheet header; RESET_ALL_LUCK in the admin panel restores every player to max +- **Humanity → EMP** — editing Humanity recomputes current EMP (= Humanity ÷ 10) on every write path (template-declared derived fields) +- **NPC library** — create/delete NPC sheets, folders with MOVE control, ATTACH sheet to a token, OPEN full sheet editor; NPC sheets mirror their linked token's HP live +- **Leveled NPC generation** — GENERATE_SHEET takes a per-system tier (CP:R: MOOK/SKILLED/PRO/ELITE) seeding stats, skills, armor, weapons, token HP and DVs; melee DV computed from the sheet (6/10 + DEX + Evasion), GM can override via EDIT_DV +- **Sheet import** — IMPORT on every sheet window: fillable-PDF form extraction, JSON paste, or stat-block text; per-system alias mapping with preview before apply; linked fields (HP/cash) refused with explanation +- **Token defense per system** — MELEE_AC/RANGED_AC labels become MELEE_DV/RANGED_DV under CP:R; CP:R hides the two-button melee/ranged flow behind one ATTACK button +- **OPEN_SHEET on token windows** — players open their own sheet from their token; admins open any player's or NPC's sheet from any token +- **`npm run dev` (backend)** — nodemon auto-restart so backend code changes apply without manual restarts + +### Changed +- **TTRPG_SYSTEM panel** moved above CURRENCY_ICON in the admin panel; SHEETS list removed +- **Sheet UX polish** — placeholders (ghost example text) on all free-form fields, upload hint bar attached under the portrait, weapon rows replace the free-text weapons area (notes field retained), CUR ≤ MAX clamping on paired fields (frontend + server) +- **CHECK_HEALTH window** — now resolves NPC tokens and tracks live HP by token id (was frozen at open and player-only) + +### Fixed +- **NPC armor ignored in attacks** — defender sheet lookup branched on token owner instead of token type, so enemy tokens (which carry an owner) never found their linked NPC sheet; SP always read 0 +- **DV edits not reflected** — EDIT_DV saved correctly but the token window showed the stale snapshot until reopen +- **Death save / FIRE appearing dead** — stale backend process; mitigated permanently by the new `npm run dev` watcher + +### Tests +- 377 backend / 574 frontend — new suites: `cpr_attack` (to-hit, armor, shield, crits, LUCK, death saves), `sockets.deathsave` (socket integration: death saves, NPC SP, import apply, tiered generation, fumble-shield gating), `sheet_import` (PDF/JSON/text extraction + mapping), `npc_tiers`, plus renderer/library/import-dialog coverage + +--- + ## [1.3.1] - 2026-07-08 ### Added diff --git a/README.md b/README.md index 569b888..a655596 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,15 @@ CITY_NET/ │ │ ├── overpasses.js # Overpass CRUD (GET all / POST one / DELETE :id) │ │ ├── signs.js # Custom sign CRUD (GET all / POST / PATCH :id / DELETE :id); text optional when image_url set │ │ ├── fonts.js # Font file upload/list/delete (.ttf .otf .woff .woff2); served as static under /uploads/fonts/ -│ │ └── player.js # Player auth (register, login, forgot, reset, registration status poll) +│ │ ├── player.js # Player auth (register, login, forgot, reset, registration status poll) +│ │ └── sheets.js # Character sheets — admin sheet access, NPC library, portraits, LUCK reset, import preview +│ ├── sheets/ +│ │ ├── templates.js # Server-side template metadata (public/combat/linked fields, max pairs, derived fields) +│ │ ├── rolls.js # Per-system roll map (fieldId → formula); server-authoritative +│ │ ├── rollEngine.js # Formula parse/resolve/execute (explode10, deterministic RNG for tests) +│ │ ├── attack.js # CP:R combat resolution — to-hit, damage, SP soak/ablation, shield, crits, death saves +│ │ ├── importers.js # Modular sheet import — PDF form extraction + per-system field mappers +│ │ └── npcTiers.js # Per-system NPC power tiers for GENERATE_SHEET (CP:R: Mook→Elite) │ ├── sockets/ │ │ └── index.js # All Socket.IO event handlers │ ├── startup/ @@ -351,6 +359,13 @@ CITY_NET/ │ ├── player.test.js # Player auth (register, login, forgot/reset, registration flow) │ ├── roads.test.js # Road API (GET / POST / DELETE / DELETE :id) │ ├── signs.test.js # Sign API (GET / POST / PATCH / DELETE, auth, image-only, filter_intensity clamping, XSS) +│ ├── sheets.test.js # Sheet routes (system switch, admin access, portraits, derived fields) +│ ├── npc_sheets.test.js # NPC library routes (CRUD, links, folders, LUCK reset, HP overlay) +│ ├── cpr_attack.test.js # CP:R attack module (to-hit, armor, shield, crits, death saves) +│ ├── npc_tiers.test.js # NPC tier packages (escalation, weapon validity) +│ ├── sheet_import.test.js # Import pipeline (PDF form extraction, alias mapping, preview route) +│ ├── rollEngine.test.js # Roll formula engine +│ ├── sockets.deathsave.test.js # Socket integration: death saves, sheetAttack vs NPC SP, import apply, tiered generation │ ├── sockets.editing.test.js # Socket editing access flow; regression for stale elevatedUsers bug │ └── undo.test.js # Undo endpoint (all action types, auth, ordering) │ @@ -387,6 +402,12 @@ CITY_NET/ │ │ │ ├── Streamer.tsx # Camera broadcaster/rig pairs for streamer mode │ │ │ ├── StreamerOverlay.tsx # HUD overlay rendered on the spectator window │ │ │ ├── StreamerDirectorPanel.tsx # Admin director controls (camera mode, visibility flags) +│ │ │ ├── CharacterSheetWindow.tsx # Player's own character sheet (socket-based, self-only) +│ │ │ ├── NpcSheetWindow.tsx # Admin view/edit of NPC or player sheets (REST-based) +│ │ │ ├── NpcLibrary.tsx # NPC sheet library (folders, attach-to-token, move, open) +│ │ │ ├── SheetRenderer.tsx # Template-driven sheet renderer (any game system) +│ │ │ ├── ImportSheetDialog.tsx # Sheet import — fillable PDF / JSON / stat-block paste with preview +│ │ │ ├── QuickSheetCard.tsx # Public sheet card shown to other players │ │ │ ├── UpdateModal.tsx # Draggable update notification modal (shown on admin login when update available; Update Now / Remind Me Later / Skip Version; docker-aware) │ │ │ └── __tests__/ # Component unit tests (Vitest + Testing Library) │ │ │ ├── AdminPanel.test.tsx @@ -406,6 +427,10 @@ CITY_NET/ │ │ │ ├── RadioPlayer.test.tsx │ │ │ ├── Rhombuses.test.tsx │ │ │ ├── SecureLogin.test.tsx # Login, register, approval polling, password reset, deny flows +│ │ │ ├── CharacterSheet.test.tsx # Template registry, renderer, sheet window, weapon rows, death saves +│ │ │ ├── NpcLibrary.test.tsx +│ │ │ ├── ImportSheetDialog.test.tsx +│ │ │ ├── QuickSheetCard.test.tsx │ │ │ ├── Sidebar.test.tsx │ │ │ └── UpdateModal.test.tsx # Rendering, docker/non-docker branching, button callbacks, update flow │ │ ├── context/ @@ -417,6 +442,13 @@ CITY_NET/ │ │ │ └── __tests__/ │ │ │ ├── useApi.test.ts # Fetch helper unit tests │ │ │ └── useSocket.pendingRequests.test.ts # Pending edit-request state; regression for stale requests on newly-promoted temp admins +│ │ ├── sheets/ +│ │ │ ├── types.ts # Sheet template type system (fields, sections, header, death saves, NPC tiers) +│ │ │ ├── index.ts # Template registry + getMaxPairs helper +│ │ │ ├── SheetPage entry # (src/SheetPage.tsx) standalone browser-tab sheet (?sheet=true) +│ │ │ └── templates/ +│ │ │ ├── generic.ts # Minimal fallback template +│ │ │ └── cyberpunk_red.ts # Cyberpunk RED — stats, skills, weapons, armor, tiers (labels + dice math only, no book content) │ │ ├── streamerMode.ts # IS_SPECTATOR constant — detects ?streamer=true URL param │ │ └── utils/ │ │ ├── locationHelpers.ts # Location geometry utilities; exports ZONE_TYPE_NAMES and isUserDefinedName diff --git a/backend/__tests__/cpr_attack.test.js b/backend/__tests__/cpr_attack.test.js new file mode 100644 index 0000000..8f81f57 --- /dev/null +++ b/backend/__tests__/cpr_attack.test.js @@ -0,0 +1,252 @@ +import { describe, it, expect } from 'vitest'; +import { + AIMED_PENALTY, getWeapon, rollToHit, rollDamage, applyArmor, applyShield, + isCriticalInjury, rollDeathSave, MELEE_SKILLS, RANGED_SKILLS, checkPenalties, + resolveLuckSpend, +} from '../sheets/attack.js'; + +// Deterministic RNG: feed die faces in order (works for any sides because the +// engine floors rng()*sides — pass the fraction for the face you want). +const dieRng = (sides, ...faces) => { + const queue = [...faces]; + return () => (queue.shift() - 1 + 0.5) / sides; +}; + +const sheet = { + ref: 7, dex: 6, handgun: 5, melee_weapon: 3, body: 6, + weapon1_name: 'Militech Avenger', weapon1_dmg: '2d6', weapon1_skill: 'handgun', weapon1_rof: 2, + weapon2_name: 'Sword', weapon2_dmg: '3d6', weapon2_skill: 'melee_weapon', weapon2_rof: 2, + weapon3_name: 'Bad Row', weapon3_dmg: '2d6 + @ref', weapon3_skill: 'handgun', + weapon4_name: 'No Skill', weapon4_dmg: '2d6', weapon4_skill: 'perception', +}; + +describe('getWeapon', () => { + it('reads a valid ranged weapon row', () => { + expect(getWeapon(sheet, 1)).toEqual({ + name: 'Militech Avenger', dmg: '2d6', skill: 'handgun', attackType: 'ranged', + }); + }); + + it('classifies melee skills as melee attacks', () => { + expect(getWeapon(sheet, 2).attackType).toBe('melee'); + }); + + it('rejects damage that is not pure dice (no field refs or flat bonuses)', () => { + expect(getWeapon(sheet, 3)).toBeNull(); + }); + + it('rejects non-weapon skills', () => { + expect(getWeapon(sheet, 4)).toBeNull(); + }); + + it('rejects out-of-range and non-integer indexes', () => { + expect(getWeapon(sheet, 0)).toBeNull(); + expect(getWeapon(sheet, 5)).toBeNull(); + expect(getWeapon(sheet, '1; DROP')).toBeNull(); + }); + + it('falls back to WEAPON N when the row has no name', () => { + const s = { ...sheet, weapon1_name: '' }; + expect(getWeapon(s, 1).name).toBe('WEAPON 1'); + }); +}); + +describe('rollToHit', () => { + const weapon = getWeapon(sheet, 1); // handgun keys off REF + + it('rolls 1d10 + stat + skill', () => { + const out = rollToHit(sheet, weapon, false, dieRng(10, 6)); + expect(out.total).toBe(6 + 7 + 5); + expect(out.critical).toBeNull(); + }); + + it('applies the aimed-shot penalty', () => { + const out = rollToHit(sheet, weapon, true, dieRng(10, 6)); + expect(out.total).toBe(6 + 7 + 5 - AIMED_PENALTY); + }); + + it('explodes on a natural 10', () => { + const out = rollToHit(sheet, weapon, false, dieRng(10, 10, 4)); + expect(out.critical).toBe('success'); + expect(out.total).toBe(10 + 4 + 7 + 5); + }); + + it('implodes on a natural 1', () => { + const out = rollToHit(sheet, weapon, false, dieRng(10, 1, 4)); + expect(out.critical).toBe('failure'); + expect(out.total).toBe(1 - 4 + 7 + 5); + }); +}); + +describe('rollDamage', () => { + it('rolls the weapon dice as a plain sum (no explosion)', () => { + const weapon = getWeapon(sheet, 2); // 3d6 + const out = rollDamage(weapon, dieRng(6, 6, 6, 6)); + expect(out.total).toBe(18); + expect(out.critical).toBeNull(); + }); +}); + +describe('applyArmor', () => { + it('SP soaks damage; only the excess gets through and armor ablates', () => { + expect(applyArmor(10, 7, false)).toEqual({ through: 3, ablated: true }); + }); + + it('no penetration means no damage and no ablation', () => { + expect(applyArmor(7, 7, false)).toEqual({ through: 0, ablated: false }); + expect(applyArmor(3, 7, false)).toEqual({ through: 0, ablated: false }); + }); + + it('aimed (head) hits double the damage that gets through', () => { + expect(applyArmor(10, 7, true)).toEqual({ through: 6, ablated: true }); + }); + + it('treats missing/negative SP as 0', () => { + expect(applyArmor(5, undefined, false)).toEqual({ through: 5, ablated: true }); + expect(applyArmor(5, -3, false)).toEqual({ through: 5, ablated: true }); + }); +}); + +describe('rollDeathSave', () => { + it('succeeds when die + penalty <= BODY', () => { + expect(rollDeathSave(6, 0, dieRng(10, 5)).success).toBe(true); + expect(rollDeathSave(6, 0, dieRng(10, 6)).success).toBe(true); + }); + + it('fails when die + penalty > BODY', () => { + expect(rollDeathSave(6, 0, dieRng(10, 7)).success).toBe(false); + }); + + it('escalating penalty makes later saves harder', () => { + // BODY 6: a 5 passes untreated round 1, but fails with +2 penalty + expect(rollDeathSave(6, 0, dieRng(10, 5)).success).toBe(true); + const later = rollDeathSave(6, 2, dieRng(10, 5)); + expect(later.total).toBe(7); + expect(later.success).toBe(false); + }); + + it('a natural 10 always fails, even with a huge BODY', () => { + expect(rollDeathSave(20, 0, dieRng(10, 10)).success).toBe(false); + }); + + it('clamps a negative stored penalty to 0', () => { + expect(rollDeathSave(6, -3, dieRng(10, 5)).penalty).toBe(0); + }); +}); + +describe('skill lists', () => { + it('melee and ranged lists do not overlap', () => { + expect(MELEE_SKILLS.filter(s => RANGED_SKILLS.includes(s))).toEqual([]); + }); +}); + +describe('applyShield', () => { + it('shield absorbs damage and loses points', () => { + expect(applyShield(4, 10)).toEqual({ absorbed: 4, remaining: 0, newShield: 6, destroyed: false }); + }); + + it('overflow past the shield passes on and the shield is destroyed', () => { + expect(applyShield(12, 10)).toEqual({ absorbed: 10, remaining: 2, newShield: 0, destroyed: true }); + }); + + it('no shield means everything passes through untouched', () => { + expect(applyShield(8, 0)).toEqual({ absorbed: 0, remaining: 8, newShield: 0, destroyed: false }); + expect(applyShield(8, undefined)).toEqual({ absorbed: 0, remaining: 8, newShield: 0, destroyed: false }); + }); +}); + +describe('isCriticalInjury', () => { + it('two dice at max face is a critical injury', () => { + expect(isCriticalInjury({ 6: [6, 6, 2] })).toBe(true); + }); + + it('one max die is not', () => { + expect(isCriticalInjury({ 6: [6, 3, 2] })).toBe(false); + }); + + it('works across die sizes (max face per size)', () => { + expect(isCriticalInjury({ 8: [8, 8] })).toBe(true); + expect(isCriticalInjury({ 8: [6, 6] })).toBe(false); // 6 is not max on a d8 + }); + + it('handles missing rolls', () => { + expect(isCriticalInjury(undefined)).toBe(false); + expect(isCriticalInjury({})).toBe(false); + }); +}); + +describe('checkPenalties', () => { + it('armor penalty hits REF/DEX checks only', () => { + const data = { armor_penalty: 2 }; + expect(checkPenalties(data, 'ref', 20)).toEqual([{ label: 'armor', value: -2 }]); + expect(checkPenalties(data, 'dex', 20)).toEqual([{ label: 'armor', value: -2 }]); + expect(checkPenalties(data, 'int', 20)).toEqual([]); + }); + + it('seriously wounded gives -2, mortally wounded -4', () => { + const data = { seriously_wounded: 17 }; + expect(checkPenalties(data, 'int', 20)).toEqual([]); + expect(checkPenalties(data, 'int', 17)).toEqual([{ label: 'wounded', value: -2 }]); + expect(checkPenalties(data, 'int', 0)).toEqual([{ label: 'mortally wounded', value: -4 }]); + }); + + it('no hp info means no wound penalty', () => { + expect(checkPenalties({ seriously_wounded: 17 }, 'int', null)).toEqual([]); + }); + + it('penalties stack: wounded solo in heavy armor', () => { + const mods = checkPenalties({ armor_penalty: 1, seriously_wounded: 17 }, 'ref', 10); + expect(mods.reduce((a, m) => a + m.value, 0)).toBe(-3); + }); +}); + +describe('rollToHit with LUCK and penalties', () => { + const weapon = getWeapon(sheet, 1); // handgun, REF 7 + skill 5 + + it('adds declared LUCK as a flat bonus', () => { + const out = rollToHit(sheet, weapon, false, dieRng(10, 6), { luck: 3 }); + expect(out.total).toBe(6 + 7 + 5 + 3); + }); + + it('noFumble keeps a natural 1 at face value (fumble shield / house rule)', () => { + const out = rollToHit(sheet, weapon, false, dieRng(10, 1), { noFumble: true }); + expect(out.critical).toBeNull(); + expect(out.total).toBe(1 + 7 + 5); + }); + + it('LUCK bonus alone does NOT negate a fumble (RAW)', () => { + const out = rollToHit(sheet, weapon, false, dieRng(10, 1, 4), { luck: 1 }); + expect(out.critical).toBe('failure'); + expect(out.total).toBe(1 - 4 + 7 + 5 + 1); + }); + + it('without LUCK a natural 1 still fumbles', () => { + const out = rollToHit(sheet, weapon, false, dieRng(10, 1, 4)); + expect(out.critical).toBe('failure'); + }); + + it('applies armor and wound penalties to the attack', () => { + const armored = { ...sheet, armor_penalty: 2, seriously_wounded: 17 }; + const out = rollToHit(armored, weapon, false, dieRng(10, 6), { hp: 10 }); + expect(out.total).toBe(6 + 7 + 5 - 2 - 2); + }); +}); + + +describe('resolveLuckSpend', () => { + it('bonus is clamped to the pool', () => { + expect(resolveLuckSpend(2, 5, false)).toEqual({ bonus: 2, negate: false, total: 2 }); + }); + + it('the fumble shield is paid first, bonus gets the rest', () => { + expect(resolveLuckSpend(3, 5, true)).toEqual({ bonus: 2, negate: true, total: 3 }); + }); + + it('shield alone costs exactly 1', () => { + expect(resolveLuckSpend(4, 0, true)).toEqual({ bonus: 0, negate: true, total: 1 }); + }); + + it('no pool means no shield and no bonus', () => { + expect(resolveLuckSpend(0, 3, true)).toEqual({ bonus: 0, negate: false, total: 0 }); + }); +}); 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__/locations.test.js b/backend/__tests__/locations.test.js index c605f27..96d6fda 100644 --- a/backend/__tests__/locations.test.js +++ b/backend/__tests__/locations.test.js @@ -12,10 +12,13 @@ const ADMIN_TOKEN = jwt.sign( 'test-secret' ); +let emitted; + const makeApp = (db) => { const app = express(); app.use(express.json()); - const io = { emit: () => {} }; + emitted = []; + const io = { emit: (event, payload) => emitted.push({ event, payload }) }; app.use('/api/locations', locationsRouteFactory(db, io, { emitUpdate: () => {}, recordAction: () => {}, @@ -252,6 +255,15 @@ describe('PUT /api/locations/:id/health', () => { expect(row.hp_current).toBe(17); // 5 temp absorbs first, 3 bleeds to current }); + it('emits sheetUpdated for the owner when rhombus HP changes (sheet mirror)', async () => { + const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max, hp_temp) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 20, 20, 0)`); + await request(app) + .put(`/api/locations/${r.lastID}/health`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ action: 'damage', amount: 5 }); + expect(emitted.some(e => e.event === 'sheetUpdated' && e.payload.username === 'GHOST')).toBe(true); + }); + it('heal action increases hp_current capped at hp_max', async () => { const r = await run(db, `INSERT INTO locations (name, x, y, z, shape, hp_current, hp_max, hp_temp) VALUES ('MEDIC', 0, 0, 0, 'rhombus', 10, 20, 0)`); await request(app) diff --git a/backend/__tests__/npc_sheets.test.js b/backend/__tests__/npc_sheets.test.js new file mode 100644 index 0000000..71f3580 --- /dev/null +++ b/backend/__tests__/npc_sheets.test.js @@ -0,0 +1,281 @@ +import { describe, it, expect, beforeEach } 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'; + +const ADMIN_TOKEN = jwt.sign( + { id: 1, username: 'admin', role: 'admin', isTemporary: false }, + 'test-secret' +); +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 setSystem = (database, system) => + run(database, `INSERT INTO global_settings (key, value) VALUES ('game_system', ?)`, [system]); + +beforeEach(async () => { + db = await makeTestDb(); + app = makeApp(db); + await setSystem(db, 'cyberpunk_red'); +}); + +// ─── GET /api/sheets/npcs ───────────────────────────────────────────────────── + +describe('GET /api/sheets/npcs', () => { + it('returns NPC list for admin', async () => { + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label, folder) VALUES ('admin','cyberpunk_red','{}',1,'Gang Member','Gangs')`); + const res = await request(app).get('/api/sheets/npcs').set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].npc_label).toBe('Gang Member'); + expect(res.body[0].folder).toBe('Gangs'); + }); + + it('rejects player tokens', async () => { + const res = await request(app).get('/api/sheets/npcs').set('Authorization', `Bearer ${PLAYER_TOKEN}`); + expect(res.status).toBe(403); + }); + + it('only returns NPCs on the active system', async () => { + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin','cyberpunk_red','{}',1,'CPR NPC')`); + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin','generic','{}',1,'Generic NPC')`); + const res = await request(app).get('/api/sheets/npcs').set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].npc_label).toBe('CPR NPC'); + }); +}); + +// ─── POST /api/sheets/npcs ──────────────────────────────────────────────────── + +describe('POST /api/sheets/npcs', () => { + it('creates an NPC sheet', async () => { + const res = await request(app) + .post('/api/sheets/npcs') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ npc_label: 'Fixer', folder: 'Contacts' }); + expect(res.status).toBe(200); + expect(res.body.id).toBeGreaterThan(0); + expect(res.body.npc_label).toBe('Fixer'); + expect(res.body.folder).toBe('Contacts'); + const row = await get(db, `SELECT * FROM character_sheets WHERE id = ?`, [res.body.id]); + expect(row.is_npc).toBe(1); + expect(row.system).toBe('cyberpunk_red'); + }); + + it('requires npc_label', async () => { + const res = await request(app) + .post('/api/sheets/npcs') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({}); + expect(res.status).toBe(400); + }); +}); + +// ─── DELETE /api/sheets/npcs/:id ───────────────────────────────────────────── + +describe('DELETE /api/sheets/npcs/:id', () => { + it('deletes the NPC', async () => { + const { lastID } = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin','cyberpunk_red','{}',1,'Gang Member')`); + const res = await request(app).delete(`/api/sheets/npcs/${lastID}`).set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + const row = await get(db, `SELECT * FROM character_sheets WHERE id = ?`, [lastID]); + expect(row).toBeUndefined(); + }); + + it('404s for unknown id', async () => { + const res = await request(app).delete('/api/sheets/npcs/9999').set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(404); + }); +}); + +// ─── POST /api/sheets/npcs/:id/link ────────────────────────────────────────── + +describe('POST /api/sheets/npcs/:id/link', () => { + it('links an NPC sheet to a location and emits npcLinkChanged', async () => { + await run(db, `INSERT INTO locations (id, name, x, y, z, width, height, depth, shape) VALUES (42, 'hostile', 0, 0, 0, 1, 1, 1, 'enemy_rhombus')`); + const { lastID } = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin','cyberpunk_red','{}',1,'Goon')`); + const res = await request(app) + .post(`/api/sheets/npcs/${lastID}/link`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ location_id: 42 }); + expect(res.status).toBe(200); + const link = await get(db, `SELECT * FROM npc_sheet_links WHERE location_id = 42`); + expect(link.sheet_id).toBe(lastID); + const evt = emitted.find(e => e.event === 'npcLinkChanged'); + expect(evt?.payload).toEqual({ location_id: 42, sheet_id: lastID }); + }); + + it('re-attaches when already linked (upsert)', async () => { + await run(db, `INSERT INTO locations (id, name, x, y, z, width, height, depth, shape) VALUES (43, 'hostile', 0, 0, 0, 1, 1, 1, 'enemy_rhombus')`); + const { lastID: id1 } = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin','cyberpunk_red','{}',1,'NPC1')`); + const { lastID: id2 } = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin','cyberpunk_red','{}',1,'NPC2')`); + await request(app).post(`/api/sheets/npcs/${id1}/link`).set('Authorization', `Bearer ${ADMIN_TOKEN}`).send({ location_id: 43 }); + const res = await request(app).post(`/api/sheets/npcs/${id2}/link`).set('Authorization', `Bearer ${ADMIN_TOKEN}`).send({ location_id: 43 }); + expect(res.status).toBe(200); + const link = await get(db, `SELECT sheet_id FROM npc_sheet_links WHERE location_id = 43`); + expect(link.sheet_id).toBe(id2); + }); +}); + +// ─── DELETE /api/sheets/npcs/:id/link/:location_id ─────────────────────────── + +describe('ATTACH stamps melee DV from the sheet', () => { + it('sets the token melee_ac to 6 + DEX + Evasion on link', async () => { + const npc = await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin', 'cyberpunk_red', '{"dex":6,"evasion":4}', 1, 'Guy')`); + await run(db, + `INSERT INTO locations (id, name, x, y, z, shape, melee_ac) VALUES (77, 'Guy', 0, 0, 0, 'enemy_rhombus', 10)`); + const res = await request(app) + .post(`/api/sheets/npcs/${npc.lastID}/link`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ location_id: 77 }); + expect(res.status).toBe(200); + const loc = await get(db, `SELECT melee_ac FROM locations WHERE id = 77`); + expect(loc.melee_ac).toBe(16); + }); + + it('take-10 setting raises the stamped melee DV base to 10', async () => { + await run(db, `INSERT INTO global_settings (key, value) VALUES ('melee_dv_take10', '1')`); + const npc = await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin', 'cyberpunk_red', '{"dex":6,"evasion":4}', 1, 'Guy')`); + await run(db, + `INSERT INTO locations (id, name, x, y, z, shape, melee_ac) VALUES (78, 'Guy', 0, 0, 0, 'enemy_rhombus', 10)`); + await request(app) + .post(`/api/sheets/npcs/${npc.lastID}/link`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ location_id: 78 }); + const loc = await get(db, `SELECT melee_ac FROM locations WHERE id = 78`); + expect(loc.melee_ac).toBe(20); + }); +}); + +describe('DELETE /api/sheets/npcs/:id/link/:location_id', () => { + it('unlinks NPC from location and emits npcLinkChanged', async () => { + await run(db, `INSERT INTO locations (id, name, x, y, z, width, height, depth, shape) VALUES (44, 'hostile', 0, 0, 0, 1, 1, 1, 'enemy_rhombus')`); + const { lastID } = await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin','cyberpunk_red','{}',1,'Goon')`); + await run(db, `INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (44, ?)`, [lastID]); + const res = await request(app) + .delete(`/api/sheets/npcs/${lastID}/link/44`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + const link = await get(db, `SELECT * FROM npc_sheet_links WHERE location_id = 44`); + expect(link).toBeUndefined(); + const evt = emitted.find(e => e.event === 'npcLinkChanged'); + expect(evt?.payload).toEqual({ location_id: 44, sheet_id: null }); + }); +}); + +// ─── POST /api/sheets/reset-luck ───────────────────────────────────────────── + +describe('POST /api/sheets/reset-luck', () => { + it('resets luck to luck_max for all player sheets', async () => { + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('ghost','cyberpunk_red','{"luck":2,"luck_max":5}',0)`); + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('pyro','cyberpunk_red','{"luck":0,"luck_max":3}',0)`); + const res = await request(app).post('/api/sheets/reset-luck').set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body.reset).toBe(2); + const ghost = await get(db, `SELECT data FROM character_sheets WHERE username = 'ghost'`); + expect(JSON.parse(ghost.data).luck).toBe(5); + const pyro = await get(db, `SELECT data FROM character_sheets WHERE username = 'pyro'`); + expect(JSON.parse(pyro.data).luck).toBe(3); + }); + + it('skips sheets with no luck_max defined', async () => { + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('nobody','cyberpunk_red','{"luck":1}',0)`); + const res = await request(app).post('/api/sheets/reset-luck').set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body.reset).toBe(0); + }); + + it('skips NPC sheets', async () => { + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin','cyberpunk_red','{"luck":1,"luck_max":6}',1,'Boss')`); + const res = await request(app).post('/api/sheets/reset-luck').set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body.reset).toBe(0); + }); + + it('returns reason when system has no luckField', async () => { + await run(db, `UPDATE global_settings SET value = 'generic' WHERE key = 'game_system'`); + const res = await request(app).post('/api/sheets/reset-luck').set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body.reset).toBe(0); + expect(res.body.reason).toBeTruthy(); + }); + + it('rejects non-admin requests', async () => { + const res = await request(app).post('/api/sheets/reset-luck').set('Authorization', `Bearer ${PLAYER_TOKEN}`); + expect(res.status).toBe(403); + }); + + it('emits sheetUpdated for each reset sheet', async () => { + await run(db, `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('ghost','cyberpunk_red','{"luck":1,"luck_max":4}',0)`); + emitted = []; + await request(app).post('/api/sheets/reset-luck').set('Authorization', `Bearer ${ADMIN_TOKEN}`); + const updates = emitted.filter(e => e.event === 'sheetUpdated'); + expect(updates).toHaveLength(1); + expect(updates[0].payload).toEqual({ username: 'ghost' }); + }); +}); + +// ─── NPC sheet HP linking ───────────────────────────────────────────────────── + +describe('GET /api/sheets/npcs/:id token HP overlay', () => { + it('overlays the linked token HP onto the sheet data', async () => { + const npc = await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin', 'cyberpunk_red', '{"sp_body":6}', 1, 'Guy')`); + const loc = await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('Guy', 0, 0, 0, 'enemy_rhombus', 'SYSTEM', 21, 35)`); + await run(db, `INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)`, [loc.lastID, npc.lastID]); + + const res = await request(app) + .get(`/api/sheets/npcs/${npc.lastID}`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body.data.hp).toBe(21); + expect(res.body.data.hp_max).toBe(35); + expect(res.body.data.sp_body).toBe(6); + }); + + it('leaves stored data alone when the sheet is not linked to a token', async () => { + const npc = await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin', 'cyberpunk_red', '{"hp":9}', 1, 'Loner')`); + const res = await request(app) + .get(`/api/sheets/npcs/${npc.lastID}`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`); + expect(res.status).toBe(200); + expect(res.body.data.hp).toBe(9); + }); + + it('PUT refuses to store linked HP fields in the sheet JSON', async () => { + const npc = await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin', 'cyberpunk_red', '{}', 1, 'Guy')`); + const res = await request(app) + .put(`/api/sheets/npcs/${npc.lastID}`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ fields: { hp: 99, hp_max: 99, sp_body: 4 } }); + expect(res.status).toBe(200); + const row = await get(db, `SELECT data FROM character_sheets WHERE id = ?`, [npc.lastID]); + const data = JSON.parse(row.data); + expect(data.hp).toBeUndefined(); + expect(data.hp_max).toBeUndefined(); + expect(data.sp_body).toBe(4); + }); +}); diff --git a/backend/__tests__/npc_tiers.test.js b/backend/__tests__/npc_tiers.test.js new file mode 100644 index 0000000..3678719 --- /dev/null +++ b/backend/__tests__/npc_tiers.test.js @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { TIERS, getTierOptions, buildTier } from '../sheets/npcTiers.js'; +import { getWeapon } from '../sheets/attack.js'; + +describe('CP:R NPC tiers', () => { + it('offers four tiers', () => { + expect(getTierOptions('cyberpunk_red').map(t => t.id)).toEqual(['mook', 'skilled', 'pro', 'elite']); + }); + + it('tiers escalate: stats, SP, HP and DV all rise', () => { + const ids = ['mook', 'skilled', 'pro', 'elite']; + const built = ids.map(id => buildTier('cyberpunk_red', id)); + for (let i = 1; i < built.length; i++) { + expect(built[i].data.ref).toBeGreaterThan(built[i - 1].data.ref); + expect(built[i].data.sp_body).toBeGreaterThan(built[i - 1].data.sp_body); + expect(built[i].hp).toBeGreaterThan(built[i - 1].hp); + expect(built[i].dv.ranged).toBeGreaterThan(built[i - 1].dv.ranged); + } + }); + + it('every tier weapon row is valid for the attack engine', () => { + getTierOptions('cyberpunk_red').forEach(({ id }) => { + const { data } = buildTier('cyberpunk_red', id); + expect(getWeapon(data, 1)).not.toBeNull(); + }); + }); + + it('unknown tier ids fall back to the system default', () => { + const t = buildTier('cyberpunk_red', 'boss_of_all_bosses'); + expect(t.tierId).toBe('mook'); + }); + + it('systems without tiers return null', () => { + expect(buildTier('generic', 'mook')).toBeNull(); + }); + + it('death save target matches BODY in every tier', () => { + getTierOptions('cyberpunk_red').forEach(({ id }) => { + const { data } = buildTier('cyberpunk_red', id); + expect(data.death_save).toBe(data.body); + }); + }); +}); diff --git a/backend/__tests__/rollEngine.test.js b/backend/__tests__/rollEngine.test.js new file mode 100644 index 0000000..d9c9a2a --- /dev/null +++ b/backend/__tests__/rollEngine.test.js @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { parseFormula, resolveFormula, executeRoll } from '../sheets/rollEngine.js'; +import { ROLLS, getRoll } from '../sheets/rolls.js'; + +// Deterministic RNG: feed die faces in order; each call returns a fraction +// that makes rollDie land exactly on the queued d10 face. +const d10rng = (...faces) => { + const queue = [...faces]; + return () => (queue.shift() - 1 + 0.5) / 10; +}; + +describe('parseFormula', () => { + it('parses dice, field refs, and literals with signs', () => { + expect(parseFormula('1d10 + @ref - 2')).toEqual([ + { kind: 'dice', sign: 1, count: 1, sides: 10 }, + { kind: 'field', sign: 1, field: 'ref' }, + { kind: 'int', sign: -1, value: 2 }, + ]); + }); + + it('rejects garbage terms', () => { + expect(() => parseFormula('1d10 + DROP TABLE')).toThrow(); + expect(() => parseFormula('!!')).toThrow(); + }); + + it('rejects out-of-range dice', () => { + expect(() => parseFormula('999d10')).toThrow(); + expect(() => parseFormula('1d1')).toThrow(); + }); +}); + +describe('resolveFormula', () => { + it('substitutes stored sheet values', () => { + const r = resolveFormula('1d10 + @ref + @handgun', { ref: 7, handgun: 5 }); + expect(r.dice).toEqual([{ count: 1, sides: 10, sign: 1 }]); + expect(r.modifiers).toEqual([ + { label: 'ref', value: 7 }, + { label: 'handgun', value: 5 }, + ]); + }); + + it('missing or non-numeric fields resolve to 0', () => { + const r = resolveFormula('1d10 + @ref + @handgun', { ref: 'abc' }); + expect(r.modifiers.map(m => m.value)).toEqual([0, 0]); + }); + + it('requires at least one dice term', () => { + expect(() => resolveFormula('@ref + 2', { ref: 5 })).toThrow(); + }); +}); + +describe('executeRoll — sum', () => { + it('totals dice plus modifiers', () => { + const resolved = resolveFormula('1d10 + @ref + @handgun', { ref: 7, handgun: 5 }); + const out = executeRoll(resolved, 'sum', d10rng(6)); + expect(out.total).toBe(18); + expect(out.rolls).toEqual({ 10: [6] }); + expect(out.critical).toBeNull(); + expect(out.breakdown).toBe('(6) + 12'); + }); +}); + +describe('executeRoll — explode10 (CP:R check die)', () => { + it('natural 10 adds one extra d10 (critical success)', () => { + const resolved = resolveFormula('1d10 + @ref', { ref: 7 }); + const out = executeRoll(resolved, 'explode10', d10rng(10, 4)); + expect(out.total).toBe(10 + 4 + 7); + expect(out.critical).toBe('success'); + expect(out.rolls[10]).toEqual([10, 4]); + expect(out.breakdown).toContain('10!+4'); + }); + + it('natural 1 subtracts one extra d10 (fumble)', () => { + const resolved = resolveFormula('1d10 + @ref', { ref: 7 }); + const out = executeRoll(resolved, 'explode10', d10rng(1, 6)); + expect(out.total).toBe(1 - 6 + 7); + expect(out.critical).toBe('failure'); + expect(out.breakdown).toContain('1!-6'); + }); + + it('never chains: a 10 on the extra die does not explode again', () => { + const resolved = resolveFormula('1d10 + @ref', { ref: 0 }); + const out = executeRoll(resolved, 'explode10', d10rng(10, 10)); + expect(out.total).toBe(20); + expect(out.rolls[10]).toEqual([10, 10]); + }); + + it('mid-range rolls do not explode', () => { + const resolved = resolveFormula('1d10 + @ref', { ref: 3 }); + const out = executeRoll(resolved, 'explode10', d10rng(5)); + expect(out.total).toBe(8); + expect(out.critical).toBeNull(); + }); +}); + +describe('executeRoll — pool', () => { + it('is reserved but not implemented', () => { + const resolved = resolveFormula('6d6', {}); + expect(() => executeRoll(resolved, 'pool')).toThrow(/not implemented/); + }); +}); + +describe('roll map', () => { + it('CP:R stats and skills all resolve and parse', () => { + const rolls = ROLLS.cyberpunk_red; + expect(Object.keys(rolls).length).toBeGreaterThan(60); + Object.values(rolls).forEach(({ formula, shape }) => { + expect(shape).toBe('explode10'); + expect(() => resolveFormula(formula, {})).not.toThrow(); + }); + }); + + it('getRoll returns null for unknown fields and systems', () => { + expect(getRoll('cyberpunk_red', 'sp_body')).toBeNull(); // combat field: not rollable + expect(getRoll('cyberpunk_red', 'nope')).toBeNull(); + expect(getRoll('calvinball', 'handgun')).toBeNull(); + }); + + it('skill rolls reference the skill and its stat', () => { + expect(getRoll('cyberpunk_red', 'handgun').formula).toBe('1d10 + @ref + @handgun'); + expect(getRoll('cyberpunk_red', 'perception').formula).toBe('1d10 + @int + @perception'); + }); +}); diff --git a/backend/__tests__/sheet_import.test.js b/backend/__tests__/sheet_import.test.js new file mode 100644 index 0000000..7837965 --- /dev/null +++ b/backend/__tests__/sheet_import.test.js @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import { PDFDocument } from 'pdf-lib'; +import { makeTestDb, run } from './helpers/testDb.js'; +import sheetsRouteFactory from '../routes/sheets.js'; +import { extractPdfFields, getImporter } from '../sheets/importers.js'; + +process.env.JWT_SECRET = 'test-secret'; + +let db; +let app; + +const makeApp = (database) => { + const application = express(); + application.use(express.json()); + const io = { emit: () => {} }; + application.use('/api/sheets', sheetsRouteFactory(database, io)); + return application; +}; + +beforeEach(async () => { + db = await makeTestDb(); + app = makeApp(db); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cyberpunk_red')`); +}); + +// Build a small fillable PDF in-memory, like an official character sheet +const buildFormPdf = async (fields) => { + const doc = await PDFDocument.create(); + const page = doc.addPage([400, 800]); + const form = doc.getForm(); + let y = 760; + for (const [name, value] of Object.entries(fields)) { + const tf = form.createTextField(name); + tf.setText(String(value)); + tf.addToPage(page, { x: 20, y, width: 150, height: 16 }); + y -= 24; + } + return Buffer.from(await doc.save()); +}; + +describe('extractPdfFields', () => { + it('reads text form fields out of a fillable PDF', async () => { + const pdf = await buildFormPdf({ Handle: 'V', REF: '7' }); + const fields = await extractPdfFields(pdf); + expect(fields).toEqual({ Handle: 'V', REF: '7' }); + }); + + it('returns null for a PDF without form fields', async () => { + const doc = await PDFDocument.create(); + doc.addPage(); + const fields = await extractPdfFields(Buffer.from(await doc.save())); + expect(fields).toBeNull(); + }); +}); + +describe('CP:R importer mapping', () => { + const importer = getImporter('cyberpunk_red'); + + it('maps stats, skills, armor and identity via aliases', () => { + const { mapped, unmapped } = importer.mapFields({ + Handle: 'V', Role: 'Solo', INT: '6', REF: '7', 'SP (Head)': '11', + Handgun: '5', 'Pilot Air Vehicle (x2)': '2', mystery_field: 'x', + }); + expect(mapped.handle).toBe('V'); + expect(mapped.role).toBe('Solo'); + expect(mapped.int).toBe(6); + expect(mapped.ref).toBe(7); + expect(mapped.sp_head_max).toBe(11); + expect(mapped.sp_head).toBe(11); // max seeds current + expect(mapped.handgun).toBe(5); + expect(mapped.pilot_air).toBe(2); + expect(unmapped.mystery_field).toBe('x'); + }); + + it('skips linked fields (HP, cash) and reports them', () => { + const { mapped, skipped } = importer.mapFields({ hp: 30, cash: 500, ref: 7 }); + expect(mapped.hp).toBeUndefined(); + expect(mapped.cash).toBeUndefined(); + expect(skipped.hp).toBe(30); + expect(mapped.ref).toBe(7); + }); + + it('LUCK/EMP single values seed both current and max', () => { + const { mapped } = importer.mapFields({ LUCK: '6', EMP: '5', Humanity: '50' }); + expect(mapped.luck_max).toBe(6); + expect(mapped.luck).toBe(6); + expect(mapped.emp_max).toBe(5); + expect(mapped.emp).toBe(5); + expect(mapped.humanity).toBe(50); + expect(mapped.humanity_max).toBe(50); + }); + + it('rejects non-numeric values for numeric fields', () => { + const { mapped, unmapped } = importer.mapFields({ REF: 'seven' }); + expect(mapped.ref).toBeUndefined(); + expect(unmapped.REF).toBe('seven'); + }); + + it('parses a plain stat block', () => { + const raw = importer.parseText('HANDLE: Nyx Role: Netrunner\nINT 8 REF 6 TECH 7\nHandgun: 3 Stealth 4'); + const { mapped } = importer.mapFields(raw); + expect(mapped.handle).toBe('Nyx'); + expect(mapped.int).toBe(8); + expect(mapped.tech).toBe(7); + expect(mapped.handgun).toBe(3); + expect(mapped.stealth).toBe(4); + }); +}); + +describe('POST /api/sheets/import/preview', () => { + it('previews a fillable PDF', async () => { + const pdf = await buildFormPdf({ Handle: 'V', REF: '7', Handgun: '5' }); + const res = await request(app) + .post('/api/sheets/import/preview') + .attach('pdf', pdf, { filename: 'sheet.pdf', contentType: 'application/pdf' }); + expect(res.status).toBe(200); + expect(res.body.source).toBe('pdf-form'); + expect(res.body.mapped.handle).toBe('V'); + expect(res.body.mapped.ref).toBe(7); + expect(res.body.mapped.handgun).toBe(5); + }); + + it('previews pasted JSON', async () => { + const res = await request(app) + .post('/api/sheets/import/preview') + .send({ json: JSON.stringify({ ref: 7, cool: 5 }) }); + expect(res.status).toBe(200); + expect(res.body.source).toBe('json'); + expect(res.body.mapped).toMatchObject({ ref: 7, cool: 5 }); + }); + + it('previews pasted stat-block text', async () => { + const res = await request(app) + .post('/api/sheets/import/preview') + .send({ text: 'REF 7 COOL 5 Handgun: 4' }); + expect(res.status).toBe(200); + expect(res.body.source).toBe('text'); + expect(res.body.mapped).toMatchObject({ ref: 7, cool: 5, handgun: 4 }); + }); + + it('422s for a flat PDF with a helpful message', async () => { + const doc = await PDFDocument.create(); + doc.addPage(); + const res = await request(app) + .post('/api/sheets/import/preview') + .attach('pdf', Buffer.from(await doc.save()), { filename: 'flat.pdf', contentType: 'application/pdf' }); + expect(res.status).toBe(422); + expect(res.body.error).toMatch(/paste/i); + }); + + it('400s when the active system has no importer', async () => { + await run(db, `UPDATE global_settings SET value = 'generic' WHERE key = 'game_system'`); + const res = await request(app) + .post('/api/sheets/import/preview') + .send({ json: '{"x":1}' }); + expect(res.status).toBe(400); + }); +}); diff --git a/backend/__tests__/sheets.test.js b/backend/__tests__/sheets.test.js new file mode 100644 index 0000000..397a098 --- /dev/null +++ b/backend/__tests__/sheets.test.js @@ -0,0 +1,369 @@ +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, getLinkedFields } 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({}); + }); + + it('declares hp/hp_max/cash as linked fields on every shipped system', () => { + Object.keys(TEMPLATES).forEach(system => { + const linked = getLinkedFields(system); + expect(linked.hp).toBe('token_hp'); + expect(linked.hp_max).toBe('token_hp_max'); + expect(linked.cash).toBe('bank_balance'); + }); + }); +}); + +// ─── 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('never stores linked fields (hp, cash) in the sheet JSON', async () => { + await setSystem(db, 'cyberpunk_red'); + await insertSheet(db, { username: 'GHOST', data: JSON.stringify({ handle: 'GHOST' }) }); + + const res = await request(app) + .put('/api/sheets/user/GHOST') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ fields: { hp: 12, hp_max: 40, cash: 9999, int: 6 } }); + 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: 6 }); // hp/cash live on token & bank + }); + + 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); + }); +}); + +// ─── POST /api/sheets/portrait ──────────────────────────────────────────────── + +describe('POST /api/sheets/portrait', () => { + it('rejects unauthenticated requests', async () => { + const res = await request(app) + .post('/api/sheets/portrait') + .attach('portrait', Buffer.from('fake-img'), { filename: 'p.jpg', contentType: 'image/jpeg' }); + expect(res.status).toBe(401); + }); + + it('rejects unsupported file extension', async () => { + await setSystem(db, 'cyberpunk_red'); + await insertSheet(db, { username: 'testadmin', system: 'cyberpunk_red' }); + const res = await request(app) + .post('/api/sheets/portrait') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .attach('portrait', Buffer.from('fake-img'), { filename: 'p.exe', contentType: 'application/octet-stream' }); + expect(res.status).toBe(400); + }); + + it('admin uploads portrait for themselves and emits sheetUpdated', async () => { + await setSystem(db, 'cyberpunk_red'); + await insertSheet(db, { username: 'testadmin', system: 'cyberpunk_red' }); + const res = await request(app) + .post('/api/sheets/portrait') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .attach('portrait', Buffer.from('fake-png'), { filename: 'me.png', contentType: 'image/png' }); + expect(res.status).toBe(200); + expect(res.body.portrait_url).toMatch(/^\/uploads\/portraits\/.+\.png$/); + const evt = emitted.find(e => e.event === 'sheetUpdated'); + expect(evt?.payload.username).toBe('testadmin'); + }); + + it('player token uploads own portrait', async () => { + await setSystem(db, 'cyberpunk_red'); + await insertSheet(db, { username: 'ghost', system: 'cyberpunk_red' }); + const res = await request(app) + .post('/api/sheets/portrait') + .set('Authorization', `Bearer ${PLAYER_TOKEN}`) + .attach('portrait', Buffer.from('fake-jpg'), { filename: 'me.jpg', contentType: 'image/jpeg' }); + expect(res.status).toBe(200); + expect(res.body.portrait_url).toMatch(/^\/uploads\/portraits\/.+\.jpg$/); + }); + + it('admin uploads portrait for a specific user via ?username=', async () => { + await setSystem(db, 'cyberpunk_red'); + await insertSheet(db, { username: 'GHOST', system: 'cyberpunk_red' }); + const res = await request(app) + .post('/api/sheets/portrait?username=GHOST') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .attach('portrait', Buffer.from('fake-webp'), { filename: 'ghost.webp', contentType: 'image/webp' }); + expect(res.status).toBe(200); + const updated = await get(db, `SELECT portrait_url FROM character_sheets WHERE username = 'GHOST'`); + expect(updated.portrait_url).toMatch(/\.webp$/); + }); + + it('admin uploads a portrait for an NPC via ?npc_id=', async () => { + await setSystem(db, 'cyberpunk_red'); + const { lastID } = await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('testadmin', 'cyberpunk_red', '{}', 1, 'Gang Member')`); + const res = await request(app) + .post(`/api/sheets/portrait?npc_id=${lastID}`) + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .attach('portrait', Buffer.from('fake-png'), { filename: 'npc.png', contentType: 'image/png' }); + expect(res.status).toBe(200); + const updated = await get(db, `SELECT portrait_url FROM character_sheets WHERE id = ?`, [lastID]); + expect(updated.portrait_url).toMatch(/\.png$/); + }); + + it('npc_id targeting 404s for a missing NPC', async () => { + const res = await request(app) + .post('/api/sheets/portrait?npc_id=99999') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .attach('portrait', Buffer.from('fake-png'), { filename: 'npc.png', contentType: 'image/png' }); + expect(res.status).toBe(404); + }); +}); + +// ─── Derived fields (Humanity → EMP) ───────────────────────────────────────── + +describe('CP:R Humanity drives EMP', () => { + it('admin PUT of humanity recomputes emp = floor(humanity/10)', async () => { + await setSystem(db, 'cyberpunk_red'); + await insertSheet(db, { username: 'GHOST', system: 'cyberpunk_red', data: JSON.stringify({ emp: 7, emp_max: 7 }) }); + const res = await request(app) + .put('/api/sheets/user/GHOST') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ fields: { humanity: 34 } }); + 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.humanity).toBe(34); + expect(data.emp).toBe(3); + }); + + it('non-derived fields leave emp alone', async () => { + await setSystem(db, 'cyberpunk_red'); + await insertSheet(db, { username: 'GHOST', system: 'cyberpunk_red', data: JSON.stringify({ emp: 7 }) }); + await request(app) + .put('/api/sheets/user/GHOST') + .set('Authorization', `Bearer ${ADMIN_TOKEN}`) + .send({ fields: { cool: 5 } }); + const row = await get(db, `SELECT data FROM character_sheets WHERE username = 'GHOST'`); + expect(JSON.parse(row.data).emp).toBe(7); + }); +}); diff --git a/backend/__tests__/sockets.deathsave.test.js b/backend/__tests__/sockets.deathsave.test.js new file mode 100644 index 0000000..0baee46 --- /dev/null +++ b/backend/__tests__/sockets.deathsave.test.js @@ -0,0 +1,302 @@ +/** + * Integration tests for the requestDeathSave and sheetAttack socket handlers, + * booting the real sockets module against an in-memory DB. + * + * Regression focus: the death save must be rollable repeatedly (one per + * combat round) with the penalty escalating each attempt. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import jwt from 'jsonwebtoken'; +import { makeTestDb, get, run } from './helpers/testDb.js'; + +process.env.JWT_SECRET = 'test-secret'; + +const socketsFactory = (await import('../sockets/index.js')).default; + +const flush = (ms = 25) => new Promise((r) => setTimeout(r, ms)); + +// Poll until cond() is true (async socket handlers under parallel test load +// can take longer than a fixed tick). +const waitFor = async (cond, timeout = 2000) => { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > timeout) return; + await flush(10); + } +}; + +function boot(db) { + const emitted = []; + let connectionCb; + const io = { + on: (event, cb) => { if (event === 'connection') connectionCb = cb; }, + emit: (event, data) => emitted.push({ event, data }), + to: () => ({ emit: (event, data) => emitted.push({ event, data }) }), + }; + socketsFactory(io, db, { elevatedUsers: new Set(), emitUpdate: vi.fn(), recordAction: vi.fn() }); + + const handlers = {}; + const socket = { + id: 'sock-1', + on: (event, fn) => { handlers[event] = fn; }, + emit: (event, data) => emitted.push({ event, data, direct: true }), + use: () => {}, + join: () => {}, + }; + connectionCb(socket); + return { handlers, emitted }; +} + +let db; +beforeEach(async () => { + db = await makeTestDb(); + await run(db, `CREATE TABLE dice_rolls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT, total INTEGER, + results TEXT, color TEXT, historyString TEXT, + timestamp DATETIME DEFAULT CURRENT_TIMESTAMP + )`); + await run(db, `CREATE TABLE IF NOT EXISTS player_banks (username TEXT PRIMARY KEY, balance REAL, debt REAL)`); + await run(db, `INSERT INTO global_settings (key, value) VALUES ('game_system', 'cyberpunk_red')`); +}); + +const setupDyingPlayer = async (penalty) => { + const data = { body: 6, ...(penalty !== undefined ? { death_save_penalty: penalty } : {}) }; + await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'cyberpunk_red', ?, 0)`, + [JSON.stringify(data)]); + await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 0, 20)`); +}; + +describe('requestDeathSave', () => { + it('rolls, escalates the penalty, and can be rolled again', async () => { + await setupDyingPlayer(); + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + + handlers['requestDeathSave'](); + await waitFor(() => emitted.some(e => e.event === 'deathSaveResult')); + const first = emitted.filter(e => e.event === 'deathSaveResult'); + expect(first).toHaveLength(1); + expect(first[0].data.penalty).toBe(0); + + let sheet = await get(db, `SELECT data FROM character_sheets WHERE username = 'GHOST'`); + expect(JSON.parse(sheet.data).death_save_penalty).toBe(1); + + // Second round: must roll again with the escalated penalty + handlers['requestDeathSave'](); + await waitFor(() => emitted.filter(e => e.event === 'deathSaveResult').length >= 2); + const both = emitted.filter(e => e.event === 'deathSaveResult'); + expect(both).toHaveLength(2); + expect(both[1].data.penalty).toBe(1); + + sheet = await get(db, `SELECT data FROM character_sheets WHERE username = 'GHOST'`); + expect(JSON.parse(sheet.data).death_save_penalty).toBe(2); + }); + + it('broadcasts a dice roll for each save', async () => { + await setupDyingPlayer(); + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + + handlers['requestDeathSave'](); + handlers['requestDeathSave'](); + await waitFor(() => emitted.filter(e => e.event === 'diceRollBroadcast').length >= 2); + const rolls = emitted.filter(e => e.event === 'diceRollBroadcast'); + expect(rolls).toHaveLength(2); + expect(rolls[0].data.historyString).toContain('DEATH SAVE'); + }); + + it('refuses when HP is above 0', async () => { + await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'cyberpunk_red', '{"body":6}', 0)`); + await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 5, 20)`); + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + + handlers['requestDeathSave'](); + await flush(150); + expect(emitted.filter(e => e.event === 'deathSaveResult')).toHaveLength(0); + }); +}); + +describe('sheetAttack vs NPC token', () => { + it('uses the linked NPC sheet SP even when the enemy token has an owner', async () => { + // Attacker with a valid weapon + await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'cyberpunk_red', ?, 0)`, + [JSON.stringify({ ref: 8, handgun: 6, weapon1_name: 'Gun', weapon1_dmg: '3d6', weapon1_skill: 'handgun' })]); + await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 20, 20)`); + // Enemy token with owner set (the generator stamps one) + linked NPC sheet with SP 6 + const loc = await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, melee_ac, ranged_ac, hp_current, hp_max) VALUES ('Guy', 0, 0, 0, 'enemy_rhombus', 'SYSTEM', 10, 10, 15, 15)`); + const npc = await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) VALUES ('admin', 'cyberpunk_red', ?, 1, 'Guy')`, + [JSON.stringify({ sp_body: 6, sp_body_max: 6 })]); + await run(db, `INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?)`, [loc.lastID, npc.lastID]); + + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + handlers['sheetAttack']({ targetId: loc.lastID, weaponIndex: 1 }); + await waitFor(() => emitted.some(e => e.event === 'attackResult')); + + const result = emitted.find(e => e.event === 'attackResult'); + expect(result).toBeTruthy(); + if (result.data.hit) { + // SP 6 must have soaked - never 0 + expect(result.data.sp).toBe(6); + // Ablation only when damage got through + const sheet = await get(db, `SELECT data FROM character_sheets WHERE id = ?`, [npc.lastID]); + const sp = JSON.parse(sheet.data).sp_body; + expect(sp).toBe(result.data.through > 0 ? 5 : 6); + } + }); +}); + +describe('importSheetFields', () => { + it('bulk-applies fields, refuses linked ones, and recomputes derived EMP', async () => { + await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'cyberpunk_red', '{}', 0)`); + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + + handlers['importSheetFields']({ fields: { ref: 7, handgun: 5, humanity: 42, hp: 99 } }); + await waitFor(() => emitted.some(e => e.event === 'sheetImportApplied')); + + const row = await get(db, `SELECT data FROM character_sheets WHERE username = 'GHOST'`); + const data = JSON.parse(row.data); + expect(data.ref).toBe(7); + expect(data.handgun).toBe(5); + expect(data.humanity).toBe(42); + expect(data.emp).toBe(4); // derived from humanity + expect(data.hp).toBeUndefined(); // linked - refused + expect(emitted.some(e => e.event === 'sheetUpdated')).toBe(true); + }); +}); + +describe('generateNpcSheet with tier', () => { + it('seeds the sheet from the tier package and tunes the token HP/DV', async () => { + await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('Guy', 0, 0, 0, 'enemy_rhombus', 'SYSTEM', 5, 5)`); + const loc = await get(db, `SELECT id FROM locations WHERE name = 'Guy'`); + const { handlers, emitted } = boot(db); + handlers['identify']({ userName: 'admin', isAdmin: true, token: jwt.sign({ username: 'admin', isTemporary: false }, 'test-secret') }); + await flush(50); + + handlers['generateNpcSheet']({ location_id: loc.id, tier: 'elite' }); + await waitFor(() => emitted.some(e => e.event === 'npcSheetGenerated')); + + const evt = emitted.find(e => e.event === 'npcSheetGenerated'); + expect(evt.data.tier).toBe('elite'); + const sheet = await get(db, `SELECT data FROM character_sheets WHERE id = ?`, [evt.data.sheet_id]); + const data = JSON.parse(sheet.data); + expect(data.ref).toBe(8); + expect(data.weapon1_dmg).toBe('5d6'); + const token = await get(db, `SELECT hp_current, hp_max, melee_ac, ranged_ac FROM locations WHERE id = ?`, [loc.id]); + expect(token.hp_max).toBe(45); + expect(token.melee_ac).toBe(20); // 6 + DEX 8 + Evasion 6 from the sheet + expect(token.ranged_ac).toBe(15); + }); +}); + +describe('requestSheetRoll with LUCK', () => { + it('adds the declared LUCK, caps it at current, and decrements the pool', async () => { + await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'cyberpunk_red', '{"ref":7,"luck":2,"luck_max":5}', 0)`); + await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 20, 20)`); + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + + // Declares 5 but only has 2 - server caps at 2 + handlers['requestSheetRoll']({ fieldId: 'ref', luck: 5 }); + await waitFor(() => emitted.some(e => e.event === 'diceRollBroadcast')); + + const roll = emitted.find(e => e.event === 'diceRollBroadcast'); + expect(roll.data.historyString).toContain('(LUCK +2)'); + const sheet = await get(db, `SELECT data FROM character_sheets WHERE username = 'GHOST'`); + expect(JSON.parse(sheet.data).luck).toBe(0); + }); + + it('rolls without LUCK leave the pool alone', async () => { + await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'cyberpunk_red', '{"ref":7,"luck":3}', 0)`); + await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 20, 20)`); + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + + handlers['requestSheetRoll']({ fieldId: 'ref' }); + await waitFor(() => emitted.some(e => e.event === 'diceRollBroadcast')); + const sheet = await get(db, `SELECT data FROM character_sheets WHERE username = 'GHOST'`); + expect(JSON.parse(sheet.data).luck).toBe(3); + }); + + it('tags wounded rolls in the history', async () => { + await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'cyberpunk_red', '{"ref":7,"seriously_wounded":10}', 0)`); + await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 8, 20)`); + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + + handlers['requestSheetRoll']({ fieldId: 'ref' }); + await waitFor(() => emitted.some(e => e.event === 'diceRollBroadcast')); + const roll = emitted.find(e => e.event === 'diceRollBroadcast'); + expect(roll.data.historyString).toContain('(WOUNDED -2)'); + }); +}); + +describe('LUCK fumble shield', () => { + it('luckNegate burns 1 extra LUCK and tags the roll (house rule on)', async () => { + await run(db, `INSERT INTO global_settings (key, value) VALUES ('luck_negates_fumble', '1')`); + await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'cyberpunk_red', '{"ref":7,"luck":3}', 0)`); + await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 20, 20)`); + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + + handlers['requestSheetRoll']({ fieldId: 'ref', luck: 1, luckNegate: true }); + await waitFor(() => emitted.some(e => e.event === 'diceRollBroadcast')); + + const roll = emitted.find(e => e.event === 'diceRollBroadcast'); + expect(roll.data.historyString).toContain('(LUCK +1)'); + expect(roll.data.historyString).toContain('(LUCK: FUMBLE SHIELD)'); + const sheet = await get(db, `SELECT data FROM character_sheets WHERE username = 'GHOST'`); + expect(JSON.parse(sheet.data).luck).toBe(1); // 3 - (1 bonus + 1 shield) + }); +}); + + +describe('LUCK fumble shield gated off', () => { + it('luckNegate is ignored while the house rule is off: no burn, no tag', async () => { + await run(db, + `INSERT INTO character_sheets (username, system, data, is_npc) VALUES ('GHOST', 'cyberpunk_red', '{"ref":7,"luck":3}', 0)`); + await run(db, + `INSERT INTO locations (name, x, y, z, shape, owner, hp_current, hp_max) VALUES ('GHOST', 0, 0, 0, 'rhombus', 'GHOST', 20, 20)`); + const { handlers, emitted } = boot(db); + handlers['identify']('GHOST'); + await flush(50); + + handlers['requestSheetRoll']({ fieldId: 'ref', luckNegate: true }); + await waitFor(() => emitted.some(e => e.event === 'diceRollBroadcast')); + + const roll = emitted.find(e => e.event === 'diceRollBroadcast'); + expect(roll.data.historyString).not.toContain('FUMBLE SHIELD'); + const sheet = await get(db, `SELECT data FROM character_sheets WHERE username = 'GHOST'`); + expect(JSON.parse(sheet.data).luck).toBe(3); // nothing spent + }); +}); 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/package-lock.json b/backend/package-lock.json index dda1fad..8120414 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "jsonwebtoken": "^9.0.3", "mapsystem": "file:..", "multer": "^2.2.0", + "pdf-lib": "^1.17.1", "socket.io": "^4.8.3", "sqlite3": "^6.0.1" }, @@ -32,7 +33,7 @@ }, "..": { "name": "mapsystem", - "version": "1.0.0", + "version": "1.3.0", "license": "ISC" }, "node_modules/@emnapi/core": { @@ -140,6 +141,24 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", @@ -2746,6 +2765,12 @@ "wrappy": "1" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2772,6 +2797,24 @@ "dev": true, "license": "MIT" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index c9ee86d..961f0d1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,6 +4,8 @@ "description": "", "main": "index.js", "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", "test": "vitest run" }, "keywords": [], @@ -17,6 +19,7 @@ "jsonwebtoken": "^9.0.3", "mapsystem": "file:..", "multer": "^2.2.0", + "pdf-lib": "^1.17.1", "socket.io": "^4.8.3", "sqlite3": "^6.0.1" }, diff --git a/backend/routes/locations.js b/backend/routes/locations.js index 5e6e149..d93e15b 100644 --- a/backend/routes/locations.js +++ b/backend/routes/locations.js @@ -302,6 +302,23 @@ module.exports = (db, io, { emitUpdate, recordAction }) => { db.run('UPDATE locations SET hp_current = ?, hp_max = ?, hp_temp = ? WHERE shape = "rhombus" AND owner = ?', [newCurrent, newMax, newTemp, row.owner], function(err2) { if (err2) return res.status(500).json({ error: err2.message }); emitUpdate(); + // Character sheets mirror token HP - tell open sheets to re-fetch + io.emit('sheetUpdated', { username: row.owner }); + // Back above 0 HP: no longer mortally wounded, death-save penalty resets + if (newCurrent > 0) { + db.all('SELECT id, data FROM character_sheets WHERE username = ? AND is_npc = 0', [row.owner], (err3, sheets) => { + if (err3 || !sheets) return; + sheets.forEach((s) => { + try { + const data = JSON.parse(s.data || '{}'); + if (Number(data.death_save_penalty) > 0) { + data.death_save_penalty = 0; + db.run('UPDATE character_sheets SET data = ? WHERE id = ?', [JSON.stringify(data), s.id]); + } + } catch (e) { /* bad JSON - leave it */ } + }); + }); + } res.json({ id, hp_current: newCurrent, hp_max: newMax, hp_temp: newTemp }); }); } else { diff --git a/backend/routes/sheets.js b/backend/routes/sheets.js new file mode 100644 index 0000000..59e402c --- /dev/null +++ b/backend/routes/sheets.js @@ -0,0 +1,446 @@ +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const multer = require('multer'); +const { authenticate } = require('../middleware/auth'); +const { TEMPLATES, DEFAULT_SYSTEM, isValidSystem, getLinkedFields, applyDerived } = require('../sheets/templates'); +const sheetImporters = require('../sheets/importers'); +const sheetAttack = require('../sheets/attack'); + +// 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 portraitsDir = path.join(__dirname, '../uploads/portraits'); + if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true }); + const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024 } }); + + 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). + // Linked fields (token HP, bank cash) are overlaid at read time, same as + // the player's own socket fetch. + 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' }); + const data = JSON.parse(row.data || '{}'); + const linked = getLinkedFields(system); + const done = () => res.json({ ...row, data }); + const overlayCash = () => { + if (!Object.values(linked).includes('bank_balance')) return done(); + db.get(`SELECT balance FROM player_banks WHERE username = ?`, [req.params.username], (e3, bank) => { + Object.entries(linked).forEach(([fieldId, source]) => { + if (source === 'bank_balance') data[fieldId] = bank ? bank.balance : 0; + }); + done(); + }); + }; + if (!Object.values(linked).some(s => s === 'token_hp' || s === 'token_hp_max')) return overlayCash(); + db.get( + `SELECT hp_current, hp_max FROM locations WHERE shape = 'rhombus' AND owner = ? + ORDER BY (battle_map_id IS NULL) DESC LIMIT 1`, + [req.params.username], + (e2, hpRow) => { + Object.entries(linked).forEach(([fieldId, source]) => { + if (source === 'token_hp') data[fieldId] = hpRow ? hpRow.hp_current : null; + if (source === 'token_hp_max') data[fieldId] = hpRow ? hpRow.hp_max : null; + }); + overlayCash(); + } + ); + } + ); + }); + }); + + // 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' }); + // Linked fields (token HP, cash) live in other systems and are + // edited through their own windows - never stored in sheet JSON. + const linked = getLinkedFields(system); + const patch = Object.fromEntries(Object.entries(fields).filter(([k]) => !linked[k])); + const data = { ...JSON.parse(row.data || '{}'), ...patch }; + Object.keys(patch).forEach((f) => applyDerived(system, data, f)); + 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' }); + } + ); + } + ); + }); + }); + + // --- Sheet import --- + + // Stage-1+2 preview: extract candidates from a fillable PDF, JSON paste, or + // raw text, and map them onto the active system's fields. No auth needed - + // it only transforms what the caller sends; applying is where identity is + // enforced (socket for players, admin PUTs for admins). + router.post('/import/preview', upload.single('pdf'), (req, res) => { + getGameSystem(async (err, system) => { + if (err) return res.status(500).json({ error: err.message }); + const importer = sheetImporters.getImporter(system); + if (!importer) return res.status(400).json({ error: `No importer for ${system} yet` }); + try { + let raw = null; + let source = null; + if (req.file) { + raw = await sheetImporters.extractPdfFields(req.file.buffer); + source = 'pdf-form'; + if (!raw) return res.status(422).json({ error: 'PDF has no fillable form fields. Copy the character text and paste it instead.' }); + } else if (req.body && req.body.json) { + raw = typeof req.body.json === 'string' ? JSON.parse(req.body.json) : req.body.json; + source = 'json'; + } else if (req.body && req.body.text) { + raw = importer.parseText(String(req.body.text)); + source = 'text'; + } else { + return res.status(400).json({ error: 'Send a PDF file, json, or text' }); + } + const { mapped, unmapped, skipped } = importer.mapFields(raw); + res.json({ system, source, mapped, unmapped, skipped }); + } catch (e) { + res.status(422).json({ error: `Could not read input: ${e.message}` }); + } + }); + }); + + // --- NPC library (admin-only) --- + + // List all NPC sheets for the current game system, grouped by folder + router.get('/npcs', authenticate, requireAdmin, (req, res) => { + getGameSystem((err, system) => { + if (err) return res.status(500).json({ error: err.message }); + db.all( + `SELECT id, npc_label, folder, portrait_url, updated_at + FROM character_sheets WHERE is_npc = 1 AND system = ? + ORDER BY folder, npc_label`, + [system], + (err2, rows) => { + if (err2) return res.status(500).json({ error: err2.message }); + res.json(rows); + } + ); + }); + }); + + // Get full NPC sheet data (for editing). Like player sheets, HP is a + // linked field: when the sheet is attached to a token, the token's HP is + // overlaid at read time (first link wins if the sheet is on several tokens). + router.get('/npcs/:id', authenticate, requireAdmin, (req, res) => { + db.get( + `SELECT * FROM character_sheets WHERE id = ? AND is_npc = 1`, + [req.params.id], + (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: 'NPC not found' }); + const data = JSON.parse(row.data || '{}'); + const linked = getLinkedFields(row.system); + const wantsHp = Object.values(linked).some(s => s === 'token_hp' || s === 'token_hp_max'); + if (!wantsHp) return res.json({ ...row, data }); + db.get( + `SELECT loc.hp_current, loc.hp_max FROM npc_sheet_links l + JOIN locations loc ON loc.id = l.location_id + WHERE l.sheet_id = ? LIMIT 1`, + [req.params.id], + (err2, hpRow) => { + if (!err2 && hpRow) { + Object.entries(linked).forEach(([fieldId, source]) => { + if (source === 'token_hp') data[fieldId] = hpRow.hp_current; + if (source === 'token_hp_max') data[fieldId] = hpRow.hp_max; + }); + } + res.json({ ...row, data }); + } + ); + } + ); + }); + + // Create a new NPC sheet + router.post('/npcs', authenticate, requireAdmin, (req, res) => { + const { npc_label, folder, data } = req.body; + if (!npc_label) return res.status(400).json({ error: 'npc_label required' }); + getGameSystem((err, system) => { + if (err) return res.status(500).json({ error: err.message }); + db.run( + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label, folder) + VALUES (?, ?, ?, 1, ?, ?)`, + [req.user.username, system, JSON.stringify(data || {}), npc_label, folder || null], + function (err2) { + if (err2) return res.status(500).json({ error: err2.message }); + res.json({ id: this.lastID, npc_label, folder: folder || null, system }); + } + ); + }); + }); + + // Patch NPC sheet data / metadata + router.put('/npcs/:id', authenticate, requireAdmin, (req, res) => { + db.get(`SELECT id, data, system FROM character_sheets WHERE id = ? AND is_npc = 1`, [req.params.id], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: 'NPC not found' }); + const { fields, npc_label, folder } = req.body; + // Linked fields (token HP) live on the location - never store them in + // the sheet's JSON, same rule as player sheets + let cleanFields = fields; + if (fields) { + const linked = getLinkedFields(row.system); + cleanFields = Object.fromEntries(Object.entries(fields).filter(([k]) => !linked[k])); + } + let data = row.data; + if (cleanFields) { + const merged = { ...JSON.parse(row.data || '{}'), ...cleanFields }; + Object.keys(cleanFields).forEach((f) => applyDerived(row.system, merged, f)); + data = JSON.stringify(merged); + } + const sets = ['data = ?', 'updated_at = CURRENT_TIMESTAMP']; + const params = [data]; + if (npc_label !== undefined) { sets.push('npc_label = ?'); params.push(npc_label); } + if (folder !== undefined) { sets.push('folder = ?'); params.push(folder || null); } + params.push(req.params.id); + db.run(`UPDATE character_sheets SET ${sets.join(', ')} WHERE id = ?`, params, (err2) => { + if (err2) return res.status(500).json({ error: err2.message }); + res.json({ message: 'NPC updated' }); + }); + }); + }); + + // Delete NPC sheet (cascades to npc_sheet_links) + router.delete('/npcs/:id', authenticate, requireAdmin, (req, res) => { + db.run(`DELETE FROM character_sheets WHERE id = ? AND is_npc = 1`, [req.params.id], function (err) { + if (err) return res.status(500).json({ error: err.message }); + if (this.changes === 0) return res.status(404).json({ error: 'NPC not found' }); + res.json({ message: 'NPC deleted' }); + }); + }); + + // Which NPC sheet (if any) is linked to a token - drives the token menu's + // GENERATE_SHEET vs OPEN_SHEET button + router.get('/npcs/link/:location_id', authenticate, requireAdmin, (req, res) => { + db.get( + `SELECT cs.id AS sheet_id, cs.npc_label FROM npc_sheet_links l + JOIN character_sheets cs ON cs.id = l.sheet_id WHERE l.location_id = ?`, + [req.params.location_id], + (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + res.json(row || { sheet_id: null }); + } + ); + }); + + // Attach NPC sheet to a token (location). Under CP:R the sheet also stamps + // the token's melee DV (6 + DEX + Evasion) - a starting value the GM can + // override any time via EDIT_DV. + router.post('/npcs/:id/link', authenticate, requireAdmin, (req, res) => { + const { location_id } = req.body; + if (!location_id) return res.status(400).json({ error: 'location_id required' }); + db.get(`SELECT id, system, data FROM character_sheets WHERE id = ? AND is_npc = 1`, [req.params.id], (err, row) => { + if (err) return res.status(500).json({ error: err.message }); + if (!row) return res.status(404).json({ error: 'NPC not found' }); + db.run( + `INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?) + ON CONFLICT(location_id) DO UPDATE SET sheet_id = excluded.sheet_id`, + [location_id, req.params.id], + (err2) => { + if (err2) return res.status(500).json({ error: err2.message }); + const finish = () => { + io.emit('npcLinkChanged', { location_id, sheet_id: Number(req.params.id) }); + res.json({ message: 'Linked' }); + }; + if (row.system !== 'cyberpunk_red') return finish(); + let data; + try { data = JSON.parse(row.data || '{}'); } catch { return finish(); } + db.get(`SELECT value FROM global_settings WHERE key = 'melee_dv_take10'`, (sErr, sRow) => { + db.run( + `UPDATE locations SET melee_ac = ? WHERE id = ?`, + [sheetAttack.staticMeleeDv(data, !sErr && sRow?.value === '1'), location_id], + () => { io.emit('dataUpdated', { isRhombusOnly: true }); finish(); } + ); + }); + } + ); + }); + }); + + // Detach NPC sheet from a token + router.delete('/npcs/:id/link/:location_id', authenticate, requireAdmin, (req, res) => { + db.run( + `DELETE FROM npc_sheet_links WHERE sheet_id = ? AND location_id = ?`, + [req.params.id, req.params.location_id], + (err) => { + if (err) return res.status(500).json({ error: err.message }); + io.emit('npcLinkChanged', { location_id: Number(req.params.location_id), sheet_id: null }); + res.json({ message: 'Unlinked' }); + } + ); + }); + + // Portrait upload — player uploads their own portrait; admin can upload + // for any username via ?username= query param. + router.post('/portrait', authenticate, upload.single('portrait'), (req, res) => { + if (!req.file) return res.status(400).json({ error: 'portrait file required' }); + const ext = path.extname(req.file.originalname).toLowerCase() || '.jpg'; + const allowed = ['.jpg', '.jpeg', '.png', '.webp', '.gif']; + if (!allowed.includes(ext)) return res.status(400).json({ error: 'Unsupported image format' }); + + const hash = crypto.createHash('sha256').update(req.file.buffer).digest('hex'); + const filename = hash + ext; + const filepath = path.join(portraitsDir, filename); + if (!fs.existsSync(filepath)) fs.writeFileSync(filepath, req.file.buffer); + const portrait_url = '/uploads/portraits/' + filename; + + // Determine whose sheet to update + const u = req.user; + const isAdmin = u && (u.role === 'admin' || u.isTemporary); + + // Admin can target an NPC sheet directly by id + if (isAdmin && req.query.npc_id) { + return db.run( + `UPDATE character_sheets SET portrait_url = ? WHERE id = ? AND is_npc = 1`, + [portrait_url, req.query.npc_id], + function (err2) { + if (err2) return res.status(500).json({ error: err2.message }); + if (this.changes === 0) return res.status(404).json({ error: 'NPC not found' }); + res.json({ portrait_url }); + } + ); + } + + const targetUsername = isAdmin && req.query.username ? req.query.username : u.username; + if (!targetUsername) return res.status(400).json({ error: 'Cannot determine target username' }); + + getGameSystem((err, system) => { + if (err) return res.status(500).json({ error: err.message }); + db.run( + `UPDATE character_sheets SET portrait_url = ? WHERE username = ? AND system = ? AND is_npc = 0`, + [portrait_url, targetUsername, system], + (err2) => { + if (err2) return res.status(500).json({ error: err2.message }); + io.emit('sheetUpdated', { username: targetUsername }); + res.json({ portrait_url }); + } + ); + }); + }); + + // Reset all player LUCK to max for the active system (admin-only) + router.post('/reset-luck', authenticate, requireAdmin, (req, res) => { + getGameSystem((err, system) => { + if (err) return res.status(500).json({ error: err.message }); + const meta = TEMPLATES[system]; + if (!meta || !meta.luckField || !meta.luckMaxField) { + return res.json({ reset: 0, reason: 'System has no LUCK field' }); + } + const { luckField, luckMaxField } = meta; + db.all( + `SELECT id, username, data FROM character_sheets WHERE system = ? AND is_npc = 0`, + [system], + (err2, rows) => { + if (err2) return res.status(500).json({ error: err2.message }); + const updates = []; + for (const row of rows) { + let data; + try { data = JSON.parse(row.data || '{}'); } catch { data = {}; } + const max = data[luckMaxField]; + if (max === undefined || max === null) continue; + data[luckField] = Number(max); + updates.push({ id: row.id, username: row.username, data: JSON.stringify(data) }); + } + if (updates.length === 0) return res.json({ reset: 0 }); + let done = 0; + for (const u of updates) { + db.run( + `UPDATE character_sheets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [u.data, u.id], + () => { + io.emit('sheetUpdated', { username: u.username }); + done++; + if (done === updates.length) res.json({ reset: updates.length }); + } + ); + } + } + ); + }); + }); + + 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/attack.js b/backend/sheets/attack.js new file mode 100644 index 0000000..a51e83b --- /dev/null +++ b/backend/sheets/attack.js @@ -0,0 +1,155 @@ +// CP:R attack + death-save resolution - pure functions, no I/O. +// +// Attack flow (Cyberpunk RED): +// to-hit: 1d10 (exploding check die) + STAT + skill level, -8 if aimed. +// Hit if total >= target DV (matches the app-wide >= convention). +// Damage: weapon dice (e.g. 3d6). Target SP soaks; only damage that gets +// through armor counts, and a penetrated location ablates -1 SP. +// Aimed shots strike the head: soak vs SP HEAD, damage through is doubled. +// +// Death saves: +// At 0 HP or less, roll 1d10 + penalty; survive if the total is <= BODY. +// A natural 10 always fails. The penalty starts at 0 and rises +1 per +// attempt until the character is stabilized/healed above 0 HP. + +const rollEngine = require('./rollEngine'); +const { CPR_SKILLS } = require('./rolls'); + +const AIMED_PENALTY = 8; + +// Weapon skills allowed on the structured weapon rows, split by attack type. +const MELEE_SKILLS = ['brawling', 'martial_arts', 'melee_weapon']; +const RANGED_SKILLS = ['handgun', 'shoulder_arms', 'heavy_weapons', 'autofire', 'archery']; +const WEAPON_SKILLS = [...MELEE_SKILLS, ...RANGED_SKILLS]; + +const WEAPON_ROWS = 4; + +const num = (v) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; + +// Read and validate one structured weapon row off a sheet's data. +// Returns { name, dmg, skill, attackType } or null if the row is unusable. +const getWeapon = (data, index) => { + const i = Number(index); + if (!Number.isInteger(i) || i < 1 || i > WEAPON_ROWS) return null; + const skill = String(data[`weapon${i}_skill`] || ''); + if (!WEAPON_SKILLS.includes(skill)) return null; + const dmg = String(data[`weapon${i}_dmg`] || '').trim(); + // Damage must be pure dice (no @field or flat sneak-ins from the client). + if (!/^\d+d\d+$/i.test(dmg)) return null; + return { + name: String(data[`weapon${i}_name`] || '').trim() || `WEAPON ${i}`, + dmg, + skill, + attackType: MELEE_SKILLS.includes(skill) ? 'melee' : 'ranged', + }; +}; + +// Situational check modifiers, applied server-side to every CP:R check: +// - armor penalty: heavy armor's stat penalty hits REF/DEX-keyed checks +// - wounded: -2 while Seriously Wounded (0 < HP <= threshold), -4 at 0 HP +// Returns [{ label, value }] entries ready for a resolved formula. +const checkPenalties = (data, stat, hp) => { + const mods = []; + const armor = num(data.armor_penalty); + if (armor > 0 && (stat === 'ref' || stat === 'dex')) { + mods.push({ label: 'armor', value: -armor }); + } + const threshold = num(data.seriously_wounded); + if (hp !== null && hp !== undefined) { + if (hp <= 0) mods.push({ label: 'mortally wounded', value: -4 }); + else if (threshold > 0 && hp <= threshold) mods.push({ label: 'wounded', value: -2 }); + } + return mods; +}; + +// Roll the to-hit check. Returns rollEngine outcome plus the aimed modifier +// already folded into total/modTotal. +// opts: { luck: declared LUCK bonus (flat +luck), +// noFumble: nat-1 is not a critical failure (dedicated 1-LUCK burn, +// or the house-rule that any LUCK spend negates fumbles), +// hp: attacker's current token HP (wound penalties) } +const rollToHit = (data, weapon, aimed, rng = Math.random, opts = {}) => { + const stat = (CPR_SKILLS[weapon.skill] || [null, 'ref'])[1]; + let formula = `1d10 + @${stat} + @${weapon.skill}`; + if (aimed) formula += ` - ${AIMED_PENALTY}`; + const resolved = rollEngine.resolveFormula(formula, data); + const luck = Math.max(0, num(opts.luck)); + if (luck > 0) resolved.modifiers.push({ label: 'luck', value: luck }); + resolved.modifiers.push(...checkPenalties(data, stat, opts.hp)); + return rollEngine.executeRoll(resolved, 'explode10', rng, { noFumble: !!opts.noFumble }); +}; + +// How much LUCK a declared spend actually costs, honoring the pool: +// the fumble-shield burn (1 pip, no bonus) is paid first, the flat bonus is +// clamped to what remains. Returns { bonus, negate, total }. +const resolveLuckSpend = (pool, wantBonus, wantNegate) => { + const cur = Math.max(0, num(pool)); + const negate = !!wantNegate && cur >= 1; + const bonus = Math.max(0, Math.min(num(wantBonus), cur - (negate ? 1 : 0))); + return { bonus, negate, total: bonus + (negate ? 1 : 0) }; +}; + +// Roll weapon damage (plain sum, no explosion). +const rollDamage = (weapon, rng = Math.random) => { + const resolved = rollEngine.resolveFormula(weapon.dmg, {}); + return rollEngine.executeRoll(resolved, 'sum', rng); +}; + +// Soak damage with armor. Only damage that beats SP gets through; a +// penetrated location ablates 1 SP. Head hits double the damage through. +const applyArmor = (damage, sp, aimed) => { + const soaked = Math.max(0, num(sp)); + const raw = Math.max(0, damage - soaked); + const through = aimed ? raw * 2 : raw; + return { through, ablated: raw > 0 }; +}; + +// Shield intercepts first: its points absorb the roll, overflow passes on to +// normal SP soak. Returns the shield's new value and the overflow damage. +const applyShield = (damage, shield) => { + const points = Math.max(0, num(shield)); + if (points <= 0) return { absorbed: 0, remaining: damage, newShield: 0, destroyed: false }; + const absorbed = Math.min(points, damage); + return { + absorbed, + remaining: damage - absorbed, + newShield: points - absorbed, + destroyed: absorbed >= points, + }; +}; + +// CP:R critical injury: two or more damage dice landing on their maximum +// face. Deals bonus damage that ignores armor; the GM rolls the actual +// injury on the book's table (never embedded here - licensing). +const CRIT_BONUS_DAMAGE = 5; +const isCriticalInjury = (rolls) => + Object.entries(rolls || {}).reduce( + (n, [sides, values]) => n + values.filter(v => v === Number(sides)).length, + 0 + ) >= 2; + +// Static melee DV from a sheet, stamped onto the token at sheet +// generation/attach; the GM can override it any time via EDIT_DV. +// default: 6 + DEX + Evasion (average-roll equivalent of the RAW opposed +// Evasion contest) +// take-10: 10 + DEX + Evasion (harder melee defense - the 'take 10' +// variant, toggled in ADMIN > TTRPG_SYSTEM) +const staticMeleeDv = (data, takeTen = false) => + (takeTen ? 10 : 6) + num(data.dex) + num(data.evasion); + +// Death save: 1d10 + penalty vs BODY, natural 10 always fails. +const rollDeathSave = (body, penalty, rng = Math.random) => { + const die = Math.floor(rng() * 10) + 1; + const total = die + Math.max(0, num(penalty)); + const success = die !== 10 && total <= num(body); + return { die, penalty: Math.max(0, num(penalty)), total, success }; +}; + +module.exports = { + AIMED_PENALTY, WEAPON_ROWS, MELEE_SKILLS, RANGED_SKILLS, WEAPON_SKILLS, CRIT_BONUS_DAMAGE, + getWeapon, rollToHit, rollDamage, applyArmor, applyShield, isCriticalInjury, rollDeathSave, + staticMeleeDv, checkPenalties, resolveLuckSpend, +}; diff --git a/backend/sheets/importers.js b/backend/sheets/importers.js new file mode 100644 index 0000000..6bbdd30 --- /dev/null +++ b/backend/sheets/importers.js @@ -0,0 +1,198 @@ +// Modular sheet import. +// +// Two stages, deliberately separated so every game system can reuse stage 1: +// 1. EXTRACT (shared): turn the upload into flat key/value candidates. +// - Fillable PDF: AcroForm field names/values (pdf-lib) +// - Flat PDF / plain paste: raw text, handed to the system's text parser +// - JSON paste: used as-is +// 2. MAP (per system): normalize candidate keys onto the template's field +// ids via an alias table. Anything unrecognized is reported back so the +// user can fix it by hand - imports never guess silently. +// +// Linked fields (token HP, bank cash) are never importable: they live in +// other systems. They come back in `skipped` so the user knows why. + +const { PDFDocument } = require('pdf-lib'); +const { CPR_SKILLS } = require('./rolls'); +const { getLinkedFields } = require('./templates'); + +// 'SP (Head)' / 'sp_head' / 'SP HEAD' all normalize to 'sphead' +const norm = (key) => String(key).toLowerCase().replace(/[^a-z0-9]/g, ''); + +// ─── Stage 1: extraction ───────────────────────────────────────────────────── + +// Fillable-PDF form fields → { name: value }. Returns null when the PDF has +// no usable form fields (flat/scanned sheet). +const extractPdfFields = async (buffer) => { + const doc = await PDFDocument.load(buffer, { ignoreEncryption: true }); + let fields; + try { + fields = doc.getForm().getFields(); + } catch { + return null; + } + if (!fields || fields.length === 0) return null; + const out = {}; + for (const f of fields) { + const name = f.getName(); + try { + if (typeof f.getText === 'function') out[name] = f.getText() ?? ''; + else if (typeof f.isChecked === 'function') out[name] = f.isChecked(); + else if (typeof f.getSelected === 'function') out[name] = [].concat(f.getSelected()).join(', '); + } catch { /* unsupported field type - skip */ } + } + return out; +}; + +// ─── Stage 2: per-system mappers ───────────────────────────────────────────── + +// CP:R alias table: normalized candidate key → sheet field id. +// Includes every field id itself, so a JSON export round-trips unchanged. +const buildCprAliases = () => { + const a = {}; + const alias = (keys, fieldId) => keys.forEach((k) => { a[norm(k)] = fieldId; }); + + // Identity + alias(['handle', 'name', 'charactername'], 'handle'); + alias(['role', 'class'], 'role'); + alias(['roleability', 'role ability'], 'role_ability'); + alias(['rank', 'roleabilityrank'], 'role_ability_rank'); + alias(['description', 'appearance'], 'description'); + alias(['aliases', 'alias'], 'aliases'); + + // Linked fields: mapped so they surface under `skipped` (with the reason), + // instead of looking like unrecognized keys + alias(['hp', 'hitpoints', 'currenthp'], 'hp'); + alias(['hpmax', 'maxhp', 'hp max'], 'hp_max'); + alias(['cash', 'eb', 'eurobucks', 'money'], 'cash'); + + // Stats (current+max pairs map the single value to both) + ['int', 'ref', 'dex', 'tech', 'cool', 'will', 'move', 'body'].forEach((s) => alias([s], s)); + alias(['intelligence'], 'int'); + alias(['reflexes', 'reflex'], 'ref'); + alias(['dexterity'], 'dex'); + alias(['technique'], 'tech'); + alias(['willpower'], 'will'); + alias(['movement', 'mov'], 'move'); + alias(['luck'], 'luck_max'); + alias(['luckmax', 'luckcurrent'], 'luck_max'); + alias(['emp', 'empathy'], 'emp_max'); + alias(['empmax'], 'emp_max'); + alias(['humanity', 'hum'], 'humanity'); + alias(['humanitymax'], 'humanity_max'); + alias(['seriouslywounded'], 'seriously_wounded'); + alias(['deathsave'], 'death_save'); + + // Armor + alias(['sphead', 'headsp', 'headarmor', 'armorhead'], 'sp_head_max'); + alias(['spheadmax'], 'sp_head_max'); + alias(['spbody', 'bodysp', 'bodyarmor', 'armorbody'], 'sp_body_max'); + alias(['spbodymax'], 'sp_body_max'); + alias(['spshield', 'shield', 'shieldhp'], 'sp_shield_max'); + alias(['spshieldmax'], 'sp_shield_max'); + alias(['armorpenalty', 'penalty'], 'armor_penalty'); + + // Skills: every roll-map skill by id and by label + Object.entries(CPR_SKILLS).forEach(([id, [label]]) => { + alias([id, label], id); + // Labels like 'Pilot Air Vehicle (x2)' also match without the multiplier + const plain = label.replace(/\s*\(x\d+\)\s*/i, ''); + if (plain !== label) alias([plain], id); + }); + + // Notes / gear + alias(['weapons', 'weaponsnotes'], 'weapons_notes'); + alias(['ammunition', 'ammo'], 'ammunition'); + alias(['gear', 'gearnotes', 'equipment'], 'gear_notes'); + alias(['cyberware', 'cyberwarenotes'], 'cyberware_notes'); + alias(['lifepath', 'lifepathnotes'], 'lifepath_notes'); + alias(['criticalinjuries', 'injuries'], 'critical_injuries'); + alias(['addictions'], 'addictions'); + + // Weapon rows round-trip + for (let i = 1; i <= 4; i++) { + ['name', 'dmg', 'skill', 'rof'].forEach((part) => alias([`weapon${i}${part}`], `weapon${i}_${part}`)); + } + return a; +}; + +// Fields where the import value should also seed the paired current value. +const CPR_MAX_SEEDS = { + luck_max: 'luck', + emp_max: 'emp', + sp_head_max: 'sp_head', + sp_body_max: 'sp_body', + sp_shield_max: 'sp_shield', +}; + +const NUMERIC_CPR_FIELDS = new Set([ + 'int', 'ref', 'dex', 'tech', 'cool', 'will', 'move', 'body', + 'luck', 'luck_max', 'emp', 'emp_max', 'humanity', 'humanity_max', + 'seriously_wounded', 'death_save', 'armor_penalty', + 'sp_head', 'sp_head_max', 'sp_body', 'sp_body_max', 'sp_shield', 'sp_shield_max', + ...Object.keys(CPR_SKILLS), + 'weapon1_rof', 'weapon2_rof', 'weapon3_rof', 'weapon4_rof', +]); + +// Plain-text parser for CP:R stat blocks: matches 'REF 7', 'Handgun: 5', +// 'INT=6' style lines. Used when a PDF has no form fields, or for raw paste. +const parseCprText = (text) => { + const out = {}; + const labelPatterns = [ + ...['INT', 'REF', 'DEX', 'TECH', 'COOL', 'WILL', 'LUCK', 'MOVE', 'BODY', 'EMP', 'HUMANITY'].map(s => [s, s]), + ...Object.entries(CPR_SKILLS).map(([id, [label]]) => [label.replace(/\s*\(x\d+\)\s*/i, ''), id]), + ]; + for (const [label, key] of labelPatterns) { + const esc = label.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); + const re = new RegExp(`(?:^|[^a-zA-Z])${esc}\\s*[:=]?\\s*(\\d{1,2})(?![0-9])`, 'i'); + const m = text.match(re); + if (m) out[key] = m[1]; + } + // Names stop at a double space, line end, or the next known label + const handle = text.match(/handle\s*[:=]?\s*(\S+(?:\s\S+)*?)(?=\s{2,}|\s*[\r\n]|\s+role\b|$)/i); + if (handle) out.handle = handle[1].trim(); + const role = text.match(/role\s*[:=]?\s*([A-Za-z]+(?:\s[A-Za-z]+)?)(?=\s{2,}|\s*[\r\n]|$)/i); + if (role) out.role = role[1].trim(); + return out; +}; + +// Map raw candidates onto CP:R sheet fields. +const mapCprFields = (raw) => { + const aliases = buildCprAliases(); + const mapped = {}; + const unmapped = {}; + const skipped = {}; + const linked = getLinkedFields('cyberpunk_red'); + + Object.entries(raw || {}).forEach(([key, value]) => { + if (value === '' || value === null || value === undefined) return; + const fieldId = aliases[norm(key)]; + if (!fieldId) { unmapped[key] = value; return; } + if (linked[fieldId]) { skipped[key] = value; return; } + const v = NUMERIC_CPR_FIELDS.has(fieldId) ? Number(value) : String(value); + if (NUMERIC_CPR_FIELDS.has(fieldId) && !Number.isFinite(v)) { unmapped[key] = value; return; } + mapped[fieldId] = v; + }); + + // A single imported value seeds both max and current for paired fields + Object.entries(CPR_MAX_SEEDS).forEach(([maxField, curField]) => { + if (mapped[maxField] !== undefined && mapped[curField] === undefined) { + mapped[curField] = mapped[maxField]; + } + }); + if (mapped.humanity !== undefined && mapped.humanity_max === undefined) { + mapped.humanity_max = mapped.humanity; + } + return { mapped, unmapped, skipped }; +}; + +// ─── Registry ──────────────────────────────────────────────────────────────── + +const IMPORTERS = { + cyberpunk_red: { mapFields: mapCprFields, parseText: parseCprText }, + // generic: no importer - import is only offered for systems that define one +}; + +const getImporter = (system) => IMPORTERS[system] || null; + +module.exports = { extractPdfFields, getImporter, IMPORTERS, norm }; diff --git a/backend/sheets/npcTiers.js b/backend/sheets/npcTiers.js new file mode 100644 index 0000000..f376993 --- /dev/null +++ b/backend/sheets/npcTiers.js @@ -0,0 +1,89 @@ +// Per-system NPC power tiers for GENERATE_SHEET. +// +// Every system defines its own tier vocabulary (CP:R uses Mook → Elite; +// a D&D template would use CR bands, CY_BORG something else). A tier is a +// complete seed package: sheet fields, token HP, and token DV/AC values. +// Numbers here are original tuning for this app, not book stat blocks. + +const cprTier = ({ stats, combatSkills, utilitySkills, sp, hp, dv, weapons }) => { + const data = { + int: stats, ref: stats, dex: stats, tech: Math.max(2, stats - 2), + cool: stats, will: stats, move: 4, body: stats, + luck: 0, luck_max: 0, + emp: Math.max(2, stats - 2), emp_max: Math.max(2, stats - 2), + humanity: Math.max(2, stats - 2) * 10, humanity_max: Math.max(2, stats - 2) * 10, + sp_head: sp.head, sp_head_max: sp.head, + sp_body: sp.body, sp_body_max: sp.body, + seriously_wounded: Math.ceil(hp / 2), + death_save: stats, + }; + ['handgun', 'brawling', 'evasion'].forEach((s) => { data[s] = combatSkills; }); + ['perception', 'athletics', 'stealth'].forEach((s) => { data[s] = utilitySkills; }); + if (combatSkills >= 4) { + data.shoulder_arms = combatSkills; + data.autofire = combatSkills - 1; + data.melee_weapon = combatSkills - 1; + } + weapons.forEach((w, i) => { + data[`weapon${i + 1}_name`] = w.name; + data[`weapon${i + 1}_dmg`] = w.dmg; + data[`weapon${i + 1}_skill`] = w.skill; + data[`weapon${i + 1}_rof`] = w.rof ?? 2; + }); + return { data, hp, dv }; +}; + +const TIERS = { + cyberpunk_red: { + default: 'mook', + options: [ + { id: 'mook', label: 'MOOK' }, + { id: 'skilled', label: 'SKILLED' }, + { id: 'pro', label: 'PRO' }, + { id: 'elite', label: 'ELITE' }, + ], + build: { + mook: () => cprTier({ + stats: 4, combatSkills: 2, utilitySkills: 2, + sp: { head: 0, body: 4 }, hp: 20, dv: { melee: 10, ranged: 10 }, + weapons: [{ name: 'Pistol', dmg: '2d6', skill: 'handgun' }], + }), + skilled: () => cprTier({ + stats: 5, combatSkills: 3, utilitySkills: 2, + sp: { head: 4, body: 7 }, hp: 30, dv: { melee: 12, ranged: 12 }, + weapons: [ + { name: 'Heavy Pistol', dmg: '3d6', skill: 'handgun' }, + { name: 'Knife', dmg: '1d6', skill: 'melee_weapon' }, + ], + }), + pro: () => cprTier({ + stats: 6, combatSkills: 4, utilitySkills: 3, + sp: { head: 7, body: 11 }, hp: 35, dv: { melee: 13, ranged: 13 }, + weapons: [ + { name: 'Assault Rifle', dmg: '5d6', skill: 'shoulder_arms', rof: 1 }, + { name: 'Heavy Pistol', dmg: '3d6', skill: 'handgun' }, + ], + }), + elite: () => cprTier({ + stats: 8, combatSkills: 6, utilitySkills: 4, + sp: { head: 11, body: 12 }, hp: 45, dv: { melee: 15, ranged: 15 }, + weapons: [ + { name: 'Assault Rifle', dmg: '5d6', skill: 'shoulder_arms', rof: 1 }, + { name: 'Monokatana', dmg: '3d6', skill: 'melee_weapon' }, + ], + }), + }, + }, +}; + +const getTierOptions = (system) => TIERS[system]?.options ?? []; + +// Returns { data, hp, dv } or null when the system has no tiers / unknown id. +const buildTier = (system, tierId) => { + const t = TIERS[system]; + if (!t) return null; + const id = t.build[tierId] ? tierId : t.default; + return t.build[id] ? { tierId: id, ...t.build[id]() } : null; +}; + +module.exports = { TIERS, getTierOptions, buildTier }; diff --git a/backend/sheets/rollEngine.js b/backend/sheets/rollEngine.js new file mode 100644 index 0000000..ae1d7b6 --- /dev/null +++ b/backend/sheets/rollEngine.js @@ -0,0 +1,116 @@ +// Sheet roll engine - pure functions, no I/O. +// +// Grammar (deliberately tiny): +// formula := term (('+'|'-') term)* +// term := NdS dice, e.g. 1d10, 2d6 +// | @field sheet field reference (missing/non-numeric -> 0) +// | N integer literal +// +// Shapes: +// sum - plain total (default) +// explode10 - CP:R check die: first d10 natural 10 -> +1 extra d10 added, +// natural 1 -> +1 extra d10 subtracted. Never chains. +// pool - reserved for hit-counting systems (SR6/CY_BORG); not yet +// implemented. + +const TERM_DICE = /^(\d+)d(\d+)$/i; +const TERM_FIELD = /^@([a-z0-9_]+)$/i; +const TERM_INT = /^\d+$/; + +// '1d10 + @ref - 2' -> [{ sign: 1, raw: '1d10' }, { sign: 1, raw: '@ref' }, { sign: -1, raw: '2' }] +const tokenize = (formula) => { + const parts = String(formula).replace(/\s+/g, '').match(/[+-]?[^+-]+/g) || []; + return parts.map((p) => { + const sign = p.startsWith('-') ? -1 : 1; + return { sign, raw: p.replace(/^[+-]/, '') }; + }); +}; + +const parseFormula = (formula) => { + return tokenize(formula).map(({ sign, raw }) => { + let m; + if ((m = raw.match(TERM_DICE))) { + const count = parseInt(m[1], 10); + const sides = parseInt(m[2], 10); + if (count < 1 || count > 100 || sides < 2 || sides > 1000) { + throw new Error(`Dice term out of range: ${raw}`); + } + return { kind: 'dice', sign, count, sides }; + } + if ((m = raw.match(TERM_FIELD))) return { kind: 'field', sign, field: m[1] }; + if (TERM_INT.test(raw)) return { kind: 'int', sign, value: parseInt(raw, 10) }; + throw new Error(`Bad formula term: ${raw}`); + }); +}; + +const numeric = (v) => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +}; + +// Substitute @field terms with the sheet's stored values. +// Returns { dice: [{count, sides, sign}], modifiers: [{label, value}] } +const resolveFormula = (formula, data) => { + const dice = []; + const modifiers = []; + parseFormula(formula).forEach((t) => { + if (t.kind === 'dice') dice.push({ count: t.count, sides: t.sides, sign: t.sign }); + else if (t.kind === 'field') modifiers.push({ label: t.field, value: t.sign * numeric(data[t.field]) }); + else modifiers.push({ label: null, value: t.sign * t.value }); + }); + if (dice.length === 0) throw new Error('Formula has no dice term'); + return { dice, modifiers }; +}; + +const rollDie = (sides, rng) => Math.floor(rng() * sides) + 1; + +// Roll a resolved formula. Returns: +// { rolls: { [sides]: [values...] }, diceTotal, modTotal, total, +// critical: 'success' | 'failure' | null, breakdown: '(6+3) + 12' } +// opts.noFumble: a natural 1 is NOT a critical failure (CP:R: spending any +// LUCK on the check negates the fumble; the 1 still counts at face value). +const executeRoll = (resolved, shape = 'sum', rng = Math.random, opts = {}) => { + if (shape === 'pool') throw new Error('pool rolls are not implemented yet'); + + const rolls = {}; + const parts = []; + let diceTotal = 0; + let critical = null; + let firstD10Done = false; + + resolved.dice.forEach(({ count, sides, sign }) => { + if (!rolls[sides]) rolls[sides] = []; + for (let i = 0; i < count; i++) { + const v = rollDie(sides, rng); + rolls[sides].push(v); + diceTotal += sign * v; + parts.push(sign < 0 ? `-${v}` : `${v}`); + + // CP:R critical: only the first d10 of the formula explodes, once. + if (shape === 'explode10' && sides === 10 && !firstD10Done) { + firstD10Done = true; + if (v === 10 || (v === 1 && !opts.noFumble)) { + const extra = rollDie(10, rng); + rolls[sides].push(extra); + if (v === 10) { + critical = 'success'; + diceTotal += sign * extra; + parts[parts.length - 1] = `${v}!+${extra}`; + } else { + critical = 'failure'; + diceTotal -= sign * extra; + parts[parts.length - 1] = `${v}!-${extra}`; + } + } + } + } + }); + + const modTotal = resolved.modifiers.reduce((a, m) => a + m.value, 0); + const total = diceTotal + modTotal; + let breakdown = `(${parts.join('+')})`; + if (modTotal !== 0) breakdown += ` ${modTotal > 0 ? '+' : '-'} ${Math.abs(modTotal)}`; + return { rolls, diceTotal, modTotal, total, critical, breakdown }; +}; + +module.exports = { parseFormula, resolveFormula, executeRoll }; diff --git a/backend/sheets/rolls.js b/backend/sheets/rolls.js new file mode 100644 index 0000000..9b62c71 --- /dev/null +++ b/backend/sheets/rolls.js @@ -0,0 +1,110 @@ +// Server-side roll definitions per system: fieldId -> { formula, label, shape }. +// +// The server is authoritative: a client sends only { fieldId } and the +// formula resolves against the STORED sheet - clients cannot inflate rolls. +// The frontend templates carry the same formulas for display; a drift test +// (frontend CharacterSheet tests) cross-checks the two so they can't diverge. + +// CP:R stats roll 1d10 + stat, skills roll 1d10 + stat + skill level. +// All CP:R checks use the exploding check die (nat 10 adds a d10, nat 1 +// subtracts one). +const CPR_STATS = ['int', 'ref', 'dex', 'tech', 'cool', 'will', 'emp']; + +const CPR_SKILLS = { + // AWARENESS + concentration: ['Concentration', 'will'], + conceal_reveal: ['Conceal/Reveal Object', 'int'], + lip_reading: ['Lip Reading', 'int'], + perception: ['Perception', 'int'], + tracking: ['Tracking', 'int'], + // BODY + athletics: ['Athletics', 'dex'], + contortionist: ['Contortionist', 'dex'], + dance: ['Dance', 'dex'], + endurance: ['Endurance', 'will'], + resist_torture: ['Resist Torture/Drugs', 'will'], + stealth: ['Stealth', 'dex'], + // CONTROL + drive_land: ['Drive Land Vehicle', 'ref'], + pilot_air: ['Pilot Air Vehicle (x2)', 'ref'], + pilot_sea: ['Pilot Sea Vehicle', 'ref'], + riding: ['Riding', 'ref'], + // EDUCATION + accounting: ['Accounting', 'int'], + animal_handling: ['Animal Handling', 'int'], + bureaucracy: ['Bureaucracy', 'int'], + business: ['Business', 'int'], + composition: ['Composition', 'int'], + criminology: ['Criminology', 'int'], + cryptography: ['Cryptography', 'int'], + deduction: ['Deduction', 'int'], + education: ['Education', 'int'], + gamble: ['Gamble', 'int'], + language_streetslang: ['Language (Streetslang)', 'int'], + language_other: ['Language (Other)', 'int'], + library_search: ['Library Search', 'int'], + local_expert: ['Local Expert (Your Home)', 'int'], + science: ['Science', 'int'], + tactics: ['Tactics', 'int'], + wilderness_survival: ['Wilderness Survival', 'int'], + // FIGHTING + brawling: ['Brawling', 'dex'], + evasion: ['Evasion', 'dex'], + martial_arts: ['Martial Arts (x2)', 'dex'], + melee_weapon: ['Melee Weapon', 'dex'], + // PERFORMANCE + acting: ['Acting', 'cool'], + play_instrument: ['Play Instrument', 'tech'], + // RANGED WEAPONS + archery: ['Archery', 'ref'], + autofire: ['Autofire (x2)', 'ref'], + handgun: ['Handgun', 'ref'], + heavy_weapons: ['Heavy Weapons (x2)', 'ref'], + shoulder_arms: ['Shoulder Arms', 'ref'], + // SOCIAL + bribery: ['Bribery', 'cool'], + conversation: ['Conversation', 'emp'], + human_perception: ['Human Perception', 'emp'], + interrogation: ['Interrogation', 'cool'], + persuasion: ['Persuasion', 'cool'], + personal_grooming: ['Personal Grooming', 'cool'], + streetwise: ['Streetwise', 'cool'], + trading: ['Trading', 'cool'], + wardrobe_style: ['Wardrobe & Style', 'cool'], + // TECHNIQUE + air_vehicle_tech: ['Air Vehicle Tech', 'tech'], + basic_tech: ['Basic Tech', 'tech'], + cybertech: ['Cybertech', 'tech'], + demolitions: ['Demolitions (x2)', 'tech'], + electronics_security: ['Electronics/Security Tech (x2)', 'tech'], + first_aid: ['First Aid', 'tech'], + forgery: ['Forgery', 'tech'], + land_vehicle_tech: ['Land Vehicle Tech', 'tech'], + paint_draw_sculpt: ['Paint/Draw/Sculpt', 'tech'], + paramedic: ['Paramedic (x2)', 'tech'], + photography_film: ['Photography/Film', 'tech'], + pick_lock: ['Pick Lock', 'tech'], + pick_pocket: ['Pick Pocket', 'tech'], + sea_vehicle_tech: ['Sea Vehicle Tech', 'tech'], + weaponstech: ['Weaponstech', 'tech'], +}; + +const cprRolls = () => { + const rolls = {}; + CPR_STATS.forEach((stat) => { + rolls[stat] = { formula: `1d10 + @${stat}`, label: stat.toUpperCase(), shape: 'explode10' }; + }); + Object.entries(CPR_SKILLS).forEach(([id, [label, stat]]) => { + rolls[id] = { formula: `1d10 + @${stat} + @${id}`, label, shape: 'explode10' }; + }); + return rolls; +}; + +const ROLLS = { + generic: {}, + cyberpunk_red: cprRolls(), +}; + +const getRoll = (system, fieldId) => (ROLLS[system] || {})[fieldId] || null; + +module.exports = { ROLLS, getRoll, CPR_SKILLS }; diff --git a/backend/sheets/templates.js b/backend/sheets/templates.js new file mode 100644 index 0000000..1faf0f2 --- /dev/null +++ b/backend/sheets/templates.js @@ -0,0 +1,84 @@ +// 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. + +// - linkedFields: fields whose value lives in another system (token HP, +// bank balance). The server overlays them at read time and refuses to +// store them in the sheet's JSON - one source of truth, no drift. +const TEMPLATES = { + generic: { + name: 'Generic', + publicFields: ['name', 'description'], + combatFields: [], + linkedFields: { hp: 'token_hp', hp_max: 'token_hp_max', cash: 'bank_balance' }, + }, + 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'], + linkedFields: { hp: 'token_hp', hp_max: 'token_hp_max', cash: 'bank_balance' }, + luckField: 'luck', + luckMaxField: 'luck_max', + // maxField → currentField: when a max is written, clamp current ≤ max + maxPairs: { + luck_max: 'luck', + emp_max: 'emp', + humanity_max: 'humanity', + sp_head_max: 'sp_head', + sp_body_max: 'sp_body', + sp_shield_max: 'sp_shield', + }, + // sourceField → { target, divisor }: writing the source recomputes the + // target (CP:R: current EMP = Humanity / 10, rounded down) + derived: { + humanity: { target: 'emp', divisor: 10 }, + }, + }, +}; + +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; +}; + +const getLinkedFields = (system) => + (TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]).linkedFields || {}; + +// Returns a map of maxFieldId → currentFieldId for the system. +const getMaxPairs = (system) => + (TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]).maxPairs || {}; + +// Recompute derived fields after a write. Mutates data; returns the ids of +// fields it changed (empty when the changed field derives nothing). +const applyDerived = (system, data, changedFieldId) => { + const derived = (TEMPLATES[system] || TEMPLATES[DEFAULT_SYSTEM]).derived || {}; + const rule = derived[changedFieldId]; + if (!rule) return []; + const src = Number(data[changedFieldId]); + if (!Number.isFinite(src)) return []; + data[rule.target] = Math.floor(src / rule.divisor); + return [rule.target]; +}; + +module.exports = { TEMPLATES, DEFAULT_SYSTEM, isValidSystem, filterPublicData, getLinkedFields, getMaxPairs, applyDerived }; diff --git a/backend/sockets/index.js b/backend/sockets/index.js index aedf33e..35bdd40 100644 --- a/backend/sockets/index.js +++ b/backend/sockets/index.js @@ -1,4 +1,9 @@ const jwt = require('jsonwebtoken'); +const sheetTemplates = require('../sheets/templates'); +const sheetRolls = require('../sheets/rolls'); +const rollEngine = require('../sheets/rollEngine'); +const sheetAttack = require('../sheets/attack'); +const npcTiers = require('../sheets/npcTiers'); const SECRET = process.env.JWT_SECRET; @@ -6,7 +11,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 +616,348 @@ 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); + }); + }; + + // Linked fields (declared per-template) live in other systems: token HP + // in locations, cash in player_banks. Overlay their live values onto the + // sheet data at read time - they are never stored in the sheet's JSON. + const overlayLinkedData = (username, system, data, cb) => { + const linked = sheetTemplates.getLinkedFields(system); + const out = { ...data }; + const wantsHp = Object.values(linked).some(s => s === 'token_hp' || s === 'token_hp_max'); + const wantsCash = Object.values(linked).includes('bank_balance'); + const afterHp = (hpRow) => { + Object.entries(linked).forEach(([fieldId, source]) => { + if (source === 'token_hp') out[fieldId] = hpRow ? hpRow.hp_current : null; + if (source === 'token_hp_max') out[fieldId] = hpRow ? hpRow.hp_max : null; + }); + if (!wantsCash) return cb(out); + db.get(`SELECT balance FROM player_banks WHERE username = ?`, [username], (err, bank) => { + Object.entries(linked).forEach(([fieldId, source]) => { + if (source === 'bank_balance') out[fieldId] = bank ? bank.balance : 0; + }); + cb(out); + }); + }; + if (!wantsHp) return afterHp(null); + db.get( + `SELECT hp_current, hp_max FROM locations WHERE shape = 'rhombus' AND owner = ? + ORDER BY (battle_map_id IS NULL) DESC LIMIT 1`, + [username], + (err, hpRow) => afterHp(err ? null : hpRow) + ); + }; + + 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) { + overlayLinkedData(info.userName, system, JSON.parse(row.data || '{}'), (data) => { + socket.emit('sheetData', { ...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; + const newId = this.lastID; + overlayLinkedData(info.userName, system, {}, (data) => { + socket.emit('sheetData', { + id: newId, 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; + // Linked fields are owned by other systems (token HP, bank) - never + // stored in sheet JSON, and not writable through the sheet. + if (sheetTemplates.getLinkedFields(system)[payload.fieldId]) 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; + // If the changed field is a max, clamp the paired current field. + const curField = sheetTemplates.getMaxPairs(system)[payload.fieldId]; + if (curField && data[curField] !== undefined) { + const newMax = Number(payload.value); + if (Number(data[curField]) > newMax) data[curField] = newMax; + } + // Recompute derived fields (CP:R: EMP = Humanity / 10) + sheetTemplates.applyDerived(system, data, payload.fieldId); + 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 }); + } + ); + } + ); + }); + }); + + // Bulk-apply imported fields to the caller's own sheet (import flow). + // Same rules as updateSheetField, one write: linked fields refused, + // derived fields recomputed. + socket.on('importSheetFields', (payload) => { + const info = userSockets.get(socket.id); + if (!info || !info.userName) return; + if (!payload || typeof payload.fields !== 'object' || payload.fields === null) return; + getGameSystem((err, system) => { + if (err) return; + const linked = sheetTemplates.getLinkedFields(system); + const entries = Object.entries(payload.fields) + .filter(([k, v]) => !linked[k] && (typeof v === 'string' || typeof v === 'number')); + if (entries.length === 0) 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 || '{}'); + entries.forEach(([k, v]) => { data[k] = v; }); + entries.forEach(([k]) => sheetTemplates.applyDerived(system, data, k)); + 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 }); + socket.emit('sheetImportApplied', { count: entries.length }); + } + ); + } + ); + }); + }); + + // Roll a sheet field. Server-authoritative: the client sends only the + // fieldId - the formula and the stat values come from the server-side + // roll map and the STORED sheet, so a client can't inflate a roll. The + // result flows through the same insert + broadcast as manual dice. + socket.on('requestSheetRoll', (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; + const rollDef = sheetRolls.getRoll(system, payload.fieldId); + if (!rollDef) return; + db.get(`SELECT value FROM global_settings WHERE key = 'luck_negates_fumble'`, (lnErr, lnRow) => { + const luckNegatesFumble = !lnErr && lnRow && lnRow.value === '1'; + 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; + db.get( + `SELECT hp_current FROM locations WHERE shape = 'rhombus' AND owner = ? + ORDER BY (battle_map_id IS NULL) DESC LIMIT 1`, + [info.userName], + (hpErr, hpRow) => + + { + const data = JSON.parse(row.data || '{}'); + const hp = !hpErr && hpRow ? hpRow.hp_current : null; + // Declared LUCK: flat bonus and/or a 1-pip fumble shield. The + // house rule (bonus spend also negates fumbles) is settings-gated. + // Fumble negation (shield or bonus) only exists while the + // house rule is on - off means a nat-1 always fumbles. + const spend = sheetAttack.resolveLuckSpend( + data.luck, + Number.isInteger(payload.luck) ? payload.luck : 0, + payload.luckNegate === true && luckNegatesFumble + ); + const noFumble = spend.negate || (luckNegatesFumble && spend.bonus > 0); + let outcome; + let statField = null; + try { + const resolved = rollEngine.resolveFormula(rollDef.formula, data); + // First @field in the formula is the governing stat (armor + // penalty applies to REF/DEX checks) + const firstField = rollEngine.parseFormula(rollDef.formula).find(t => t.kind === 'field'); + statField = firstField ? firstField.field : null; + if (spend.bonus > 0) resolved.modifiers.push({ label: 'luck', value: spend.bonus }); + resolved.modifiers.push(...sheetAttack.checkPenalties(data, statField, hp)); + outcome = rollEngine.executeRoll(resolved, rollDef.shape, Math.random, { noFumble }); + } catch (e) { + return; + } + const luck = spend.total; + const critTag = outcome.critical === 'success' ? ' — CRITICAL!' + : outcome.critical === 'failure' ? ' — FUMBLE!' : ''; + const luckTag = (spend.bonus > 0 ? ` (LUCK +${spend.bonus})` : '') + + (spend.negate ? ' (LUCK: FUMBLE SHIELD)' : ''); + const woundTag = hp !== null && hp <= 0 ? ' (MORTALLY WOUNDED -4)' + : hp !== null && Number(data.seriously_wounded) > 0 && hp <= Number(data.seriously_wounded) ? ' (WOUNDED -2)' : ''; + const historyString = + `${info.userName} rolled ${rollDef.label} [${outcome.breakdown} = ${outcome.total}]${luckTag}${woundTag}${critTag}`; + // Spend the declared LUCK + if (luck > 0) { + data.luck = Math.max(0, (Number(data.luck) || 0) - luck); + db.run( + `UPDATE character_sheets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [JSON.stringify(data), row.id], + () => io.emit('sheetUpdated', { username: info.userName, system }) + ); + } + const color = typeof payload.color === 'string' ? payload.color : '#00ff00'; + const broadcastData = { + userName: info.userName, + results: outcome.rolls, + modifiers: outcome.modTotal !== 0 ? [outcome.modTotal] : [], + color, + total: outcome.total, + historyString, + }; + db.run( + 'INSERT INTO dice_rolls (username, total, results, color, historyString) VALUES (?, ?, ?, ?, ?)', + [info.userName, outcome.total, JSON.stringify(outcome.rolls), color, historyString], + (err3) => { + if (err3) console.error('Error saving sheet roll:', err3); + io.emit('diceRollBroadcast', broadcastData); + } + ); + } + ); + } + ); + }); + }); + }); + + // 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), + }); + } + ); + }); + }); + + // Admin: seed an NPC sheet from a token (enemy_rhombus / friendly_rhombus). + // Creates a character_sheets row pre-filled with the token's name, description + // and current HP, then links it to the location via npc_sheet_links. + socket.on('generateNpcSheet', (data) => { + const callerInfo = userSockets.get(socket.id); + if (!callerInfo || (!callerInfo.isAdmin && !elevatedUsers.has(callerInfo.userName))) return; + if (!data || !data.location_id) return; + const { location_id } = data; + db.get(`SELECT * FROM locations WHERE id = ?`, [location_id], (err, loc) => { + if (err || !loc) return; + getGameSystem((err2, system) => { + if (err2) return; + const label = loc.name || `Token #${location_id}`; + // Tier package (per-system power level) seeds stats/skills/armor/ + // weapons plus token HP and DV. Systems without tiers keep the + // bare token-mirroring sheet. + const tier = npcTiers.buildTier(system, data.tier); + const sheetData = { + name: loc.name || '', + description: loc.description || '', + handle: loc.name || '', + ...(tier ? tier.data : { + hp: loc.hp_current ?? loc.hp_max ?? 0, + hp_max: loc.hp_max ?? 0, + }), + }; + const insertSheet = () => db.run( + `INSERT INTO character_sheets (username, system, data, is_npc, npc_label) + VALUES (?, ?, ?, 1, ?)`, + [callerInfo.userName, system, JSON.stringify(sheetData), label], + function (err3) { + if (err3) return; + const sheetId = this.lastID; + db.run( + `INSERT INTO npc_sheet_links (location_id, sheet_id) VALUES (?, ?) + ON CONFLICT(location_id) DO UPDATE SET sheet_id = excluded.sheet_id`, + [location_id, sheetId], + (err4) => { + if (err4) return; + socket.emit('npcSheetGenerated', { location_id, sheet_id: sheetId, npc_label: label, tier: tier ? tier.tierId : null }); + } + ); + } + ); + if (tier) { + // Tiered NPCs also get their token tuned: HP pool + DVs. + // Melee DV comes from the sheet (base + DEX + Evasion; base per + // the take-10 house-rule toggle) - the GM can still override it + // any time via EDIT_DV. + db.get(`SELECT value FROM global_settings WHERE key = 'melee_dv_take10'`, (sErr, sRow) => { + const meleeDv = sheetAttack.staticMeleeDv(tier.data, !sErr && sRow?.value === '1'); + db.run( + `UPDATE locations SET hp_current = ?, hp_max = ?, melee_ac = ?, ranged_ac = ? WHERE id = ?`, + [tier.hp, tier.hp, meleeDv, tier.dv.ranged, location_id], + () => { emitUpdate({ isRhombusOnly: true }); insertSheet(); } + ); + }); + } else { + insertSheet(); + } + }); + }); + }); + socket.on('markFirstPayDone', (data) => { if (!data || !data.username) return; db.run('UPDATE player_banks SET first_pay_done = 1 WHERE username = ?', [data.username]); @@ -710,6 +1059,303 @@ module.exports = (io, db, { elevatedUsers, emitUpdate, recordAction }) => { pendingAttacks.delete(socket.id); }); + // ── CP:R attack flow ───────────────────────────────────────────────────── + // Fully server-authoritative: the client names a target, one of its own + // structured weapon rows, and whether the shot is aimed. Everything else + // (to-hit formula, DV, damage dice, SP soak, ablation, HP write) resolves + // against stored data. + const RHOMBUS_SHAPES = ['rhombus', 'enemy_rhombus', 'friendly_rhombus']; + + const broadcastRoll = (userName, outcome, historyString, color, cb) => { + const broadcastData = { + userName, + results: outcome.rolls, + modifiers: outcome.modTotal !== 0 ? [outcome.modTotal] : [], + color, + total: outcome.total, + historyString, + }; + db.run( + 'INSERT INTO dice_rolls (username, total, results, color, historyString) VALUES (?, ?, ?, ?, ?)', + [userName, outcome.total, JSON.stringify(outcome.rolls), color, historyString], + () => { io.emit('diceRollBroadcast', broadcastData); if (cb) cb(); } + ); + }; + + // Defender armor lives on a sheet: the owner's player sheet for player + // rhombi, or the linked NPC sheet for enemy/friendly tokens (their owner + // field is set too, so branch on shape, not owner). Null when neither + // exists (SP treated as 0). + const getDefenderSheet = (target, system, cb) => { + if (target.shape === 'rhombus' && target.owner) { + db.get( + `SELECT id, username, data, is_npc FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [target.owner, system], (err, row) => cb(err ? null : row || null) + ); + } else { + db.get( + `SELECT cs.id, cs.username, cs.data, cs.is_npc FROM npc_sheet_links l + JOIN character_sheets cs ON cs.id = l.sheet_id WHERE l.location_id = ?`, + [target.id], (err, row) => cb(err ? null : row || null) + ); + } + }; + + // Apply damage to a token's HP: temp HP absorbs first (same rules as the + // /health route). Player rhombi update every copy by owner. + const applyTokenDamage = (target, amount, cb) => { + let temp = target.hp_temp || 0; + let current = target.hp_current === null || target.hp_current === undefined + ? (target.hp_max || 0) : target.hp_current; + let remaining = amount; + if (temp > 0) { + if (temp >= remaining) { temp -= remaining; remaining = 0; } + else { remaining -= temp; temp = 0; } + } + current = Math.max(0, current - remaining); + const done = () => { + emitUpdate({ isRhombusOnly: true }); + if (target.owner) io.emit('sheetUpdated', { username: target.owner }); + cb(current); + }; + if (target.shape === 'rhombus' && target.owner) { + db.run('UPDATE locations SET hp_current = ?, hp_temp = ? WHERE shape = "rhombus" AND owner = ?', + [current, temp, target.owner], done); + } else { + db.run('UPDATE locations SET hp_current = ?, hp_temp = ? WHERE id = ?', + [current, temp, target.id], done); + } + }; + + socket.on('sheetAttack', (payload) => { + const info = userSockets.get(socket.id); + if (!info || !info.userName) return; + if (!payload || !payload.targetId) return; + pendingAttacks.delete(socket.id); // CP:R resolves in one step - no manual roll pending + const color = typeof payload.color === 'string' ? payload.color : '#00ff00'; + getGameSystem((err, system) => { + if (err || system !== 'cyberpunk_red') return; + db.get( + `SELECT data FROM character_sheets WHERE username = ? AND system = ? AND is_npc = 0`, + [info.userName, system], + (err2, sheetRow) => { + if (err2 || !sheetRow) return; + const attackerData = JSON.parse(sheetRow.data || '{}'); + const weapon = sheetAttack.getWeapon(attackerData, payload.weaponIndex); + if (!weapon) { + return socket.emit('sheetAttackError', { message: 'INVALID_WEAPON // SET NAME, DMG (e.g. 3d6) AND SKILL ON YOUR SHEET' }); + } + const aimed = !!payload.aimed; + db.get( + `SELECT id, name, owner, x, z, melee_ac, ranged_ac, shape, battle_map_id, floor_index, + hp_current, hp_max, hp_temp + FROM locations WHERE id = ?`, + [payload.targetId], + (err3, target) => { + if (err3 || !target || !RHOMBUS_SHAPES.includes(target.shape)) return; + const meleeDv = target.melee_ac !== null && target.melee_ac !== undefined ? target.melee_ac : 10; + const rangedDv = target.ranged_ac !== null && target.ranged_ac !== undefined ? target.ranged_ac : meleeDv; + const dv = weapon.attackType === 'ranged' ? rangedDv : meleeDv; + + db.get( + `SELECT hp_current FROM locations WHERE shape = 'rhombus' AND owner = ? + ORDER BY (battle_map_id IS NULL) DESC LIMIT 1`, + [info.userName], + (hpErr, hpRow) => { + db.get(`SELECT value FROM global_settings WHERE key = 'luck_negates_fumble'`, (lnErr, lnRow) => { + const luckNegatesFumble = !lnErr && lnRow && lnRow.value === '1'; + // Declared LUCK on the to-hit: flat bonus and/or 1-pip fumble + // shield - the shield only exists while the house rule is on + const spend = sheetAttack.resolveLuckSpend( + attackerData.luck, + Number.isInteger(payload.luck) ? payload.luck : 0, + payload.luckNegate === true && luckNegatesFumble + ); + const noFumble = spend.negate || (luckNegatesFumble && spend.bonus > 0); + const luck = spend.total; + const attackerHp = !hpErr && hpRow ? hpRow.hp_current : null; + let toHit; + try { toHit = sheetAttack.rollToHit(attackerData, weapon, aimed, Math.random, { luck: spend.bonus, noFumble, hp: attackerHp }); } catch (e) { return; } + const hit = toHit.total >= dv; + const critTag = toHit.critical === 'success' ? ' — CRITICAL!' + : toHit.critical === 'failure' ? ' — FUMBLE!' : ''; + const aimedTag = aimed ? ' (AIMED)' : ''; + const luckTag = (spend.bonus > 0 ? ` (LUCK +${spend.bonus})` : '') + + (spend.negate ? ' (LUCK: FUMBLE SHIELD)' : ''); + const woundTag = attackerHp !== null && attackerHp <= 0 ? ' (MORTALLY WOUNDED -4)' + : attackerHp !== null && Number(attackerData.seriously_wounded) > 0 && attackerHp <= Number(attackerData.seriously_wounded) ? ' (WOUNDED -2)' : ''; + const hitHistory = + `${info.userName} attacks ${target.name} with ${weapon.name}${aimedTag}${luckTag}${woundTag} ` + + `[${toHit.breakdown} = ${toHit.total} vs DV ${dv}] — ${hit ? 'HIT' : 'MISS'}${critTag}`; + // Spend the declared LUCK + if (luck > 0) { + attackerData.luck = Math.max(0, (Number(attackerData.luck) || 0) - luck); + db.run( + `UPDATE character_sheets SET data = ?, updated_at = CURRENT_TIMESTAMP + WHERE username = ? AND system = ? AND is_npc = 0`, + [JSON.stringify(attackerData), info.userName, system], + () => io.emit('sheetUpdated', { username: info.userName, system }) + ); + } + + const emitResult = (extra) => { + const onBattleMap = target.battle_map_id !== null && target.battle_map_id !== undefined; + const attackerSql = onBattleMap + ? 'SELECT x, z FROM locations WHERE shape = "rhombus" AND owner = ? AND battle_map_id = ? AND floor_index = ?' + : 'SELECT x, z FROM locations WHERE shape = "rhombus" AND owner = ? AND battle_map_id IS NULL'; + const attackerParams = onBattleMap + ? [info.userName, target.battle_map_id, target.floor_index] + : [info.userName]; + db.get(attackerSql, attackerParams, (posErr, attackerRow) => { + io.emit('attackResult', { + hit, + attackerId: socket.id, + attackerName: info.userName, + targetId: target.id, + targetName: target.name, + attackType: weapon.attackType, + roll: toHit.total, + ac: dv, + attackerPos: attackerRow ? { x: attackerRow.x, z: attackerRow.z } : null, + targetPos: { x: target.x, z: target.z }, + isBattleMap: onBattleMap, + weaponName: weapon.name, + aimed, + ...extra, + }); + }); + }; + + if (!hit) return broadcastRoll(info.userName, toHit, hitHistory, color, () => emitResult({})); + + // Hit: damage, SP soak, ablation, HP write-through. + broadcastRoll(info.userName, toHit, hitHistory, color, () => { + let dmg; + try { dmg = sheetAttack.rollDamage(weapon); } catch (e) { return emitResult({}); } + getDefenderSheet(target, system, (defender) => { + const defenderData = defender ? JSON.parse(defender.data || '{}') : {}; + const spField = aimed ? 'sp_head' : 'sp_body'; + const sp = Number(defenderData[spField]) || 0; + + // Shield intercepts first (only when the defender has a sheet) + const shieldBefore = defender ? (Number(defenderData.sp_shield) || 0) : 0; + const shield = sheetAttack.applyShield(dmg.total, shieldBefore); + // Overflow past the shield soaks against location SP + const { through: armorThrough, ablated } = sheetAttack.applyArmor(shield.remaining, sp, aimed); + // Critical injury (2+ max-face damage dice): bonus damage ignores armor + const crit = sheetAttack.isCriticalInjury(dmg.rolls); + const through = armorThrough + (crit ? sheetAttack.CRIT_BONUS_DAMAGE : 0); + const location = aimed ? 'HEAD' : 'BODY'; + + let dmgHistory = `${weapon.name} damage vs ${target.name} [${dmg.breakdown} = ${dmg.total}]`; + if (shield.absorbed > 0) { + dmgHistory += ` — SHIELD absorbs ${shield.absorbed}${shield.destroyed ? ' (SHIELD DOWN)' : ` (${shield.newShield} left)`}`; + } + if (shield.remaining > 0) { + dmgHistory += ` — SP ${location} ${sp} soaks ${Math.min(sp, shield.remaining)}` + + (armorThrough > 0 + ? `, ${armorThrough} DAMAGE THROUGH${aimed ? ' (HEADSHOT x2)' : ''}${ablated && defender ? ', SP ABLATES -1' : ''}` + : ' — NO PENETRATION'); + } + if (crit) { + dmgHistory += ` — CRITICAL INJURY! +${sheetAttack.CRIT_BONUS_DAMAGE} DIRECT · GM: ROLL THE INJURY TABLE`; + } + + const resultExtras = { + damage: dmg.total, sp, through, ablated, location, + shieldAbsorbed: shield.absorbed, shieldLeft: shield.newShield, + criticalInjury: crit, + }; + + const finish = () => { + broadcastRoll(info.userName, dmg, dmgHistory, color, () => { + if (through <= 0) return emitResult({ ...resultExtras, through: 0 }); + applyTokenDamage(target, through, (newHp) => { + emitResult({ ...resultExtras, targetHp: newHp, targetDown: newHp <= 0 }); + }); + }); + }; + + const sheetChanged = defender && ((ablated && shield.remaining > 0) || shield.absorbed > 0); + if (sheetChanged) { + if (ablated && shield.remaining > 0) defenderData[spField] = Math.max(0, sp - 1); + if (shield.absorbed > 0) defenderData.sp_shield = shield.newShield; + db.run( + `UPDATE character_sheets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [JSON.stringify(defenderData), defender.id], + () => { + if (!defender.is_npc) io.emit('sheetUpdated', { username: defender.username, system }); + finish(); + } + ); + } else { + finish(); + } + }); + }); + }); + } + ); + } + ); + } + ); + }); + }); + + // ── CP:R death saves ───────────────────────────────────────────────────── + // Only meaningful at 0 HP or less. The escalating penalty lives in the + // sheet's own data (death_save_penalty) and resets when healed above 0. + socket.on('requestDeathSave', () => { + const info = userSockets.get(socket.id); + if (!info || !info.userName) return; + getGameSystem((err, system) => { + if (err || system !== 'cyberpunk_red') 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; + db.get( + `SELECT hp_current FROM locations WHERE shape = 'rhombus' AND owner = ? + ORDER BY (battle_map_id IS NULL) DESC LIMIT 1`, + [info.userName], + (err3, hpRow) => { + if (err3 || !hpRow) return; + if (hpRow.hp_current === null || hpRow.hp_current > 0) return; + const data = JSON.parse(row.data || '{}'); + const body = Number(data.body) || 0; + const save = sheetAttack.rollDeathSave(body, data.death_save_penalty); + data.death_save_penalty = save.penalty + 1; + const penTag = save.penalty > 0 ? `+${save.penalty} ` : ''; + const historyString = + `${info.userName} DEATH SAVE [${save.die} ${penTag}= ${save.total} vs BODY ${body}] — ` + + (save.success ? 'STABILIZED THIS ROUND' : save.die === 10 ? 'NATURAL 10 — DEAD' : 'DEAD'); + db.run( + `UPDATE character_sheets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + [JSON.stringify(data), row.id], + () => { + io.emit('sheetUpdated', { username: info.userName, system }); + const outcome = { rolls: { 10: [save.die] }, modTotal: save.penalty, total: save.total }; + broadcastRoll(info.userName, outcome, historyString, '#ff3333', () => { + io.emit('deathSaveResult', { + userName: info.userName, + die: save.die, + penalty: save.penalty, + total: save.total, + body, + success: save.success, + }); + }); + } + ); + } + ); + } + ); + }); + }); + // ── Radio Feed ────────────────────────────────────────────────────────────── socket.on('musicLoad', (data) => { diff --git a/frontend/package.json b/frontend/package.json index ba2d12a..d426403 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.3.0", + "version": "1.4.0", "type": "module", "scripts": { "dev": "vite --host", diff --git a/frontend/src/App.css b/frontend/src/App.css index 61014e9..1f2eabe 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1111,6 +1111,16 @@ button:disabled, .utility-btn:disabled, .upload-btn:disabled { 50% { opacity: 0; } } +@keyframes wound-pulse { + 0%, 100% { color: #ffcc00; text-shadow: none; } + 50% { color: #ffee55; text-shadow: 0 0 8px #ffee55; } +} + +@keyframes death-pulse { + 0%, 100% { color: #ff3333; text-shadow: none; } + 50% { color: #ff6666; text-shadow: 0 0 8px #ff6666; } +} + .login-panel h1 { letter-spacing: 10px; margin-bottom: 5px; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19c4453..51c58b7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,11 @@ 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 { QuickSheetCard } from './components/QuickSheetCard'; +import { NpcLibrary } from './components/NpcLibrary'; +import { NpcSheetWindow } from './components/NpcSheetWindow'; +import { getTemplate } from './sheets'; import { DiceTrayWindow, DotMatrixScoreboard, DiceScene } from './components/DiceTray'; import { EnemyRhombus, FriendlyRhombus, PlayerRhombus, OverlapChecker } from './components/Rhombuses'; import { UpdateModal } from './components/UpdateModal'; @@ -155,7 +160,16 @@ function App() { // Attack state const [attackPending, setAttackPending] = useState<{ targetId: number; targetName: string; attackType: 'melee' | 'ranged'; ac: number } | null>(null); - const [lastAttackResult, setLastAttackResult] = useState<{ hit: boolean; roll: number; ac: number; targetName: string } | null>(null); + // Active TTRPG system - drives token defense labels (AC vs DV) and the + // token menu's armor section + const [gameSystem, setGameSystem] = useState('generic'); + useEffect(() => { + fetch('/api/sheets/system') + .then(r => r.json()) + .then(d => { if (d?.system) setGameSystem(d.system); }) + .catch(() => {}); + }, []); + const [lastAttackResult, setLastAttackResult] = useState<{ hit: boolean; roll: number; ac: number; targetName: string; damage?: number; through?: number; targetDown?: boolean; criticalInjury?: boolean; shieldAbsorbed?: number } | null>(null); const [attackAnimations, setAttackAnimations] = useState<{ id: string; hit: boolean; attackType: 'melee' | 'ranged'; attackerPos: { x: number; z: number } | null; targetPos: { x: number; z: number }; targetId: number; isBattleMap: boolean }[]>([]); // Radio Feed @@ -177,7 +191,41 @@ function App() { const [acEdit, setAcEdit] = useState<{ melee: string; ranged: string } | null>(null); const [reviewHealthOwner, setReviewHealthOwner] = useState(null); + // Track the reviewed token by id so the window follows live HP updates + // (NPC tokens share owner names; selectedLocation is a stale snapshot) + const [reviewHealthLocId, setReviewHealthLocId] = useState(null); const [reviewHealthPos, setReviewHealthPos] = useState(() => ({ x: window.innerWidth / 2 - 100, y: window.innerHeight / 2 - 100 })); + const [quickSheetPos, setQuickSheetPos] = useState(() => ({ x: window.innerWidth / 2 + 170, y: window.innerHeight / 2 - 100 })); + const [isNpcLibraryOpen, setIsNpcLibraryOpen] = useState(false); + const [npcLibraryPos, setNpcLibraryPos] = useState(() => ({ x: window.innerWidth / 2 - 150, y: window.innerHeight / 2 - 200 })); + const [openNpcSheet, setOpenNpcSheet] = useState<{ id: number; npc_label: string } | null>(null); + // NPC sheet linked to the currently selected token (admin) - drives + // GENERATE_SHEET vs OPEN_SHEET on the token menu + const [tokenSheetLink, setTokenSheetLink] = useState<{ location_id: number; sheet_id: number; npc_label: string } | null>(null); + // Admin view of another player's sheet (opened from their token) + const [openPlayerSheetUser, setOpenPlayerSheetUser] = useState(null); + // Bumped when npc_sheet_links change so the link lookup effect re-runs + const [linkRefresh, setLinkRefresh] = useState(0); + // Power tier for GENERATE_SHEET (per-system; CP:R: mook..elite) + const [genTier, setGenTier] = useState(''); + + useEffect(() => { + const loc = selectedLocation; + if (!token || !loc || !['enemy_rhombus', 'friendly_rhombus'].includes(loc.shape)) { + setTokenSheetLink(null); + return; + } + let cancelled = false; + fetch(`/api/sheets/npcs/link/${loc.id}`, { headers: { Authorization: `Bearer ${token}` } }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (cancelled) return; + setTokenSheetLink(data?.sheet_id ? { location_id: loc.id, sheet_id: data.sheet_id, npc_label: data.npc_label } : null); + }) + .catch(() => { if (!cancelled) setTokenSheetLink(null); }); + return () => { cancelled = true; }; + }, [selectedLocation?.id, selectedLocation?.shape, token, linkRefresh]); // eslint-disable-line react-hooks/exhaustive-deps + const [npcSheetPos, setNpcSheetPos] = useState(() => ({ x: window.innerWidth / 2 - 260, y: 60 })); const [infoPanelPos, setInfoPanelPos] = useState(() => ({ x: window.innerWidth / 2 - 175, y: window.innerHeight / 2 - 200 })); const [diceTrayPos, setDiceTrayPos] = useState(() => ({ x: window.innerWidth / 2 - 240, y: window.innerHeight / 2 - 250 })); @@ -210,6 +258,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); @@ -661,11 +711,32 @@ function App() { setActiveSidebarMenu('dice_menu'); setIsDiceTrayOpen(true); }, + onGameSystemChanged: (system) => setGameSystem(system), + onNpcSheetGenerated: (data) => { + setNotification(`NPC_SHEET_CREATED: ${data.npc_label.toUpperCase()}`); + // Flip the token menu's GENERATE_SHEET to OPEN_SHEET immediately. + // Safe unconditionally: the button only reads the link when its + // location_id matches the selected token. + setTokenSheetLink({ location_id: data.location_id, sheet_id: data.sheet_id, npc_label: data.npc_label }); + }, + onNpcLinkChanged: () => setLinkRefresh(n => n + 1), + onDiceRollBroadcast: (data) => { + // Open the dice tray when any roll arrives for this user — catches rolls + // originating from the standalone sheet tab which can't call onRolled(). + if (data.userName === userName) { + setIsDiceTrayOpen(true); + setActiveSidebarMenu('dice_menu'); + } + }, onAttackResult: (data) => { setAttackPending(null); // Delay result reveal and animation until after the dice tray's 5-second roll display finishes setTimeout(() => { - setLastAttackResult({ hit: data.hit, roll: data.roll, ac: data.ac, targetName: data.targetName }); + setLastAttackResult({ + hit: data.hit, roll: data.roll, ac: data.ac, targetName: data.targetName, + damage: (data as any).damage, through: (data as any).through, targetDown: (data as any).targetDown, + criticalInjury: (data as any).criticalInjury, shieldAbsorbed: (data as any).shieldAbsorbed, + }); // Skip animation if the target rhombus isn't rendered in this client's current view if (!(window as any).activeRhombuses?.[data.targetId]) return; setAttackAnimations(prev => [...prev, { @@ -1160,8 +1231,11 @@ function App() { socketRef.current.emit('requestRhombusPurge', { owner: userName }); } + setIsSheetOpen(false); + setOpenNpcSheet(null); + setOpenPlayerSheetUser(null); setNotification("TERMINATING_SESSION..."); - + // 2. Wait for animation to finish before unmounting map setTimeout(() => { if (socketRef.current) socketRef.current.disconnect(); @@ -1172,6 +1246,7 @@ function App() { setSelectedSignId(null); setSignMesh(null); setSignTransformActive(false); + setIsBankOpen(false); }, 2500); }; @@ -1272,6 +1347,9 @@ function App() { locations={locations} isBankOpen={isBankOpen} setIsBankOpen={setIsBankOpen} + isSheetOpen={isSheetOpen} + setIsSheetOpen={setIsSheetOpen} + gameSystem={gameSystem} onSelect={setSelectedLocation} onZoom={setCameraTarget} selectedLocation={selectedLocation} @@ -1388,7 +1466,7 @@ function App() { token={token} userName={userName} controlsRef={controlsRef} - onLogout={() => { setToken(''); setIsAdmin(false); setShowAdminPanel(false); }} + onLogout={() => { setToken(''); setIsAdmin(false); setShowAdminPanel(false); setIsSheetOpen(false); setOpenNpcSheet(null); setOpenPlayerSheetUser(null); setIsBankOpen(false); }} tempCityMapScale={tempCityMapScale} setTempCityMapScale={setTempCityMapScale} globalSettings={globalSettings} @@ -1508,6 +1586,7 @@ function App() { activeUsers={activeUsers} onGrantAccess={handleGrantAccess} onRevokeAccess={handleRevokeAccess} + onOpenNpcLibrary={() => setIsNpcLibraryOpen(true)} /> )} {adminBankPlayer && ( @@ -1550,7 +1629,24 @@ function App() { }} currencyIcon={globalSettings?.currency_icon} /> - setIsSheetOpen(false)} + socket={socketRef.current} + userName={userName} + playerToken={playerToken} + adminToken={token} + onOpenLink={(source) => { + // Linked fields jump to the window that owns the value + if (source === 'bank_balance') setIsBankOpen(true); + else setIsHitPointsOpen(true); + }} + onRolled={() => setIsDiceTrayOpen(true)} + /> + )} + setIsChatOpen(false)} @@ -1639,17 +1735,61 @@ function App() { /> )} {reviewHealthOwner && (() => { - const reviewLoc = locations.find((l: any) => l.shape === 'rhombus' && l.owner === reviewHealthOwner) + const rhombusShapes = ['rhombus', 'enemy_rhombus', 'friendly_rhombus']; + const reviewLoc = (reviewHealthLocId !== null ? locations.find((l: any) => l.id === reviewHealthLocId) : null) + ?? locations.find((l: any) => rhombusShapes.includes(l.shape) && l.owner === reviewHealthOwner) ?? (selectedLocation?.owner === reviewHealthOwner ? selectedLocation : null); return reviewLoc ? ( setReviewHealthOwner(null)} + onClose={() => { setReviewHealthOwner(null); setReviewHealthLocId(null); }} /> ) : null; })()} + {reviewHealthOwner && ( + setReviewHealthOwner(null)} + /> + )} + {isNpcLibraryOpen && token && ( + setIsNpcLibraryOpen(false)} + onOpenNpc={(npc) => setOpenNpcSheet(npc)} + attachLocationId={selectedLocation && ['enemy_rhombus', 'friendly_rhombus'].includes(selectedLocation.shape) ? selectedLocation.id : null} + /> + )} + {openPlayerSheetUser && token && ( + setOpenPlayerSheetUser(null)} + /> + )} + {openNpcSheet && token && ( + setOpenNpcSheet(null)} + /> + )} {isDiceTrayOpen && (

ID_TAG: {selectedLocation.name || (selectedLocation.shape === 'enemy_rhombus' ? 'UNKNOWN_HOSTILE' : (selectedLocation.shape === 'friendly_rhombus' ? 'UNKNOWN_FRIENDLY' : 'UNTAGGED'))}

DATA_DESCRIPTION: {selectedLocation.description || 'NO_DATA'}

- {/* AC display — admin can edit; owner can view their own; other players see nothing */} - {(isAdmin || isOwner) && ( + {/* Defense display (AC or DV per game system) — admin can edit; owner can view their own; other players see nothing */} + {(isAdmin || isOwner) && (() => { const defLabel = getTemplate(gameSystem).tokenDefense?.label ?? 'AC'; return ( isAdmin && acEdit ? (
- MELEE_AC: + MELEE_{defLabel}: setAcEdit(a => a ? { ...a, melee: e.target.value } : a)} style={{ width: '60px' }} />
- RANGED_AC: + RANGED_{defLabel}: setAcEdit(a => a ? { ...a, ranged: e.target.value } : a)} style={{ width: '60px' }} /> - ? + ?
@@ -1706,14 +1848,14 @@ function App() {
) : (
-

MELEE_AC: {selectedLocation.melee_ac ?? 10}

-

RANGED_AC: {selectedLocation.ranged_ac != null ? selectedLocation.ranged_ac : {selectedLocation.melee_ac ?? 10} (melee)}

+

MELEE_{defLabel}: {selectedLocation.melee_ac ?? 10}

+

RANGED_{defLabel}: {selectedLocation.ranged_ac != null ? selectedLocation.ranged_ac : {selectedLocation.melee_ac ?? 10} (melee)}

{isAdmin && ( - + )}
) - )} + ); })()} ) : ( <> @@ -1779,17 +1921,58 @@ function App() { {isAdmin && (selectedLocation.shape === 'enemy_rhombus' || selectedLocation.shape === 'friendly_rhombus') && ( )} + {isAdmin && (selectedLocation.shape === 'enemy_rhombus' || selectedLocation.shape === 'friendly_rhombus') && ( + tokenSheetLink?.location_id === selectedLocation.id ? ( + + ) : (() => { + const tiers = getTemplate(gameSystem).npcTiers; + return ( +
+ {tiers && tiers.length > 0 && ( + + )} + +
+ ); + })() + )} + {/* Player token sheet: owner opens their own; admin opens any player's */} + {selectedLocation.shape === 'rhombus' && selectedLocation.owner && (isOwner || isAdmin) && ( + + )} {/* ATTACK — visible to any logged-in player not attacking their own rhombus */} {isRhombus && isLoggedIn && !isOwner && (
{attackPending?.targetId === selectedLocation.id ? (
- AWAITING_ROLL — {attackPending.attackType.toUpperCase()}{token ? ` vs AC ${attackPending.ac}` : ''} + {gameSystem === 'cyberpunk_red' + ? 'SELECT_WEAPON — DICE_ROLLER' + : <>AWAITING_ROLL — {attackPending.attackType.toUpperCase()}{token ? ` vs ${getTemplate(gameSystem).tokenDefense?.label ?? 'AC'} ${attackPending.ac}` : ''}}
) : ( <> {attackPending ? (
Attack in progress vs {attackPending.targetName}
+ ) : gameSystem === 'cyberpunk_red' ? ( + // CP:R: the weapon decides melee vs ranged - one button, pick the weapon in the dice menu + ) : (
@@ -1799,6 +1982,12 @@ function App() { {lastAttackResult && lastAttackResult.targetName === selectedLocation.name && (
{lastAttackResult.hit ? 'HIT!' : 'MISS'} — rolled {lastAttackResult.roll} + {lastAttackResult.damage !== undefined && ( + <> · DMG {lastAttackResult.damage}{lastAttackResult.through !== undefined && ` (${lastAttackResult.through} through armor)`} + )} + {(lastAttackResult.shieldAbsorbed ?? 0) > 0 && <> · SHIELD −{lastAttackResult.shieldAbsorbed}} + {lastAttackResult.criticalInjury && <> · CRIT INJURY!} + {lastAttackResult.targetDown && <> · TARGET DOWN}
)} @@ -1809,6 +1998,7 @@ function App() { )} {isRhombus && (isAdmin || (isPlayerRhombus && selectedLocation.owner === userName)) && ( diff --git a/frontend/src/SheetPage.tsx b/frontend/src/SheetPage.tsx new file mode 100644 index 0000000..45e2748 --- /dev/null +++ b/frontend/src/SheetPage.tsx @@ -0,0 +1,191 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { io } from 'socket.io-client'; +import './App.css'; +import { SheetRenderer } from './components/SheetRenderer'; +import { getTemplate, getMaxPairs, type CharacterSheet } from './sheets'; + +// Standalone character-sheet tab (?sheet=true). Gives the player a full +// browser tab for their sheet instead of the in-game floating window. +// +// Auth handshake: the main app writes { userName, playerToken } to +// localStorage under 'sheet_tab_auth' right before window.open; this page +// reads it once and deletes it. Fallback: the remembered userName from a +// simple-mode login. Secure Mode still verifies the token server-side on +// identify - this page can't fake its way in. + +const readAuth = (): { userName: string | null; playerToken: string | null; adminToken: string | null } => { + try { + const raw = localStorage.getItem('sheet_tab_auth'); + if (raw) { + localStorage.removeItem('sheet_tab_auth'); + const parsed = JSON.parse(raw); + if (parsed.userName) { + return { + userName: parsed.userName, + playerToken: parsed.playerToken ?? null, + adminToken: parsed.adminToken ?? null, + }; + } + } + } catch { /* fall through */ } + return { userName: localStorage.getItem('userName'), playerToken: null, adminToken: null }; +}; + +export default function SheetPage() { + const [{ userName, playerToken, adminToken }] = useState(readAuth); + const [sheet, setSheet] = useState(null); + const [error, setError] = useState(null); + const [lastRoll, setLastRoll] = useState(null); + const [allowFumbleShield, setAllowFumbleShield] = useState(false); + const socketRef = useRef(null); + const pendingSaves = useRef>>(new Map()); + + useEffect(() => { + if (!userName) return; + const socket = io(); + socketRef.current = socket; + + const identify = () => { + if (adminToken) { + socket.emit('identify', { userName, isAdmin: true, token: adminToken }); + } else if (playerToken) { + socket.emit('identify', { userName, playerToken }); + } else { + socket.emit('identify', userName); + } + socket.emit('requestMySheet'); + }; + + socket.on('connect', identify); + socket.on('authError', (e: { message: string }) => setError(e.message)); + socket.on('sheetData', (data: CharacterSheet) => { + if (data.username !== userName) return; + // Don't stomp fields with a pending debounced edit on re-fetch + setSheet(prev => { + if (!prev || pendingSaves.current.size === 0) return data; + const merged = { ...data.data }; + pendingSaves.current.forEach((_t, fieldId) => { + if (prev.data[fieldId] !== undefined) merged[fieldId] = prev.data[fieldId]; + }); + return { ...data, data: merged }; + }); + }); + socket.on('sheetUpdated', (info: { username: string }) => { + if (info.username === userName) socket.emit('requestMySheet'); + }); + socket.on('bankUpdate', (info: { username: string }) => { + // Cash is a linked field mirroring the bank balance + if (info.username === userName) socket.emit('requestMySheet'); + }); + socket.on('gameSystemChanged', () => socket.emit('requestMySheet')); + const fetchRules = () => { + fetch('/api/settings').then(r => r.json()).then((rows) => { + if (Array.isArray(rows)) { + setAllowFumbleShield(rows.find((r: any) => r.key === 'luck_negates_fumble')?.value === '1'); + } + }).catch(() => {}); + }; + fetchRules(); + socket.on('settingsUpdated', fetchRules); + // No dice tray on this page — delay matches the 5s dice animation in the main app + socket.on('diceRollBroadcast', (roll: { historyString?: string }) => { + if (roll?.historyString) setTimeout(() => setLastRoll(roll.historyString!), 5000); + }); + + return () => { socket.disconnect(); }; + }, [userName, playerToken, adminToken]); + + useEffect(() => () => { + pendingSaves.current.forEach(t => clearTimeout(t)); + pendingSaves.current.clear(); + }, []); + + const handleFieldChange = useCallback((fieldId: string, value: string | number) => { + setSheet(prev => { + if (!prev) return prev; + const tmpl = getTemplate(prev.system); + const pairs = getMaxPairs(tmpl); + const curField = pairs[fieldId]; + const data = { ...prev.data, [fieldId]: value }; + if (curField !== undefined && data[curField] !== undefined) { + const newMax = Number(value); + if (Number(data[curField]) > newMax) data[curField] = newMax; + } + return { ...prev, data }; + }); + const timers = pendingSaves.current; + const existing = timers.get(fieldId); + if (existing) clearTimeout(existing); + timers.set(fieldId, setTimeout(() => { + timers.delete(fieldId); + socketRef.current?.emit('updateSheetField', { fieldId, value }); + }, 400)); + }, []); + + const template = sheet ? getTemplate(sheet.system) : null; + + const handlePortraitUpload = useCallback(async (file: File) => { + const authToken = adminToken || playerToken; + if (!authToken) return; + const form = new FormData(); + form.append('portrait', file); + await fetch('/api/sheets/portrait', { + method: 'POST', + headers: { Authorization: `Bearer ${authToken}` }, + body: form, + }); + }, [adminToken, playerToken]); + + const message = !userName + ? 'NO OPERATOR IDENTITY FOUND — open this page from CHARACTER_SHEET in the main app.' + : error + ? `ACCESS DENIED: ${error} — reopen this page from CHARACTER_SHEET in the main app.` + : 'ACCESSING RECORD...'; + + return ( +
+
+
+ CHARACTER_SHEET // {(userName ?? 'UNKNOWN').toUpperCase()} + {template && ( + + {template.name.toUpperCase()} + + )} +
+ {lastRoll && ( +
+ ⌁ {lastRoll} +
+ )} + {sheet && template ? ( +
+ socketRef.current?.emit('requestSheetRoll', { fieldId, luck, luckNegate: negateFumble })} + onDeathSave={() => socketRef.current?.emit('requestDeathSave')} + allowFumbleShield={allowFumbleShield} + /> +
+ ) : ( +
{message}
+ )} +
+
+ ); +} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 01e0534..16ee313 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -420,7 +420,7 @@ export function AdminPanel({ tempCityMapScale, setTempCityMapScale, globalSettings, fetchGlobalSettings, tempBattleMapScale, setTempBattleMapScale, activeBattleMapData, setIsAdminPayOpen, secureModeEnabled, currentLocBattleMaps, enterBattleMap, signs, fetchSigns, remoteFonts, setRemoteFonts, isPlacingSign, setIsPlacingSign, pendingSignPos, setPendingSignPos, selectedSignId, setSelectedSignId, signTransformMode, setSignTransformMode, signTransformActive, setSignTransformActive, handleUpdateSign, signMesh, - activeUsers, onGrantAccess, onRevokeAccess, + activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, }: any) { if (view === 'battle_map') { let resolvedBattleMapScale: number | string = 5; @@ -993,6 +993,9 @@ export function AdminPanel({
)} + {/* TTRPG SYSTEM (character sheets) */} + +
@@ -2054,6 +2057,179 @@ const BANK_SOUND_TESTERS: Record void> = { overdraft: playWompWomp, }; +function TTRPGSystemPanel({ token, onOpenNpcLibrary }: { token: string; onOpenNpcLibrary?: () => void }) { + const [open, setOpen] = useState(false); + const [system, setSystem] = useState('generic'); + const [systems, setSystems] = useState<{ id: string; name: string }[]>([]); + const [luckResetMsg, setLuckResetMsg] = useState(null); + // House rules: staged locally, written on APPLY (not on every click) + const [houseRules, setHouseRules] = useState({ meleeTake10: false, luckNegates: false }); + const [savedRules, setSavedRules] = useState({ meleeTake10: false, luckNegates: false }); + const [rulesMsg, setRulesMsg] = useState(null); + + 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/settings').then(r => r.json()).then((rows) => { + if (!Array.isArray(rows)) return; + const loaded = { + meleeTake10: rows.find((r: any) => r.key === 'melee_dv_take10')?.value === '1', + luckNegates: rows.find((r: any) => r.key === 'luck_negates_fumble')?.value === '1', + }; + setHouseRules(loaded); + setSavedRules(loaded); + }).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. +

+ {system === 'cyberpunk_red' && (() => { + const dirty = houseRules.meleeTake10 !== savedRules.meleeTake10 + || houseRules.luckNegates !== savedRules.luckNegates; + const applyRules = async () => { + const writes: [string, boolean][] = [ + ['melee_dv_take10', houseRules.meleeTake10], + ['luck_negates_fumble', houseRules.luckNegates], + ]; + try { + for (const [key, on] of writes) { + await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ key, value: on ? '1' : '0' }), + }); + } + setSavedRules({ ...houseRules }); + setRulesMsg('HOUSE RULES APPLIED'); + } catch { + setRulesMsg('APPLY FAILED'); + } + setTimeout(() => setRulesMsg(null), 3000); + }; + return ( +
+ + + +
+ + {dirty && ( + + )} + {rulesMsg && ( + {rulesMsg} + )} + {dirty && !rulesMsg && ( + UNSAVED CHANGES + )} +
+
+ ); + })()} +
+ {onOpenNpcLibrary && ( + + )} + +
+ {luckResetMsg && ( +
+ {luckResetMsg} +
+ )} +
+ )} +
+ ); +} + 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..6c46d2e --- /dev/null +++ b/frontend/src/components/CharacterSheetWindow.tsx @@ -0,0 +1,206 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import ReactDOM from 'react-dom'; +import { DraggableWindow } from './DraggableWindow'; +import { SheetRenderer } from './SheetRenderer'; +import { ImportSheetDialog } from './ImportSheetDialog'; +import { getTemplate, getMaxPairs, 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; + playerToken?: string | null; + adminToken?: string; + /** Open the window that owns a linked field (HIT_POINTS / BANK). */ + onOpenLink?: (source: 'token_hp' | 'token_hp_max' | 'bank_balance') => void; + /** Called when the player rolls from the sheet - App opens the dice tray + * so the result is visible. */ + onRolled?: () => void; +} + +export function CharacterSheetWindow({ pos, setPos, onClose, socket, userName, playerToken, adminToken, onOpenLink, onRolled }: CharacterSheetWindowProps) { + const [sheet, setSheet] = useState(null); + const [isImportOpen, setIsImportOpen] = useState(false); + const [allowFumbleShield, setAllowFumbleShield] = useState(false); + const [importPos, setImportPos] = useState({ x: pos.x + 60, y: pos.y + 60 }); + const pendingSaves = useRef>>(new Map()); + + useEffect(() => { + if (!socket) return; + const onSheetData = (data: CharacterSheet) => { + if (data.username !== userName) return; + // A re-fetch must not stomp fields the player is mid-typing (debounce + // still pending) - keep the local value for those, take the rest. + setSheet(prev => { + if (!prev || pendingSaves.current.size === 0) return data; + const merged = { ...data.data }; + pendingSaves.current.forEach((_t, fieldId) => { + if (prev.data[fieldId] !== undefined) merged[fieldId] = prev.data[fieldId]; + }); + return { ...data, data: merged }; + }); + }; + const onSheetUpdated = (info: { username: string }) => { + // Re-sync when the admin edits this sheet or token HP changes + if (info.username === userName) socket.emit('requestMySheet'); + }; + const onBankUpdate = (info: { username: string }) => { + // Cash is a linked field mirroring the bank balance + if (info.username === userName) socket.emit('requestMySheet'); + }; + // Fumble-shield house rule: read once, refresh when the admin applies + const fetchRules = () => { + fetch('/api/settings').then(r => r.json()).then((rows) => { + if (Array.isArray(rows)) { + setAllowFumbleShield(rows.find((r: any) => r.key === 'luck_negates_fumble')?.value === '1'); + } + }).catch(() => {}); + }; + fetchRules(); + socket.on('sheetData', onSheetData); + socket.on('sheetUpdated', onSheetUpdated); + socket.on('bankUpdate', onBankUpdate); + socket.on('settingsUpdated', fetchRules); + socket.emit('requestMySheet'); + return () => { + socket.off('sheetData', onSheetData); + socket.off('sheetUpdated', onSheetUpdated); + socket.off('bankUpdate', onBankUpdate); + socket.off('settingsUpdated', fetchRules); + }; + }, [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 => { + if (!prev) return prev; + const template = getTemplate(prev.system); + const pairs = getMaxPairs(template); + const curField = pairs[fieldId]; // non-null when fieldId is a max field + const data = { ...prev.data, [fieldId]: value }; + if (curField !== undefined && data[curField] !== undefined) { + const newMax = Number(value); + if (Number(data[curField]) > newMax) data[curField] = newMax; + } + return { ...prev, data }; + }); + 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 handlePortraitUpload = useCallback(async (file: File) => { + const authToken = adminToken || playerToken; + if (!authToken) return; + const form = new FormData(); + form.append('portrait', file); + await fetch('/api/sheets/portrait', { + method: 'POST', + headers: { Authorization: `Bearer ${authToken}` }, + body: form, + }); + // Server emits sheetUpdated → socket listener re-fetches sheet with new portrait_url + }, [adminToken, playerToken]); + + const template = sheet ? getTemplate(sheet.system) : null; + + return ( + <> + + + {template && ( + + {template.name.toUpperCase()} + + )} + + + } + windowStyle={{ + width: '520px', height: '74vh', + minWidth: '360px', maxWidth: '520px', minHeight: '320px', maxHeight: '92vh', + resize: 'both', overflow: 'hidden', display: 'flex', flexDirection: 'column', + }} + contentStyle={{ flex: 1, minHeight: 0, maxHeight: 'none', display: 'flex', flexDirection: 'column', padding: '4px 10px 0' }} + > + {sheet && template ? ( + { + socket?.emit('requestSheetRoll', { fieldId, luck, luckNegate: negateFumble }); + onRolled?.(); + }} + onDeathSave={() => { + socket?.emit('requestDeathSave'); + onRolled?.(); + }} + allowFumbleShield={allowFumbleShield} + /> + ) : ( +
ACCESSING RECORD...
+ )} +
+ {isImportOpen && ReactDOM.createPortal( + setIsImportOpen(false)} + onApply={(fields) => { + socket?.emit('importSheetFields', { fields }); + }} + />, + document.body + )} + + ); +} diff --git a/frontend/src/components/DraggableWindow.tsx b/frontend/src/components/DraggableWindow.tsx index 20f455f..dcfc0cb 100644 --- a/frontend/src/components/DraggableWindow.tsx +++ b/frontend/src/components/DraggableWindow.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'; import notifyOnIcon from '../assets/Notification-on.svg'; import notifyOffIcon from '../assets/Notification-off.svg'; +let zCounter = 2000; + interface DraggableWindowProps { title: string; children: React.ReactNode; @@ -22,8 +24,11 @@ export function DraggableWindow({ }: DraggableWindowProps) { const [isDragging, setIsDragging] = useState(false); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [zIndex, setZIndex] = useState(() => ++zCounter); const windowRef = React.useRef(null); + const bringToFront = () => setZIndex(++zCounter); + // Clamp position when the browser is resized so panels never escape the viewport. useEffect(() => { const handleResize = () => { @@ -60,8 +65,8 @@ export function DraggableWindow({ }, [isDragging, dragOffset, setPos]); return ( -
-
+
+
{ bringToFront(); handleMouseDown(e); }}>
{title}
{titleControls} diff --git a/frontend/src/components/ImportSheetDialog.tsx b/frontend/src/components/ImportSheetDialog.tsx new file mode 100644 index 0000000..1572901 --- /dev/null +++ b/frontend/src/components/ImportSheetDialog.tsx @@ -0,0 +1,148 @@ +import React, { useState } from 'react'; +import { DraggableWindow } from './DraggableWindow'; + +// Sheet import dialog. Three inputs, one preview, one APPLY: +// - a fillable character-sheet PDF (form fields extracted server-side) +// - pasted JSON ({ "ref": 7, "handgun": 5, ... } or an exported sheet) +// - pasted plain text (stat-block style: 'REF 7', 'Handgun: 5') +// The server maps candidates onto the active system's fields and reports +// what it could not place - nothing is applied until APPLY is clicked. + +interface Preview { + system: string; + source: string; + mapped: Record; + unmapped: Record; + skipped: Record; +} + +interface ImportSheetDialogProps { + pos: { x: number; y: number }; + setPos: (pos: { x: number; y: number }) => void; + onClose: () => void; + /** Apply the mapped fields to the target sheet (socket or admin REST). */ + onApply: (fields: Record) => Promise | void; +} + +const label9: React.CSSProperties = { fontFamily: 'monospace', fontSize: 9, letterSpacing: 0.5 }; + +export function ImportSheetDialog({ pos, setPos, onClose, onApply }: ImportSheetDialogProps) { + const [pasted, setPasted] = useState(''); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [applied, setApplied] = useState(false); + + const runPreview = async (body: FormData | string, isForm: boolean) => { + setBusy(true); + setError(null); + setPreview(null); + setApplied(false); + try { + const res = await fetch('/api/sheets/import/preview', { + method: 'POST', + headers: isForm ? undefined : { 'Content-Type': 'application/json' }, + body, + }); + const data = await res.json(); + if (!res.ok) setError(data.error || 'Import failed'); + else setPreview(data); + } catch { + setError('Could not reach server'); + } + setBusy(false); + }; + + const handlePdf = (file: File) => { + const form = new FormData(); + form.append('pdf', file); + runPreview(form, true); + }; + + const handlePaste = () => { + const text = pasted.trim(); + if (!text) return; + // JSON if it parses, otherwise treat as a stat block + try { + JSON.parse(text); + runPreview(JSON.stringify({ json: text }), false); + } catch { + runPreview(JSON.stringify({ text }), false); + } + }; + + const handleApply = async () => { + if (!preview) return; + setBusy(true); + await onApply(preview.mapped); + setBusy(false); + setApplied(true); + }; + + const mappedCount = preview ? Object.keys(preview.mapped).length : 0; + const unmappedKeys = preview ? Object.keys(preview.unmapped) : []; + const skippedKeys = preview ? Object.keys(preview.skipped) : []; + + return ( + +
+ +
— OR PASTE JSON / STAT BLOCK —
+