- 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>
142 lines
6.7 KiB
JavaScript
142 lines
6.7 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 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}`);
|
||
}
|