diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5de81c..7c8a4b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ 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. 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 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' && ( ({ - 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/__tests__/initiativeWords.test.tsx b/frontend/src/modules/initiative/__tests__/initiativeWords.test.tsx new file mode 100644 index 00000000..8ae5b62c --- /dev/null +++ b/frontend/src/modules/initiative/__tests__/initiativeWords.test.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { InitiativeWindow } from '../components/InitiativeWindow'; +import { InitiativeSideView } from '../components/InitiativeSideView'; +import { InitiativeNavPanel } from '../components/InitiativeNavPanel'; +import { Sidebar } from '../../../components/Sidebar'; +import type { InitiativeState } from '../hooks/useInitiative'; +import { registerCustomTemplate, clearCustomTemplates } from '../../../sheets/customTemplates'; + +/** + * The glossary in the initiative tracker (3a5a). Every place reads exactly as today under each + * built-in system - CWN and Cyberpunk RED counting ROUNDs, Shadowrun PASSes, Generic TURNs - + * and under a custom system that renamed nothing; a custom system that renamed initiative or + * the turn shows its own words. + */ + +const RENAMED = 'sys_aaaaaaaaaaaaaaaa'; +const PLAIN = 'sys_bbbbbbbbbbbbbbbb'; +const COUNTERS: Record = { 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 && ( - )} - -
- 1d20 GENERIC INITIATIVE -
-
- - ); -} diff --git a/frontend/src/modules/initiative/components/InitiativeSideView.tsx b/frontend/src/modules/initiative/components/InitiativeSideView.tsx index 70b5c90b..4db4fe02 100644 --- a/frontend/src/modules/initiative/components/InitiativeSideView.tsx +++ b/frontend/src/modules/initiative/components/InitiativeSideView.tsx @@ -2,6 +2,7 @@ import React, { useRef, useState } from 'react'; import { InitiativeCombatantRow } from './InitiativeCombatantRow'; import type { InitiativeState, Side } from '../hooks/useInitiative'; import { getInitiativeSystem } from '../systems'; +import { useWords } from '../../../sheets/words'; interface Props { state: InitiativeState; @@ -23,6 +24,10 @@ export function InitiativeSideView({ const [dragOverSideId, setDragOverSideId] = useState(null); const dragFrom = useRef(null); const dragSideId = useRef(null); + // A custom system's own words for initiative and the turn; today's text otherwise. + const word = useWords(state.system); + const INITIATIVE = word('initiative', 'singular', 'INITIATIVE').toUpperCase(); + const INIT = word('initiative', 'short', 'INIT').toUpperCase(); const sortedSides: Side[] = [...state.sides].sort((a, b) => { const diff = b.score - a.score; @@ -60,7 +65,7 @@ export function InitiativeSideView({
{/* Counter */}
- {`${sys.counterLabel} ${state.turnCounter}`} + {`${word('turn', 'singular', sys.counterLabel).toUpperCase()} ${state.turnCounter}`}
{/* Sides */} @@ -101,7 +106,7 @@ export function InitiativeSideView({ {side.name} - {side.score > 0 ? `INIT ${side.score}` : 'NOT ROLLED'} + {side.score > 0 ? `${INIT} ${side.score}` : 'NOT ROLLED'}
@@ -153,7 +158,7 @@ export function InitiativeSideView({ style={{ width: '100%' }} onClick={() => onJoin(null, state.system)} > - JOIN INITIATIVE + JOIN {INITIATIVE}
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' 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';