Seven steps in one commit, 1.15.0 to 1.21.0. They change the same files and were not saved apart, so they go in together. - The battle at a glance (1.15). A strip across the top of an encounter lists everyone in turn order and stays in view; a click goes to that card. On a wide screen the stat block of whoever is on turn stays beside the cards, and Pin keeps anyone else's there. Saving throws, every skill and passive Perception, Investigation and Insight are on encounter cards and in the character panel, which "Sheet" on a card and a button on the party overview open. For the DM, N is Next Turn and S opens the sheet of whoever is on turn. The party overview shows passive scores and spell save DC. - Subclasses (1.16). Three per class, picked in the level-up checklist at level 2 (cleric, druid, wizard) or 3, shown beside the class everywhere, and changed by the DM from the sheet: the old one's lines come out of Special Abilities and the new one's go in. Characters already at that level keep the path they were on. - Character state (1.17). Inspiration, exhaustion and conditions on the sheet and in the panel, and counters for what a class does a set number of times (Rage, Ki, Channel Divinity...), which the player spends and a rest gives back. A long rest takes a level of exhaustion off. A character's conditions are their own now: they stay from one encounter to the next. - Faster battles (1.18). Up to 20 of a monster added at once, numbered. One button rolls initiative for the monsters and NPCs. A condition can be given a number of rounds and then comes off by itself. Hovering over a condition shows its rule, and damaging someone who is Concentrating shows the save to call for. - Rolls from the sheet (1.19). A number with a dotted underline rolls when clicked, with its bonus: skills, saves, ability checks, attacks, damage, spell dice, and a monster's attack, damage and abilities. The dice roller has advantage and disadvantage. "Call for a roll" rolls one check or save for the whole party. Rolls stay in the browser. - XP and difficulty (1.20). An encounter shows the DM how hard it is, Trivial to Deadly, from the monsters' challenge ratings against the characters in it, and what it is worth in XP; one button shares that out, once. The sheet and the party overview show XP and when a level is due. Levelling up stays the DM's decision. - Subclasses are optional per campaign (1.21). With "Characters choose a subclass" unticked in the campaign's Settings nobody is asked, each class follows its first subclass, and none is named. - Conditions, exhaustion, counters and subclass features are reminders for the table: none of them changes a number on the sheet. - The docs cover all of it, with generated tables of what each subclass adds. - New migrations: a character's subclass; inspiration, exhaustion, conditions and spent counters on a character; the rounds left on a combatant's conditions; the XP awarded for an encounter; the campaign's subclass setting. - All packages at version 1.21.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
8.4 KiB
JavaScript
177 lines
8.4 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}` : '', 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}`);
|
||
}
|