Files
the-sunken-reach/frontend/src/lib/previewCalc.ts
T
joshuaandClaude Opus 5.5 4a6a09bae8 V1.1.0: gear levels, encounter overhaul, guided builder, trading, docs
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>
2026-10-05 16:47:28 +02:00

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;
}