From 82171f215363752a686b584b9a3de97f3ca4f8e9 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 1 Oct 2026 14:48:51 -0500 Subject: [PATCH 1/2] feat: a custom system's words in the initiative tracker and the dice log START, JOIN and END INITIATIVE, the INIT score, the 'no active initiative' notices and the turn counter in the tracker, its side view and the sidebar's panel ask for their words with the text they show today. The counter keeps each built-in's own word (CWN and Cyberpunk RED count ROUNDs, Shadowrun PASSes) and generic's TURN, and a custom system that renamed the turn uses its word instead. The dice log's initiative lines are written by the server, so they take the word from the system the combat was started under: 'JADE rolled ORDER [14]' in a game that calls initiative ORDER, exactly as before everywhere else. --- CHANGELOG.md | 5 + README.md | 2 + .../system_builder_initiative_words.test.js | 112 +++++++++++++ backend/sockets/initiative.js | 21 ++- frontend/src/components/Sidebar.tsx | 1 + .../__tests__/initiativeWords.test.tsx | 151 ++++++++++++++++++ .../components/InitiativeNavPanel.tsx | 13 +- .../components/InitiativeSideView.tsx | 13 +- .../components/InitiativeWindow.tsx | 19 ++- 9 files changed, 315 insertions(+), 22 deletions(-) create mode 100644 backend/__tests__/system_builder_initiative_words.test.js create mode 100644 frontend/src/modules/initiative/__tests__/initiativeWords.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5de81c..8901388f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **A custom system's words reach the initiative tracker and the dice log.** START, JOIN and END + INITIATIVE, the INIT score, the turn counter and the dice log's "rolled INITIATIVE" lines use the + words a custom system's GM chose for initiative and the turn. The built-in systems keep their + wording exactly, ROUND and PASS included. + - **A custom system's words reach the shops and the bank button.** The shop's messages, receipt and refusals, the steps for stocking an empty shop, and the SHOP, VIEW_BANK and SHOP_CATALOGUES buttons use the words a custom system's GM chose for shops, money, the bank, the GM and diff --git a/README.md b/README.md index 30a7a582..0b8a2862 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,7 @@ CITY_NET/ │ ├── system_builder_runtime.test.js # The sheet format's checks and starter sheet; a published system known to the game (never a draft), answering the same helpers as the built-ins without changing them, its render copy free of formulas, switched to from the picker, and a player's edit recomputing its derived values │ ├── system_builder_core.test.js # The setup answers: every health model's starter sheet (and that it passes the sheet checks), the unanswered starter unchanged to the byte, and every mistake reported with where it is │ ├── system_builder_health.test.js # Every health model's DAMAGE and HEAL rules; the HIT_POINTS route using them for players and linked NPCs, refusing what a model cannot do, keeping an edit made while damage lands, and leaving the built-in systems and a custom one-pool system on the route as before +│ ├── system_builder_initiative_words.test.js # The dice log's initiative lines, written by the server: today's text under every built-in system and an unrenamed custom one, a custom system's own word (in capitals) where it renamed initiative │ ├── system_builder_health_view.test.js # Every model's full and described view; the socket sending the full one only to the GM, a granted editor or the owner (never through an NPC's owner field) and answering only the asker; a second track's SET MAX; the moved-up and turned-heavier details │ ├── system_builder_npc_privacy.test.js # GM-only fields refused to the owner by edit, batch and upload but not to the GM or a granted admin; the NPC layout and tier checks; tiers generating a sheet and setting (or keeping) the token's HP and defense; built-ins unchanged │ ├── system_builder_citysys.test.js # Export (published only, readable, never a character), reading a file as untrusted input, a preview that changes nothing, installing as new / update / keep both with their refusals, deleting as a hide, and a deleted system coming back under its old id with its characters @@ -694,6 +695,7 @@ CITY_NET/ │ │ │ │ ├── cwn.ts # 1d8+DEX mod roll; ROUND counter; PCs win ties; defaultMode: 'side' │ │ │ │ └── random.ts # cryptoRng — uniform [0,1) from crypto.getRandomValues; shared by every system │ │ │ └── __tests__/ +│ │ │ ├── initiativeWords.test.tsx # The glossary in the tracker, side view and nav panel: today's text under every built-in system (ROUND, PASS, TURN kept) and an unrenamed custom one, a custom system's own words for initiative and the turn where it renamed them │ │ │ ├── systems.test.ts # Registry lookup, generic/SR6/CP:R/CWN formulas, extra dice, breakdown format, diceResults shape │ │ │ ├── npcPortrait.test.ts # A silhouetted NPC enters initiative with no portrait, since the tracker goes to every player │ │ │ ├── random.test.ts # Browser cryptoRng range/uniqueness; every system exercised on its default rng diff --git a/backend/__tests__/system_builder_initiative_words.test.js b/backend/__tests__/system_builder_initiative_words.test.js new file mode 100644 index 00000000..37c1aae8 --- /dev/null +++ b/backend/__tests__/system_builder_initiative_words.test.js @@ -0,0 +1,112 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import sqlite3 from 'sqlite3'; +import { createRequire } from 'module'; + +/** + * The dice log's initiative lines, written by the server (3a5a). Under every built-in system, + * and a custom system that did not rename initiative, they read exactly as today; under a + * custom system that renamed it, they use its word. The combat records which system it runs. + */ + +const require_ = createRequire(import.meta.url); +const runtime = require_('../systemBuilder/runtime'); +const mod = await import('../sockets/initiative.js'); +const registerInitiativeHandlers = mod.registerInitiativeHandlers || mod.default.registerInitiativeHandlers; + +const RENAMED = 'sys_aaaaaaaaaaaaaaaa'; +const PLAIN = 'sys_bbbbbbbbbbbbbbbb'; + +const run = (db, sql, p = []) => new Promise((res, rej) => db.run(sql, p, function (e) { e ? rej(e) : res(this); })); +const all = (db, sql, p = []) => new Promise((res, rej) => db.all(sql, p, (e, r) => (e ? rej(e) : res(r)))); +const waitFor = async (fn, timeout = 2000) => { + const start = Date.now(); + for (;;) { + const v = await fn(); + if (v) return v; + if (Date.now() - start > timeout) throw new Error('timed out'); + await new Promise((r) => setTimeout(r, 5)); + } +}; + +const makeDb = async () => { + const db = new sqlite3.Database(':memory:'); + await run(db, `CREATE TABLE initiative_combat (id INTEGER PRIMARY KEY AUTOINCREMENT, turn_counter INTEGER DEFAULT 1, + pass_counter INTEGER DEFAULT 1, system TEXT DEFAULT 'generic', mode TEXT DEFAULT 'individual', created_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); + await run(db, `CREATE TABLE initiative_scene (scene_key TEXT PRIMARY KEY, combat_id INTEGER NOT NULL, combatants TEXT NOT NULL DEFAULT '[]', + sides TEXT NOT NULL DEFAULT '[]', turn_index INTEGER DEFAULT 0, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`); + 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)`); + // Two published custom systems, loaded the way the server loads them on start. + await run(db, `CREATE TABLE custom_systems (id TEXT PRIMARY KEY, name TEXT, draft TEXT, published TEXT, version INTEGER, deleted_at DATETIME)`); + const renamed = JSON.stringify({ format: 1, name: 'Hearth', words: { initiative: { singular: 'order', short: 'ORD' } } }); + const plain = JSON.stringify({ format: 1, name: 'Plain' }); + await run(db, 'INSERT INTO custom_systems (id, name, draft, published, version) VALUES (?, ?, ?, ?, 1), (?, ?, ?, ?, 1)', + [RENAMED, 'Hearth', renamed, renamed, PLAIN, 'Plain', plain, plain]); + await new Promise((resolve) => runtime.load(db, resolve)); + return db; +}; + +const boot = (db) => { + let connect; + registerInitiativeHandlers({ on: (e, cb) => { if (e === 'connection') connect = cb; }, emit: () => {}, to: () => ({ emit: () => {} }) }, db); + const handlers = {}; + connect({ id: 'sock-1', on: (e, fn) => { handlers[e] = fn; }, emit: () => {}, use: () => {}, join: () => {} }); + return handlers; +}; + +let db; +let handlers; +beforeEach(async () => { + db = await makeDb(); + handlers = boot(db); +}); + +/** Start a combat under `system`, roll as asked, and return what the dice log says. */ +const logged = async (system, mode, roll) => { + handlers['initiative:start']({ sceneKey: 'city:0', system, mode }); + await waitFor(async () => (await all(db, 'SELECT scene_key FROM initiative_scene')).length); + roll(); + return (await waitFor(async () => { + const rows = await all(db, 'SELECT historyString FROM dice_rolls'); + return rows.length ? rows : null; + }))[0].historyString; +}; +const rollOne = (breakdown) => () => handlers['initiative:roll']({ + sceneKey: 'city:0', combatant: { id: 'player:jade', name: 'JADE', score: 14, ...(breakdown ? { breakdown } : {}) }, +}); +const rollSide = (breakdown) => () => handlers['initiative:roll_side']({ sceneKey: 'city:0', score: 9, ...(breakdown ? { breakdown } : {}) }); + +describe('a combatant\'s roll in the dice log', () => { + it('reads as today under every built-in system, and a custom one that renamed nothing', async () => { + for (const system of ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic', PLAIN]) { + db = await makeDb(); + handlers = boot(db); + expect(await logged(system, 'individual', rollOne()), system).toBe('JADE rolled INITIATIVE [14]'); + } + db = await makeDb(); + handlers = boot(db); + expect(await logged('generic', 'individual', rollOne('1d20 (14)'))).toBe('JADE INITIATIVE: 1d20 (14)'); + }); + + it('uses a custom system\'s own word, in capitals like the rest of the line', async () => { + expect(await logged(RENAMED, 'individual', rollOne())).toBe('JADE rolled ORDER [14]'); + db = await makeDb(); + handlers = boot(db); + expect(await logged(RENAMED, 'individual', rollOne('1d20 (14)'))).toBe('JADE ORDER: 1d20 (14)'); + }); +}); + +describe('the NPC side\'s roll in the dice log', () => { + it('reads as today under a built-in system, and uses a custom system\'s own word', async () => { + expect(await logged('cities_without_number', 'side', rollSide())).toBe('NPC SIDE rolled INITIATIVE [9]'); + db = await makeDb(); + handlers = boot(db); + expect(await logged(PLAIN, 'side', rollSide('1d8 (9)'))).toBe('NPC SIDE INITIATIVE: 1d8 (9)'); + db = await makeDb(); + handlers = boot(db); + expect(await logged(RENAMED, 'side', rollSide())).toBe('NPC SIDE rolled ORDER [9]'); + db = await makeDb(); + handlers = boot(db); + expect(await logged(RENAMED, 'side', rollSide('1d8 (9)'))).toBe('NPC SIDE ORDER: 1d8 (9)'); + }); +}); diff --git a/backend/sockets/initiative.js b/backend/sockets/initiative.js index 2d237847..14a2c267 100644 --- a/backend/sockets/initiative.js +++ b/backend/sockets/initiative.js @@ -1,4 +1,8 @@ const { cryptoRng } = require('../utils/random'); +const customSystems = require('../systemBuilder/runtime'); + +/** A custom system's own word for initiative in the dice log; today's text for the rest. */ +const initiativeWord = (system) => customSystems.wordIn(system, 'initiative', 'singular', 'INITIATIVE').toUpperCase(); // Initiative Tracker — socket event handlers // All events namespaced under initiative:* to avoid collisions. @@ -228,7 +232,7 @@ function registerInitiativeHandlers(io, db) { (err) => { if (err) return; broadcastScene(io, db, sceneKey); - logRoll(io, db, combatant); + logRoll(io, db, combatant, system); } ); } else { @@ -254,7 +258,7 @@ function registerInitiativeHandlers(io, db) { (err) => { if (err) return; broadcastScene(io, db, sceneKey); - logRoll(io, db, combatant); + logRoll(io, db, combatant, system); } ); } @@ -268,7 +272,8 @@ function registerInitiativeHandlers(io, db) { if (!sceneKey || score === undefined) return; db.get( - `SELECT s.sides, s.combatants FROM initiative_scene s WHERE s.scene_key = ?`, + `SELECT s.sides, s.combatants, c.system FROM initiative_scene s + LEFT JOIN initiative_combat c ON c.id = s.combat_id WHERE s.scene_key = ?`, [sceneKey], (err, row) => { if (err || !row) return; @@ -287,8 +292,8 @@ function registerInitiativeHandlers(io, db) { broadcastScene(io, db, sceneKey); // Log to dice tray const historyString = breakdown - ? `NPC SIDE INITIATIVE: ${breakdown}` - : `NPC SIDE rolled INITIATIVE [${score}]`; + ? `NPC SIDE ${initiativeWord(row.system)}: ${breakdown}` + : `NPC SIDE rolled ${initiativeWord(row.system)} [${score}]`; const results = diceResults || { 8: [score] }; db.run( `INSERT INTO dice_rolls (username, total, results, color, historyString) VALUES (?, ?, ?, ?, ?)`, @@ -547,11 +552,11 @@ function registerInitiativeHandlers(io, db) { } // ── Shared helper: log a roll to dice tray ──────────────────────────────────── -function logRoll(io, db, combatant) { +function logRoll(io, db, combatant, system) { const explodSuffix = combatant.exploded ? ' 💥EXPLOD' : ''; const historyString = combatant.breakdown - ? `${combatant.name} INITIATIVE: ${combatant.breakdown}${explodSuffix}` - : `${combatant.name} rolled INITIATIVE [${combatant.score}]${explodSuffix}`; + ? `${combatant.name} ${initiativeWord(system)}: ${combatant.breakdown}${explodSuffix}` + : `${combatant.name} rolled ${initiativeWord(system)} [${combatant.score}]${explodSuffix}`; const results = combatant.diceResults || { 20: [combatant.score] }; const resultsJson = JSON.stringify(results); db.run( diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index f9ba47d1..aed3ff35 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1406,6 +1406,7 @@ export function Sidebar({ activeMenu, setActiveMenu, locations, onSelect, onZoom {activeMenu === 'dice_menu' && } {activeMenu === 'initiative_tracker' && ( = { cities_without_number: 'ROUND', cyberpunk_red: 'ROUND', shadowrun_6e: 'PASS', generic: 'TURN', [PLAIN]: 'TURN' }; + +beforeEach(() => { + clearCustomTemplates(); + registerCustomTemplate({ id: RENAMED, name: 'Hearth', parts: {}, derived: [], sheet: { sections: [] }, + words: { initiative: { singular: 'ORDER', plural: 'ORDER', short: 'ORD' }, turn: { singular: 'BEAT', plural: 'BEATS', short: 'BEAT' } } }); + registerCustomTemplate({ id: PLAIN, name: 'Plain', parts: {}, derived: [], sheet: { sections: [] }, words: {} }); +}); +afterEach(() => { cleanup(); clearCustomTemplates(); }); + +const windowProps = (system: string, over = {}) => ({ + state: null as InitiativeState | null, activeCombats: [], sceneKey: 'city:0', sceneLabel: 'CITY MAP', isAdmin: true, + onClose: vi.fn(), onStart: vi.fn(), onListCombats: vi.fn(), onNext: vi.fn(), onEnd: vi.fn(), onRemove: vi.fn(), onReorder: vi.fn(), + system, ...over, +}); +const state = (system: string, over = {}): InitiativeState => ({ + sceneKey: 'city:0', combatId: 1, combatants: [], turnIndex: 0, turnCounter: 3, passCounter: 2, system, ...over, +} as InitiativeState); + +describe('the tracker window', () => { + const read = async (system: string) => { + const out: Record = {}; + let r = render(); + out.start = screen.getByText(/^START /).textContent; + await userEvent.click(screen.getByText('JOIN EXISTING COMBAT')); + out.combat = screen.getByText(/^COMBAT #4/).textContent; + r.unmount(); + r = render(); + out.none = screen.getByText(/^NO ACTIVE /).textContent; + r.unmount(); + r = render(); + out.counter = screen.getByText(/ [23]$/).textContent; + out.end = screen.getByText(/^END /).textContent; + r.unmount(); + render(); + out.join = screen.getByText(/^JOIN /).textContent; + cleanup(); + return out; + }; + + it('reads as today under every built-in system, and a custom one that renamed nothing', async () => { + for (const system of Object.keys(COUNTERS)) { + expect(await read(system), system).toEqual({ + start: 'START INITIATIVE', combat: 'COMBAT #4 — TURN 7 [1 SCENE]', none: 'NO ACTIVE INITIATIVE IN THIS SCENE', + counter: `${COUNTERS[system]} ${system === 'shadowrun_6e' ? 2 : 3}`, end: 'END INIT', join: 'JOIN INITIATIVE', + }); + } + }); + + it('uses a custom system\'s own words', async () => { + expect(await read(RENAMED)).toEqual({ + start: 'START ORDER', combat: 'COMBAT #4 — BEAT 7 [1 SCENE]', none: 'NO ACTIVE ORDER IN THIS SCENE', + counter: 'BEAT 3', end: 'END ORD', join: 'JOIN ORDER', + }); + }); +}); + +describe('the side view', () => { + const read = (system: string) => { + const s = state(system, { mode: 'side', sides: [{ id: 'pc', name: 'PLAYERS', score: 12, isPlayerSide: true }, { id: 'npc', name: 'NPC', score: 0, isPlayerSide: false }] }); + let r = render(); + const out: Record = { + counter: screen.getByText(/ 3$/).textContent, + score: screen.getByText(/ 12$/).textContent, + end: screen.getByText(/^END /).textContent, + }; + r.unmount(); + r = render(); + out.join = screen.getByText(/^JOIN /).textContent; + cleanup(); + return out; + }; + + it('reads as today under every built-in system, and a custom one that renamed nothing', () => { + for (const system of Object.keys(COUNTERS)) { + expect(read(system), system).toEqual({ counter: `${COUNTERS[system]} 3`, score: 'INIT 12', end: 'END INIT', join: 'JOIN INITIATIVE' }); + } + }); + + it('uses a custom system\'s own words', () => { + expect(read(RENAMED)).toEqual({ counter: 'BEAT 3', score: 'ORD 12', end: 'END ORD', join: 'JOIN ORDER' }); + }); +}); + +describe('the nav panel', () => { + const read = (system?: string) => { + let r = render(); + const out: Record = { + heading: screen.getByRole('heading', { level: 3 }).textContent, + hint: screen.getByText(/^NO ACTIVE COMBATS/).textContent, + }; + r.unmount(); + r = render(); + out.combat = screen.getByText(/^COMBAT #4/).textContent; + cleanup(); + return out; + }; + + it('reads as today under every built-in system, and a custom one that renamed nothing', () => { + for (const system of [undefined, ...Object.keys(COUNTERS)]) { + expect(read(system), String(system)).toEqual({ + heading: 'INITIATIVE', + hint: 'NO ACTIVE COMBATS. OPEN THE TRACKER AND CLICK START INITIATIVE TO BEGIN. EACH ACTIVE SCENE WILL APPEAR HERE FOR QUICK NAVIGATION.', + combat: 'COMBAT #4 — TURN 7', + }); + } + }); + + it('gets the running system from the sidebar', () => { + const props: any = { + activeMenu: 'initiative_tracker', setActiveMenu: vi.fn(), locations: [], onSelect: vi.fn(), onZoom: vi.fn(), selectedLocation: null, + userName: 'GHOST', token: '', onLogout: vi.fn(), audioEnabled: false, setAudioEnabled: vi.fn(), masterVolume: 1, + setMasterVolume: vi.fn(), musicVolume: 1, setMusicVolume: vi.fn(), rhombusState: { color: '#00ff00' }, setRhombusState: vi.fn(), + refreshLocations: vi.fn(), socketRef: { current: { on: vi.fn(), off: vi.fn(), emit: vi.fn() } }, isChatOpen: false, + setIsChatOpen: vi.fn(), hasUnreadChat: false, syncRhombusToDB: vi.fn(), view: 'list', activeBattleMapData: null, + isHitPointsOpen: false, setIsHitPointsOpen: vi.fn(), activeUsers: [], setIsDiceTrayOpen: vi.fn(), setNotification: vi.fn(), + measureMode: false, setMeasureMode: vi.fn(), isBankOpen: false, setIsBankOpen: vi.fn(), isSheetOpen: false, + setIsSheetOpen: vi.fn(), gameSystem: RENAMED, activeCombats: [], + }; + render(); + expect(screen.getByRole('heading', { level: 3, name: 'ORDER' })).toBeTruthy(); + }); + + it('uses a custom system\'s own words', () => { + expect(read(RENAMED)).toEqual({ + heading: 'ORDER', + hint: 'NO ACTIVE COMBATS. OPEN THE TRACKER AND CLICK START ORDER TO BEGIN. EACH ACTIVE SCENE WILL APPEAR HERE FOR QUICK NAVIGATION.', + combat: 'COMBAT #4 — BEAT 7', + }); + }); +}); diff --git a/frontend/src/modules/initiative/components/InitiativeNavPanel.tsx b/frontend/src/modules/initiative/components/InitiativeNavPanel.tsx index 4f5eb37a..adb7a3af 100644 --- a/frontend/src/modules/initiative/components/InitiativeNavPanel.tsx +++ b/frontend/src/modules/initiative/components/InitiativeNavPanel.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { ActiveCombat } from '../hooks/useInitiative'; +import { useWords } from '../../../sheets/words'; interface Props { initiativeActive: boolean; @@ -11,6 +12,8 @@ interface Props { onJumpToScene?: (sceneKey: string) => void; onEndCombat?: (combatId: number) => void; onClose: () => void; + /** The running game system, for a custom system's own words for initiative and the turn. */ + system?: string; } export function sceneLabel(sceneKey: string, locations: any[]): string { @@ -29,12 +32,14 @@ export function sceneLabel(sceneKey: string, locations: any[]): string { export function InitiativeNavPanel({ initiativeActive, activeCombats, locations, - onRollEnemies, onRollFriendlies, onToggleTracker, onJumpToScene, onEndCombat, onClose, + onRollEnemies, onRollFriendlies, onToggleTracker, onJumpToScene, onEndCombat, onClose, system, }: Props) { + const word = useWords(system); + const INITIATIVE = word('initiative', 'singular', 'INITIATIVE').toUpperCase(); return (
-

