diff --git a/src/features/characters/AbilityScoreTable.tsx b/src/features/characters/AbilityScoreTable.tsx new file mode 100644 index 0000000..0cdcca4 --- /dev/null +++ b/src/features/characters/AbilityScoreTable.tsx @@ -0,0 +1,565 @@ +/** + * MPMB-style ability score tables: one row per ability, one column per source. + * Three variants: + * SheetAbilityTable – sheet breakdown modal (build sources + manual override) + * Wizard5eAbilityTable – 5e wizard Abilities step + * WizardPf2eAbilityTable – PF2e wizard boost-picker + */ +import { useState } from 'react'; +import { ABILITY_ABBR, ABILITY_LABELS, abilityModifier } from '@/lib/rules'; +import { abilityBreakdown } from '@/lib/rules/abilityBuild'; +import type { AbilityBuild } from '@/lib/schemas/character'; +import type { AbilityKey, AbilityScores } from '@/lib/rules/types'; +import { POINT_BUY_MIN, POINT_BUY_MAX, pointBuyRemaining } from '@/lib/rules/abilityGen'; +import { NumberField } from '@/components/ui/NumberField'; +import { Select } from '@/components/ui/Input'; +import { cn } from '@/lib/cn'; +import { formatModifier } from '@/lib/format'; + +const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; + +// ─── shared cell/header styles ─────────────────────────────────────────────── +const TH = 'px-2 py-1.5 text-[10px] font-medium smallcaps text-muted text-center whitespace-nowrap select-none'; +const TH_L = 'px-2 py-1.5 text-[10px] font-medium smallcaps text-muted text-left whitespace-nowrap select-none'; +const TD = 'px-2 py-1 text-center tabular-nums'; +const TD_L = 'px-2 py-1 text-left'; + +function DeltaBadge({ delta, kind }: { delta: number; kind?: 'boost' | 'flat' | undefined }) { + if (delta === 0) return ; + const pos = delta > 0; + return ( + + {pos ? '+' : ''}{delta} + + ); +} + +// ─── Sheet breakdown table ──────────────────────────────────────────────────── + +export interface SheetAbilityTableProps { + build: AbilityBuild; + /** When provided, shows the Manual override column and calls this on change. */ + onSetManual?: (ability: AbilityKey, delta: number) => void; + keyAbilities?: AbilityKey[] | undefined; +} + +export function SheetAbilityTable({ build, onSetManual, keyAbilities }: SheetAbilityTableProps) { + const bd = abilityBreakdown(build); + + // Collect unique source labels; show Manual last (it's the override column) + const nonManualLabels = [...new Set( + build.adjustments.filter((a) => a.label !== 'Manual').map((a) => a.label), + )]; + + function ManualCell({ ability }: { ability: AbilityKey }) { + const adj = build.adjustments.find((a) => a.label === 'Manual' && a.ability === ability); + const [editing, setEditing] = useState(false); + const delta = adj?.amount ?? 0; + if (!onSetManual) { + return 0 ? 'text-accent' : 'text-danger') : 'text-muted/30')}>{delta ? (delta > 0 ? `+${delta}` : delta) : '—'}; + } + if (editing) { + return ( + onSetManual(ability, v)} + /> + ); + } + return ( + + ); + } + + return ( +
+ + + + + + {nonManualLabels.map((label) => ( + + ))} + {onSetManual && } + + + + + + {ABILITIES.map((ability, idx) => { + const b = bd[ability]; + const total = b.total; + const mod = abilityModifier(total); + const isKey = keyAbilities?.includes(ability); + return ( + + + + {nonManualLabels.map((label) => { + const parts = b.parts.filter((p) => p.label === label); + const totalDelta = parts.reduce((s, p) => s + p.delta, 0); + const adj = build.adjustments.find((a) => a.label === label && a.ability === ability); + return ( + + ); + })} + {onSetManual && ( + + )} + + + + ); + })} + +
AbilityBase{label}ManualTotalMod
+ {ABILITY_LABELS[ability]} + {ABILITY_ABBR[ability]} + {isKey && } + {b.base} + {totalDelta !== 0 + ? + : } + + + + = 18 ? 'text-accent-deep' : 'text-ink', + )}>{total} + + = 0 ? 'text-accent' : 'text-danger', + )}>{formatModifier(mod)} +
+
+ ); +} + +// ─── 5e Wizard table ────────────────────────────────────────────────────────── + +export interface Wizard5eAbilityTableProps { + method: 'standard' | 'pointbuy' | 'roll' | 'manual'; + pool: number[]; // sorted pool for standard/roll + assignment: number[]; // pool[assignment[i]] = base score for ABILITIES[i] + pb: number[]; // point-buy raw bases per ability (same order as ABILITIES) + racial: Partial; + asiAlloc: Partial; + asiCount: number; // total ASI points available (asiCount*2 pts from feat/ASI) + keyAbilities?: string[] | undefined; + onChangeAssignment: (abilityIdx: number, poolIdx: number) => void; + onChangePb: (abilityIdx: number, value: number) => void; + onChangeAsi: (ability: AbilityKey, delta: number) => void; +} + +export function Wizard5eAbilityTable({ + method, pool, assignment, pb, racial, asiAlloc, asiCount, keyAbilities, + onChangeAssignment, onChangePb, onChangeAsi, +}: Wizard5eAbilityTableProps) { + const usesPool = method === 'standard' || method === 'roll'; + const hasRacial = ABILITIES.some((a) => (racial[a] ?? 0) !== 0); + const hasAsi = asiCount > 0; + + const asiUsed = ABILITIES.reduce((s, a) => s + (asiAlloc[a] ?? 0), 0); + const asiRemaining = asiCount * 2 - asiUsed; + + const pbRemaining = pointBuyRemaining(pb); + + return ( +
+ + + + + + {hasRacial && } + {hasAsi && ( + + )} + + + + + + {ABILITIES.map((ability, i) => { + const base = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; + const race = racial[ability] ?? 0; + const asi = asiAlloc[ability] ?? 0; + const total = base + race + asi; + const mod = abilityModifier(total); + const isKey = keyAbilities?.includes(ability); + const pbMax = method === 'pointbuy' + ? (() => { + let best = pb[i]!; + for (let c = pb[i]! + 1; c <= POINT_BUY_MAX; c++) { + if (pointBuyRemaining(pb.map((x, j) => (j === i ? c : x))) >= 0) best = c; else break; + } + return best; + })() + : 30; + + return ( + + {/* Ability name */} + + + {/* Base score input */} + + + {/* Racial bonus */} + {hasRacial && ( + + )} + + {/* ASI stepper */} + {hasAsi && ( + + )} + + {/* Total */} + + + {/* Modifier */} + + + ); + })} + + {method === 'pointbuy' && ( + + + + + + )} + {method === 'standard' && ( + + + + + + )} +
Ability + {method === 'standard' ? 'Array' : method === 'roll' ? 'Roll' : 'Base'} + Race + ASI + + ({asiRemaining} left) + + TotalMod
+ {ABILITY_LABELS[ability]} + {ABILITY_ABBR[ability]} + {isKey && } + + {usesPool ? ( + + ) : ( + onChangePb(i, v)} + /> + )} + + {race !== 0 + ? + : } + +
+ + 0 ? 'text-accent font-medium' : 'text-muted/40', + )}> + {asi > 0 ? `+${asi}` : '0'} + + +
+
+ = 18 ? 'text-accent-deep' : 'text-ink', + )}>{total} + + = 0 ? 'text-accent' : 'text-danger', + )}>{formatModifier(mod)} +
+ + Point buy remaining:{' '} + + {pbRemaining} / 27 + + (scores 8–15, standard array: 15 14 13 12 10 8) + + +
+ Standard array: 15 · 14 · 13 · 12 · 10 · 8 — assign each value once +
+
+ ); +} + +// ─── PF2e Wizard boost-picker table ────────────────────────────────────────── + +export interface PF2eBoostSlot { + id: string; + label: string; + options: AbilityKey[]; +} + +export interface WizardPf2eAbilityTableProps { + fixed: AbilityKey[]; // ancestry fixed boosts + flaw?: AbilityKey | undefined; + classBoost?: AbilityKey | undefined; + slots: PF2eBoostSlot[]; // interactive free-boost slots + picks: Record; + abilities: AbilityScores; // final computed scores (for Total column) + keyAbilities?: string[] | undefined; + onPick: (slotId: string, ability: AbilityKey) => void; +} + +function slotGroup(id: string): string { + if (id.startsWith('anc')) return 'ancestry'; + if (id.startsWith('bg')) return 'background'; + if (id === 'class') return 'class'; + if (id.startsWith('free')) return 'free'; + // lvl5-0, lvl10-1, etc. + const m = id.match(/^(lvl\d+)/); + return m ? m[1]! : id; +} + +function slotHeader(s: PF2eBoostSlot): string { + const { id, label } = s; + if (id.startsWith('anc-free')) { + const n = parseInt(id.split('-')[2] ?? '0') + 1; + return `Anc. ${n}`; + } + if (id === 'bg-choice') return 'Bg.'; + if (id.startsWith('bg-free')) { + const n = parseInt(id.split('-')[2] ?? '0') + 1; + return `Bg. ${n}`; + } + if (id === 'class') return 'Class'; + if (id.startsWith('free-')) return `Free ${parseInt(id.split('-')[1] ?? '0') + 1}`; + const m = id.match(/^lvl(\d+)-(\d+)/); + if (m) return `L${m[1]} ${parseInt(m[2]!) + 1}`; + return label.replace('boost', '').trim(); +} + +export function WizardPf2eAbilityTable({ + fixed, flaw, classBoost, slots, picks, abilities, keyAbilities, onPick, +}: WizardPf2eAbilityTableProps) { + const fixedSet = new Set(fixed); + const hasFlaw = !!flaw; + const hasClassBoost = !!classBoost; + + return ( +
+ + + + + + {fixed.length > 0 && } + {hasFlaw && } + {hasClassBoost && } + {slots.map((s) => ( + + ))} + + + + + + {ABILITIES.map((ability, idx) => { + const total = abilities[ability]; + const mod = abilityModifier(total); + const isKey = keyAbilities?.includes(ability); + + return ( + + {/* Name */} + + + {/* Base = 10 */} + + + {/* Fixed ancestry boosts */} + {fixed.length > 0 && ( + + )} + + {/* Flaw */} + {hasFlaw && ( + + )} + + {/* Class key ability boost */} + {hasClassBoost && ( + + )} + + {/* Interactive free boost slots */} + {slots.map((s) => { + const isAllowed = s.options.includes(ability); + const pickedHere = picks[s.id] === ability; + const group = slotGroup(s.id); + + // Can't pick this ability in this slot if: + // (a) it's in fixed ancestry boosts and the slot is an ancestry slot + const blockedByFixed = group === 'ancestry' && fixedSet.has(ability); + // (b) another slot in the same group already picked this ability + const blockedByGroup = !pickedHere && slots.some( + (o) => o.id !== s.id && slotGroup(o.id) === group && picks[o.id] === ability, + ); + const canPick = isAllowed && !blockedByFixed && !blockedByGroup; + + return ( + + ); + })} + + {/* Total */} + + + {/* Modifier */} + + + ); + })} + + + + + + +
AbilityBaseAncestryFlawClass + {slotHeader(s)} + TotalMod
+ {ABILITY_LABELS[ability]} + {ABILITY_ABBR[ability]} + {isKey && } + 10 + {fixedSet.has(ability) + ? + : } + + {flaw === ability + ? + : } + + {classBoost === ability + ? + : } + + {isAllowed ? ( + + ) : ( + + )} + + = 18 ? 'text-accent-deep' : 'text-ink', + )}>{total} + + = 0 ? 'text-accent' : 'text-danger', + )}>{formatModifier(mod)} +
0 ? 1 : 0) + (hasFlaw ? 1 : 0) + (hasClassBoost ? 1 : 0) + slots.length + 2} className="px-2 py-1.5 text-left"> + + Each boost adds +2 (or +1 if the score is already 18 or above). A source cannot boost the same ability twice. + +
+
+ ); +} diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index bf18567..5b03ec5 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -30,6 +30,7 @@ import { DefensesSection } from './sheet/DefensesSection'; import { FeatsSection } from './sheet/FeatsSection'; import { ClassFeaturesSection } from './sheet/ClassFeaturesSection'; import { AbilityGenModal } from './sheet/AbilityGenModal'; +import { SheetAbilityTable } from './AbilityScoreTable'; import { LevelUpModal } from './sheet/LevelUpModal'; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; @@ -43,6 +44,7 @@ export function CharacterSheet({ character }: { character: Character }) { const campaigns = useCampaigns(); const [levelUp, setLevelUp] = useState(false); const [genScores, setGenScores] = useState(false); + const [showAbilityTable, setShowAbilityTable] = useState(false); const [shared, setShared] = useState(false); // Manual fallback: holds the link to show in a selectable dialog when neither // Web Share nor Clipboard is available (e.g. iPad without a secure context). @@ -237,40 +239,59 @@ 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' && } +
+ + {c.system === '5e' && } +
-
+ {/* Compact at-a-glance row */} +
{ABILITIES.map((a) => { const mod = sys.abilityModifier(c.abilities[a]); - const breakdown = abilityBuildView[a]; + const bd = abilityBuildView[a]; return ( -
-
{ABILITY_ABBR[a]}
-
{formatModifier(mod)}
- setAbilityTotal(a, v)} - /> - {breakdown.parts.length > 0 && ( -
` · ${p.delta >= 0 ? '+' : ''}${p.delta} ${p.label}`).join('')}`}> - {breakdown.base} - {breakdown.parts.map((p, i) => ( - {p.delta >= 0 ? '+' : ''}{p.delta} - ))} +
+ ); })}
-

Hover a score to see its sources. Editing a score records a “Manual” adjustment.

+ {/* Ability score breakdown modal (MPMB-style) */} + setShowAbilityTable(false)} + title="Ability Scores" + className="max-w-4xl" + > + { + const otherAdjs = abilityBuild.adjustments.filter( + (adj) => !(adj.label === 'Manual' && adj.ability === ability), + ); + const baseTotal = computeAbilities({ base: abilityBuild.base, adjustments: otherAdjs })[ability]; + setAbilityTotal(ability, baseTotal + delta); + }} + /> +

+ Click Manual to add an override adjustment. Violet = PF2e boost (+2/+1 rule). Click any score on the sheet to open this table. +

+
+ {/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */} {c.system === 'pf2e' && (
diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index a83e9b1..aa33aca 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; -import { Lightbulb, RotateCcw, Star } from 'lucide-react'; +import { Lightbulb, RotateCcw } from 'lucide-react'; import { type Campaign, type Character, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas'; import { charactersRepo } from '@/lib/db/repositories'; import { @@ -15,13 +15,13 @@ import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } fro import type { RulesetClass } from '@/lib/ruleset/normalize'; import { createRng } from '@/lib/rng'; import { newId } from '@/lib/ids'; -import { formatModifier } from '@/lib/format'; import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder'; import { briefOverview, dedupeByName } from './overview'; import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin'; import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression'; import { DND5E_SUBCLASSES, dnd5eAsiLevels } from '@/lib/rules/dnd5e/progression'; import { Modal } from '@/components/ui/Modal'; +import { Wizard5eAbilityTable, WizardPf2eAbilityTable } from '../AbilityScoreTable'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Codex'; import { Field, Input, Select } from '@/components/ui/Input'; @@ -640,151 +640,97 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un )} {stepName === 'Abilities' && ( -
+
{system === 'pf2e' && boostSlots && ( -
-

PF2e scores are built from boosts: each starts at 10, and a boost adds +2 (or +1 once a score reaches 18). Assign your free boosts below.

- {(boostSlots.fixed.length > 0 || boostSlots.flaw) && ( -
- {selectedOrigin?.name ?? 'Ancestry'}: - {boostSlots.fixed.map((a, i) => +{ABILITY_ABBR[a]})} - {boostSlots.flaw && −{ABILITY_ABBR[boostSlots.flaw]}} - (fixed) -
- )} - {boostSlots.slots.length > 0 && ( -
- {boostSlots.slots.map((s) => { - // Abilities already taken by OTHER slots in the same source (can't double-boost). - const taken = new Set(boostSource(s.id) === 'ancestry' ? boostSlots.fixed : []); - for (const o of boostSlots.slots) { - if (o.id !== s.id && boostSource(o.id) === boostSource(s.id) && boostPicks[o.id]) taken.add(boostPicks[o.id]!); - } - return ( - - ); - })} -
- )} -
- {ABILITIES.map((a) => { - const isKey = selectedClass?.keyAbilities.includes(a); - return ( -
-
{ABILITY_ABBR[a]}{isKey && }
-
{abilities[a]}
-
{formatModifier(abilityModifier(abilities[a]))}
-
- ); - })} -
-
- )} - {system !== 'pf2e' && (<> -
- {([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => ( - - ))} -
- {method === 'roll' && } - {method === 'pointbuy' &&

Points remaining: {pointBuyRemaining(pb)} / 27

} - {method === 'manual' &&

Enter each score directly (1–30).

} - {system === '5e' && selectedOrigin && Object.values(selectedOrigin.asiBonuses ?? {}).some(Boolean) && ( -

- {selectedOrigin.name} racial bonus ({ABILITIES.filter((a) => selectedOrigin.asiBonuses?.[a]).map((a) => `+${selectedOrigin.asiBonuses![a]} ${ABILITY_ABBR[a]}`).join(', ')}) is added to your final scores. -

- )} - {asiCount > 0 && ( -
-
- Ability Score Improvements at level {level} ({asiCount} ASI{asiCount > 1 ? 's' : ''}) - {asiRemaining} / {asiCount * 2} pts left -
-
- {ABILITIES.map((a) => { - const v = asiAlloc[a] ?? 0; - const base = (usesPool ? (pool[assignment[ABILITIES.indexOf(a)]!] ?? 10) : pb[ABILITIES.indexOf(a)]!) + (selectedOrigin?.asiBonuses?.[a] ?? 0); - return ( -
- {ABILITY_ABBR[a]} {v > 0 ? +{v} : null} - - - - -
- ); - })} -
-

Assign your improvements (+1 each, max 20 per ability). You can take feats instead later on the sheet.

-
- )} - {selectedClass && (() => { - const tip = getClassTip(system, selectedClass.slug); - const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0 - ? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.` - : null); - return text ? ( -

- - {text} + <> +

+ PF2e scores start at 10. Each boost adds +2 (or +1 if the score is already 18+). + Click a cell in a free-boost column to assign it — highlighted cells are active.

- ) : null; - })()} - {usesPool && !poolValid &&

Assign each value to a different ability.

} -
- {ABILITIES.map((a, i) => { - const racial = (system === '5e' && selectedOrigin?.asiBonuses?.[a]) || 0; - const asi = (system === '5e' && asiAlloc[a]) || 0; - const base = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!; - const value = base + racial + asi; - const isKey = selectedClass?.keyAbilities.includes(a); - const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1; - // 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; + { + setBoostPicks((prev) => { + if (prev[slotId] === ability) { + const next = { ...prev }; + delete next[slotId]; + return next; } - return best; - })() - : 30; - return ( -
-
{ABILITY_ABBR[a]}{isKey && }
- {usesPool ? ( - - ) : ( - setPb((prev) => prev.map((x, j) => (j === i ? Math.max(fMin, Math.min(fMax, v)) : x)))} /> - )} -
- {value} - {formatModifier(abilityModifier(value))} - {(racial || asi) ?
{base} base{racial ? ` · +${racial} race` : ''}{asi ? ` · +${asi} ASI` : ''}
: null} -
-
- ); - })} -
- )} + return { ...prev, [slotId]: ability }; + }); + }} + /> + + )} + + {system !== 'pf2e' && ( + <> + {/* Method selector */} +
+ {([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => ( + + ))} + {method === 'roll' && ( + + )} +
+ + {/* Class tip */} + {selectedClass && (() => { + const tip = getClassTip(system, selectedClass.slug); + const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0 + ? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.` + : null); + return text ? ( +

+ + {text} +

+ ) : null; + })()} + + {usesPool && !poolValid && ( +

Assign each value to a different ability.

+ )} + + {/* MPMB-style ability table */} + { + setAssignment((prev) => { + const next = [...prev]; + // Swap on collision so a value can be moved between abilities directly. + const j = next.findIndex((x, k) => x === poolIdx && k !== abilityIdx); + if (j !== -1) next[j] = next[abilityIdx]!; + next[abilityIdx] = poolIdx; + return next; + }); + }} + onChangePb={(abilityIdx, value) => { + setPb((prev) => prev.map((x, j) => (j === abilityIdx ? value : x))); + }} + onChangeAsi={(ability, delta) => { + setAsiAlloc((p) => ({ ...p, [ability]: Math.max(0, (p[ability] ?? 0) + delta) })); + }} + /> + + )}
)} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 9a6b19b..184b736 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/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 +{"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/AbilityScoreTable.tsx","./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