- Settings grouped into Characters, Dice & rolls, Combat and Progression - Dice: digital, physical or both; a campaign's own choice applies to every character - Ability scores by 4d6, the standard array or point buy (27 points, 8 to 15) - Track XP can be turned off for milestone levelling: XP is hidden and not awarded - New settings saved ahead of their features: multiclassing, auto-rolled monster and NPC attacks, roll visibility, hidden monster rolls, HP on level up, crit rule - docs/CAMPAIGN_SETTINGS.md lists every setting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
7.1 KiB
JavaScript
197 lines
7.1 KiB
JavaScript
// Roll-Your-Character build flow tables — see /docs/ROLL_YOUR_CHARACTER.md.
|
|
// Dice are always rolled server-side (crypto.randomInt), never trusted from the client.
|
|
|
|
const crypto = require('crypto');
|
|
const { SPECIES } = require('./data/species');
|
|
const { CLASSES } = require('./data/classes');
|
|
const { SPECIES_TRAITS, CLASS_FEATURES, TALENTS, TALENT_PICKS } = require('./data/characterOptions');
|
|
|
|
const ALL_SPECIES = Object.keys(SPECIES);
|
|
const ALL_CLASSES = Object.keys(CLASSES);
|
|
|
|
const SPECIES_ROLL_TABLE = {
|
|
1: ['HUMAN', 'HALF_ELF'],
|
|
2: ['ELF', 'GNOME'],
|
|
3: ['DWARF', 'HALFLING'],
|
|
4: ['DRAGONBORN', 'TIEFLING'],
|
|
5: ['HALF_ORC', 'ORC'],
|
|
6: 'WILDCARD',
|
|
};
|
|
|
|
const CLASS_ROLL_TABLE = {
|
|
1: ['FIGHTER', 'BARBARIAN'],
|
|
2: ['ROGUE', 'RANGER'],
|
|
3: ['WIZARD', 'SORCERER'],
|
|
4: ['CLERIC', 'PALADIN'],
|
|
5: ['BARD', 'WARLOCK'],
|
|
6: ['DRUID', 'MONK'],
|
|
7: 'REROLL',
|
|
8: 'WILDCARD',
|
|
};
|
|
|
|
function rollDie(sides) {
|
|
return crypto.randomInt(1, sides + 1);
|
|
}
|
|
|
|
// Standard method: 4d6, drop the lowest, sum the rest. Shared by both the
|
|
// digital path (dice rolled here) and the physical path (dice typed in by
|
|
// the roller, validated by validateManualRoll and passed in already-rolled).
|
|
function abilityScoreFromDice(dice) {
|
|
const sorted = [...dice].sort((a, b) => a - b);
|
|
const dropped = sorted[0];
|
|
const total = sorted.slice(1).reduce((a, b) => a + b, 0);
|
|
return { dice, dropped, total };
|
|
}
|
|
|
|
function rollAbilityScore() {
|
|
const dice = Array.from({ length: 4 }, () => rollDie(6));
|
|
return abilityScoreFromDice(dice);
|
|
}
|
|
|
|
// The other two ways to make ability scores (Campaign.abilityScoreMethod).
|
|
// The standard array is six fixed scores, assigned like rolled ones. Point
|
|
// buy starts every score at 8 and spends a budget to raise them, to 15 at most.
|
|
const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8];
|
|
const POINT_BUY_BUDGET = 27;
|
|
const POINT_BUY_COSTS = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 };
|
|
|
|
// What a set of point-buy scores costs, or null if a score is out of range.
|
|
function pointBuyCost(scores) {
|
|
let total = 0;
|
|
for (const score of Object.values(scores)) {
|
|
const cost = POINT_BUY_COSTS[score];
|
|
if (cost === undefined) return null;
|
|
total += cost;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
// Which dice a character rolls: the campaign's, unless it leaves the choice
|
|
// to each character (BOTH).
|
|
function effectiveDiceMode(campaign, character) {
|
|
if (campaign && campaign.diceMode && campaign.diceMode !== 'BOTH') return campaign.diceMode;
|
|
return character.diceMode;
|
|
}
|
|
|
|
// Fisher-Yates-ish random pick of `count` distinct items from `options`,
|
|
// using the same crypto RNG as dice rolls (never Math.random for anything
|
|
// that ends up on a character sheet).
|
|
function randomSample(options, count) {
|
|
const pool = [...options];
|
|
const picked = [];
|
|
for (let i = 0; i < count && pool.length > 0; i++) {
|
|
const idx = crypto.randomInt(0, pool.length);
|
|
picked.push(pool.splice(idx, 1)[0]);
|
|
}
|
|
return picked;
|
|
}
|
|
|
|
// Auto-generates skill proficiencies for a class per its SRD "choose N from
|
|
// this list" rule (classes.js `skillChoices`) — replaces the manual
|
|
// checkbox pick the builder used to require.
|
|
function autoSelectSkills(characterClass) {
|
|
const data = characterClass ? CLASSES[characterClass] : null;
|
|
const choices = data ? data.skillChoices : null;
|
|
if (!choices) return [];
|
|
const picked = randomSample(choices.options, choices.count);
|
|
return picked.map((skill) => ({ skill, proficient: true, expertise: false }));
|
|
}
|
|
|
|
// Validates a physically-rolled die value a player types in against the
|
|
// step's real die range — physical-dice-mode's equivalent of trusting
|
|
// crypto.randomInt's output range for the digital path.
|
|
function validateManualRoll(sides, value) {
|
|
return Number.isInteger(value) && value >= 1 && value <= sides;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Story steps: background, appearance and special abilities are composed from
|
|
// picks out of fixed lists, never typed in.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// `picks` maps each group's key to the option keys chosen in it. Returns a
|
|
// message describing the first problem, or null if the picks are valid.
|
|
function validatePicks(groups, picks) {
|
|
for (const group of groups) {
|
|
const chosen = picks[group.key] || [];
|
|
if (!Array.isArray(chosen) || new Set(chosen).size !== chosen.length) return `${group.label}: pick each option once.`;
|
|
if (chosen.some((key) => !group.options.some((o) => o.key === key))) return `${group.label}: that is not one of the options.`;
|
|
if (chosen.length < group.min || chosen.length > group.max) {
|
|
const range = group.min === group.max ? `${group.min}` : `${group.min} to ${group.max}`;
|
|
return `${group.label}: pick ${range}.`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// One line per group, in the groups' order: "Upbringing: Soldier — Served in
|
|
// an army…" or, for several picks, "Personality: Brave, Curious".
|
|
function composeFromPicks(groups, picks) {
|
|
const lines = [];
|
|
for (const group of groups) {
|
|
const chosen = group.options.filter((o) => (picks[group.key] || []).includes(o.key));
|
|
if (chosen.length === 0) continue;
|
|
const detail = chosen.length === 1 && chosen[0].text ? ` — ${chosen[0].text}` : '';
|
|
lines.push(`${group.label}: ${chosen.map((o) => o.label).join(', ')}${detail}`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function validateTalents(talentKeys) {
|
|
const valid = new Set(talentKeys).size === talentKeys.length && talentKeys.every((key) => TALENTS.some((t) => t.key === key));
|
|
return valid && talentKeys.length === TALENT_PICKS ? null : `Pick ${TALENT_PICKS} different talents from the list.`;
|
|
}
|
|
|
|
// Everything a new character can do beyond the numbers: what its species and
|
|
// class grant automatically, then the talents the player picked.
|
|
function composeSpecialAbilities(species, characterClass, talentKeys = []) {
|
|
const abilities = [
|
|
...(SPECIES_TRAITS[species] || []),
|
|
...(CLASS_FEATURES[characterClass] || []),
|
|
...TALENTS.filter((t) => talentKeys.includes(t.key)),
|
|
];
|
|
// A trait granted twice over (two sources of Darkvision, say) is listed once.
|
|
const seen = new Set();
|
|
return abilities
|
|
.filter((a) => !seen.has(a.name) && seen.add(a.name))
|
|
.map((a) => `${a.name} — ${a.text}`)
|
|
.join('\n');
|
|
}
|
|
|
|
function speciesChoicesForRoll(roll) {
|
|
const entry = SPECIES_ROLL_TABLE[roll];
|
|
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_SPECIES };
|
|
return { type: 'choice', options: entry };
|
|
}
|
|
|
|
function classChoicesForRoll(roll) {
|
|
const entry = CLASS_ROLL_TABLE[roll];
|
|
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_CLASSES };
|
|
if (entry === 'REROLL') return { type: 'reroll', options: [] };
|
|
return { type: 'choice', options: entry };
|
|
}
|
|
|
|
module.exports = {
|
|
SPECIES_ROLL_TABLE,
|
|
CLASS_ROLL_TABLE,
|
|
ALL_SPECIES,
|
|
ALL_CLASSES,
|
|
rollDie,
|
|
rollAbilityScore,
|
|
abilityScoreFromDice,
|
|
STANDARD_ARRAY,
|
|
POINT_BUY_BUDGET,
|
|
POINT_BUY_COSTS,
|
|
pointBuyCost,
|
|
effectiveDiceMode,
|
|
speciesChoicesForRoll,
|
|
classChoicesForRoll,
|
|
randomSample,
|
|
autoSelectSkills,
|
|
validateManualRoll,
|
|
validatePicks,
|
|
composeFromPicks,
|
|
validateTalents,
|
|
composeSpecialAbilities,
|
|
};
|