Characters - Builder: pick-then-Next on every step with live preview, Back button, plain-language help throughout, and "Suggest for me" for ability scores - Motivation is picked from a list; new steps for special abilities, background and appearance, all composed from fixed lists and DM-only to edit afterwards - Copy a character to another campaign - Characters can give gold, weapons, items and spells to each other - Money tracker: gold only changes through a logged ledger - Fixes: level/XP/location are DM-only, HP stays within 0..max, physical dice ability rolls, every die is rolled once Gear - DM-managed item pool; weapons, items and spells are handed out by the DM - Starting-option flag per pool entry - Gear levels 1-9 per held copy with a damage ladder, growth rate and starting level; spells gated by spell level with a fixed damage value - Spell library carries default damage; damage is picked from a list Encounters - Battle and NPC quest modes, turn tracking, scene description, DM notes - NPCs, richer combatant cards, conditions from a list - Rewards (gold, weapons, items) lined up and awarded, with gold splitting - Character HP is shared between the sheet and the encounter - Monster stat blocks and NPC secrets are never sent to players Other - Dice roller on every page - Docs for characters, weapons, spells, items, encounters, monsters and NPCs, with full lists generated from the data (npm run docs:lists) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
118 lines
5.4 KiB
JavaScript
118 lines
5.4 KiB
JavaScript
// Writes the full lists (every library spell, weapon and monster, and every
|
|
// option the character builder offers) into the docs, straight from the data
|
|
// files the app itself uses, so the docs cannot drift from the app.
|
|
//
|
|
// npm run docs:lists (from /backend)
|
|
//
|
|
// Each list lives between a pair of markers in a doc:
|
|
// <!-- BEGIN GENERATED: name --> ... <!-- END GENERATED: name -->
|
|
// Only the text between the markers is replaced; everything else in the docs
|
|
// is written by hand.
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
|
|
const options = require('../src/game/data/characterOptions');
|
|
const { SPECIES } = require('../src/game/data/species');
|
|
const { CLASSES } = require('../src/game/data/classes');
|
|
|
|
const DOCS = path.join(__dirname, '..', '..', 'docs');
|
|
|
|
const cell = (value) => (value === null || value === undefined || value === '' ? '—' : String(value).replace(/\|/g, '\\|'));
|
|
const table = (headers, rows) =>
|
|
[`| ${headers.join(' | ')} |`, `|${headers.map(() => '---').join('|')}|`, ...rows.map((r) => `| ${r.map(cell).join(' | ')} |`)].join('\n');
|
|
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
|
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
|
|
function spellLibrary() {
|
|
const levels = [...new Set(SPELL_LIBRARY.map((s) => s.level))].sort((a, b) => a - b);
|
|
return levels
|
|
.map((level) => {
|
|
const spells = SPELL_LIBRARY.filter((s) => s.level === level).sort((a, b) => a.name.localeCompare(b.name));
|
|
const heading = level === 0 ? `### Cantrips (${spells.length})` : `### Level ${level} (${plural(spells.length, 'spell')})`;
|
|
const who = level === 0 ? 'Usable by any character.' : `Needs character level ${level}.`;
|
|
return `${heading}\n\n${who}\n\n${table(['Spell', 'School', 'Damage', 'What it does'], spells.map((s) => [s.name, s.school, s.damageDice, s.description]))}`;
|
|
})
|
|
.join('\n\n');
|
|
}
|
|
|
|
const ATTACK_WITH = { STR: 'Strength', DEX: 'Dexterity', FINESSE: 'Strength or Dexterity (finesse)' };
|
|
|
|
function weaponLibrary() {
|
|
const categories = [...new Set(WEAPON_LIBRARY.map((w) => w.category))];
|
|
return categories
|
|
.map((category) => {
|
|
const weapons = WEAPON_LIBRARY.filter((w) => w.category === category);
|
|
const rows = weapons.map((w) => [
|
|
w.name,
|
|
w.damageDice,
|
|
w.damageType,
|
|
ATTACK_WITH[w.attackAbility],
|
|
w.properties.join(', '),
|
|
w.rangeNormal ? `${w.rangeNormal}/${w.rangeLong} ft` : null,
|
|
]);
|
|
return `### ${category} (${weapons.length})\n\n${table(['Weapon', 'Base damage', 'Type', 'Attacks with', 'Properties', 'Range'], rows)}`;
|
|
})
|
|
.join('\n\n');
|
|
}
|
|
|
|
function monsterLibrary() {
|
|
const rows = MONSTER_LIBRARY.map((m) => [
|
|
m.name,
|
|
m.challengeRating,
|
|
m.ac,
|
|
m.maxHp,
|
|
m.speed,
|
|
signed(m.attackBonus),
|
|
m.damageDice,
|
|
[m.strScore, m.dexScore, m.conScore, m.intScore, m.wisScore, m.chaScore].join(' / '),
|
|
m.specialTrait,
|
|
m.notes,
|
|
]);
|
|
return table(['Monster', 'CR', 'AC', 'HP', 'Speed', 'Attack', 'Damage', 'STR / DEX / CON / INT / WIS / CHA', 'Trait', 'Notes'], rows);
|
|
}
|
|
|
|
function builderLists() {
|
|
const abilities = (list) => list.map((a) => `**${a.name}** — ${a.text}`).join('<br>');
|
|
const groups = (list) =>
|
|
list
|
|
.map((g) => {
|
|
const pick = g.min === g.max ? `pick ${g.min}` : `pick ${g.min} to ${g.max}`;
|
|
return `**${g.label}** — ${g.prompt} (${pick})\n\n${table(['Option', 'What it adds'], g.options.map((o) => [o.label, o.text]))}`;
|
|
})
|
|
.join('\n\n');
|
|
|
|
return [
|
|
`### Motivations\n\n${options.MOTIVATIONS.map((m) => `- ${m.label}`).join('\n')}`,
|
|
`### Special abilities granted by species\n\n${table(['Species', 'Abilities'], Object.keys(SPECIES).map((key) => [SPECIES[key].name, abilities(options.SPECIES_TRAITS[key])]))}`,
|
|
`### Special abilities granted by class\n\n${table(['Class', 'Abilities'], Object.keys(CLASSES).sort().map((key) => [CLASSES[key].name, abilities(options.CLASS_FEATURES[key])]))}`,
|
|
`### Talents (pick ${options.TALENT_PICKS})\n\n${table(['Talent', 'What it does'], options.TALENTS.map((t) => [t.name, t.text]))}`,
|
|
`### Background questions\n\n${groups(options.BACKGROUND_GROUPS)}`,
|
|
`### Appearance questions\n\n${groups(options.APPEARANCE_GROUPS)}`,
|
|
].join('\n\n');
|
|
}
|
|
|
|
const SECTIONS = {
|
|
'SPELLS.md': { 'spell-library': spellLibrary },
|
|
'WEAPONS.md': { 'weapon-library': weaponLibrary },
|
|
'MONSTERS.md': { 'monster-library': monsterLibrary },
|
|
'CHARACTERS.md': { 'builder-lists': builderLists },
|
|
};
|
|
|
|
for (const [file, sections] of Object.entries(SECTIONS)) {
|
|
const filePath = path.join(DOCS, file);
|
|
const original = fs.readFileSync(filePath, 'utf8');
|
|
const newline = original.includes('\r\n') ? '\r\n' : '\n';
|
|
let text = original.replace(/\r\n/g, '\n');
|
|
for (const [name, render] of Object.entries(sections)) {
|
|
const begin = `<!-- BEGIN GENERATED: ${name} -->`;
|
|
const end = `<!-- END GENERATED: ${name} -->`;
|
|
const from = text.indexOf(begin);
|
|
const to = text.indexOf(end);
|
|
if (from === -1 || to === -1 || to < from) throw new Error(`${file}: missing the "${name}" markers`);
|
|
text = `${text.slice(0, from + begin.length)}\n${render()}\n${text.slice(to)}`;
|
|
}
|
|
const output = text.replace(/\n/g, newline);
|
|
if (output !== original) fs.writeFileSync(filePath, output);
|
|
console.log(`${output === original ? 'unchanged' : 'updated '} docs/${file}`);
|
|
}
|