Characters - Builder: pick-then-Next on every step with live preview, Back button, plain-language help throughout, and "Suggest for me" for ability scores - Motivation is picked from a list; new steps for special abilities, background and appearance, all composed from fixed lists and DM-only to edit afterwards - Copy a character to another campaign - Characters can give gold, weapons, items and spells to each other - Money tracker: gold only changes through a logged ledger - Fixes: level/XP/location are DM-only, HP stays within 0..max, physical dice ability rolls, every die is rolled once Gear - DM-managed item pool; weapons, items and spells are handed out by the DM - Starting-option flag per pool entry - Gear levels 1-9 per held copy with a damage ladder, growth rate and starting level; spells gated by spell level with a fixed damage value - Spell library carries default damage; damage is picked from a list Encounters - Battle and NPC quest modes, turn tracking, scene description, DM notes - NPCs, richer combatant cards, conditions from a list - Rewards (gold, weapons, items) lined up and awarded, with gold splitting - Character HP is shared between the sheet and the encounter - Monster stat blocks and NPC secrets are never sent to players Other - Dice roller on every page - Docs for characters, weapons, spells, items, encounters, monsters and NPCs, with full lists generated from the data (npm run docs:lists) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
132 lines
4.8 KiB
TypeScript
132 lines
4.8 KiB
TypeScript
import type { Ability } from '../api/types';
|
|
|
|
// Mirrors the pure formulas in backend/src/game/calculations.js — used only
|
|
// to render a live "what does this change" preview while building a
|
|
// character. Display-only: the server recomputes authoritatively at
|
|
// finalize, so this duplication is never gameplay-deciding. Keep in sync by
|
|
// hand if the backend formulas change.
|
|
|
|
export function abilityModifier(score: number): number {
|
|
return Math.floor((score - 10) / 2);
|
|
}
|
|
|
|
export function proficiencyBonus(level: number): number {
|
|
return 2 + Math.floor((level - 1) / 4);
|
|
}
|
|
|
|
export function armorClass(dexMod: number, armorBonus = 0): number {
|
|
return 10 + dexMod + armorBonus;
|
|
}
|
|
|
|
// Level 1 always uses the maximum possible Hit Die roll; every level after
|
|
// uses the fixed "average" value (floor(hitDieSize / 2) + 1).
|
|
export function hpMax(level: number, hitDieSize: number, conMod: number): number {
|
|
const base = hitDieSize + conMod;
|
|
if (level <= 1) return base;
|
|
const perLevelAverage = Math.floor(hitDieSize / 2) + 1;
|
|
return base + (level - 1) * (perLevelAverage + conMod);
|
|
}
|
|
|
|
export function saveBonus(abilityMod: number, proficient: boolean, profBonus: number): number {
|
|
return abilityMod + (proficient ? profBonus : 0);
|
|
}
|
|
|
|
export function skillBonus(abilityMod: number, proficient: boolean, profBonus: number, expertise = false): number {
|
|
if (!proficient) return abilityMod;
|
|
return abilityMod + profBonus * (expertise ? 2 : 1);
|
|
}
|
|
|
|
export function passivePerception(perceptionSkillBonus: number): number {
|
|
return 10 + perceptionSkillBonus;
|
|
}
|
|
|
|
export function spellSaveDC(profBonus: number, spellAbilityMod: number): number {
|
|
return 8 + profBonus + spellAbilityMod;
|
|
}
|
|
|
|
export function spellAttackBonus(profBonus: number, spellAbilityMod: number): number {
|
|
return profBonus + spellAbilityMod;
|
|
}
|
|
|
|
export function fmtMod(n: number): string {
|
|
return n >= 0 ? `+${n}` : `${n}`;
|
|
}
|
|
|
|
export interface PreviewAbilities {
|
|
STR: number;
|
|
DEX: number;
|
|
CON: number;
|
|
INT: number;
|
|
WIS: number;
|
|
CHA: number;
|
|
}
|
|
|
|
export const ABILITY_ORDER: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
|
|
|
// --------------------------------------------------------------------------
|
|
// Gear levels — mirrors scaledDamageDice in backend/src/game/calculations.js.
|
|
// Used only to label the DM's level pickers ("Lv 3 · 1d10"); the dice actually
|
|
// shown for held gear always come from the server.
|
|
// --------------------------------------------------------------------------
|
|
|
|
export const MAX_GEAR_LEVEL = 9;
|
|
export const DAMAGE_SCALING_LABELS: Record<number, string> = { 1: 'Standard', 2: 'Powerful', 3: 'Legendary' };
|
|
|
|
const DIE_SIZES = [4, 6, 8, 10, 12];
|
|
|
|
function formatDice(sizes: number[]): string {
|
|
const counts = new Map<number, number>();
|
|
for (const size of sizes) counts.set(size, (counts.get(size) || 0) + 1);
|
|
return [...counts].map(([size, count]) => `${count}d${size}`).join('+');
|
|
}
|
|
|
|
function buildDamageLadder(rungs: number): { dice: string; average: number }[] {
|
|
const ladder: { dice: string; average: number }[] = [];
|
|
let k = 1;
|
|
let steps = 0;
|
|
while (ladder.length < rungs) {
|
|
if (steps > k * (DIE_SIZES.length - 1)) {
|
|
const previousAverage = ladder[ladder.length - 1].average;
|
|
k += 1;
|
|
steps = Math.floor(previousAverage - k * 2.5) + 1;
|
|
continue;
|
|
}
|
|
const sizes: number[] = [];
|
|
for (let i = 0; i < k; i++) sizes.push(DIE_SIZES[Math.floor(steps / k) + (i >= k - (steps % k) ? 1 : 0)]);
|
|
ladder.push({ dice: formatDice(sizes), average: k * 2.5 + steps });
|
|
steps += 1;
|
|
}
|
|
return ladder;
|
|
}
|
|
|
|
const DAMAGE_LADDER = buildDamageLadder(120);
|
|
|
|
function diceAverage(expression: string | null): number | null {
|
|
if (!expression || !expression.trim()) return null;
|
|
let total = 0;
|
|
for (const term of expression.replace(/\s+/g, '').toLowerCase().replace(/-/g, '+-').split('+')) {
|
|
if (!term) continue;
|
|
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
|
|
if (die) total += (die[1] ? -1 : 1) * (Number(die[2]) || 1) * ((Number(die[3]) + 1) / 2);
|
|
else if (/^-?\d+$/.test(term)) total += Number(term);
|
|
else return null;
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function clampGearLevel(level: number): number {
|
|
return Math.min(Math.max(Math.round(level || 1), 1), MAX_GEAR_LEVEL);
|
|
}
|
|
|
|
// `minLevel` is the level the gear starts at; baseDice is its damage there.
|
|
export function scaledDamageDice(baseDice: string, level = 1, scaling = 1, minLevel = 1): string {
|
|
const levelsGained = clampGearLevel(level) - clampGearLevel(minLevel);
|
|
if (levelsGained <= 0) return baseDice;
|
|
const baseAverage = diceAverage(baseDice);
|
|
if (baseAverage === null) return baseDice;
|
|
let baseRung = -1;
|
|
while (baseRung + 1 < DAMAGE_LADDER.length && DAMAGE_LADDER[baseRung + 1].average <= baseAverage) baseRung += 1;
|
|
const rungsPerLevel = Math.min(Math.max(Math.round(scaling || 1), 1), 3);
|
|
return DAMAGE_LADDER[Math.min(baseRung + levelsGained * rungsPerLevel, DAMAGE_LADDER.length - 1)].dice;
|
|
}
|