- Spell slots. Every class that casts has a slot tracker, worked out from class and level: the full-caster table for bards, clerics, druids, sorcerers and wizards, the half-caster table for paladins and rangers (from level 2), and a warlock's few slots of one level. It is in the Spellcasting card on the sheet and in the character panel, and the party overview has a column for it. A player presses Use to spend a slot; only the DM gives one back. - Rests. The DM's "Rest…" button, beside the character list and on the party overview, rests any of the party at once. A short rest shows which Hit Dice each character can roll and heals from what they rolled (typed in, or rolled by the site), and gives a warlock their slots. A long rest puts back HP, every spell slot and half the level in Hit Dice, and clears temp HP. - Hit Dice are counted: one per level, shown on the sheet, the panel and the party overview. - Level Up. "Level Up…" on a sheet takes a character up one level with the whole checklist: the new max HP and a full heal, the class's new special abilities for levels 2 to 10, a Boon where the level grants one (Toughened raises Extra AC, Hardy gives temp HP now and after each long rest), fresh spell slots and new spells, and the free upgrade of one standard weapon. - A level typed into the Level field still writes down what the class gains on the way up, and leaves a line where a Boon is owed. - The docs cover all of it, with a generated list of what each class gains at each level. The Hollowcrown seed's note no longer says to track spell slots on paper. - New migration: spent spell slots and Hit Dice on a character. - All packages at version 1.14.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
164 lines
7.8 KiB
JavaScript
164 lines
7.8 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 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, 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']);
|
||
}
|
||
return `### ${CLASSES[key].name}\n\n${table(['Level', 'Written into Special Abilities'], rows)}`;
|
||
});
|
||
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}`);
|
||
}
|