Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
112 changes: 112 additions & 0 deletions backend/__tests__/system_builder_initiative_words.test.js
Original file line number Diff line number Diff line change
@@ -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)');
});
});
21 changes: 13 additions & 8 deletions backend/sockets/initiative.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
);
}
Expand All @@ -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;
Expand All @@ -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 (?, ?, ?, ?, ?)`,
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1406,6 +1406,7 @@ export function Sidebar({ activeMenu, setActiveMenu, locations, onSelect, onZoom
{activeMenu === 'dice_menu' && <DiceMenu userName={userName} token={token} socketRef={socketRef} rhombusState={rhombusState} setIsDiceTrayOpen={setIsDiceTrayOpen} setNotification={setNotification} attackPending={attackPending} onCancelAttack={onCancelAttack} gameSystem={gameSystem} customDice={customDice} onOpenCustomDieBuilder={onOpenCustomDieBuilder} onDeleteCustomDie={onDeleteCustomDie} />}
{activeMenu === 'initiative_tracker' && (
<InitiativeNavPanel
system={gameSystem}
initiativeActive={!!initiativeActive}
activeCombats={activeCombats || []}
locations={locations}
Expand Down

This file was deleted.

Loading
Loading