Files
the-sunken-reach/backend/scripts/generate-doc-lists.js
T
joshuaandClaude Opus 5.5 0124e3bc53 V1.11.0: gear that applies itself, equipping that matters, a character panel
- Weapons and items can carry effects: bonuses and penalties to AC, speed,
  initiative, max HP, passive Perception, ability scores, saving throws
  and skills, plus a note. They apply on their own while equipped and stop
  when the thing comes off. Armor is an item with an AC effect. The DM
  sets them with "Effects" on a row in the weapon or item pool.
- The sheet's "armor bonus" is now "Extra AC", the DM's own adjustment on
  top of gear, and only the DM can set it.
- Only what is equipped counts: a weapon has to be equipped to be used in
  an encounter. A character can have two things equipped at once, one of
  them a weapon (campaign settings). Equipping at the limit is refused
  with a message, and can be changed at any time, mid-encounter included.
- A built-in item library: armor, shields, worn gear and supplies, with
  their effects set. The weapon library gains paired weapons (a pair of
  daggers is one weapon).
- A floating character button on every page opens a panel with a
  character's numbers and everything they hold, with equip ticks. Players
  get their own characters, the DM everyone's, and each character card in
  an encounter has an Inventory button. It replaces the quick stats drawer.
- A player's own characters stand out: marked and highlighted in
  encounters, with a button that jumps to their card; highlighted on the
  party screen; and listed once, not twice, on the campaign page.
- All packages at version 1.11.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:03:53 +02:00

142 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Writes the full lists (every library spell, weapon, item 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, ITEM_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,
[effectList(w.effects), w.effectNote].filter(Boolean).join('. ') || null,
]);
return `### ${category} (${weapons.length})\n\n${table(['Weapon', 'Base damage', 'Type', 'Attacks with', 'Properties', 'Range', 'While equipped'], rows)}`;
})
.join('\n\n');
}
// "+6 AC, −10 Speed": what a library entry does while equipped.
const STAT_NAMES = { AC: 'AC', SPEED: 'Speed', INITIATIVE: 'Initiative', MAX_HP: 'Max HP', PASSIVE_PERCEPTION: 'Passive Perception', SAVES: 'all saves', SKILLS: 'all skill checks' };
const title = (key) => key.toLowerCase().split('_').map((w, i) => (i > 0 && w === 'of' ? w : w[0].toUpperCase() + w.slice(1))).join(' ');
function statName(stat) {
if (STAT_NAMES[stat]) return STAT_NAMES[stat];
if (stat.startsWith('SAVE_')) return `${stat.slice(5)} saves`;
if (stat.startsWith('SKILL_')) return title(stat.slice(6));
return stat;
}
const effectList = (effects = []) => effects.map((e) => `${signed(e.amount).replace('-', '−')} ${statName(e.stat)}`).join(', ');
function itemLibrary() {
const categories = [...new Set(ITEM_LIBRARY.map((i) => i.category))];
return categories
.map((category) => {
const items = ITEM_LIBRARY.filter((i) => i.category === category);
const rows = items.map((i) => [i.name, effectList(i.effects), i.effectNote, i.damageDice, i.description]);
return `### ${category} (${items.length})\n\n${table(['Item', 'While equipped', 'Note', 'Dice', 'Description'], 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 },
'ITEMS.md': { 'item-library': itemLibrary },
'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}`);
}