// 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: // ... // 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('
'); 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 = ``; const end = ``; 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}`); }