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.