From 8fd530df73e0c364d16cfba0b6071c47d9d43eb4 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 20:37:01 +0200 Subject: [PATCH 1/3] Perfection pass: complete level-up/build flows + dying polish Second adversarial audit of the freshly-shipped code; fixed bugs + completeness gaps. - abilityBuild stays in sync with totals on level-up (appendLevelIncreases) - 5e feat picker in level-up; PF2e feat-name autocomplete; expertise sets ranks - Higher-level creation collects PF2e skill increases + 5e expertise - PF2e heritage: schema field, wizard picker, sheet, Pathbuilder import - 5e Hit Dice as a tracked resource (half-level long-rest recovery via recoverStep) - Dying flow: knockout sets HP 0, stays unconscious at 0 HP, healing wakes +Wounded, Heroic Recovery (spend hero points); damage-while-dying + recovery reminders (pf2e) - Healing caps at effective max (drained/exhaustion-4) on sheet, tracker, and rest; massive-damage death uses effective max; persistent-damage badge - UX: in-place valued-condition steppers, point-buy budget caps, prepared-vs-known spell guidance, granted skills in review, system-gated score generator Co-Authored-By: Claude Opus 4.8 --- src/features/characters/CharacterSheet.tsx | 24 ++- .../characters/builder/CreationWizard.tsx | 176 ++++++++++++++++-- .../characters/sheet/DefensesSection.tsx | 26 ++- .../characters/sheet/LevelUpModal.tsx | 114 +++++++++++- .../characters/sheet/ResourcesSection.tsx | 13 +- src/features/combat/EncounterTracker.tsx | 93 +++++++-- src/lib/combat/engine.test.ts | 7 + src/lib/combat/engine.ts | 11 +- src/lib/io/pathbuilder.ts | 7 +- src/lib/mechanics/creatureState.ts | 7 + src/lib/mechanics/dying.test.ts | 12 +- src/lib/mechanics/dying.ts | 10 + src/lib/mechanics/index.ts | 2 +- src/lib/rules/abilityBuild.test.ts | 35 +++- src/lib/rules/abilityBuild.ts | 30 ++- src/lib/rules/depth.test.ts | 16 ++ src/lib/rules/index.ts | 2 +- src/lib/rules/progression.ts | 21 ++- src/lib/rules/rest.ts | 43 +++-- src/lib/rules/rules.test.ts | 21 +++ src/lib/schemas/character.ts | 8 +- 21 files changed, 609 insertions(+), 69 deletions(-) diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index 25bda40..bf18567 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -187,6 +187,12 @@ export function CharacterSheet({ character }: { character: Character }) { update({ ancestry: e.target.value })} /> + {c.system === 'pf2e' && ( + + {/* pre-migration rows lack the field — keep the input controlled */} + update({ heritage: e.target.value })} /> + + )} {c.system === '5e' ? (
) : ( @@ -231,7 +237,8 @@ export function CharacterSheet({ character }: { character: Character }) {
Ability Scores - + {/* The generator (array/point-buy/4d6) is a 5e concept; PF2e scores come from boosts. */} + {c.system === '5e' && }
{ABILITIES.map((a) => { @@ -413,18 +420,27 @@ export function CharacterSheet({ character }: { character: Character }) { function HpCard({ c, update }: { c: Character; update: (p: Partial) => void }) { const [delta, setDelta] = useState(0); + const eff = deriveEffectiveMaxHp({ system: c.system, baseMaxHp: c.hp.max, level: c.level, exhaustion: c.defenses.exhaustion, conditions: c.conditions }); + const effMax = eff.max; const apply = (mode: 'damage' | 'heal') => { if (!Number.isFinite(delta) || delta <= 0) return; if (mode === 'damage') { const absorbed = Math.min(c.hp.temp, delta); update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } }); } else { - update({ hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + delta) } }); + // Heal caps at the EFFECTIVE max (drained / exhaustion 4 reduce it). + const current = Math.min(effMax, c.hp.current + delta); + const patch: Partial = { hp: { ...c.hp, current } }; + // pf2e: healing above 0 HP ends dying and wakes the character (pure bookkeeping). + // Losing the dying condition always increases wounded by 1. + if (c.system === 'pf2e' && c.hp.current <= 0 && current > 0) { + if (c.defenses.dying > 0) patch.defenses = { ...c.defenses, dying: 0, wounded: c.defenses.wounded + 1 }; + patch.conditions = c.conditions.filter((x) => x.name.trim().toLowerCase() !== 'unconscious'); + } + update(patch); } setDelta(0); }; - const eff = deriveEffectiveMaxHp({ system: c.system, baseMaxHp: c.hp.max, level: c.level, exhaustion: c.defenses.exhaustion, conditions: c.conditions }); - const effMax = eff.max; const ratio = effMax > 0 ? c.hp.current / effMax : 0; return (
diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index 8a500c6..a83e9b1 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -6,8 +6,10 @@ import { charactersRepo } from '@/lib/db/repositories'; import { getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS, pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, + bumpRank, collectChoices, type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId, } from '@/lib/rules'; +import { pf2eSkillIncreaseLevels } from '@/lib/rules/pf2e/progression'; import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen'; import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium'; import type { RulesetClass } from '@/lib/ruleset/normalize'; @@ -179,6 +181,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un const [classSlug, setClassSlug] = useState(''); const [subclass, setSubclass] = useState(''); const [ancestry, setAncestry] = useState(''); + // PF2e heritage — chosen at level 1, refines the ancestry. Loaded lazily; the data + // carries no ancestry link, so we match by name ("Ancient-Blooded Dwarf") and always + // include versatile heritages (selectable by any ancestry). + const [heritage, setHeritage] = useState(''); + const [allHeritages, setAllHeritages] = useState<{ name: string; summary: string; versatile: boolean }[]>([]); + useEffect(() => { + if (system !== 'pf2e' || allHeritages.length) return; + let on = true; + void loadPf2e('heritages').then((hs) => on && setAllHeritages(hs.map((h) => ({ + name: String(h.name), + summary: briefOverview(String((h.summary ?? h.text) ?? '')), + versatile: /versatile heritage/i.test(String(h.text ?? h.summary ?? '')), + })))); + return () => { on = false; }; + }, [system, allHeritages.length]); + const heritageOptions = useMemo(() => { + if (system !== 'pf2e' || !ancestry.trim()) return []; + const want = ancestry.trim().toLowerCase(); + return allHeritages.filter((h) => h.name.toLowerCase().includes(want) || h.versatile); + }, [system, ancestry, allHeritages]); + useEffect(() => { setHeritage(''); }, [ancestry]); // a new ancestry invalidates the heritage const [background, setBackground] = useState(''); const selectedClass = classes.find((c) => c.slug === classSlug) ?? null; @@ -325,6 +348,43 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un // Drop any free pick that becomes granted (e.g. after switching background). useEffect(() => { setSkills((prev) => prev.filter((k) => !grantedSkills.includes(k))); }, [grantedSkills]); + // Higher-level creation owes the picks earned along the way — collect them here + // rather than deferring to the level-up flow: PF2e skill increases (levels 3,5,7…) + // and 5e Expertise (Bard/Rogue, scaling with level). + const skillIncLevels = useMemo( + () => (system === 'pf2e' ? pf2eSkillIncreaseLevels().filter((l) => l <= level) : []), + [system, level], + ); + const expertiseCount = useMemo(() => { + if (system !== '5e' || !selectedClass) return 0; + const ch = collectChoices('5e', [{ className: selectedClass.name, level }]).find((x) => x.key.endsWith(':expertise')); + return ch?.count ?? 0; + }, [system, selectedClass, level]); + const [skillIncPicks, setSkillIncPicks] = useState([]); + const [expertisePicks, setExpertisePicks] = useState([]); + useEffect(() => { setSkillIncPicks([]); setExpertisePicks([]); }, [classSlug, level, system]); + // Seed sensible defaults from the chosen/granted skills (kept once the user edits). + useEffect(() => { + setExpertisePicks((prev) => Array.from({ length: expertiseCount }, (_, i) => prev[i] || skills[i] || skills[0] || '')); + }, [expertiseCount, skills]); + useEffect(() => { + const pool = [...skills, ...grantedSkills]; + setSkillIncPicks((prev) => skillIncLevels.map((_, i) => prev[i] || pool[i % Math.max(1, pool.length)] || '')); + }, [skillIncLevels, skills, grantedSkills]); + /** Skill ranks after base training + the first `uptoIdx` increases (for previews). */ + const ranksAfterIncreases = (uptoIdx: number): Record => { + const r: Record = {}; + for (const k of [...skills, ...grantedSkills]) r[k] = 'trained'; + for (let i = 0; i < uptoIdx; i++) { const k = skillIncPicks[i]; if (k) r[k] = bumpRank(r[k] ?? 'untrained'); } + return r; + }; + /** PF2e rank caps: master needs the increase earned at level 7+, legendary 15+. */ + const incAllowed = (rank: ProficiencyRank, earnedAt: number): boolean => + rank === 'untrained' || rank === 'trained' ? true + : rank === 'expert' ? earnedAt >= 7 + : rank === 'master' ? earnedAt >= 15 + : false; + // ---- spells ---- const [spellPicks, setSpellPicks] = useState([]); const [spellQuery, setSpellQuery] = useState(''); @@ -390,7 +450,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un Class: !!selectedClass, Origin: true, Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0, - Skills: skills.length === Math.min(skillCount, freeSkillOptions.length), + Skills: skills.length === Math.min(skillCount, freeSkillOptions.length) + && skillIncPicks.every(Boolean) + && expertisePicks.every(Boolean), Spells: true, Details: true, Review: true, @@ -408,13 +470,19 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank; } const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) })); + // Layer expertise (5e) and skill increases (pf2e) on top of the trained ranks. + const skillRanks: Record = { ...built.skillRanks }; + for (const k of expertisePicks) if (k) skillRanks[k] = 'expert'; + for (const k of skillIncPicks) if (k) skillRanks[k] = bumpRank(skillRanks[k] ?? 'untrained'); await charactersRepo.update(created.id, { ...built, + skillRanks, abilityBuild: buildAbilityBuild(), ...(Object.keys(saveRanks).length ? { saveRanks } : {}), classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }], spellcasting: { ...built.spellcasting, spells }, ...(background ? { background } : {}), + ...(heritage ? { heritage } : {}), ...(alignment.trim() ? { alignment: alignment.trim() } : {}), ...(appearance.trim() ? { appearance: appearance.trim() } : {}), ...(personality.trim() ? { personality: personality.trim() } : {}), @@ -540,6 +608,24 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
+ {system === 'pf2e' && ancestry.trim() && heritageOptions.length > 0 && ( +
+ + + + {heritage && ( +

{heritageOptions.find((h) => h.name === heritage)?.summary}

+ )} +
+ )} + {!ancestry.trim() && ( +

+ No {system === 'pf2e' ? 'ancestry' : 'race'} selected — you can continue (handy for homebrew), but its ability bonuses, HP, speed, and skills won’t be applied. +

+ )} {selectedClass && selectedOrigin && (() => { const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities); if (!synergy) return null; @@ -658,7 +744,17 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un const value = base + racial + asi; const isKey = selectedClass?.keyAbilities.includes(a); const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1; - const fMax = method === 'pointbuy' ? POINT_BUY_MAX : 30; + // Point-buy caps each field at what the remaining budget can afford, + // so the user can't overspend and wonder why Next is disabled. + const fMax = method === 'pointbuy' + ? (() => { + let best = pb[i]!; + for (let cand = pb[i]! + 1; cand <= POINT_BUY_MAX; cand++) { + if (pointBuyRemaining(pb.map((x, j) => (j === i ? cand : x))) >= 0) best = cand; else break; + } + return best; + })() + : 30; return (
{ABILITY_ABBR[a]}{isKey && }
@@ -694,7 +790,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un {stepName === 'Skills' && (
-

Choose {Math.min(skillCount, freeSkillOptions.length)} {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).

+

+ Choose {Math.min(skillCount, freeSkillOptions.length)} {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected{grantedSkillSources.length > 0 ? `, plus ${grantedSkillSources.length} already granted below` : ''}). +

{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}

{grantedSkillSources.length > 0 && (
@@ -708,13 +806,6 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
)} - {level > 1 && ( -

- {system === 'pf2e' - ? 'Higher-level characters also gain skill increases at levels 3, 5, 7, 9… — apply those from the character sheet’s level-up flow.' - : 'Some classes gain extra skill/expertise picks as they level — apply those from the character sheet’s level-up flow.'} -

- )} {selectedClass && (() => { const tip = getClassTip(system, selectedClass.slug); return tip?.skillSuggestions ? ( @@ -736,13 +827,70 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un ); })}
+ {skills.length < Math.min(skillCount, freeSkillOptions.length) && ( +

Select {Math.min(skillCount, freeSkillOptions.length) - skills.length} more {Math.min(skillCount, freeSkillOptions.length) - skills.length === 1 ? 'skill' : 'skills'} to continue.

+ )} + + {/* PF2e: skill increases earned by this level (3, 5, 7, …) — rank bumps. */} + {skillIncLevels.length > 0 && ( +
+

Skill increases (earned at levels {skillIncLevels.join(', ')})

+

Each increase raises one skill a rank (Master from level 7, Legendary from level 15).

+
+ {skillIncLevels.map((earnedAt, i) => { + const ranks = ranksAfterIncreases(i); + return ( + + ); + })} +
+
+ )} + + {/* 5e: Expertise picks (Bard/Rogue) — double proficiency on chosen skills. */} + {expertiseCount > 0 && ( +
+

Expertise (choose {expertiseCount} — proficiency doubled)

+
+ {Array.from({ length: expertiseCount }, (_, i) => ( + + ))} +
+
+ )}
)} {stepName === 'Spells' && (

Pick your cantrips and a few starting spells — about {suggestedSpells} is a good start at level {level}. Search by name; you can always adjust on the sheet later.

-

These are the spells you {system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells you’ve learned)'}. You prepare or cast them from slots on the character sheet.

+

+ {(() => { + // Prepared casters re-select daily from a book/list; known casters have a + // fixed repertoire. Different mental model, so spell out which applies. + const prepared = (system === '5e' ? ['cleric', 'druid', 'paladin', 'wizard', 'artificer'] : ['wizard', 'cleric', 'druid', 'witch', 'magus', 'animist']) + .includes((selectedClass?.name ?? '').toLowerCase()); + return prepared ? ( + <>As a prepared caster, these go into your {system === 'pf2e' ? 'spellbook/list' : 'spellbook'} — each day you prepare a subset on the sheet, so pick a versatile starting set. + ) : ( + <>These are the spells you know (your repertoire) — a fixed list you cast from slots; you can swap picks when you level up. + ); + })()} +

suggestedSpells + 4 ? 'text-warning' : 'text-muted')}> {spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — that’s quite a few; you can trim some later if you like.' : ''}

@@ -800,7 +948,11 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
`${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} /> {background && } - + {heritage && } + `${skillLabel(g.key)} (${g.source})`), + ].join(', ') || '—'} /> {built.spellcasting.slots.length > 0 && `${s.max}×L${s.level}`).join(' ')} />} {spellPicks.length > 0 && s.name).join(', ')} />}

You can fine-tune equipment, feats, and more on the sheet next.

diff --git a/src/features/characters/sheet/DefensesSection.tsx b/src/features/characters/sheet/DefensesSection.tsx index 587501d..5e95eed 100644 --- a/src/features/characters/sheet/DefensesSection.tsx +++ b/src/features/characters/sheet/DefensesSection.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { Minus, Plus, Skull, Star } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { NumberField } from '@/components/ui/NumberField'; -import { maxDying, isDead, recoveryDc, knockOut, applyRecovery } from '@/lib/mechanics'; +import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue } from '@/lib/mechanics'; import type { Degree } from '@/lib/dice/check'; import type { Condition } from '@/lib/schemas/common'; import { SheetSection, type SectionProps } from './common'; @@ -39,17 +39,27 @@ function Pf2eDefenses({ c, update }: SectionProps) { conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious'); const setDefenses = (p: Partial) => update({ defenses: { ...d, ...p } }); - /** Set dying and keep the Unconscious condition in sync (dying>0 ⇒ unconscious). */ + // You stay Unconscious at 0 HP even after recovering out of dying — only healing + // above 0 HP (or the GM) wakes you. So the sync is: dying>0 OR hp<=0 ⇒ unconscious. + const syncUnconscious = (dying: number, hpCurrent: number) => + dying > 0 || hpCurrent <= 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions); + /** Set dying and keep the Unconscious condition in sync. */ const setDying = (dying: number) => - update({ defenses: { ...d, dying }, conditions: dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) }); + update({ defenses: { ...d, dying }, conditions: syncUnconscious(dying, c.hp.current) }); const doKnockOut = () => { const { dying } = knockOut(d, fromCrit); - update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions) }); + // Knocked out = at 0 HP and dying; drop HP to 0 so the sheet state is coherent. + update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions), hp: { ...c.hp, current: Math.min(0, c.hp.current) } }); }; const doRecover = (degree: Degree) => { const res = applyRecovery(d, degree); - update({ defenses: { ...d, ...res }, conditions: res.dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) }); + update({ defenses: { ...d, ...res }, conditions: syncUnconscious(res.dying, c.hp.current) }); + }; + const doHeroRescue = () => { + const res = heroPointRescue(d); + if (!res) return; + update({ defenses: { ...d, ...res }, conditions: syncUnconscious(0, c.hp.current) }); }; return ( @@ -98,6 +108,12 @@ function Pf2eDefenses({ c, update }: SectionProps) { + {d.heroPoints >= 1 && ( +
+ Heroic Recovery: spend ALL your Hero Points ({d.heroPoints}) to drop to Dying 0 and stabilize. + +
+ )} )} diff --git a/src/features/characters/sheet/LevelUpModal.tsx b/src/features/characters/sheet/LevelUpModal.tsx index 43eb0d7..fc80eeb 100644 --- a/src/features/characters/sheet/LevelUpModal.tsx +++ b/src/features/characters/sheet/LevelUpModal.tsx @@ -1,17 +1,19 @@ import { useEffect, useMemo, useState } from 'react'; -import type { Campaign, Character, ClassEntry, Spellcasting } from '@/lib/schemas'; +import type { Campaign, Character, ClassEntry, Feat, Spellcasting } from '@/lib/schemas'; import { normalizeClassMirror, totalLevel } from '@/lib/schemas'; import { abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank, - planLevelUp, applyIncreases, bumpRank, getClassDef, + planLevelUp, appendLevelIncreases, bumpRank, getClassDef, hitDiceResource, collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature, } from '@/lib/rules'; import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression'; +import { loadPf2e } from '@/lib/compendium'; import { rollDice } from '@/lib/dice/notation'; import { createRng } from '@/lib/rng'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; +import { FeatPickerModal } from './FeatPickerModal'; import { LevelUpAdvisor } from './LevelUpAdvisor'; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; @@ -54,6 +56,9 @@ export function LevelUpModal({ character, onApply, onClose }: { const keyDefaults = def?.keyAbilities ?? ['str', 'dex']; const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi'); const [asiPicks, setAsiPicks] = useState([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']); + // Feat-instead-of-ASI (5e): picked right here, recorded onto the sheet on apply. + const [featPick, setFeatPick] = useState(null); + const [featBrowse, setFeatBrowse] = useState(false); // Boosts (pf2e): four distinct picks. const [boostPicks, setBoostPicks] = useState(['str', 'dex', 'con', 'wis']); // Skill increase (pf2e): one skill to bump. @@ -109,6 +114,15 @@ export function LevelUpModal({ character, onApply, onClose }: { const [choicePicks, setChoicePicks] = useState>({}); const [subclassPick, setSubclassPick] = useState(''); + // PF2e feat-slot inputs autocomplete against the compendium so names land typo-free. + const [pf2eFeatNames, setPf2eFeatNames] = useState([]); + const wantsFeatSuggestions = !is5e && pendingChoices.some(({ choice }) => !choice.options && /-feat$/.test(choice.key.split(':')[1] ?? '')); + useEffect(() => { + if (!wantsFeatSuggestions || pf2eFeatNames.length) return; + let on = true; + void loadPf2e('feats').then((fs) => on && setPf2eFeatNames(fs.map((f) => String(f.name)).filter(Boolean))); + return () => { on = false; }; + }, [wantsFeatSuggestions, pf2eFeatNames.length]); const setChoicePick = (key: string, i: number, val: string) => setChoicePicks((p) => { const arr = [...(p[key] ?? [])]; arr[i] = val; return { ...p, [key]: arr }; }); // Seed defaults for option-based choices so an untouched picker still records its pick. @@ -131,9 +145,16 @@ export function LevelUpModal({ character, onApply, onClose }: { ? plan.hpGainAverage : hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod); + // Ability increases go through the build so the sheet's per-source breakdown + // stays in sync with the totals (the build is the single source of truth). let abilities = character.abilities; - if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e'); - if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e'); + let abilityBuild = character.abilityBuild; + if (asi && asiMode === 'asi') { + ({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, asiPicks, '5e', `ASI L${plan.nextLevel}`)); + } + if (boosts) { + ({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, boostPicks, 'pf2e', `L${plan.nextLevel} boost`)); + } // Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry. const subFromChoice = Object.entries(choicePicks) @@ -156,6 +177,7 @@ export function LevelUpModal({ character, onApply, onClose }: { const patch: Partial = { hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain }, abilities, + ...(abilityBuild ? { abilityBuild } : {}), classes: nextClassesFinal, ...normalizeClassMirror({ classes: nextClassesFinal }), choices: merged, @@ -180,6 +202,31 @@ export function LevelUpModal({ character, onApply, onClose }: { const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained'; patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) }; } + // 5e Expertise picks aren't just recorded — they raise the skill rank to expert + // so the doubled proficiency actually lands in the math. + const expertisePicks = Object.entries(choicePicks).filter(([k, v]) => k.endsWith(':expertise') && v.some((x) => x.trim())); + if (expertisePicks.length) { + const ranks: Record = { ...(patch.skillRanks ?? character.skillRanks) as Record }; + for (const [, vals] of expertisePicks) { + for (const label of vals) { + const want = label.trim().toLowerCase(); + const key = sys.skills.find((s) => s.label.toLowerCase() === want || s.key === want)?.key; + if (key) ranks[key] = 'expert'; + } + } + patch.skillRanks = ranks; + } + // 5e: each level grants a Hit Die — keep the tracked resource in step. + if (is5e) { + const newTotal = Math.min(20, total + 1); + const i = character.resources.findIndex((r) => r.name.trim().toLowerCase() === 'hit dice'); + patch.resources = i >= 0 + ? character.resources.map((r, j) => (j === i ? { ...r, max: r.max + 1, current: Math.min(r.max + 1, r.current + 1), recoverStep: Math.ceil(newTotal / 2) } : r)) + : [...character.resources, hitDiceResource(newTotal)]; + } + if (asi && asiMode === 'feat' && featPick) { + patch.feats = [...character.feats, featPick]; + } onApply(patch); onClose(); }; @@ -262,10 +309,18 @@ export function LevelUpModal({ character, onApply, onClose }: { pick the same twice for +2 ) : ( -

Add your chosen feat on the sheet after leveling.

+
+ {featPick ? ( + {featPick.name} + ) : ( + No feat chosen yet. + )} + +
)}
)} + {featBrowse && setFeatPick(f)} onClose={() => setFeatBrowse(false)} />} {/* Boosts (pf2e) */} {boosts && ( @@ -335,7 +390,14 @@ export function LevelUpModal({ character, onApply, onClose }: { {choice.options.map((o) => )} ) : ( - setChoicePick(choice.key, i, e.target.value)} /> + setChoicePick(choice.key, i, v)} + suggestions={/-feat$/.test(choice.key.split(':')[1] ?? '') ? pf2eFeatNames : []} + /> ) ))} @@ -352,3 +414,43 @@ export function LevelUpModal({ character, onApply, onClose }: { ); } + +/** Free-text input with a lightweight suggestion dropdown (used for PF2e feat names). */ +function SuggestInput({ value, onChange, suggestions, ariaLabel, placeholder }: { + value: string; + onChange: (v: string) => void; + suggestions: string[]; + ariaLabel: string; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + const q = value.trim().toLowerCase(); + const matches = q.length >= 2 ? suggestions.filter((n) => n.toLowerCase().includes(q) && n !== value).slice(0, 8) : []; + return ( +
+ { onChange(e.target.value); setOpen(true); }} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + /> + {open && matches.length > 0 && ( +
+ {matches.map((n) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/features/characters/sheet/ResourcesSection.tsx b/src/features/characters/sheet/ResourcesSection.tsx index 83dd923..f6dbd84 100644 --- a/src/features/characters/sheet/ResourcesSection.tsx +++ b/src/features/characters/sheet/ResourcesSection.tsx @@ -17,6 +17,17 @@ const RECOVERY_LABEL: Record = { none: 'Manual', }; +/** Tooltip spelling out EVERYTHING the rest does, not just resource tags. */ +function restTitle(system: string, optId: string, recovers: readonly string[]): string { + if (system === 'pf2e' && optId === 'rest') { + return 'Rest for the Night: restore HP (capped by Drained), refill spell slots, clear Wounded, reduce Drained and Doomed by 1, remove Fatigued.'; + } + if (system === 'pf2e' && optId === 'refocus') return 'Refocus (10 min): regain 1 Focus Point.'; + if (optId === 'long') return 'Long rest: restore HP, spell slots, and long-rest resources (Hit Dice recover half your level); exhaustion −1; death saves reset.'; + if (optId === 'short') return 'Short rest: regain pact slots and short-rest resources; spend Hit Dice to heal.'; + return `Restore: ${recovers.join(', ')}`; +} + export function ResourcesSection({ c, update }: SectionProps) { const [name, setName] = useState(''); const sys = getSystem(c.system); @@ -42,7 +53,7 @@ export function ResourcesSection({ c, update }: SectionProps) { actions={
{sys.restOptions.map((o) => ( - ))} diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index f289c33..5068ae8 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -23,7 +23,7 @@ import { createRng } from '@/lib/rng'; import { rollDice } from '@/lib/dice/notation'; import { getSystem, getConditions, type SystemId } from '@/lib/rules'; import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget'; -import { deriveState, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics'; +import { deriveState, deriveEffectiveMaxHp, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics'; import { useCharacters, useAllPcs } from '@/features/characters/hooks'; import { useConditionGlossary } from './useConditionGlossary'; import { @@ -82,6 +82,9 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter */ const noteConcentrationCheck = (combatant: Combatant, damage: number) => { if (damage <= 0) return; + // 5e only: damage forces a Con save to hold concentration. PF2e has no such save — + // sustained spells simply require the Sustain action, so there is nothing to roll. + if (campaign.system !== '5e') return; // The GM-set combatant flag works for any creature (monster/NPC/PC); fall back to // the linked Character's concentration (set when a seated player casts a spell). const ch = pcCharacter(combatant); @@ -92,12 +95,29 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter logEvent(e, `${combatant.name}: roll a DC ${dc} Constitution save or lose concentration on ${spellName}.`)); }; + /** Effective max HP for a combatant — drained / exhaustion 4 reduce it. */ + const effMaxOf = (c: Combatant) => deriveEffectiveMaxHp({ + system: campaign.system, + baseMaxHp: c.hp.max, + level: c.level ?? 1, + exhaustion: c.conditions.find((x) => x.name.trim().toLowerCase() === 'exhaustion')?.value ?? 0, + conditions: c.conditions, + }).max; + /** Advance the turn; remind (don't roll) when a downed PC's turn begins. */ const advanceTurn = () => { mutate((e) => nextTurn(e, campaign.system)); const upcoming = currentCombatant(nextTurn(encounter, campaign.system)); if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) { - void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`)); + const reminder = campaign.system === 'pf2e' + ? (() => { + const dying = pcCharacter(upcoming)?.defenses.dying ?? 0; + return dying > 0 + ? `${upcoming.name} is dying — roll a flat recovery check (DC ${10 + dying}) on their sheet.` + : `${upcoming.name} is down — use “Knock out” on their sheet to start recovery checks.`; + })() + : `${upcoming.name} is down — make a death saving throw.`; + void encountersRepo.mutate(encounter.id, (e) => logEvent(e, reminder)); } }; @@ -246,11 +266,17 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter ? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage` : `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`; // Surface (don't auto-apply) the death rules for downed PCs — the player - // owns their character's death-save count, so we remind rather than mutate it. + // owns their character's death state, so we remind rather than mutate it. let reminder: string | null = null; if (c.kind !== 'monster' && hpLoss > 0) { - if (isMassiveDamageDeath(c.hp.max, after.hp.current)) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`; - else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`; + if (campaign.system === 'pf2e') { + if (c.hp.current <= 0) reminder = `${c.name} took damage while dying — increase Dying by 1 (2 on a critical hit).`; + else if (after.hp.current <= 0) reminder = `${c.name} drops to 0 HP — use “Knock out” on their sheet (Dying 1 + Wounded; 2 on a crit).`; + } else { + // Massive damage compares against the (possibly reduced) effective max. + if (isMassiveDamageDeath(effMaxOf(c), after.hp.current)) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`; + else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`; + } } mutate((e) => { let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note); @@ -259,7 +285,20 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter }); noteConcentrationCheck(c, hpLoss); }} - onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))} + onHeal={(amt) => { + // Cap at the effective max (drained / exhaustion 4), and for pf2e let + // healing above 0 do the wake-up bookkeeping (Dying ends → Wounded +1). + const healed = applyHealing(c, amt, effMaxOf(c)); + const woke = campaign.system === 'pf2e' && c.hp.current <= 0 && healed.hp.current > 0; + const conditions = woke + ? c.conditions.filter((x) => !['unconscious', 'dying'].includes(x.name.trim().toLowerCase())) + : c.conditions; + mutate((e) => { + let next = logEvent(updateCombatant(e, c.id, { hp: healed.hp, ...(woke ? { conditions } : {}) }), `${c.name} heals ${amt}`); + if (woke) next = logEvent(next, `${c.name} is back up — Dying ends (increase Wounded by 1 on their sheet) and they wake.`); + return next; + }); + }} onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))} onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))} /> @@ -489,7 +528,11 @@ function CombatantRow({ type="button" onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })} className={cn('rounded p-0.5', c.concentrating ? 'text-accent' : 'text-faint hover:text-muted')} - title={c.concentrating ? `Concentrating on ${c.concentrating} — click to clear` : 'Mark as concentrating (prompts a Con save when damaged)'} + title={c.concentrating + ? `Concentrating on ${c.concentrating} — click to clear` + : system === 'pf2e' + ? 'Mark as sustaining a spell (a reminder, in case they stop Sustaining)' + : 'Mark as concentrating (prompts a Con save when damaged)'} aria-label={c.concentrating ? `${c.name} is concentrating; clear` : `Mark ${c.name} as concentrating`} aria-pressed={!!c.concentrating} > @@ -513,17 +556,41 @@ function CombatantRow({ {c.conditions.length > 0 && (
{c.conditions.map((cond, i) => ( - + {/* Valued conditions (Frightened 2, Drained 3…) step in place — no delete-and-re-add. */} + {cond.value !== undefined && ( + <> + + + + )} + + ))}
)} diff --git a/src/lib/combat/engine.test.ts b/src/lib/combat/engine.test.ts index 8e266d2..2ab52cb 100644 --- a/src/lib/combat/engine.test.ts +++ b/src/lib/combat/engine.test.ts @@ -189,6 +189,13 @@ describe('HP transitions', () => { expect(applyDamage(im, 10, 'fire').hp.current).toBe(30); // immune to all → no change expect(applyDamage(im, 10, 'cold').hp.current).toBe(30); }); + + it('healing honors a reduced effective-max cap (drained / exhaustion 4)', () => { + const c = { ...mk('a', 0, 30), hp: { current: 10, max: 30, temp: 0 } }; + expect(applyHealing(c, 25, 20).hp.current).toBe(20); // capped at effective max + expect(applyHealing(c, 25).hp.current).toBe(30); // no cap → stored max + expect(applyHealing(c, 5, 40).hp.current).toBe(15); // cap never exceeds stored max + }); }); describe('endEncounter', () => { diff --git a/src/lib/combat/engine.ts b/src/lib/combat/engine.ts index 4b6884e..0866b73 100644 --- a/src/lib/combat/engine.ts +++ b/src/lib/combat/engine.ts @@ -267,10 +267,15 @@ export function isMassiveDamageDeath(hpMax: number, currentAfter: number): boole return currentAfter <= 0 && -currentAfter >= hpMax; } -/** Heal up to max. Does not touch temporary HP. */ -export function applyHealing(c: Combatant, amount: number): Combatant { +/** + * Heal up to max. Does not touch temporary HP. `capMax` overrides the cap when the + * effective maximum is reduced by conditions (5e exhaustion 4, pf2e drained) — pass + * `deriveEffectiveMaxHp(...).max` so healing can't exceed the reduced maximum. + */ +export function applyHealing(c: Combatant, amount: number, capMax?: number): Combatant { if (!Number.isFinite(amount) || amount <= 0) return c; - return { ...c, hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + amount) } }; + const cap = capMax !== undefined ? Math.min(capMax, c.hp.max) : c.hp.max; + return { ...c, hp: { ...c.hp, current: Math.min(cap, c.hp.current + amount) } }; } /** Set temporary HP — 5e/PF2e temp HP does not stack; take the higher value. */ diff --git a/src/lib/io/pathbuilder.ts b/src/lib/io/pathbuilder.ts index a69a236..201e314 100644 --- a/src/lib/io/pathbuilder.ts +++ b/src/lib/io/pathbuilder.ts @@ -1,4 +1,5 @@ import type { Character } from '@/lib/schemas'; +import { synthManualBuild } from '@/lib/rules/abilityBuild'; /** Convert a Pathbuilder 2e export (build JSON) into our PF2e character fields. */ @@ -43,10 +44,14 @@ export function pathbuilderToCharacterFields(b: PathbuilderBuild): Partial (value !== undefined ? { name, value } : { name }); @@ -50,6 +50,16 @@ describe('applyRecovery', () => { }); }); +describe('heroPointRescue', () => { + it('spends all hero points to drop to dying 0 and increases wounded', () => { + expect(heroPointRescue({ dying: 3, heroPoints: 2, wounded: 1 })).toEqual({ dying: 0, heroPoints: 0, wounded: 2 }); + }); + it('returns null when not dying or no points to spend', () => { + expect(heroPointRescue({ dying: 0, heroPoints: 2, wounded: 0 })).toBeNull(); + expect(heroPointRescue({ dying: 2, heroPoints: 0, wounded: 0 })).toBeNull(); + }); +}); + describe('pf2eRestDecay', () => { it('decays doomed, clears wounded, decrements/removes drained, ends fatigued', () => { const r = pf2eRestDecay({ doomed: 2, wounded: 3 }, [cond('Drained', 2), cond('Fatigued'), cond('Frightened', 1)]); diff --git a/src/lib/mechanics/dying.ts b/src/lib/mechanics/dying.ts index 35b9586..9acd263 100644 --- a/src/lib/mechanics/dying.ts +++ b/src/lib/mechanics/dying.ts @@ -45,6 +45,16 @@ export function applyRecovery(d: Pick, degree: De return { dying, wounded }; } +/** + * Heroic Recovery: spend ALL remaining Hero Points (minimum 1) to avoid death — + * dying drops to 0 and you stabilize (you stay unconscious at 0 HP until healed). + * Losing the dying condition always increases wounded by 1. + */ +export function heroPointRescue(d: Pick): { dying: number; heroPoints: number; wounded: number } | null { + if (d.heroPoints < 1 || d.dying <= 0) return null; + return { dying: 0, heroPoints: 0, wounded: d.wounded + 1 }; +} + /** * Condition decay from a full night's rest (Rest for the Night): doomed −1, wounded * cleared (you wake at full HP), Drained −1, and Fatigued removed. Dying is assumed diff --git a/src/lib/mechanics/index.ts b/src/lib/mechanics/index.ts index 973079d..58e6798 100644 --- a/src/lib/mechanics/index.ts +++ b/src/lib/mechanics/index.ts @@ -18,7 +18,7 @@ export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefens export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast'; export { spendResource, regainResource } from './resources'; export { rollDeathSave, type DeathSaveOutcome } from './deathSaves'; -export { maxDying, isDead, recoveryDc, knockOut, applyRecovery, pf2eRestDecay } from './dying'; +export { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue, pf2eRestDecay } from './dying'; export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects'; export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState'; diff --git a/src/lib/rules/abilityBuild.test.ts b/src/lib/rules/abilityBuild.test.ts index f8ec87a..6b011bd 100644 --- a/src/lib/rules/abilityBuild.test.ts +++ b/src/lib/rules/abilityBuild.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal } from './abilityBuild'; +import { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, appendLevelIncreases } from './abilityBuild'; import type { AbilityBuild } from '@/lib/schemas/character'; const base10 = { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }; @@ -66,3 +66,36 @@ describe('setManualTotal', () => { expect(computeAbilities(reset).str).toBe(12); }); }); + +describe('appendLevelIncreases', () => { + it('5e ASI picks append as flat +1 each and totals match', () => { + const build: AbilityBuild = { base: { ...base10, str: 15 }, adjustments: [{ label: 'Race', ability: 'str', kind: 'flat', amount: 2 }] }; + const current = computeAbilities(build); // str 17 + const r = appendLevelIncreases(build, current, ['str', 'str'], '5e', 'ASI L4'); + expect(r.abilities.str).toBe(19); + expect(computeAbilities(r.build)).toEqual(r.abilities); // breakdown stays in sync + expect(r.build.adjustments.filter((a) => a.label === 'ASI L4')).toHaveLength(2); + }); + + it('pf2e boosts append with the <18 (+2) / ≥18 (+1) rule', () => { + const build: AbilityBuild = { base: base10, adjustments: [ + { label: 'Ancestry', ability: 'str', kind: 'boost', amount: 0 }, + { label: 'Class', ability: 'str', kind: 'boost', amount: 0 }, + { label: 'Free', ability: 'str', kind: 'boost', amount: 0 }, + ] }; + const current = computeAbilities(build); // str 16 + const r = appendLevelIncreases(build, current, ['str', 'dex'], 'pf2e', 'L5 boost'); + expect(r.abilities.str).toBe(18); // 16 → +2 + expect(r.abilities.dex).toBe(12); + const r2 = appendLevelIncreases(r.build, r.abilities, ['str'], 'pf2e', 'L10 boost'); + expect(r2.abilities.str).toBe(19); // 18 → +1 + expect(computeAbilities(r2.build)).toEqual(r2.abilities); + }); + + it('synthesizes a manual build when the stored one is stale', () => { + const stale: AbilityBuild = { base: base10, adjustments: [] }; // says 10s + const current = { ...base10, str: 16 }; // but totals were edited elsewhere + const r = appendLevelIncreases(stale, current, ['str'], '5e', 'ASI L4'); + expect(r.abilities.str).toBe(17); // 16 + 1, not 11 + }); +}); diff --git a/src/lib/rules/abilityBuild.ts b/src/lib/rules/abilityBuild.ts index 48e4cda..a226e13 100644 --- a/src/lib/rules/abilityBuild.ts +++ b/src/lib/rules/abilityBuild.ts @@ -1,4 +1,4 @@ -import type { AbilityKey, AbilityScores } from './types'; +import type { AbilityKey, AbilityScores, SystemId } from './types'; import { ABILITY_KEYS } from './types'; import type { AbilityBuild } from '@/lib/schemas/character'; @@ -69,3 +69,31 @@ export function setManualTotal(build: AbilityBuild, ability: AbilityKey, target: export function setBuildBase(build: AbilityBuild, base: AbilityScores): AbilityBuild { return { base: { ...base }, adjustments: build.adjustments }; } + +function sameScores(a: AbilityScores, b: AbilityScores): boolean { + return ABILITY_KEYS.every((k) => a[k] === b[k]); +} + +/** + * Append level-up ability increases to a build so the per-source breakdown stays in + * sync with the totals (a 5e ASI pick is flat +1; a PF2e pick is a boost). If the + * stored build no longer reproduces `current` (legacy/imported character), a manual + * build is synthesized from `current` first so nothing jumps. Callers patch BOTH + * `abilities` and `abilityBuild` from the result. + */ +export function appendLevelIncreases( + build: AbilityBuild | undefined, + current: AbilityScores, + picks: readonly AbilityKey[], + system: SystemId, + label: string, +): { build: AbilityBuild; abilities: AbilityScores } { + const base = build && sameScores(computeAbilities(build), current) ? build : synthManualBuild(current); + const added = picks.map((ability) => ( + system === 'pf2e' + ? { label, ability, kind: 'boost' as const, amount: 0 } + : { label, ability, kind: 'flat' as const, amount: 1 } + )); + const next: AbilityBuild = { base: { ...base.base }, adjustments: [...base.adjustments, ...added] }; + return { build: next, abilities: computeAbilities(next) }; +} diff --git a/src/lib/rules/depth.test.ts b/src/lib/rules/depth.test.ts index 3fcef46..8050d03 100644 --- a/src/lib/rules/depth.test.ts +++ b/src/lib/rules/depth.test.ts @@ -106,4 +106,20 @@ describe('applyRest', () => { // no concentration → no concentration key in the patch expect('concentration' in applyRest(makeCharacter(), short)).toBe(false); }); + + it('long rest caps restored HP at the effective max while exhaustion stays 4+', () => { + const c = { ...makeCharacter(), defenses: { ...makeCharacter().defenses, exhaustion: 5 } }; + const long = dnd5e.restOptions.find((o) => o.id === 'long')!; + const patch = applyRest(c, long); + // exhaustion 5 → 4 after the rest; max HP still halved (30 → 15) + expect(patch.defenses!.exhaustion).toBe(4); + expect(patch.hp!.current).toBe(15); + }); + + it('a per-resource recoverStep restores by that amount, not to max (Hit Dice)', () => { + const base = makeCharacter(); + const c = { ...base, resources: [{ id: 'hd', name: 'Hit Dice', current: 0, max: 5, recovery: 'long' as const, recoverStep: 3 }] }; + const long = dnd5e.restOptions.find((o) => o.id === 'long')!; + expect(applyRest(c, long).resources![0]!.current).toBe(3); // 0 + ceil(5/2)=3, not 5 + }); }); diff --git a/src/lib/rules/index.ts b/src/lib/rules/index.ts index e8a0961..c30c7ff 100644 --- a/src/lib/rules/index.ts +++ b/src/lib/rules/index.ts @@ -20,7 +20,7 @@ export * from './types'; export * from './progression'; export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities'; export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities'; -export { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, type AbilityBreakdown } from './abilityBuild'; +export { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, appendLevelIncreases, type AbilityBreakdown } from './abilityBuild'; export { applyRest } from './rest'; export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions'; export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e, subclassPrompt } from './features/collect'; diff --git a/src/lib/rules/progression.ts b/src/lib/rules/progression.ts index 5f1f7b5..30dd689 100644 --- a/src/lib/rules/progression.ts +++ b/src/lib/rules/progression.ts @@ -1,5 +1,6 @@ import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types'; import { abilityModifier } from './abilities'; +import { newId } from '@/lib/ids'; import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression'; import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression'; @@ -57,6 +58,12 @@ export interface BuiltCharacter { spellcastingAbility?: SpellAbility; spellcastingRank?: ProficiencyRank; spellcasting: { slots: SpellSlot[]; pact?: SpellSlot; spells: never[] }; + resources: { id: string; name: string; current: number; max: number; recovery: 'short' | 'long' | 'daily' | 'none'; recoverStep?: number }[]; +} + +/** 5e Hit Dice as a tracked resource: one die per level; a long rest returns half (2014 PHB). */ +export function hitDiceResource(level: number): BuiltCharacter['resources'][number] { + return { id: newId(), name: 'Hit Dice', current: level, max: level, recovery: 'long', recoverStep: Math.ceil(level / 2) }; } const TABLES: Record = { '5e': DND5E_CLASSES, pf2e: PF2E_CLASSES }; @@ -123,6 +130,8 @@ export function buildCharacter(system: SystemId, choices: BuildChoices): BuiltCh perceptionRank: system === 'pf2e' ? def?.perception ?? 'trained' : 'trained', ...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}), spellcasting: { slots, ...(pact ? { pact } : {}), spells: [] }, + // 5e characters track Hit Dice from day one; PF2e has no equivalent pool. + resources: system === '5e' ? [hitDiceResource(choices.level)] : [], }; } @@ -159,7 +168,11 @@ export function planLevelUp(system: SystemId, className: string, currentLevel: n if (dnd5eAsiLevels(className).includes(nextLevel)) { choices.push({ kind: 'asi', label: 'Ability Score Improvement — raise abilities by +2 total, or take a feat instead.' }); } - if (slots.length) notes.push('Spell slots updated for the new level.'); + // Proficiency bonus thresholds (PHB): +3 at 5, +4 at 9, +5 at 13, +6 at 17. + if ([5, 9, 13, 17].includes(nextLevel)) { + notes.push(`Proficiency bonus increases to +${2 + Math.floor((nextLevel - 1) / 4)} — attacks, saves, and trained skills all improve.`); + } + if (slots.length) notes.push('Spell slots updated — add any newly learned/prepared spells in the Spellcasting section.'); return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), ...(pact ? { pact } : {}), notes, choices }; } @@ -172,8 +185,10 @@ export function planLevelUp(system: SystemId, className: string, currentLevel: n if (pf2eSkillIncreaseLevels().includes(nextLevel)) { choices.push({ kind: 'skill-increase', label: 'Skill increase — raise one skill to the next proficiency rank.' }); } - if (nextLevel % 2 === 0) choices.push({ kind: 'feat', label: 'Class feat — pick a new class feat for this level.' }); - if (slots.length) notes.push('Spell slots updated for the new level.'); + // (Feat slots are enumerated per type — class/skill/general/ancestry — by collectChoices, + // which the level-up modal renders with real owed counts; no duplicate entry here.) + notes.push('Proficiency rises with your level — every trained statistic improves by 1.'); + if (slots.length) notes.push('Spell slots updated — add any newly learned/prepared spells in the Spellcasting section.'); return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), notes, choices }; } diff --git a/src/lib/rules/rest.ts b/src/lib/rules/rest.ts index c4cb314..c383cf8 100644 --- a/src/lib/rules/rest.ts +++ b/src/lib/rules/rest.ts @@ -1,6 +1,7 @@ import type { Character } from '@/lib/schemas/character'; import type { RestOption } from './types'; import { pf2eRestDecay } from '@/lib/mechanics/dying'; +import { deriveEffectiveMaxHp } from '@/lib/mechanics/creatureState'; /** * Compute the character changes a rest produces. Pure — returns a partial patch @@ -16,15 +17,33 @@ export function applyRest(c: Character, opt: RestOption): Partial { patch.hp = { ...c.hp, current: c.hp.max }; } + // Post-rest condition state, for capping restored HP at the EFFECTIVE max: a long + // rest steps 5e exhaustion down by 1 (level 4+ halves max); a pf2e night's rest + // decays drained by 1 (each remaining point still costs level HP off the max). + const restedExhaustion = opt.reduceExhaustion && c.defenses.exhaustion > 0 ? c.defenses.exhaustion - 1 : c.defenses.exhaustion; + const pf2eDecayed = c.system === 'pf2e' && opt.restoresHp ? pf2eRestDecay(c.defenses, c.conditions) : null; + if (opt.restoresHp && patch.hp) { + const eff = deriveEffectiveMaxHp({ + system: c.system, + baseMaxHp: c.hp.max, + level: c.level, + exhaustion: restedExhaustion, + conditions: pf2eDecayed ? pf2eDecayed.conditions : c.conditions, + }); + patch.hp = { ...patch.hp, current: Math.min(patch.hp.current, eff.max) }; + } + // Any rest ends ongoing concentration (you stop holding the spell to rest). if (c.concentration) patch.concentration = null; - // Resources refresh when their recovery tag is covered by this rest. A rest with - // `recoverStep` (PF2e Refocus) restores by that step, clamped to max, rather than - // refilling the whole pool — so Refocus returns one Focus Point, not all of them. + // Resources refresh when their recovery tag is covered by this rest. A step — + // per-resource (5e Hit Dice recover ceil(level/2)) or per-rest (PF2e Refocus + // returns one Focus Point) — restores by that amount, clamped to max; otherwise + // the pool refills entirely. patch.resources = c.resources.map((r) => { if (!opt.recovers.includes(r.recovery)) return r; - const next = opt.recoverStep !== undefined ? Math.min(r.max, r.current + opt.recoverStep) : r.max; + const step = r.recoverStep ?? opt.recoverStep; + const next = step !== undefined ? Math.min(r.max, r.current + step) : r.max; return { ...r, current: next }; }); @@ -47,23 +66,17 @@ export function applyRest(c: Character, opt: RestOption): Partial { // pf2e: a full night's rest decays doomed (−1) and drained (−1), clears wounded // (you wake at full HP) and ends fatigued. Dying is assumed already 0. if (c.system === 'pf2e') { - if (opt.restoresHp) { - const decay = pf2eRestDecay(c.defenses, c.conditions); - patch.defenses = { ...c.defenses, ...decay.defenses }; - patch.conditions = decay.conditions; + if (pf2eDecayed) { + patch.defenses = { ...c.defenses, ...pf2eDecayed.defenses }; + patch.conditions = pf2eDecayed.conditions; } return patch; } - const exhaustion = - opt.reduceExhaustion && c.defenses.exhaustion > 0 - ? c.defenses.exhaustion - 1 - : c.defenses.exhaustion; - - if (opt.restoresHp || exhaustion !== c.defenses.exhaustion) { + if (opt.restoresHp || restedExhaustion !== c.defenses.exhaustion) { patch.defenses = { ...c.defenses, - exhaustion, + exhaustion: restedExhaustion, // recovering from 0 HP clears death saves (5e) ...(opt.restoresHp ? { deathSaves: { successes: 0, failures: 0 } } : {}), }; diff --git a/src/lib/rules/rules.test.ts b/src/lib/rules/rules.test.ts index 7a220cb..161ef9b 100644 --- a/src/lib/rules/rules.test.ts +++ b/src/lib/rules/rules.test.ts @@ -168,3 +168,24 @@ describe('pf2e Refocus', () => { expect(patch.resources?.[0]?.current).toBe(1); // +1, clamped to max — not 3 }); }); + +describe('pf2e Rest for the Night', () => { + it('decays drained/doomed, clears wounded/fatigued, and caps HP at the post-decay effective max', () => { + const rest = pf2e.restOptions.find((o) => o.id === 'rest')!; + const c = { + system: 'pf2e', level: 6, + resources: [], + hp: { current: 5, max: 50, temp: 0 }, + concentration: null, + spellcasting: { slots: [], spells: [] }, + conditions: [{ name: 'Drained', value: 2 }, { name: 'Fatigued' }], + defenses: { exhaustion: 0, deathSaves: { successes: 0, failures: 0 }, dying: 0, wounded: 2, doomed: 1, heroPoints: 1, inspiration: false }, + } as unknown as Parameters[0]; + const patch = applyRest(c, rest); + expect(patch.conditions).toEqual([{ name: 'Drained', value: 1 }]); // drained −1, fatigued gone + expect(patch.defenses!.wounded).toBe(0); + expect(patch.defenses!.doomed).toBe(0); + // drained 1 remains after decay → effective max 50 − 6 = 44, not 50 + expect(patch.hp!.current).toBe(44); + }); +}); diff --git a/src/lib/schemas/character.ts b/src/lib/schemas/character.ts index 9bafadc..1805bc8 100644 --- a/src/lib/schemas/character.ts +++ b/src/lib/schemas/character.ts @@ -34,6 +34,9 @@ export const resourceSchema = z.object({ current: int.min(0).default(0), max: int.min(0).default(0), recovery: recoverySchema.default('long'), + /** How much a matching rest restores. Omitted = refill to max. 5e Hit Dice use + * ceil(level/2) — a long rest returns only half your level in dice (2014 PHB). */ + recoverStep: int.positive().optional(), }); export type CharacterResource = z.infer; @@ -173,6 +176,8 @@ export const characterSchema = z.object({ portrait: z.string().optional(), /** ancestry (PF2e) / race (5e), free text for MVP */ ancestry: z.string().max(80).default(''), + /** PF2e heritage (chosen at level 1, refines the ancestry); '' for 5e. */ + heritage: z.string().max(80).default(''), /** Primary class name — a mirror of classes[0].className (kept for display/back-compat). */ className: z.string().max(80).default(''), /** Total character level — a mirror of sum(classes[].level). */ @@ -277,12 +282,13 @@ export type CharacterDraft = z.infer; /** Default values for the Phase-1 depth fields. Used by create + DB migration. */ export function characterDefaults(): Pick< Character, - 'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'alignment' | 'appearance' | 'personality' | 'classes' | 'choices' + 'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'heritage' | 'alignment' | 'appearance' | 'personality' | 'classes' | 'choices' > { return { classes: [], choices: [], background: '', + heritage: '', alignment: '', appearance: '', personality: '', From 0892b8f19c31f5f09a55519b388b45a8efc7971a Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 20:37:14 +0200 Subject: [PATCH 2/3] Backend security hardening for a small public server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit-driven; goal: one user can't exhaust the box or starve the shared Traefik stack. Resource containment: - compose: mem_limit 512m, memswap_limit, pids_limit 200, cpus 1.0 (blast radius = container) - Enforce per-user cloud quota (default 30MB, admin override) on /api/save + /api/characters → 413 - Cap published-character size (2MB); cap rooms (200), images/room (40), image bytes/room (40MB) - Cap WS image dataUrl length in the wire schema; per-IP WS connection cap (20) - MAX_USERS registration ceiling → 503 when full Availability + correctness: - Async crypto.scrypt (was scryptSync) so password hashing can't freeze the event loop; constant-time on unknown users to avoid username probing - trustProxy so req.ip is the real client behind Traefik — rate limits are per-IP, not global - Tighter auth-endpoint bucket (10/min) on /register + /login; prune stale rate buckets - O(1) token->user index; log persist() failures instead of swallowing them - Trim /healthz info; surface quota in /api/usage + the admin dashboard storage bar - Client surfaces 413 (quota) / 429 (rate) clearly Co-Authored-By: Claude Opus 4.8 --- deploy/ttrpg.compose.yml | 10 ++++ server/src/accounts.test.ts | 18 ++++++ server/src/accounts.ts | 60 ++++++++++++++----- server/src/campaigns.test.ts | 15 ++++- server/src/campaigns.ts | 22 ++++++- server/src/index.ts | 73 +++++++++++++++++++----- server/src/rooms.test.ts | 25 ++++++++ server/src/rooms.ts | 32 ++++++++++- src/features/settings/CloudCampaigns.tsx | 20 ++++++- src/lib/cloud/campaigns.ts | 4 +- src/lib/cloud/client.ts | 2 + src/lib/sync/messages.ts | 4 +- 12 files changed, 244 insertions(+), 41 deletions(-) diff --git a/deploy/ttrpg.compose.yml b/deploy/ttrpg.compose.yml index 0710707..aa55066 100644 --- a/deploy/ttrpg.compose.yml +++ b/deploy/ttrpg.compose.yml @@ -17,10 +17,20 @@ services: - ALLOWED_ORIGINS=https://ttrpg.briggen.dev # comma-separated usernames that can see the admin panel (set to your account) - ADMIN_USERS=nilsb + # default per-user cloud storage cap (bytes); admins override per user in the dashboard + - DEFAULT_QUOTA_BYTES=31457280 # 30 MB + # hard ceiling on accounts so open registration can't fill the disk + - MAX_USERS=500 volumes: - ttrpg-data:/data security_opt: - no-new-privileges:true + # Hard caps so a runaway session can't starve the host or the shared Traefik + # stack — the blast radius stays this container, then `restart: unless-stopped`. + mem_limit: 512m + memswap_limit: 512m + pids_limit: 200 + cpus: 1.0 labels: - "traefik.enable=true" - "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)" diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index d098127..6af86c2 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -54,4 +54,22 @@ describe('AccountStore', () => { expect(await adminStore.setQuota('ghost', 1)).toBe(false); expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000); }); + + it('quotaFor uses the override when set, else the default', async () => { + const r = await store.register('quotaUser', 'password123'); + if (!r.ok) throw new Error('register failed'); + const u = (await store.userByToken(r.token))!; + const def = store.quotaFor(u); // instance default + expect(def).toBeGreaterThan(0); + await store.setQuota('quotaUser', 99); + expect(store.quotaFor((await store.userByToken(r.token))!)).toBe(99); + }); + + it('refuses registration past the max-users cap', async () => { + const capped = new AccountStore(dir, undefined, [], 1); + expect((await capped.register('first', 'password123')).ok).toBe(true); + const second = await capped.register('second', 'password123'); + expect(second.ok).toBe(false); + expect((second as { code: string }).code).toBe('closed'); + }); }); diff --git a/server/src/accounts.ts b/server/src/accounts.ts index 9653c8b..c774a20 100644 --- a/server/src/accounts.ts +++ b/server/src/accounts.ts @@ -1,7 +1,12 @@ import crypto from 'node:crypto'; +import { promisify } from 'node:util'; import { promises as fs } from 'node:fs'; import path from 'node:path'; +// Async scrypt so password hashing runs on the threadpool, NOT the single event +// loop — a burst of logins can't freeze the whole server (scryptSync did). +const scryptAsync = promisify(crypto.scrypt) as (pw: crypto.BinaryLike, salt: crypto.BinaryLike, keylen: number) => Promise; + /** * Lean file-backed accounts + cloud-backup store. No external DB — a single * users.json plus one blob file per user under DATA_DIR. Passwords are scrypt- @@ -23,9 +28,11 @@ interface User { const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/; const MAX_TOKENS = 10; +/** Default per-user cloud storage cap when no admin override is set. */ +export const DEFAULT_QUOTA_BYTES = Number(process.env.DEFAULT_QUOTA_BYTES) || 30 * 1024 * 1024; function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); } -function hashPassword(password: string, salt: string): string { return crypto.scryptSync(password, salt, 64).toString('hex'); } +async function hashPassword(password: string, salt: string): Promise { return (await scryptAsync(password, salt, 64)).toString('hex'); } function timingEqual(a: string, b: string): boolean { const ab = Buffer.from(a), bb = Buffer.from(b); return ab.length === bb.length && crypto.timingSafeEqual(ab, bb); @@ -37,15 +44,22 @@ export interface AuthError { ok: false; code: string; message: string } export class AccountStore { private users = new Map(); // key: lowercased username private byId = new Map(); + private byToken = new Map(); // sha256(token) → user, for O(1) auth lookups private queue: Promise = Promise.resolve(); private loaded = false; - constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = []) {} + constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = [], private maxUsers = Infinity) {} private usersFile() { return path.join(this.dir, 'users.json'); } private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); } isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); } + /** This user's storage limit: their admin override, else the instance default. */ + quotaFor(u: User): number { return u.quotaBytes && u.quotaBytes > 0 ? u.quotaBytes : DEFAULT_QUOTA_BYTES; } + + /** Whether a new account can be created (registration is open under the cap). */ + atCapacity(): boolean { return this.users.size >= this.maxUsers; } + async listUsers(): Promise> { await this.load(); return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) })); @@ -72,8 +86,15 @@ export class AccountStore { try { const raw = await fs.readFile(this.usersFile(), 'utf8'); const arr = JSON.parse(raw) as User[]; - for (const u of arr) { this.users.set(u.username.toLowerCase(), u); this.byId.set(u.id, u); } - } catch { /* no file yet */ } + for (const u of arr) { + this.users.set(u.username.toLowerCase(), u); + this.byId.set(u.id, u); + for (const h of u.tokens) this.byToken.set(h, u); + } + } catch (e) { + // ENOENT on first boot is expected; anything else is a real problem worth seeing. + if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[accounts] load failed', e); + } } private persist(): Promise { @@ -82,14 +103,19 @@ export class AccountStore { const tmp = `${this.usersFile()}.tmp`; await fs.writeFile(tmp, JSON.stringify([...this.users.values()])); await fs.rename(tmp, this.usersFile()); - }).catch(() => {}); + }).catch((e) => { console.error('[accounts] persist failed — data may be lost on restart', e); }); return this.queue as Promise; } private issueToken(u: User): string { const token = crypto.randomBytes(32).toString('base64url'); - u.tokens.push(sha256(token)); - if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS); + const h = sha256(token); + u.tokens.push(h); + this.byToken.set(h, u); + if (u.tokens.length > MAX_TOKENS) { + for (const stale of u.tokens.slice(0, u.tokens.length - MAX_TOKENS)) this.byToken.delete(stale); + u.tokens = u.tokens.slice(-MAX_TOKENS); + } u.updatedAt = this.now(); return token; } @@ -99,8 +125,9 @@ export class AccountStore { if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 3–32 letters, digits, . _ or -.' }; if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' }; if (this.users.has(username.toLowerCase())) return { ok: false, code: 'taken', message: 'That username is taken.' }; + if (this.atCapacity()) return { ok: false, code: 'closed', message: 'Registration is temporarily closed (instance at capacity).' }; const salt = crypto.randomBytes(16).toString('hex'); - const u: User = { id: crypto.randomUUID(), username, salt, hash: hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() }; + const u: User = { id: crypto.randomUUID(), username, salt, hash: await hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() }; const token = this.issueToken(u); this.users.set(username.toLowerCase(), u); this.byId.set(u.id, u); @@ -111,7 +138,11 @@ export class AccountStore { async login(username: string, password: string): Promise { await this.load(); const u = this.users.get((username ?? '').toLowerCase()); - if (!u || !timingEqual(u.hash, hashPassword(password ?? '', u.salt))) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' }; + // Hash even when the user is unknown (against a throwaway salt) so the response + // time doesn't reveal whether a username exists. + const salt = u?.salt ?? 'absent'; + const candidate = await hashPassword(password ?? '', salt); + if (!u || !timingEqual(u.hash, candidate)) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' }; const token = this.issueToken(u); await this.persist(); return { ok: true, token, username: u.username }; @@ -120,15 +151,16 @@ export class AccountStore { async userByToken(token: string | undefined): Promise { if (!token) return null; await this.load(); - const h = sha256(token); - for (const u of this.users.values()) if (u.tokens.includes(h)) return u; - return null; + return this.byToken.get(sha256(token)) ?? null; } async logout(token: string): Promise { - const u = await this.userByToken(token); + await this.load(); + const h = sha256(token); + const u = this.byToken.get(h); if (!u) return; - u.tokens = u.tokens.filter((t) => t !== sha256(token)); + u.tokens = u.tokens.filter((t) => t !== h); + this.byToken.delete(h); await this.persist(); } diff --git a/server/src/campaigns.test.ts b/server/src/campaigns.test.ts index 6501b0d..49fd8d4 100644 --- a/server/src/campaigns.test.ts +++ b/server/src/campaigns.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { CloudStore } from './campaigns'; +import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns'; describe('CloudStore', () => { let dir: string; @@ -42,6 +42,19 @@ describe('CloudStore', () => { expect(await store.usageBytes('p1')).toBeGreaterThan(0); }); + it('rejects an oversized character and reports usage deltas', async () => { + const c = await store.createCampaign('gm1', 'C', '5e'); + await store.joinByInvite('p1', c.inviteCode); + const huge = 'x'.repeat(MAX_CHARACTER_BYTES + 1); + expect(await store.putCharacter('p1', c.id, { id: 'big', name: 'Big', data: huge })).toBeNull(); + expect(await store.characterBytes('big')).toBe(0); // nothing stored + + await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":1}' }); + expect(store.ownsCharacter('p1', 'ch1')).toBe(true); + expect(store.ownsCharacter('p2', 'ch1')).toBe(false); + expect(await store.characterBytes('ch1')).toBe(Buffer.byteLength('{"hp":1}')); + }); + it('lets the char owner or campaign owner delete; persists across instances', async () => { const c = await store.createCampaign('gm1', 'C', '5e'); await store.joinByInvite('p1', c.inviteCode); diff --git a/server/src/campaigns.ts b/server/src/campaigns.ts index bc03e21..f6915fa 100644 --- a/server/src/campaigns.ts +++ b/server/src/campaigns.ts @@ -32,6 +32,9 @@ export interface CloudCharacter { } const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +/** A single published character can't exceed this (a sheet with an embedded portrait + * is well under 2 MB); a hard stop before per-user quota even comes into play. */ +export const MAX_CHARACTER_BYTES = 2 * 1024 * 1024; export class CloudStore { private campaigns = new Map(); @@ -50,7 +53,9 @@ export class CloudStore { const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] }; for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); } for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch); - } catch { /* no file yet */ } + } catch (e) { + if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[cloud] load failed', e); + } } private persist(): Promise { @@ -59,7 +64,7 @@ export class CloudStore { const tmp = `${this.file()}.tmp`; await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] })); await fs.rename(tmp, this.file()); - }).catch(() => {}); + }).catch((e) => { console.error('[cloud] persist failed — data may be lost on restart', e); }); return this.queue as Promise; } @@ -140,6 +145,7 @@ export class CloudStore { async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise { await this.load(); if (!this.isMember(campaignId, userId)) return null; + if (Buffer.byteLength(char.data) > MAX_CHARACTER_BYTES) return null; // oversized blob — reject const existing = this.characters.get(char.id); if (existing && existing.ownerUserId !== userId) return null; // only the owner may update const record: CloudCharacter = { id: char.id, campaignId, ownerUserId: existing?.ownerUserId ?? userId, name: char.name.slice(0, 120), data: char.data, updatedAt: this.now() }; @@ -163,6 +169,18 @@ export class CloudStore { return true; } + /** Byte size of one stored character's data (0 if it doesn't exist) — for quota deltas. */ + async characterBytes(characterId: string): Promise { + await this.load(); + const ch = this.characters.get(characterId); + return ch ? Buffer.byteLength(ch.data) : 0; + } + + /** Whether the user already owns a published character with this id. */ + ownsCharacter(userId: string, characterId: string): boolean { + return this.characters.get(characterId)?.ownerUserId === userId; + } + /** Total bytes a user is responsible for (their characters' data) — for usage tracking. */ async usageBytes(userId: string): Promise { await this.load(); diff --git a/server/src/index.ts b/server/src/index.ts index 012b988..eb8f70e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,15 +6,19 @@ import type { FastifyRequest } from 'fastify'; import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; import { RoomHub, type Sender } from './rooms'; import { AccountStore } from './accounts'; -import { CloudStore } from './campaigns'; +import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns'; const PORT = Number(process.env.PORT ?? 8787); const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data')); const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS) -const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs) +const BODY_LIMIT = 32 * 1024 * 1024; // 32 MB (cloud backup blobs; > the per-user quota) const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean); const ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); +const MAX_USERS = Number(process.env.MAX_USERS) || Infinity; +// Concurrent WebSocket connections allowed from one client IP — generous for a +// household sharing a NAT, but a hard stop on socket-spam room/image floods. +const MAX_WS_PER_IP = Number(process.env.MAX_WS_PER_IP) || 20; const bearer = (req: FastifyRequest): string | undefined => { const h = req.headers.authorization; @@ -29,30 +33,49 @@ const RATE = { capacity: 80, refillMs: 10_000 }; const HEARTBEAT_MS = 30_000; export function buildServer() { - const app = Fastify({ bodyLimit: BODY_LIMIT }); + // trustProxy: behind Traefik the socket peer is the proxy, so without this every + // visitor shares one rate-limit bucket. Trust the proxy's X-Forwarded-For so + // req.ip is the real client. Only Traefik can reach the container, so this is safe. + const app = Fastify({ bodyLimit: BODY_LIMIT, trustProxy: true }); const hub = new RoomHub(); - const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS); + const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS, MAX_USERS); void accounts.load(); const cloud = new CloudStore(DATA_DIR); void cloud.load(); - const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000); + const sweeper = setInterval(() => { hub.sweep(); pruneRateBuckets(); }, 5 * 60 * 1000); app.addHook('onClose', async () => clearInterval(sweeper)); - // Simple per-IP throttle for the account/cloud API. + // Per-IP throttle for the account/cloud API. Auth endpoints get a tighter bucket + // since each call does password hashing and is the target of credential-stuffing. const ipHits = new Map(); + const authHits = new Map(); + const AUTH_PATHS = new Set(['/api/register', '/api/login']); + const bump = (map: Map, ip: string, limit: number, windowMs: number): boolean => { + const now = Date.now(); + const e = map.get(ip); + if (!e || now > e.reset) { map.set(ip, { n: 1, reset: now + windowMs }); return true; } + return ++e.n <= limit; + }; + const pruneRateBuckets = () => { + const now = Date.now(); + for (const [ip, e] of ipHits) if (now > e.reset) ipHits.delete(ip); + for (const [ip, e] of authHits) if (now > e.reset) authHits.delete(ip); + }; app.addHook('onRequest', async (req, reply) => { if (!req.url.startsWith('/api/')) return; - const now = Date.now(); - const e = ipHits.get(req.ip); - if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 }); - else if (++e.n > 60) await reply.code(429).send({ error: 'rate' }); + if (!bump(ipHits, req.ip, 60, 60_000)) return reply.code(429).send({ error: 'rate' }); + // Strip the querystring before matching the auth path. + const path0 = req.url.split('?')[0]!; + if (AUTH_PATHS.has(path0) && !bump(authHits, req.ip, 10, 60_000)) { + return reply.code(429).send({ error: 'rate', message: 'Too many attempts — wait a minute and try again.' }); + } }); // ---- accounts + cloud backup sync ---- app.post('/api/register', async (req, reply) => { const { username, password } = (req.body ?? {}) as { username?: string; password?: string }; const r = await accounts.register(String(username ?? ''), String(password ?? '')); - if (!r.ok) return reply.code(400).send({ error: r.code, message: r.message }); + if (!r.ok) return reply.code(r.code === 'closed' ? 503 : 400).send({ error: r.code, message: r.message }); return { token: r.token, username: r.username }; }); app.post('/api/login', async (req, reply) => { @@ -67,6 +90,11 @@ export function buildServer() { if (!u) return reply.code(401).send({ error: 'unauthorized' }); const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {}; if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' }); + // Quota: the backup blob plus the user's published characters must fit their limit. + const projected = Buffer.byteLength(body.blob) + (await cloud.usageBytes(u.id)); + if (projected > accounts.quotaFor(u)) { + return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached. Trim old data or ask the admin to raise your quota.', limit: accounts.quotaFor(u) }); + } // Optimistic concurrency: if the caller names the version it last synced and // the cloud has since moved on (another device pushed), refuse — no silent // overwrite. The client then resolves (pull, or force-push). @@ -121,8 +149,15 @@ export function buildServer() { const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } }; if (!campaignId || !character?.id || typeof character.data !== 'string') return reply.code(400).send({ error: 'bad-request' }); + if (Buffer.byteLength(character.data) > MAX_CHARACTER_BYTES) return reply.code(413).send({ error: 'too-large', message: 'That character is too large to publish.' }); + // Quota: total usage after this upsert (replace the old copy's bytes with the new). + const delta = Buffer.byteLength(character.data) - (cloud.ownsCharacter(u.id, character.id) ? await cloud.characterBytes(character.id) : 0); + const projected = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)) + delta; + if (projected > accounts.quotaFor(u)) { + return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached.', limit: accounts.quotaFor(u) }); + } const r = await cloud.putCharacter(u.id, campaignId, { id: character.id, name: String(character.name ?? ''), data: character.data }); - if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, or you do not own that character.' }); + if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, you do not own that character, or it is too large.' }); return { ok: true, updatedAt: r.updatedAt }; }); app.get('/api/campaigns/:id/characters', async (req, reply) => { @@ -141,7 +176,7 @@ export function buildServer() { app.get('/api/usage', async (req, reply) => { const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)); - return { bytes, admin: accounts.isAdmin(u.username) }; + return { bytes, quota: accounts.quotaFor(u), admin: accounts.isAdmin(u.username) }; }); // ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ---- @@ -166,6 +201,9 @@ export function buildServer() { void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } }); + // Live WebSocket connections per client IP — a hard cap on socket-spam. + const wsPerIp = new Map(); + void app.register(async (instance) => { instance.get('/ws', { websocket: true }, (socket, req) => { // Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured. @@ -174,6 +212,11 @@ export function buildServer() { socket.close(1008, 'origin'); return; } + const ip = req.ip; + const live = wsPerIp.get(ip) ?? 0; + if (live >= MAX_WS_PER_IP) { socket.close(1013, 'too-many'); return; } + wsPerIp.set(ip, live + 1); + const releaseIp = () => { const n = (wsPerIp.get(ip) ?? 1) - 1; if (n <= 0) wsPerIp.delete(ip); else wsPerIp.set(ip, n); }; const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } }; let tokens = RATE.capacity; const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs); @@ -209,11 +252,11 @@ export function buildServer() { case 'setName': hub.setName(sender, m.name); break; } }); - socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); }); + socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); releaseIp(); hub.disconnect(sender); }); }); }); - app.get('/healthz', async () => ({ ok: true, rooms: hub.roomCount() })); + app.get('/healthz', async () => ({ ok: true })); // Serve the built SPA with history fallback (so /play?room=... deep-links work). void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false }); diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index 194e993..f5bcbab 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -92,6 +92,31 @@ describe('RoomHub', () => { expect(hub.roomCount()).toBe(0); }); + it('caps the number of concurrent rooms', () => { + const hub = new RoomHub(() => 0, { maxRooms: 2 }); + const a = fake(); hub.host(a); + const b = fake(); hub.host(b); + expect(hub.roomCount()).toBe(2); + const c = fake(); hub.host(c); + expect(hub.roomCount()).toBe(2); // refused + expect(lastOf(c, 'error')).toMatchObject({ code: 'busy' }); + expect(lastOf(c, 'hosted')).toBeUndefined(); + }); + + it('caps per-room image count and total bytes', () => { + const hub = new RoomHub(() => 0, { maxImagesPerRoom: 2, maxRoomImageBytes: 100 }); + const gm = fake(); hub.host(gm); + const { gmSecret } = lastOf(gm, 'hosted') as Extract; + hub.image(gm, gmSecret, 'm1', 'x'.repeat(40)); + hub.image(gm, gmSecret, 'm2', 'x'.repeat(40)); // total 80 ≤ 100, count 2 ≤ 2 + expect(lastOf(gm, 'error')).toBeUndefined(); + hub.image(gm, gmSecret, 'm3', 'x'.repeat(10)); // 3rd distinct image → count cap + expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' }); + // overwriting an existing id is allowed, but not past the byte budget + hub.image(gm, gmSecret, 'm1', 'x'.repeat(90)); // 90 + 40 = 130 > 100 → rejected + expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' }); + }); + it('routes a seat claim → GM grant → player gets their sheet', () => { const hub = new RoomHub(); const { gm, player, secret } = hostAndJoin(hub); diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 872f86c..4276e12 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -34,6 +34,8 @@ interface Room { players: Set; snapshot: Snapshot | null; images: Map; + /** running total of bytes in `images`, kept so the cap is O(1) to check */ + imageBytes: number; /** granted seats keyed by playerId */ seats: Map; /** seat requests awaiting GM approval (re-sent when the GM reconnects) */ @@ -43,6 +45,13 @@ interface Room { const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle +// Memory guardrails so an anonymous GM can't balloon the (in-RAM) hub on a small box. +export interface RoomLimits { maxRooms: number; maxImagesPerRoom: number; maxRoomImageBytes: number } +const DEFAULT_LIMITS: RoomLimits = { + maxRooms: Number(process.env.MAX_ROOMS) || 200, + maxImagesPerRoom: 40, + maxRoomImageBytes: 40 * 1024 * 1024, // 40 MB of map images per room +}; function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); @@ -62,7 +71,10 @@ export class RoomHub { private rooms = new Map(); private byCode = new Map(); private conns = new Map(); - constructor(private now: () => number = () => Date.now()) {} + private limits: RoomLimits; + constructor(private now: () => number = () => Date.now(), limits: Partial = {}) { + this.limits = { ...DEFAULT_LIMITS, ...limits }; + } private mintJoinCode(): string { for (let attempt = 0; attempt < 50; attempt++) { @@ -90,13 +102,19 @@ export class RoomHub { } } } + // Cap concurrent rooms so socket-spam can't exhaust memory. Idle rooms TTL out; + // a sweep runs first to reclaim any that just expired before we refuse. + if (this.rooms.size >= this.limits.maxRooms) { + this.sweep(); + if (this.rooms.size >= this.limits.maxRooms) { socket.send({ t: 'error', code: 'busy', message: 'The server is at capacity — please try again shortly.' }); return; } + } const roomId = crypto.randomUUID(); const joinCode = this.mintJoinCode(); const gmSecret = crypto.randomBytes(32).toString('base64url'); const room: Room = { roomId, joinCode, gmSecretHash: sha256(gmSecret), passwordHash: password ? sha256(password) : null, - gm: socket, players: new Set(), snapshot: null, images: new Map(), + gm: socket, players: new Set(), snapshot: null, images: new Map(), imageBytes: 0, seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(), }; this.rooms.set(roomId, room); @@ -148,7 +166,17 @@ export class RoomHub { image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void { const room = this.gmRoom(socket, gmSecret); if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; } + // Bound per-room image memory: cap the count of distinct images and the total bytes. + const size = Buffer.byteLength(dataUrl); + const prev = room.images.get(id); + const prevSize = prev ? Buffer.byteLength(prev) : 0; + const isNew = prev === undefined; + if ((isNew && room.images.size >= this.limits.maxImagesPerRoom) || room.imageBytes - prevSize + size > this.limits.maxRoomImageBytes) { + socket.send({ t: 'error', code: 'image-limit', message: 'This session has reached its map-image limit.' }); + return; + } room.images.set(id, dataUrl); + room.imageBytes += size - prevSize; room.lastActivity = this.now(); for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl }); } diff --git a/src/features/settings/CloudCampaigns.tsx b/src/features/settings/CloudCampaigns.tsx index 08e594d..d947263 100644 --- a/src/features/settings/CloudCampaigns.tsx +++ b/src/features/settings/CloudCampaigns.tsx @@ -21,7 +21,7 @@ export function CloudCampaigns() { const [targetCloud, setTargetCloud] = useState(''); const [viewing, setViewing] = useState(null); const [party, setParty] = useState([]); - const [usage, setUsage] = useState<{ bytes: number; admin: boolean } | null>(null); + const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null); const [admins, setAdmins] = useState([]); useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]); @@ -122,11 +122,25 @@ export function CloudCampaigns() {
- {usage &&

Your cloud storage: {fmtBytes(usage.bytes)}

} + {usage && (() => { + const pct = usage.quota > 0 ? Math.min(100, Math.round((usage.bytes / usage.quota) * 100)) : 0; + const near = pct >= 90; + return ( +
+

+ Your cloud storage: {fmtBytes(usage.bytes)} of {fmtBytes(usage.quota)} ({pct}%) + {near && ' — almost full; trim old data or ask the admin to raise your quota.'} +

+
+
+
+
+ ); + })()} {usage?.admin && (
-

Admin · users & storage

+

Admin · users & storage (you are signed in as the admin)

diff --git a/src/lib/cloud/campaigns.ts b/src/lib/cloud/campaigns.ts index 2f89cea..506d2a8 100644 --- a/src/lib/cloud/campaigns.ts +++ b/src/lib/cloud/campaigns.ts @@ -53,8 +53,8 @@ export async function cloudUsageBytes(): Promise { } export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number } -export function getUsage(): Promise<{ bytes: number; admin: boolean }> { - return req<{ bytes: number; admin: boolean }>('/usage'); +export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> { + return req<{ bytes: number; quota: number; admin: boolean }>('/usage'); } export function adminListUsers(): Promise<{ users: AdminUserRow[] }> { return req<{ users: AdminUserRow[] }>('/admin/users'); diff --git a/src/lib/cloud/client.ts b/src/lib/cloud/client.ts index d8a8ad3..9015059 100644 --- a/src/lib/cloud/client.ts +++ b/src/lib/cloud/client.ts @@ -60,6 +60,8 @@ export async function pushBackup(opts?: { force?: boolean }): Promise ({}))) as { savedAt?: number }; return { ok: false, conflict: true, savedAt: Number(d.savedAt) || 0 }; } + if (res.status === 413) throw new CloudError('Cloud storage limit reached — trim old data or ask the admin to raise your quota.'); + if (res.status === 429) throw new CloudError('Too many requests — please wait a moment and try again.'); if (!res.ok) throw new CloudError('Upload failed.'); const d = (await res.json().catch(() => ({}))) as { savedAt?: number }; if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt); diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index 47ec628..4239a3a 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -130,8 +130,8 @@ export const clientMessageSchema = z.discriminatedUnion('t', [ z.object({ t: z.literal('host'), campaignId: z.string(), password: z.string().max(128).optional(), resume: z.string().optional() }), z.object({ t: z.literal('join'), joinCode: z.string().max(16), password: z.string().max(128).optional(), playerId: z.string().max(64).optional() }), z.object({ t: z.literal('state'), gmSecret: z.string(), snapshot: snapshotSchema }), - z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string(), dataUrl: z.string() }), - z.object({ t: z.literal('requestImage'), id: z.string() }), + z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string().max(128), dataUrl: z.string().max(10_000_000) }), + z.object({ t: z.literal('requestImage'), id: z.string().max(128) }), // two-way play (player → server) z.object({ t: z.literal('claimSeat'), characterId: z.string(), offlineSnapshot: characterSchema.optional() }), z.object({ t: z.literal('playerPatch'), characterId: z.string(), diff: partialCharacterDiffSchema }), From 5119fc6d1c12d46a2722f750f092c49af287bed1 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Wed, 10 Jun 2026 20:49:59 +0200 Subject: [PATCH 3/3] Proper admin panel at /admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the table tucked into Settings with a real instance dashboard, gated server-side to ADMIN_USERS (frontend nav entry appears for admins only). Backend (/api/admin/*, admin-token required): - GET overview: uptime, RSS/heap, data-dir bytes, account count vs MAX_USERS, default quota, campaign/character totals, live-room load - GET users: created date, usage vs effective quota, session count, admin flag - POST users/:name/revoke — log a user out everywhere - DELETE users/:name — delete account + backup + owned campaigns + published characters (admin accounts are protected from deletion) - GET/DELETE campaigns — oversight + removal incl. published characters - GET rooms — players/seats/images/idle per room; join codes never exposed Frontend: new /admin page (health cards, account management with quota editor + two-click destructive confirms, campaign table, live-room table), ShieldCheck nav entry via useIsAdmin(), Settings now links to the panel instead of embedding it. Co-Authored-By: Claude Opus 4.8 --- server/src/accounts.test.ts | 34 +++ server/src/accounts.ts | 38 ++++ server/src/campaigns.test.ts | 24 +++ server/src/campaigns.ts | 52 +++++ server/src/index.ts | 93 +++++++- server/src/rooms.ts | 14 ++ src/app/RootLayout.tsx | 10 +- src/features/admin/AdminPage.tsx | 263 +++++++++++++++++++++++ src/features/admin/useIsAdmin.ts | 20 ++ src/features/settings/CloudCampaigns.tsx | 36 +--- src/lib/cloud/admin.ts | 58 +++++ src/lib/cloud/campaigns.ts | 10 +- src/router.tsx | 3 + tsconfig.app.tsbuildinfo | 2 +- 14 files changed, 607 insertions(+), 50 deletions(-) create mode 100644 src/features/admin/AdminPage.tsx create mode 100644 src/features/admin/useIsAdmin.ts create mode 100644 src/lib/cloud/admin.ts diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index 6af86c2..2f223aa 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -72,4 +72,38 @@ describe('AccountStore', () => { expect(second.ok).toBe(false); expect((second as { code: string }).code).toBe('closed'); }); + + it('revokes all tokens (log out everywhere)', async () => { + const r = await store.register('revokee', 'password123'); + if (!r.ok) throw new Error('register failed'); + expect(await store.userByToken(r.token)).toBeTruthy(); + expect(await store.revokeTokens('revokee')).toBe(true); + expect(await store.userByToken(r.token)).toBeNull(); + expect(await store.revokeTokens('ghost')).toBe(false); + expect((await store.login('revokee', 'password123')).ok).toBe(true); // password still works + }); + + it('deletes a user + their blob, but never an admin', async () => { + const s = new AccountStore(dir, undefined, ['boss2']); + await s.register('boss2', 'password123'); + const r = await s.register('victim', 'password123'); + if (!r.ok) throw new Error('register failed'); + const u = (await s.userByToken(r.token))!; + await s.saveBlob(u.id, '{"x":1}'); + expect(await s.deleteUser('boss2')).toBeNull(); // admins are protected + const removedId = await s.deleteUser('victim'); + expect(removedId).toBe(u.id); + expect(await s.userByToken(r.token)).toBeNull(); // tokens dead + expect(await s.loadBlob(u.id)).toBeNull(); // blob gone + expect((await s.login('victim', 'password123')).ok).toBe(false); // account gone + }); + + it('lists detailed rows for the admin panel', async () => { + const r = await store.register('detailed', 'password123'); + if (!r.ok) throw new Error('register failed'); + const row = (await store.listUsersDetailed()).find((x) => x.username === 'detailed')!; + expect(row.tokenCount).toBe(1); + expect(row.admin).toBe(false); + expect(row.createdAt).toBeGreaterThan(0); + }); }); diff --git a/server/src/accounts.ts b/server/src/accounts.ts index c774a20..362084d 100644 --- a/server/src/accounts.ts +++ b/server/src/accounts.ts @@ -65,6 +65,44 @@ export class AccountStore { return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) })); } + /** Admin detail rows — everything the panel shows except usage (caller joins that). */ + async listUsersDetailed(): Promise> { + await this.load(); + return [...this.users.values()].map((u) => ({ + id: u.id, username: u.username, createdAt: u.createdAt, updatedAt: u.updatedAt, + quotaBytes: u.quotaBytes ?? 0, tokenCount: u.tokens.length, admin: this.isAdmin(u.username), + })); + } + + /** Invalidate every session token for a user ("log out everywhere"). */ + async revokeTokens(username: string): Promise { + await this.load(); + const u = this.users.get(username.toLowerCase()); + if (!u) return false; + for (const h of u.tokens) this.byToken.delete(h); + u.tokens = []; + u.updatedAt = this.now(); + await this.persist(); + return true; + } + + /** + * Delete an account and its backup blob. Admin accounts can't be deleted from the + * panel (protects against lockout + a compromised admin nuking another admin). + * Returns the removed user's id so the caller can purge their cloud data too. + */ + async deleteUser(username: string): Promise { + await this.load(); + const u = this.users.get(username.toLowerCase()); + if (!u || this.isAdmin(u.username)) return null; + for (const h of u.tokens) this.byToken.delete(h); + this.users.delete(u.username.toLowerCase()); + this.byId.delete(u.id); + try { await fs.unlink(this.blobFile(u.id)); } catch { /* no blob */ } + await this.persist(); + return u.id; + } + async setQuota(username: string, bytes: number): Promise { await this.load(); const u = this.users.get(username.toLowerCase()); diff --git a/server/src/campaigns.test.ts b/server/src/campaigns.test.ts index 49fd8d4..4b50e09 100644 --- a/server/src/campaigns.test.ts +++ b/server/src/campaigns.test.ts @@ -42,6 +42,30 @@ describe('CloudStore', () => { expect(await store.usageBytes('p1')).toBeGreaterThan(0); }); + it('admin: lists, deletes campaigns, and purges a user wholesale', async () => { + const mine = await store.createCampaign('gm1', 'Mine', '5e'); + const theirs = await store.createCampaign('gm2', 'Theirs', 'pf2e'); + await store.joinByInvite('gm1', theirs.inviteCode); // gm1 is also a member elsewhere + await store.putCharacter('gm1', mine.id, { id: 'c1', name: 'A', data: '{"a":1}' }); + await store.putCharacter('gm1', theirs.id, { id: 'c2', name: 'B', data: '{"b":2}' }); + + const rows = await store.adminList(); + expect(rows).toHaveLength(2); + expect(rows.find((r) => r.id === mine.id)).toMatchObject({ characters: 1, members: 0 }); + + // Purging gm1 removes the campaign they OWN (+ its chars), their membership, + // and their characters published in other campaigns. + const purged = await store.purgeUser('gm1'); + expect(purged).toEqual({ campaigns: 1, characters: 2 }); + expect(store.isMember(theirs.id, 'gm1')).toBe(false); + expect(await store.joinByInvite('p9', mine.inviteCode)).toBeNull(); // invite dead + expect((await store.totals()).campaigns).toBe(1); + + expect(await store.deleteCampaign(theirs.id)).toBe(true); + expect(await store.deleteCampaign(theirs.id)).toBe(false); // already gone + expect((await store.totals()).campaigns).toBe(0); + }); + it('rejects an oversized character and reports usage deltas', async () => { const c = await store.createCampaign('gm1', 'C', '5e'); await store.joinByInvite('p1', c.inviteCode); diff --git a/server/src/campaigns.ts b/server/src/campaigns.ts index f6915fa..f782f14 100644 --- a/server/src/campaigns.ts +++ b/server/src/campaigns.ts @@ -181,6 +181,58 @@ export class CloudStore { return this.characters.get(characterId)?.ownerUserId === userId; } + /** Admin overview rows: every campaign with member/character counts + stored bytes. */ + async adminList(): Promise> { + await this.load(); + return [...this.campaigns.values()].map((c) => { + let characters = 0, bytes = 0; + for (const ch of this.characters.values()) if (ch.campaignId === c.id) { characters++; bytes += Buffer.byteLength(ch.data); } + return { id: c.id, name: c.name, system: c.system, ownerUserId: c.ownerUserId, members: c.members.length, characters, bytes, createdAt: c.createdAt, updatedAt: c.updatedAt }; + }); + } + + /** Admin: delete a campaign and every character published into it. */ + async deleteCampaign(campaignId: string): Promise { + await this.load(); + const c = this.campaigns.get(campaignId); + if (!c) return false; + this.byInvite.delete(c.inviteCode); + this.campaigns.delete(campaignId); + for (const [id, ch] of this.characters) if (ch.campaignId === campaignId) this.characters.delete(id); + await this.persist(); + return true; + } + + /** + * Admin: purge everything a deleted user left behind — campaigns they own (with + * those campaigns' characters), their memberships, and their published characters. + */ + async purgeUser(userId: string): Promise<{ campaigns: number; characters: number }> { + await this.load(); + let campaigns = 0, characters = 0; + for (const [id, c] of [...this.campaigns]) { + if (c.ownerUserId === userId) { + campaigns++; + this.byInvite.delete(c.inviteCode); + this.campaigns.delete(id); + for (const [chId, ch] of [...this.characters]) if (ch.campaignId === id) { this.characters.delete(chId); characters++; } + } else if (c.members.includes(userId)) { + c.members = c.members.filter((m) => m !== userId); + } + } + for (const [chId, ch] of [...this.characters]) if (ch.ownerUserId === userId) { this.characters.delete(chId); characters++; } + await this.persist(); + return { campaigns, characters }; + } + + /** Instance-wide totals for the admin overview. */ + async totals(): Promise<{ campaigns: number; characters: number; bytes: number }> { + await this.load(); + let bytes = 0; + for (const ch of this.characters.values()) bytes += Buffer.byteLength(ch.data); + return { campaigns: this.campaigns.size, characters: this.characters.size, bytes }; + } + /** Total bytes a user is responsible for (their characters' data) — for usage tracking. */ async usageBytes(userId: string): Promise { await this.load(); diff --git a/server/src/index.ts b/server/src/index.ts index eb8f70e..d108839 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -4,8 +4,9 @@ import fastifyWebsocket from '@fastify/websocket'; import fastifyStatic from '@fastify/static'; import type { FastifyRequest } from 'fastify'; import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; +import { promises as fs } from 'node:fs'; import { RoomHub, type Sender } from './rooms'; -import { AccountStore } from './accounts'; +import { AccountStore, DEFAULT_QUOTA_BYTES } from './accounts'; import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns'; const PORT = Number(process.env.PORT ?? 8787); @@ -179,26 +180,102 @@ export function buildServer() { return { bytes, quota: accounts.quotaFor(u), admin: accounts.isAdmin(u.username) }; }); - // ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ---- + // ---- admin panel API (accounts in ADMIN_USERS only) ---- + const startedAt = Date.now(); + const adminOf = async (req: FastifyRequest) => { + const u = await userOf(req); + return u && accounts.isAdmin(u.username) ? u : null; + }; + /** Recursive byte total of the data dir (users.json + cloud.json + blobs). */ + const dirBytes = async (dir: string): Promise => { + let total = 0; + try { + for (const e of await fs.readdir(dir, { withFileTypes: true })) { + const p = path.join(dir, e.name); + if (e.isDirectory()) total += await dirBytes(p); + else total += (await fs.stat(p)).size.valueOf(); + } + } catch { /* missing dir = 0 */ } + return total; + }; + + app.get('/api/admin/overview', async (req, reply) => { + if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' }); + const mem = process.memoryUsage(); + const roomStats = hub.stats(); + const totals = await cloud.totals(); + return { + uptimeMs: Date.now() - startedAt, + memory: { rss: mem.rss, heapUsed: mem.heapUsed }, + dataDirBytes: await dirBytes(DATA_DIR), + users: { count: accounts.userCount(), max: Number.isFinite(MAX_USERS) ? MAX_USERS : null }, + defaultQuotaBytes: DEFAULT_QUOTA_BYTES, + campaigns: totals.campaigns, + characters: totals.characters, + characterBytes: totals.bytes, + rooms: { + count: roomStats.length, + players: roomStats.reduce((n, r) => n + r.players, 0), + imageBytes: roomStats.reduce((n, r) => n + r.imageBytes, 0), + }, + }; + }); + app.get('/api/admin/users', async (req, reply) => { - const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); - if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' }); - const users = await accounts.listUsers(); + if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' }); + const users = await accounts.listUsersDetailed(); const rows = await Promise.all(users.map(async (x) => ({ username: x.username, + admin: x.admin, + createdAt: x.createdAt, usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)), - quotaBytes: x.quotaBytes ?? 0, + quotaBytes: x.quotaBytes, + effectiveQuota: x.quotaBytes > 0 ? x.quotaBytes : DEFAULT_QUOTA_BYTES, + tokenCount: x.tokenCount, }))); return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) }; }); + app.post('/api/admin/quota', async (req, reply) => { - const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); - if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' }); + if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' }); const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number }; const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0); return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' }); }); + app.post('/api/admin/users/:username/revoke', async (req, reply) => { + if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' }); + const ok = await accounts.revokeTokens((req.params as { username: string }).username); + return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' }); + }); + + app.delete('/api/admin/users/:username', async (req, reply) => { + if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' }); + const name = (req.params as { username: string }).username; + const id = await accounts.deleteUser(name); + if (!id) return reply.code(400).send({ error: 'cannot-delete', message: 'No such user, or the account is an admin.' }); + const purged = await cloud.purgeUser(id); + return { ok: true, purged }; + }); + + app.get('/api/admin/campaigns', async (req, reply) => { + if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' }); + const byId = new Map((await accounts.listUsers()).map((x) => [x.id, x.username])); + const rows = (await cloud.adminList()).map((c) => ({ ...c, owner: byId.get(c.ownerUserId) ?? '(deleted)' })); + return { campaigns: rows.sort((a, b) => b.updatedAt - a.updatedAt) }; + }); + + app.delete('/api/admin/campaigns/:id', async (req, reply) => { + if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' }); + const ok = await cloud.deleteCampaign((req.params as { id: string }).id); + return ok ? { ok: true } : reply.code(404).send({ error: 'no-campaign' }); + }); + + app.get('/api/admin/rooms', async (req, reply) => { + if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' }); + return { rooms: hub.stats() }; + }); + void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } }); // Live WebSocket connections per client IP — a hard cap on socket-spam. diff --git a/server/src/rooms.ts b/server/src/rooms.ts index 4276e12..15aca4d 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -347,6 +347,20 @@ export class RoomHub { roomCount(): number { return this.rooms.size; } + /** Admin overview of live rooms. Deliberately excludes join codes and secrets — + * the admin can see load, not enter private games. */ + stats(): Array<{ players: number; seats: number; images: number; imageBytes: number; idleMs: number; hasGm: boolean }> { + const now = this.now(); + return [...this.rooms.values()].map((r) => ({ + players: r.players.size, + seats: r.seats.size, + images: r.images.size, + imageBytes: r.imageBytes, + idleMs: Math.max(0, now - r.lastActivity), + hasGm: r.gm !== null, + })); + } + private gmRoom(socket: Sender, gmSecret: string): Room | null { const conn = this.conns.get(socket); if (!conn || conn.role !== 'gm') return null; diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 6c10328..6b426a2 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -3,7 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router'; import { Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower, LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight, - ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, + ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, ShieldCheck, type LucideIcon, } from 'lucide-react'; import { useUiStore } from '@/stores/uiStore'; @@ -24,6 +24,7 @@ import { SignalsBell } from '@/features/assistant/SignalsBell'; import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator'; import { SessionSidebar } from '@/features/play/SessionSidebar'; import { useSessionStore } from '@/stores/sessionStore'; +import { useIsAdmin } from '@/features/admin/useIsAdmin'; type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' }; const NAV: NavItem[] = [ @@ -100,6 +101,11 @@ export function RootLayout() { useSessionBroadcaster(activeCampaign ?? null); usePlayerConnection(); useCloudAutosave(); + // Instance admins get an extra nav entry (server re-checks every /api/admin call). + const isAdmin = useIsAdmin(); + const nav: NavItem[] = isAdmin + ? [...NAV, { to: '/admin', label: 'Admin', icon: ShieldCheck, exact: false, group: 'reference' }] + : NAV; // Global Ctrl/Cmd+K opens the command palette. useEffect(() => { @@ -148,7 +154,7 @@ export function RootLayout() { {GROUPS.map((group) => (
{group.label && showLabel &&
{group.label}
} - {NAV.filter((n) => n.group === group.id).map((item) => { + {nav.filter((n) => n.group === group.id).map((item) => { const active = item.exact ? pathname === item.to : pathname.startsWith(item.to); const Ico = item.icon; return ( diff --git a/src/features/admin/AdminPage.tsx b/src/features/admin/AdminPage.tsx new file mode 100644 index 0000000..6e50b88 --- /dev/null +++ b/src/features/admin/AdminPage.tsx @@ -0,0 +1,263 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link } from '@tanstack/react-router'; +import { RefreshCw, ShieldCheck, Users, Database, RadioTower, Cpu, Star } from 'lucide-react'; +import { cloudUsername } from '@/lib/cloud/client'; +import { + adminOverview, adminUsers, adminCampaigns, adminRooms, + adminSetQuota, adminRevokeTokens, adminDeleteUser, adminDeleteCampaign, + type AdminOverview, type AdminUser, type AdminCampaign, type AdminRoom, +} from '@/lib/cloud/admin'; +import { Page, PageHeader } from '@/components/ui/Page'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; + +function fmtBytes(b: number): string { + if (b >= 1e9) return `${(b / 1e9).toFixed(2)} GB`; + if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`; + return `${Math.max(0, Math.round(b / 1024))} KB`; +} +function fmtAgo(ms: number): string { + const m = Math.floor(ms / 60_000); + if (m < 1) return 'just now'; + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + return h < 48 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d`; +} +const fmtDate = (t: number) => new Date(t).toLocaleDateString(); + +/** + * Instance administration: server health, every account (storage, quota, + * sessions), shared campaigns, and live rooms. Server-gated to ADMIN_USERS — + * this page only renders what /api/admin/* is willing to return. + */ +export function AdminPage() { + const username = cloudUsername(); + const [overview, setOverview] = useState(null); + const [users, setUsers] = useState([]); + const [campaigns, setCampaigns] = useState([]); + const [rooms, setRooms] = useState([]); + const [denied, setDenied] = useState(false); + const [msg, setMsg] = useState(''); + + const refresh = useCallback(() => { + setMsg(''); + adminOverview().then(setOverview).catch(() => setDenied(true)); + adminUsers().then((r) => setUsers(r.users)).catch(() => {}); + adminCampaigns().then((r) => setCampaigns(r.campaigns)).catch(() => {}); + adminRooms().then((r) => setRooms(r.rooms)).catch(() => {}); + }, []); + useEffect(() => { if (username) refresh(); }, [username, refresh]); + + if (!username) { + return ( + + +

Go to Settings and sign in to the cloud first.

+
+ ); + } + if (denied) { + return ( + + +

This account isn’t an instance admin. Admins are set with the ADMIN_USERS environment variable on the server.

+
+ ); + } + + return ( + + Refresh} + /> + + {/* Server health */} + {overview && ( +
+ + + + +
+ )} + + {/* Accounts */} +
+
UserUsedQuota (GB, 0=default)
+ + + + + + + + + + + {users.map((u) => ( + + ))} + +
UserCreatedStorageQuota (MB)Sessions +
+ {users.length === 0 &&

No accounts yet.

} + + + {/* Campaigns */} +
+ + + + + + + + + + + + + {campaigns.map((c) => ( + + ))} + +
CampaignOwnerMembersCharactersSizeUpdated +
+ {campaigns.length === 0 &&

No shared campaigns yet.

} +
+ + {/* Live rooms */} +
+ {rooms.length === 0 ? ( +

No active play sessions. Rooms expire after 6h idle.

+ ) : ( + + + + + + + + + + + + {rooms.map((r, i) => ( + + + + + + + + ))} + +
GMPlayersSeatsMap imagesIdle
{r.hasGm ? connected : away}{r.players}{r.seats}{r.images} · {fmtBytes(r.imageBytes)}{fmtAgo(r.idleMs)}
+ )} +

Rooms are anonymous by design — join codes and contents are never shown here.

+
+ + {msg &&

{msg}

} + + ); +} + +function StatCard({ icon: Icon, label, value, hint }: { icon: typeof Users; label: string; value: string; hint: string }) { + return ( +
+
{label}
+
{value}
+
{hint}
+
+ ); +} + +function Section({ icon: Icon, title, children }: { icon: typeof Users; title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +/** Destructive actions arm on the first click and fire on the second. */ +function ConfirmButton({ label, confirmLabel, onConfirm }: { label: string; confirmLabel: string; onConfirm: () => void }) { + const [armed, setArmed] = useState(false); + useEffect(() => { + if (!armed) return; + const t = setTimeout(() => setArmed(false), 4000); + return () => clearTimeout(t); + }, [armed]); + return ( + + ); +} + +function UserRow({ u, onChanged, onMsg }: { u: AdminUser; onChanged: () => void; onMsg: (m: string) => void }) { + const [mb, setMb] = useState(u.quotaBytes ? String(Math.round(u.quotaBytes / 1e6)) : ''); + const pct = Math.min(100, Math.round((u.usageBytes / Math.max(1, u.effectiveQuota)) * 100)); + const act = (p: Promise, done: string) => p.then(() => { onMsg(done); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.')); + return ( + + + {u.username} + {u.admin && admin} + + {fmtDate(u.createdAt)} + +
+
+
= 90 ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} /> +
+ {fmtBytes(u.usageBytes)} / {fmtBytes(u.effectiveQuota)} +
+ + +
+ setMb(e.target.value)} /> + +
+ + +
+ {u.tokenCount} + {u.tokenCount > 0 && ( + act(adminRevokeTokens(u.username), `${u.username} signed out everywhere.`)} /> + )} +
+ + + {!u.admin && ( + act(adminDeleteUser(u.username), `${u.username} deleted (account, backup, campaigns, characters).`)} /> + )} + + + ); +} + +function CampaignRow({ c, onChanged, onMsg }: { c: AdminCampaign; onChanged: () => void; onMsg: (m: string) => void }) { + return ( + + {c.name} {c.system} + {c.owner} + {c.members} + {c.characters} + {fmtBytes(c.bytes)} + {fmtDate(c.updatedAt)} + + + adminDeleteCampaign(c.id).then(() => { onMsg(`Campaign “${c.name}” deleted.`); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.'))} /> + + + ); +} diff --git a/src/features/admin/useIsAdmin.ts b/src/features/admin/useIsAdmin.ts new file mode 100644 index 0000000..ecbb94f --- /dev/null +++ b/src/features/admin/useIsAdmin.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from 'react'; +import { cloudUsername } from '@/lib/cloud/client'; +import { getUsage } from '@/lib/cloud/campaigns'; + +// Cache across mounts so the sidebar doesn't re-ask /api/usage on every navigation. +let cached: boolean | null = null; + +/** Whether the signed-in cloud account is an instance admin (false while unknown). */ +export function useIsAdmin(): boolean { + const [isAdmin, setIsAdmin] = useState(cached === true); + useEffect(() => { + if (cached !== null || !cloudUsername()) return; + let on = true; + getUsage() + .then((u) => { cached = u.admin; if (on) setIsAdmin(u.admin); }) + .catch(() => { /* signed out / offline — stay hidden */ }); + return () => { on = false; }; + }, []); + return isAdmin; +} diff --git a/src/features/settings/CloudCampaigns.tsx b/src/features/settings/CloudCampaigns.tsx index d947263..b385d0d 100644 --- a/src/features/settings/CloudCampaigns.tsx +++ b/src/features/settings/CloudCampaigns.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; +import { Link } from '@tanstack/react-router'; import { useLiveQuery } from 'dexie-react-hooks'; import { db } from '@/lib/db/db'; import { cloudUsername, CloudError } from '@/lib/cloud/client'; -import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns'; +import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns'; import { useCampaigns } from '@/features/campaigns/hooks'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; @@ -22,12 +23,9 @@ export function CloudCampaigns() { const [viewing, setViewing] = useState(null); const [party, setParty] = useState([]); const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null); - const [admins, setAdmins] = useState([]); useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]); useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]); - const loadAdmin = () => adminListUsers().then((r) => setAdmins(r.users)).catch(() => {}); - useEffect(() => { if (usage?.admin) loadAdmin(); }, [usage?.admin]); const refresh = () => listCloudCampaigns().then(setList).catch(() => {}); const run = (fn: () => Promise) => async () => { @@ -139,21 +137,9 @@ export function CloudCampaigns() { })()} {usage?.admin && ( -
-

Admin · users & storage (you are signed in as the admin)

- - - - {admins.map((u) => ( - - - - - - ))} - -
UserUsedQuota (GB, 0=default)
{u.username}{fmtBytes(u.usageBytes)}
-

Quotas are tracked but not yet enforced — a safety valve for later.

+
+ You’re the instance admin — manage accounts, storage, campaigns, and live rooms in the{' '} + Admin panel.
)} @@ -168,15 +154,3 @@ function fmtBytes(b: number): string { if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`; return `${Math.max(0, Math.round(b / 1024))} KB`; } - -function QuotaCell({ user, onSaved }: { user: AdminUserRow; onSaved: () => void }) { - const [gb, setGb] = useState(String(user.quotaBytes ? (user.quotaBytes / 1e9).toFixed(0) : '0')); - const [saving, setSaving] = useState(false); - const save = async () => { setSaving(true); try { await adminSetQuota(user.username, (Number(gb) || 0) * 1e9); onSaved(); } catch { /* ignore */ } finally { setSaving(false); } }; - return ( - - setGb(e.target.value.replace(/[^0-9]/g, ''))} className="w-12 rounded border border-line bg-surface px-1 py-0.5 text-xs" aria-label={`Quota GB for ${user.username}`} /> - - - ); -} diff --git a/src/lib/cloud/admin.ts b/src/lib/cloud/admin.ts new file mode 100644 index 0000000..5b090e6 --- /dev/null +++ b/src/lib/cloud/admin.ts @@ -0,0 +1,58 @@ +import { req } from './campaigns'; + +/** Typed client for the admin-panel API (accounts listed in ADMIN_USERS only). */ + +export interface AdminOverview { + uptimeMs: number; + memory: { rss: number; heapUsed: number }; + dataDirBytes: number; + users: { count: number; max: number | null }; + defaultQuotaBytes: number; + campaigns: number; + characters: number; + characterBytes: number; + rooms: { count: number; players: number; imageBytes: number }; +} + +export interface AdminUser { + username: string; + admin: boolean; + createdAt: number; + usageBytes: number; + quotaBytes: number; + effectiveQuota: number; + tokenCount: number; +} + +export interface AdminCampaign { + id: string; + name: string; + system: string; + owner: string; + members: number; + characters: number; + bytes: number; + updatedAt: number; +} + +export interface AdminRoom { + players: number; + seats: number; + images: number; + imageBytes: number; + idleMs: number; + hasGm: boolean; +} + +export const adminOverview = () => req('/admin/overview'); +export const adminUsers = () => req<{ users: AdminUser[] }>('/admin/users'); +export const adminCampaigns = () => req<{ campaigns: AdminCampaign[] }>('/admin/campaigns'); +export const adminRooms = () => req<{ rooms: AdminRoom[] }>('/admin/rooms'); +export const adminSetQuota = (username: string, quotaBytes: number) => + req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) }); +export const adminRevokeTokens = (username: string) => + req<{ ok: boolean }>(`/admin/users/${encodeURIComponent(username)}/revoke`, { method: 'POST' }); +export const adminDeleteUser = (username: string) => + req<{ ok: boolean; purged: { campaigns: number; characters: number } }>(`/admin/users/${encodeURIComponent(username)}`, { method: 'DELETE' }); +export const adminDeleteCampaign = (id: string) => + req<{ ok: boolean }>(`/admin/campaigns/${encodeURIComponent(id)}`, { method: 'DELETE' }); diff --git a/src/lib/cloud/campaigns.ts b/src/lib/cloud/campaigns.ts index 506d2a8..92d284f 100644 --- a/src/lib/cloud/campaigns.ts +++ b/src/lib/cloud/campaigns.ts @@ -12,7 +12,8 @@ function authHeaders(): Record { export interface CloudCampaignInfo { id: string; name: string; system: string; role: 'owner' | 'member'; inviteCode?: string } export interface CloudCharInfo { id: string; name: string; ownerUserId: string; mine: boolean; data: string; updatedAt: number } -async function req(path: string, init?: RequestInit): Promise { +/** Authenticated same-origin /api request — shared by the campaign + admin clients. */ +export async function req(path: string, init?: RequestInit): Promise { let res: Response; try { res = await fetch(`${base()}${path}`, { ...init, headers: { 'content-type': 'application/json', ...authHeaders(), ...(init?.headers ?? {}) } }); @@ -52,13 +53,6 @@ export async function cloudUsageBytes(): Promise { return (await req<{ bytes: number }>('/usage')).bytes; } -export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number } export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> { return req<{ bytes: number; quota: number; admin: boolean }>('/usage'); } -export function adminListUsers(): Promise<{ users: AdminUserRow[] }> { - return req<{ users: AdminUserRow[] }>('/admin/users'); -} -export function adminSetQuota(username: string, quotaBytes: number): Promise<{ ok: boolean }> { - return req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) }); -} diff --git a/src/router.tsx b/src/router.tsx index a08b032..1f9d718 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -19,6 +19,7 @@ import { HomebrewPage } from '@/features/world/HomebrewPage'; import { SettingsPage } from '@/features/settings/SettingsPage'; import { AssistantPage } from '@/features/assistant/AssistantPage'; import { DirectorPage } from '@/features/assistant/director/DirectorPage'; +import { AdminPage } from '@/features/admin/AdminPage'; const rootRoute = createRootRoute({ component: RootLayout }); @@ -44,6 +45,7 @@ const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/hom const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage }); const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage }); const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage }); +const adminRoute = createRoute({ getParentRoute: () => rootRoute, path: '/admin', component: AdminPage }); const routeTree = rootRoute.addChildren([ indexRoute, @@ -64,6 +66,7 @@ const routeTree = rootRoute.addChildren([ settingsRoute, assistantRoute, directorRoute, + adminRoute, ]); export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound }); diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 9941b6d..9a6b19b 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/NotFound.tsx","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/components/ui/useConfirm.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/SignalsBell.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/assistant/useSignalAction.ts","./src/features/assistant/director/ActionCard.tsx","./src/features/assistant/director/DirectorPage.tsx","./src/features/assistant/director/RollRequestCard.tsx","./src/features/assistant/director/SceneControls.tsx","./src/features/assistant/director/SuggestionChips.tsx","./src/features/assistant/director/TranscriptPane.tsx","./src/features/assistant/director/actionSummary.ts","./src/features/assistant/director/hooks.ts","./src/features/assistant/director/useDirectorAction.test.ts","./src/features/assistant/director/useDirectorAction.ts","./src/features/assistant/director/useDirectorSession.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/ClassFeaturesSection.tsx","./src/features/characters/sheet/ClassesEditor.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatPickerModal.tsx","./src/features/characters/sheet/FeatsSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/WeaponPickerModal.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.tsx","./src/features/cloud/useCloudAutosave.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.tsx","./src/features/settings/DirectorSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/assistant/signals.test.ts","./src/lib/assistant/signals.ts","./src/lib/assistant/director/context.ts","./src/lib/assistant/director/director.test.ts","./src/lib/assistant/director/engine.ts","./src/lib/assistant/director/fallback.ts","./src/lib/assistant/director/index.ts","./src/lib/assistant/director/no-auto-roll.test.ts","./src/lib/assistant/director/prompt.ts","./src/lib/assistant/director/resolve.ts","./src/lib/assistant/director/schema.ts","./src/lib/assistant/director/summarize.test.ts","./src/lib/assistant/director/summarize.ts","./src/lib/assistant/director/types.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/mpmb.test.ts","./src/lib/compendium/mpmb.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/img/resize.test.ts","./src/lib/img/resize.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/cast.ts","./src/lib/mechanics/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.ts","./src/lib/mechanics/deathSaves.ts","./src/lib/mechanics/dying.test.ts","./src/lib/mechanics/dying.ts","./src/lib/mechanics/enforce.test.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/resources.ts","./src/lib/mechanics/result.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityBuild.test.ts","./src/lib/rules/abilityBuild.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/multiclass.test.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/features/collect.ts","./src/lib/rules/features/data.5e.ts","./src/lib/rules/features/data.pf2e.ts","./src/lib/rules/features/features.test.ts","./src/lib/rules/features/subclass.5e.ts","./src/lib/rules/features/types.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.ts","./src/lib/schemas/aiSession.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/connectivityStore.ts","./src/stores/directorStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/NotFound.tsx","./src/app/RootLayout.tsx","./src/components/CommandPalette.tsx","./src/components/ui/Button.tsx","./src/components/ui/Codex.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/components/ui/RollTray.tsx","./src/components/ui/useConfirm.tsx","./src/features/admin/AdminPage.tsx","./src/features/admin/useIsAdmin.ts","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/NpcGenCard.tsx","./src/features/assistant/SessionPrepCard.tsx","./src/features/assistant/SignalsBell.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./src/features/assistant/useSignalAction.ts","./src/features/assistant/director/ActionCard.tsx","./src/features/assistant/director/DirectorPage.tsx","./src/features/assistant/director/RollRequestCard.tsx","./src/features/assistant/director/SceneControls.tsx","./src/features/assistant/director/SuggestionChips.tsx","./src/features/assistant/director/TranscriptPane.tsx","./src/features/assistant/director/actionSummary.ts","./src/features/assistant/director/hooks.ts","./src/features/assistant/director/useDirectorAction.test.ts","./src/features/assistant/director/useDirectorAction.ts","./src/features/assistant/director/useDirectorSession.ts","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/builder/CreationWizard.tsx","./src/features/characters/builder/origin.test.ts","./src/features/characters/builder/origin.ts","./src/features/characters/builder/overview.test.ts","./src/features/characters/builder/overview.ts","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/ClassFeaturesSection.tsx","./src/features/characters/sheet/ClassesEditor.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/FeatPickerModal.tsx","./src/features/characters/sheet/FeatsSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/WeaponPickerModal.tsx","./src/features/characters/sheet/common.tsx","./src/features/characters/sheet/labels.ts","./src/features/cloud/SyncStatusIndicator.tsx","./src/features/cloud/useCloudAutosave.ts","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/features/play/HandoutControl.tsx","./src/features/play/PlayerConnection.tsx","./src/features/play/SessionControl.tsx","./src/features/play/SessionSidebar.tsx","./src/features/play/usePlayerConnection.ts","./src/features/play/useSessionBroadcaster.ts","./src/features/player/ActionGuide.tsx","./src/features/player/MyCharacterPanel.tsx","./src/features/player/PlayerBoards.tsx","./src/features/player/PlayerMapView.tsx","./src/features/player/PlayerSetupPage.tsx","./src/features/player/PlayerViewPage.tsx","./src/features/player/RollFeed.tsx","./src/features/player/SeatClaimScreen.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/CloudCampaigns.tsx","./src/features/settings/DirectorSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/features/world/map/MapCanvas.tsx","./src/features/world/map/MapEditor.tsx","./src/features/world/map/TokenPalette.test.ts","./src/features/world/map/TokenPalette.tsx","./src/features/world/map/tokens.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/actions.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/builder.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/assistant/session.ts","./src/lib/assistant/signals.test.ts","./src/lib/assistant/signals.ts","./src/lib/assistant/director/context.ts","./src/lib/assistant/director/director.test.ts","./src/lib/assistant/director/engine.ts","./src/lib/assistant/director/fallback.ts","./src/lib/assistant/director/index.ts","./src/lib/assistant/director/no-auto-roll.test.ts","./src/lib/assistant/director/prompt.ts","./src/lib/assistant/director/resolve.ts","./src/lib/assistant/director/schema.ts","./src/lib/assistant/director/summarize.test.ts","./src/lib/assistant/director/summarize.ts","./src/lib/assistant/director/types.ts","./src/lib/cloud/admin.ts","./src/lib/cloud/campaigns.ts","./src/lib/cloud/client.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/combat/playerProjection.test.ts","./src/lib/combat/playerProjection.ts","./src/lib/compendium/index.ts","./src/lib/compendium/mpmb.test.ts","./src/lib/compendium/mpmb.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/img/resize.test.ts","./src/lib/img/resize.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/io/pathbuilder.test.ts","./src/lib/io/pathbuilder.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/map/distance.ts","./src/lib/map/fog.ts","./src/lib/map/grid.ts","./src/lib/map/index.ts","./src/lib/map/map.test.ts","./src/lib/map/projection.test.ts","./src/lib/map/projection.ts","./src/lib/map/shapes.ts","./src/lib/map/types.ts","./src/lib/map/viewport.test.ts","./src/lib/map/viewport.ts","./src/lib/map/vision.test.ts","./src/lib/map/vision.ts","./src/lib/mechanics/cast.ts","./src/lib/mechanics/conditionEffects.ts","./src/lib/mechanics/creatureState.test.ts","./src/lib/mechanics/creatureState.ts","./src/lib/mechanics/deathSaves.ts","./src/lib/mechanics/dying.test.ts","./src/lib/mechanics/dying.ts","./src/lib/mechanics/enforce.test.ts","./src/lib/mechanics/index.ts","./src/lib/mechanics/resources.ts","./src/lib/mechanics/result.ts","./src/lib/mechanics/types.ts","./src/lib/mechanics/normalize/armor.ts","./src/lib/mechanics/normalize/monsterDefenses.ts","./src/lib/mechanics/normalize/normalize.test.ts","./src/lib/mechanics/normalize/spell.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityBuild.test.ts","./src/lib/rules/abilityBuild.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/multiclass.test.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/features/collect.ts","./src/lib/rules/features/data.5e.ts","./src/lib/rules/features/data.pf2e.ts","./src/lib/rules/features/features.test.ts","./src/lib/rules/features/subclass.5e.ts","./src/lib/rules/features/types.ts","./src/lib/rules/pf2e/abilities.test.ts","./src/lib/rules/pf2e/abilities.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/ruleset/normalize.test.ts","./src/lib/ruleset/normalize.ts","./src/lib/schemas/aiSession.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/lib/schemas/sessionLog.ts","./src/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/lib/sync/messages.test.ts","./src/lib/sync/messages.ts","./src/lib/sync/playerLink.test.ts","./src/lib/sync/playerLink.ts","./src/lib/sync/snapshot.ts","./src/lib/sync/wsSync.ts","./src/lib/vtt/uvtt.test.ts","./src/lib/vtt/uvtt.ts","./src/stores/assistantStore.ts","./src/stores/connectivityStore.ts","./src/stores/directorStore.ts","./src/stores/macroStore.ts","./src/stores/playerSessionStore.ts","./src/stores/rollStore.ts","./src/stores/sessionStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"} \ No newline at end of file