INITIATIVE

+

{INITIATIVE}

@@ -53,7 +58,7 @@ export function InitiativeNavPanel({
ACTIVE COMBATS
{activeCombats.length === 0 ? (
- NO ACTIVE COMBATS. OPEN THE TRACKER AND CLICK START INITIATIVE TO BEGIN. EACH ACTIVE SCENE WILL APPEAR HERE FOR QUICK NAVIGATION. + NO ACTIVE COMBATS. OPEN THE TRACKER AND CLICK START {INITIATIVE} TO BEGIN. EACH ACTIVE SCENE WILL APPEAR HERE FOR QUICK NAVIGATION.
) : activeCombats.map((combat) => (
@@ -61,7 +66,7 @@ export function InitiativeNavPanel({ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '0.6rem', color: 'var(--dark-green)', marginBottom: '3px', }}> - COMBAT #{combat.id} — TURN {combat.turn_counter} + COMBAT #{combat.id} — {word('turn', 'singular', 'TURN').toUpperCase()} {combat.turn_counter} {onEndCombat && (
ROLL DEX MOD + 1d8 — ADDED TO PLAYERS SIDE @@ -177,7 +182,7 @@ export function InitiativeSideView({ style={{ flex: 1, width: 'auto', background: 'transparent', color: '#ff4444', borderColor: '#ff4444' }} onClick={onEnd} > - END INIT + END {INIT}
)} diff --git a/frontend/src/modules/initiative/components/InitiativeWindow.tsx b/frontend/src/modules/initiative/components/InitiativeWindow.tsx index 39961b19..4ed92dcf 100644 --- a/frontend/src/modules/initiative/components/InitiativeWindow.tsx +++ b/frontend/src/modules/initiative/components/InitiativeWindow.tsx @@ -4,6 +4,7 @@ import { InitiativeCombatantRow } from './InitiativeCombatantRow'; import { InitiativeSideView } from './InitiativeSideView'; import type { InitiativeState, ActiveCombat } from '../hooks/useInitiative'; import { getInitiativeSystem } from '../systems'; +import { useWords } from '../../../sheets/words'; interface Props { state: InitiativeState | null; @@ -56,6 +57,12 @@ export function InitiativeWindow({ const headerLabel = 'INITIATIVE.EXE'; const systemDef = getInitiativeSystem(activeSystem); + // A custom system's own words for initiative and the turn; today's text otherwise, so the + // built-in systems keep their ROUND and PASS. + const word = useWords(activeSystem); + const INITIATIVE = word('initiative', 'singular', 'INITIATIVE').toUpperCase(); + const INIT = word('initiative', 'short', 'INIT').toUpperCase(); + const TURN = word('turn', 'singular', 'TURN').toUpperCase(); const content = ( <> @@ -63,7 +70,7 @@ export function InitiativeWindow({
{sceneLabel}
{state && activeMode === 'individual' && (
- {`${systemDef.counterLabel} ${activeSystem === 'shadowrun_6e' ? state.passCounter : state.turnCounter}`} + {`${word('turn', 'singular', systemDef.counterLabel).toUpperCase()} ${activeSystem === 'shadowrun_6e' ? state.passCounter : state.turnCounter}`}
)}
@@ -74,7 +81,7 @@ export function InitiativeWindow({ {!showJoin ? (
{activeCombats.length > 0 && ( ))} @@ -110,7 +117,7 @@ export function InitiativeWindow({ {!state && !isAdmin && (
- NO ACTIVE INITIATIVE IN THIS SCENE + NO ACTIVE {INITIATIVE} IN THIS SCENE
)} @@ -193,7 +200,7 @@ export function InitiativeWindow({ style={{ flex: 1, width: 'auto', background: 'transparent', color: '#ff4444', borderColor: '#ff4444' }} onClick={onEnd} > - END INIT + END {INIT}
)} @@ -229,7 +236,7 @@ export function InitiativeWindow({ style={{ width: '100%' }} onClick={() => onJoin(null, activeSystem, extraDice ?? 0)} > - JOIN INITIATIVE + JOIN {INITIATIVE}
{activeSystem === 'shadowrun_6e' From f084574cdc4d7aa5e6bf88fdbf1ec290b3ec04f1 Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 1 Oct 2026 14:58:22 -0500 Subject: [PATCH 2/2] refactor: remove the first tracker's initiative roll window InitiativeRollPrompt was tracker v1's 1d20 pop-up. App stopped rendering it on 2026-07-22, when players began joining from the tracker with each system's own roll, but the component, its export and its test stayed. Nothing imported it; nothing on screen changes. --- CHANGELOG.md | 3 +- .../__tests__/InitiativeRollPrompt.test.tsx | 66 ----------------- .../components/InitiativeRollPrompt.tsx | 71 ------------------- frontend/src/modules/initiative/index.ts | 1 - 4 files changed, 2 insertions(+), 139 deletions(-) delete mode 100644 frontend/src/modules/initiative/__tests__/InitiativeRollPrompt.test.tsx delete mode 100644 frontend/src/modules/initiative/components/InitiativeRollPrompt.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8901388f..7c8a4b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **A custom system's words reach the initiative tracker and the dice log.** START, JOIN and END INITIATIVE, the INIT score, the turn counter and the dice log's "rolled INITIATIVE" lines use the words a custom system's GM chose for initiative and the turn. The built-in systems keep their - wording exactly, ROUND and PASS included. + wording exactly, ROUND and PASS included. The first tracker's 1d20 roll window, which nothing + had opened since players began joining from the tracker itself in July, was removed. - **A custom system's words reach the shops and the bank button.** The shop's messages, receipt and refusals, the steps for stocking an empty shop, and the SHOP, VIEW_BANK and SHOP_CATALOGUES diff --git a/frontend/src/modules/initiative/__tests__/InitiativeRollPrompt.test.tsx b/frontend/src/modules/initiative/__tests__/InitiativeRollPrompt.test.tsx deleted file mode 100644 index b2f78299..00000000 --- a/frontend/src/modules/initiative/__tests__/InitiativeRollPrompt.test.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { InitiativeRollPrompt } from '../components/InitiativeRollPrompt'; - -const baseProps = (overrides = {}) => ({ - sceneLabel: 'CITY MAP', - userName: 'GHOST', - userId: 'GHOST', - onRoll: vi.fn(), - onClose: vi.fn(), - ...overrides, -}); - -beforeEach(() => vi.clearAllMocks()); - -describe('InitiativeRollPrompt', () => { - it('renders without crashing', () => { - expect(() => render()).not.toThrow(); - }); - - it('shows scene label', () => { - render(); - expect(screen.getByText('OMNI TOWER — LV 3')).toBeInTheDocument(); - }); - - it('shows player name', () => { - render(); - expect(screen.getByText('GHOST')).toBeInTheDocument(); - }); - - it('shows ROLL button', () => { - render(); - expect(screen.getByText('ROLL')).toBeInTheDocument(); - }); - - it('shows 1d20 label', () => { - render(); - expect(screen.getByText(/1d20/i)).toBeInTheDocument(); - }); - - it('calls onRoll with a number between 1 and 20 when ROLL is clicked', async () => { - const props = baseProps(); - render(); - await userEvent.click(screen.getByText('ROLL')); - expect(props.onRoll).toHaveBeenCalledTimes(1); - const score = props.onRoll.mock.calls[0][0]; - expect(score).toBeGreaterThanOrEqual(1); - expect(score).toBeLessThanOrEqual(20); - }); - - it('shows the rolled score after rolling', async () => { - render(); - await userEvent.click(screen.getByText('ROLL')); - expect(screen.getByText('ADDED TO TRACKER')).toBeInTheDocument(); - expect(screen.queryByText('ROLL')).not.toBeInTheDocument(); - }); - - it('calls onClose when X button is clicked', async () => { - const props = baseProps(); - render(); - await userEvent.click(screen.getByText('×')); - expect(props.onClose).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/modules/initiative/components/InitiativeRollPrompt.tsx b/frontend/src/modules/initiative/components/InitiativeRollPrompt.tsx deleted file mode 100644 index e9bbd97a..00000000 --- a/frontend/src/modules/initiative/components/InitiativeRollPrompt.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import React, { useState } from 'react'; -import { DraggableWindow } from '../../../components/DraggableWindow'; - -interface Props { - sceneLabel: string; - userName: string; - userId: string; - portraitUrl?: string; - onRoll: (score: number) => void; - onClose: () => void; -} - -function rollD20(): number { - return Math.floor(Math.random() * 20) + 1; -} - -export function InitiativeRollPrompt({ sceneLabel, userName, userId, portraitUrl, onRoll, onClose }: Props) { - const [pos, setPos] = useState({ x: Math.max(0, window.innerWidth / 2 - 150), y: Math.max(0, window.innerHeight / 2 - 120) }); - const [rolled, setRolled] = useState(null); - - const handleRoll = () => { - const score = rollD20(); - setRolled(score); - onRoll(score); - }; - - return ( - -
-
- {sceneLabel} -
- - {portraitUrl && ( - - )} - -
- {userName.toUpperCase()} -
- - {rolled !== null ? ( -
-
- {rolled} -
-
ADDED TO TRACKER
-
- ) : ( - - )} - -
- 1d20 GENERIC INITIATIVE -
-
-
- ); -} diff --git a/frontend/src/modules/initiative/index.ts b/frontend/src/modules/initiative/index.ts index 1393f0cb..03575b9a 100644 --- a/frontend/src/modules/initiative/index.ts +++ b/frontend/src/modules/initiative/index.ts @@ -1,6 +1,5 @@ export { InitiativeWindow } from './components/InitiativeWindow'; export { InitiativeNavPanel } from './components/InitiativeNavPanel'; -export { InitiativeRollPrompt } from './components/InitiativeRollPrompt'; export { useInitiative } from './hooks/useInitiative'; export { npcInitiativePortrait } from './npcPortrait'; export type { InitiativeState, Combatant, ActiveCombat, Side } from './hooks/useInitiative';