- All 123 official subclasses (2014 books and the 2024 Player's Handbook), each with its book and features up to level 20; one file per class - The builder has a subclass step after the class: pick one, let a die suggest one, or decide later; its gifts arrive at the class's choice level - 2024 updates (Wild Heart, Warrior of the Elements) offer a one-click switch - The Road Ahead: a level 1-20 timeline on every sheet and in the builder - 33 new species (2024 Aasimar and Goliath, Monsters of the Multiverse) with chosen +2/+1 or +1/+1/+1 bonuses; species roll is now a d8 by family - The Artificer: class, half-caster slots from level 1, features, Flash of Genius - Class tables and counters run to level 20; a fifth Boon, Resolve Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
8.6 KiB
JavaScript
181 lines
8.6 KiB
JavaScript
// 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 { BOONS, MAX_FEATURE_LEVEL } = require('../src/game/data/levelFeatures');
|
||
const { featuresAt } = require('../src/game/levelUp');
|
||
const { SUBCLASSES, SUBCLASS_LEVEL } = require('../src/game/data/subclasses');
|
||
|
||
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');
|
||
}
|
||
|
||
// What "Level Up" writes for each class at each level, what each of its
|
||
// subclasses adds, and the Boons.
|
||
function levelFeatures() {
|
||
const line = (f) => (f.boon ? '**Boon** (choose one)' : `**${f.name}**${f.upgrade ? ' (replaces)' : ''}: ${f.text}`);
|
||
const classes = Object.keys(CLASSES)
|
||
.sort()
|
||
.map((key) => {
|
||
const rows = [];
|
||
for (let level = 2; level <= MAX_FEATURE_LEVEL; level++) {
|
||
const features = featuresAt(key, level);
|
||
rows.push([level, features.length > 0 ? features.map(line).join('<br>') : 'No new feature']);
|
||
}
|
||
const subclassRows = SUBCLASSES[key].flatMap((subclass) =>
|
||
Object.keys(subclass.features)
|
||
.map(Number)
|
||
.sort((a, b) => a - b)
|
||
.map((level, i) => [
|
||
i === 0 ? `**${subclass.name}**<br>${subclass.summary}<br>_${subclass.source}${subclass.replaces ? `, updates ${SUBCLASSES[key].find((s) => s.key === subclass.replaces).name}` : ''}_` : '',
|
||
level,
|
||
subclass.features[level].map(line).join('<br>'),
|
||
])
|
||
);
|
||
return [
|
||
`### ${CLASSES[key].name}`,
|
||
table(['Level', 'Written into Special Abilities'], rows),
|
||
`**${CLASSES[key].name} subclasses** (chosen at level ${SUBCLASS_LEVEL[key]}). What each adds to the table above:`,
|
||
table(['Subclass', 'Level', 'Written into Special Abilities'], subclassRows),
|
||
].join('\n\n');
|
||
});
|
||
const boons = table(
|
||
['Boon', 'First time', 'Can be taken'],
|
||
BOONS.map((b) => [b.name, b.text(b.per), b.max === 1 ? 'Once' : `${b.max} times`])
|
||
);
|
||
return [`### Boons\n\n${boons}`, ...classes].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, 'level-features': levelFeatures },
|
||
};
|
||
|
||
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}`);
|
||
}
|