Polish sprint 1/2: restore crash fix, pf2e parity, full glyph purge
Crash fix: - restoreBackup (file + cloud pull) now validates each row through its Zod schema, backfilling new fields — an older backup no longer lands characters missing feats/concentration/etc and crashing the sheet. + test. pf2e parity (closing feature gaps vs 5e): - +9 missing classes (Animist, Exemplar, Gunslinger, Inventor, Kineticist, Magus, Psychic, Summoner, Thaumaturge) with data + class tips. - the wizard now enriches pf2e classes with their caster ability, so pf2e spellcasters get the Spells step + "Caster" tag like 5e. - editable Perception rank and spellcasting proficiency on the pf2e sheet (fixes wrong initiative / spell DC at higher levels); rank-10 spell slots. - pf2e monster resistances/weaknesses/immunities now apply in combat (flat amounts: resistance subtracts, weakness adds, 'all' matches any type). + tests. Glyph purge (finishing the emoji→Lucide migration): replaced ~40 leftover text-glyphs (✕ − + ✦ 🤫 ⬆ ⬇ ☑ ☐ ▶ ◀ ‹ › ★ ↻ ▸ ↑ ●) with Lucide icons across Modal (every dialog), the sheet sections, dice, settings, player panels, world pages, map editor, roll tray, and session UI. Gate: 293 unit + e2e green; tsc + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Button } from './Button';
|
||||
|
||||
@@ -82,7 +83,7 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
|
||||
<div className="flex items-center justify-between border-b border-line px-5 py-3">
|
||||
<h2 className="text-lg font-display font-semibold text-ink">{title}</h2>
|
||||
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog">
|
||||
✕
|
||||
<X size={16} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-5 py-4">{children}</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Sparkles, Skull, X } from 'lucide-react';
|
||||
import { useRollStore, type TrayRoll } from '@/stores/rollStore';
|
||||
import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
|
||||
import { naturalD20 } from '@/lib/dice/notation';
|
||||
@@ -35,8 +36,8 @@ export function RollTray() {
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
{last.label && <div className="truncate text-xs font-medium text-muted">{last.label}</div>}
|
||||
{crit === 'crit' && <div className="text-xs font-bold uppercase tracking-wide text-warning">✦ Critical ✦</div>}
|
||||
{crit === 'fumble' && <div className="text-xs font-bold uppercase tracking-wide text-danger">✦ Fumble ✦</div>}
|
||||
{crit === 'crit' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-warning"><Sparkles size={12} aria-hidden /> Critical <Sparkles size={12} aria-hidden /></div>}
|
||||
{crit === 'fumble' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-danger"><Skull size={12} aria-hidden /> Fumble</div>}
|
||||
{last.degree && (
|
||||
<div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}>
|
||||
{DEGREE_LABEL[last.degree]}
|
||||
@@ -44,7 +45,7 @@ export function RollTray() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink">✕</button>
|
||||
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink"><X size={14} aria-hidden /></button>
|
||||
</div>
|
||||
<div className={cn('mt-1 font-display text-4xl font-bold', crit === 'crit' ? 'text-warning' : crit === 'fumble' ? 'text-danger' : 'text-accent')}>{last.result.total}</div>
|
||||
<div className="mt-1 font-mono text-xs text-muted">{last.result.breakdown}</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function SessionPrepCard({ campaign, characters, notes, quests }: Props)
|
||||
Generate three distinct session opening hooks grounded in your campaign's current threads.
|
||||
</p>
|
||||
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
|
||||
{state === 'loading' ? 'Thinking…' : state === 'ready' ? '↻ Regenerate' : canUseLlm ? 'Generate hooks (AI)' : 'Generate hooks'}
|
||||
{state === 'loading' ? 'Thinking…' : state === 'ready' ? 'Regenerate' : canUseLlm ? 'Generate hooks (AI)' : 'Generate hooks'}
|
||||
</Button>
|
||||
|
||||
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
|
||||
|
||||
@@ -229,6 +229,23 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */}
|
||||
{c.system === 'pf2e' && (
|
||||
<section className="mb-6">
|
||||
<SectionTitle>Perception</SectionTitle>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<RankRow
|
||||
label="Perception (WIS)"
|
||||
modifier={sys.passivePerception(rulesInput) - 10}
|
||||
rank={c.perceptionRank}
|
||||
ranks={ranks}
|
||||
onRank={(rank) => update({ perceptionRank: rank })}
|
||||
system={c.system}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Saves */}
|
||||
<section className="mb-6">
|
||||
<SectionTitle>Saving Throws</SectionTitle>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Lightbulb } from 'lucide-react';
|
||||
import { Lightbulb, RotateCcw, Star } from 'lucide-react';
|
||||
import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@/lib/schemas';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import {
|
||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, SYSTEM_OPTIONS,
|
||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
||||
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
||||
} from '@/lib/rules';
|
||||
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
|
||||
@@ -65,7 +65,22 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
|
||||
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
|
||||
|
||||
useEffect(() => { let on = true; void loadClasses(system).then((c) => on && setClasses([...c].sort((a, b) => a.name.localeCompare(b.name)))); return () => { on = false; }; }, [system]);
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
void loadClasses(system).then((c) => {
|
||||
if (!on) return;
|
||||
// The pf2e class data loads with caster:'none'; merge the curated caster
|
||||
// ability so pf2e spellcasters get the Spells step + "Caster" tag like 5e.
|
||||
const enriched = system === 'pf2e'
|
||||
? c.map((rc) => {
|
||||
const def = getClassDef('pf2e', rc.name);
|
||||
return def && def.caster !== 'none' && def.spellAbility ? { ...rc, caster: def.spellAbility } : rc;
|
||||
})
|
||||
: c;
|
||||
setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
});
|
||||
return () => { on = false; };
|
||||
}, [system]);
|
||||
useEffect(() => {
|
||||
let on = true;
|
||||
if (system === '5e') {
|
||||
@@ -381,7 +396,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}>↻ Reroll</Button>}
|
||||
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}><RotateCcw size={13} aria-hidden /> Reroll</Button>}
|
||||
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
|
||||
{selectedClass && (() => {
|
||||
const tip = getClassTip(system, selectedClass.slug);
|
||||
@@ -402,7 +417,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
const isKey = selectedClass?.keyAbilities.includes(a);
|
||||
return (
|
||||
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}{isKey ? ' ★' : ''}</div>
|
||||
<div className="flex items-center justify-center gap-0.5 text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
|
||||
{usesPool ? (
|
||||
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}>
|
||||
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import type { AbilityKey, AbilityScores } from '@/lib/rules';
|
||||
import { ABILITY_ABBR, abilityModifier } from '@/lib/rules';
|
||||
import { createRng } from '@/lib/rng';
|
||||
@@ -75,7 +76,7 @@ export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: Abilit
|
||||
</div>
|
||||
|
||||
{method === 'roll' && (
|
||||
<Button size="sm" variant="secondary" className="mb-3" onClick={reroll}>↻ Reroll</Button>
|
||||
<Button size="sm" variant="secondary" className="mb-3" onClick={reroll}><RotateCcw size={13} aria-hidden /> Reroll</Button>
|
||||
)}
|
||||
{method === 'pointbuy' && (
|
||||
<p className={cn('mb-3 text-sm', remaining < 0 ? 'text-danger' : 'text-muted')}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||
@@ -84,7 +86,7 @@ export function AttacksSection({ c, update }: SectionProps) {
|
||||
{result.damage}{a.damageType ? ` ${a.damageType}` : ''}
|
||||
</button>
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}>✕</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}><X size={14} aria-hidden /></Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
|
||||
import { Minus, Plus, Star } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
import { SheetSection, type SectionProps } from './common';
|
||||
@@ -44,7 +46,7 @@ export function DefensesSection({ c, update }: SectionProps) {
|
||||
</Field>
|
||||
<Field label="Inspiration">
|
||||
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
|
||||
{d.inspiration ? '★ Inspired' : 'Grant inspiration'}
|
||||
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
|
||||
</Button>
|
||||
</Field>
|
||||
<Field label="Exhaustion (0–6)">
|
||||
@@ -64,9 +66,9 @@ export function DefensesSection({ c, update }: SectionProps) {
|
||||
</Field>
|
||||
<Field label="Hero Points">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point">−</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
|
||||
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
|
||||
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point">+</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
</Field>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { newId } from '@/lib/ids';
|
||||
import type { Feat } from '@/lib/schemas';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -37,7 +38,7 @@ export function FeatsSection({ c, update }: SectionProps) {
|
||||
<li key={f.id} className="rounded-md border border-line bg-panel p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="h-8 flex-1 font-medium" value={f.name} onChange={(e) => patch(f.id, { name: e.target.value })} aria-label="Feat name" />
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(f.id)} aria-label={`Remove ${f.name}`}>✕</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(f.id)} aria-label={`Remove ${f.name}`}><X size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
<Textarea className="mt-1 text-xs" rows={2} value={f.description} onChange={(e) => patch(f.id, { description: e.target.value })} placeholder="What it does — kept handy at the table." aria-label={`${f.name} description`} />
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import type { InventoryItem } from '@/lib/schemas';
|
||||
@@ -118,7 +120,7 @@ export function InventorySection({ c, update }: SectionProps) {
|
||||
Attuned
|
||||
</label>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}>✕</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}><X size={14} aria-hidden /></Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X, Minus, Plus } from 'lucide-react';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { getSystem, applyRest } from '@/lib/rules';
|
||||
import { spendResource, regainResource } from '@/lib/mechanics';
|
||||
@@ -62,19 +64,19 @@ export function ResourcesSection({ c, update }: SectionProps) {
|
||||
{c.resources.map((r) => (
|
||||
<li key={r.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-2">
|
||||
<Input className="h-8 flex-1" value={r.name} onChange={(e) => patch(r.id, { name: e.target.value })} aria-label="Resource name" />
|
||||
<Button size="icon" variant="ghost" onClick={() => { const res = spendResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Spend">−</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => { const res = spendResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Spend"><Minus size={14} aria-hidden /></Button>
|
||||
<span className="w-12 text-center text-sm">
|
||||
<span className="font-medium text-ink">{r.current}</span>
|
||||
<span className="text-muted">/{r.max}</span>
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" onClick={() => { const res = regainResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Regain">+</Button>
|
||||
<Button size="icon" variant="ghost" onClick={() => { const res = regainResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Regain"><Plus size={14} aria-hidden /></Button>
|
||||
<NumberField className="w-14" value={r.max} min={0} onChange={(v) => patch(r.id, { max: v, current: Math.min(v, r.current) })} aria-label="Max" />
|
||||
<Select className="w-auto py-1 text-xs" value={r.recovery} onChange={(e) => patch(r.id, { recovery: e.target.value as CharacterResource['recovery'] })}>
|
||||
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
|
||||
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}>✕</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}><X size={14} aria-hidden /></Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Sparkles, BrainCircuit } from 'lucide-react';
|
||||
import { Sparkles, BrainCircuit, X, Minus, Plus } from 'lucide-react';
|
||||
import { newId } from '@/lib/ids';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||
@@ -36,9 +36,11 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
const setSlots = (slots: typeof sc.slots) => update({ spellcasting: { ...sc, slots } });
|
||||
const patchSlot = (level: number, p: Partial<(typeof sc.slots)[number]>) =>
|
||||
setSlots(sc.slots.map((s) => (s.level === level ? { ...s, ...p } : s)));
|
||||
// pf2e has 10 spell ranks; 5e tops out at 9.
|
||||
const maxRank = c.system === 'pf2e' ? 10 : 9;
|
||||
const addSlotLevel = () => {
|
||||
const next = (sc.slots.at(-1)?.level ?? 0) + 1;
|
||||
if (next > 9) return;
|
||||
if (next > maxRank) return;
|
||||
setSlots([...sc.slots, { level: next, max: 1, current: 1 }]);
|
||||
};
|
||||
|
||||
@@ -83,6 +85,21 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
{/* pf2e: spell DC/attack scale with spellcasting proficiency (expert/master/legendary). */}
|
||||
{c.system === 'pf2e' && ability && (
|
||||
<label className="text-xs text-muted">
|
||||
Proficiency
|
||||
<Select
|
||||
className="ml-2 w-auto"
|
||||
value={c.spellcastingRank ?? 'trained'}
|
||||
onChange={(e) => update({ spellcastingRank: e.target.value as ProficiencyRank })}
|
||||
>
|
||||
{(['trained', 'expert', 'master', 'legendary'] as const).map((r) => (
|
||||
<option key={r} value={r}>{r[0]!.toUpperCase() + r.slice(1)}</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
{ability && (
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-muted">Spell DC <span className="font-display text-lg font-semibold text-accent">{dc}</span></span>
|
||||
@@ -117,9 +134,9 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
<div key={s.level} className="rounded-md border border-line bg-panel px-2 py-1 text-center">
|
||||
<div className="text-[10px] uppercase text-muted">Lv {s.level}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}>−</Button>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}><Minus size={14} aria-hidden /></Button>
|
||||
<span className="text-sm"><span className="font-medium text-ink">{s.current}</span>/<NumberField className="inline-block w-12" value={s.max} min={0} onChange={(v) => patchSlot(s.level, { max: v, current: Math.min(v, s.current) })} aria-label={`Level ${s.level} max slots`} /></span>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}>+</Button>
|
||||
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}><Plus size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -138,7 +155,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
Level
|
||||
<Select className="w-20" value={spellLevel} onChange={(e) => setSpellLevel(Number(e.target.value))}>
|
||||
<option value={0}>Cantrip</option>
|
||||
{Array.from({ length: 9 }, (_, i) => i + 1).map((l) => (
|
||||
{Array.from({ length: maxRank }, (_, i) => i + 1).map((l) => (
|
||||
<option key={l} value={l}>{l}</option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -165,7 +182,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
<Button size="sm" variant="ghost" onClick={() => cast(s.id)} aria-label={`Cast ${s.name}`}>
|
||||
<Sparkles size={13} aria-hidden /> Cast
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}>✕</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}><X size={14} aria-hidden /></Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -423,7 +423,7 @@ function CombatantRow({
|
||||
const [dmgType, setDmgType] = useState('');
|
||||
// Only show the damage-type picker when this creature actually has typed defenses.
|
||||
const def = c.damageDefenses;
|
||||
const hasDefenses = !!def && (def.resist.length > 0 || def.immune.length > 0 || def.vulnerable.length > 0);
|
||||
const hasDefenses = !!def && (def.resist.length > 0 || def.immune.length > 0 || def.vulnerable.length > 0 || (def.resistFlat?.length ?? 0) > 0 || (def.weakness?.length ?? 0) > 0);
|
||||
const dead = c.hp.current <= 0;
|
||||
const foe = c.kind === 'monster';
|
||||
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
|
||||
@@ -520,9 +520,16 @@ function CombatantRow({
|
||||
{hasDefenses && (
|
||||
<Select className="w-auto py-1 text-xs" value={dmgType} onChange={(e) => setDmgType(e.target.value)} aria-label={`${c.name} damage type`}>
|
||||
<option value="">untyped</option>
|
||||
{DAMAGE_TYPES.filter((t) => t !== 'untyped').map((t) => (
|
||||
<option key={t} value={t}>{def!.immune.includes(t) ? `${t} (immune)` : def!.resist.includes(t) ? `${t} (resist)` : def!.vulnerable.includes(t) ? `${t} (vuln)` : t}</option>
|
||||
))}
|
||||
{DAMAGE_TYPES.filter((t) => t !== 'untyped').map((t) => {
|
||||
const rf = def!.resistFlat?.find((r) => r.type === t);
|
||||
const wk = def!.weakness?.find((w) => w.type === t);
|
||||
const tag = def!.immune.includes(t) ? ' (immune)'
|
||||
: def!.vulnerable.includes(t) ? ' (vuln)'
|
||||
: def!.resist.includes(t) ? ' (resist)'
|
||||
: rf ? ` (resist ${rf.amount})`
|
||||
: wk ? ` (weak ${wk.amount})` : '';
|
||||
return <option key={t} value={t}>{t}{tag}</option>;
|
||||
})}
|
||||
</Select>
|
||||
)}
|
||||
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta, dmgType || undefined); setDelta(0); }} title="Apply damage">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import { abilityModifier } from '@/lib/rules';
|
||||
import { normalizeMonsterDefenses } from '@/lib/mechanics';
|
||||
import { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e } from '@/lib/mechanics';
|
||||
import type { CombatantDamageDefenses } from '@/lib/schemas/encounter';
|
||||
import type { CompendiumEntry, Monster, Spell, MagicItem } from '@/lib/compendium/types';
|
||||
import {
|
||||
@@ -171,7 +171,7 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1,
|
||||
initBonus: abilityModifier(m.dexterity ?? 10),
|
||||
...(m.cr !== undefined ? { cr: m.cr } : {}),
|
||||
damageDefenses: normalizeMonsterDefenses(m),
|
||||
damageDefenses: { ...normalizeMonsterDefenses(m), resistFlat: [], weakness: [] },
|
||||
};
|
||||
},
|
||||
numericSort: { label: 'CR', get: (e) => Number(e.cr) },
|
||||
@@ -245,13 +245,18 @@ export const CATEGORIES: CategoryDef[] = [
|
||||
// ---------- Pathfinder 2e ----------
|
||||
{
|
||||
...pf2eCategory('creatures', 'Bestiary', 'creatures'),
|
||||
toCombatant: (e) => ({
|
||||
name: e.name,
|
||||
ac: Number(e.ac) || 10,
|
||||
hp: Number(e.hp) || 1,
|
||||
initBonus: Number(e.perception) || 0,
|
||||
...(e.level !== undefined ? { level: Number(e.level) } : {}),
|
||||
}),
|
||||
toCombatant: (e) => {
|
||||
const d = normalizeMonsterDefensesPf2e(e as { immunity?: unknown; resistance?: unknown; weakness?: unknown });
|
||||
const hasDef = d.immune.length || d.conditionImmune.length || d.resistFlat.length || d.weakness.length;
|
||||
return {
|
||||
name: e.name,
|
||||
ac: Number(e.ac) || 10,
|
||||
hp: Number(e.hp) || 1,
|
||||
initBonus: Number(e.perception) || 0,
|
||||
...(e.level !== undefined ? { level: Number(e.level) } : {}),
|
||||
...(hasDef ? { damageDefenses: { resist: [], vulnerable: [], ...d } } : {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
classCategory('pf2e'),
|
||||
pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { Dices, Sparkles, Skull, History as HistoryIcon } from 'lucide-react';
|
||||
import { Dices, Sparkles, Skull, EyeOff, X, History as HistoryIcon } from 'lucide-react';
|
||||
import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
|
||||
import { createRng } from '@/lib/rng';
|
||||
import { diceRepo } from '@/lib/db/repositories';
|
||||
@@ -199,7 +199,7 @@ export function DicePage() {
|
||||
onClick={toggleSecret}
|
||||
title="When on, your rolls are NOT shared with the players"
|
||||
>
|
||||
🤫 Secret
|
||||
<EyeOff size={14} aria-hidden /> Secret
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -209,7 +209,7 @@ export function DicePage() {
|
||||
</p>
|
||||
)}
|
||||
{isGm && secret && (
|
||||
<p className="mt-2 text-xs text-warning">🤫 Secret is on — your rolls are not shared with the players.</p>
|
||||
<p className="mt-2 flex items-center gap-1 text-xs text-warning"><EyeOff size={12} aria-hidden /> Secret is on — your rolls are not shared with the players.</p>
|
||||
)}
|
||||
|
||||
{/* Saved rolls (macros) */}
|
||||
@@ -240,7 +240,7 @@ export function DicePage() {
|
||||
onClick={() => removeMacro(macroKey, m.id)}
|
||||
aria-label={`Remove ${m.label}`}
|
||||
>
|
||||
✕
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, Megaphone } from 'lucide-react';
|
||||
import { Check, Megaphone, X } from 'lucide-react';
|
||||
import { useUiStore } from '@/stores/uiStore';
|
||||
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -24,7 +24,7 @@ export function HandoutControl() {
|
||||
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
|
||||
<Megaphone size={14} aria-hidden /> Handout {active && <Check size={12} aria-hidden />}
|
||||
</Button>
|
||||
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout">✕</Button>}
|
||||
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"><X size={14} aria-hidden /></Button>}
|
||||
{open && (
|
||||
<Modal open onClose={() => setOpen(false)} title="Handout for players"
|
||||
footer={<>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { X } from 'lucide-react';
|
||||
import { useSessionStore } from '@/stores/sessionStore';
|
||||
import { leaveRoom } from '@/lib/sync/wsSync';
|
||||
|
||||
@@ -11,9 +12,11 @@ export function PlayerSessionBadge() {
|
||||
const label = status === 'connected' ? 'Live' : status === 'connecting' ? 'Connecting…' : status;
|
||||
return (
|
||||
<div data-testid="player-live" className="flex items-center gap-1 rounded-md border border-success/40 bg-success/5 px-2 py-1 text-xs">
|
||||
<span className={status === 'connected' ? 'text-success' : 'text-muted'}>● {label}</span>
|
||||
<span className={`inline-flex items-center gap-1 ${status === 'connected' ? 'text-success' : 'text-muted'}`}>
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${status === 'connected' ? 'bg-success' : 'bg-muted'}`} aria-hidden />{label}
|
||||
</span>
|
||||
<Link to="/play" className="font-mono font-semibold text-accent" title="Open the table">{intent.joinCode}</Link>
|
||||
<button onClick={leaveRoom} className="px-1 text-danger" title="Leave session">✕</button>
|
||||
<button onClick={leaveRoom} className="px-1 text-danger" title="Leave session" aria-label="Leave session"><X size={13} aria-hidden /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { sessionLogRepo } from '@/lib/db/repositories';
|
||||
import type { SessionLogEntry } from '@/lib/schemas';
|
||||
import type { RosterEntry } from '@/lib/sync/messages';
|
||||
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
|
||||
import { Dice5, Mail, MessageSquare } from 'lucide-react';
|
||||
import { Dice5, Mail, MessageSquare, X } from 'lucide-react';
|
||||
import { Input, Textarea } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
@@ -87,7 +87,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink">✕</button>
|
||||
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink"><X size={15} aria-hidden /></button>
|
||||
</div>
|
||||
|
||||
{!inSession && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, ChevronRight, ArrowUp } from 'lucide-react';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import { getTurnGuide, getGenericGuide } from '@/lib/assistant/actions';
|
||||
|
||||
@@ -8,8 +8,8 @@ export function ActionGuide({ character }: { character: Character }) {
|
||||
|
||||
return (
|
||||
<details className="mt-3 rounded-lg border border-line bg-surface-2">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-xs text-muted hover:text-ink">
|
||||
What can I do on my turn? ▸
|
||||
<summary className="flex cursor-pointer select-none items-center gap-1 px-3 py-2 text-xs text-muted hover:text-ink">
|
||||
<ChevronRight size={13} aria-hidden /> What can I do on my turn?
|
||||
</summary>
|
||||
<div className="border-t border-line px-3 pb-3 pt-2">
|
||||
<ul className="space-y-2">
|
||||
@@ -21,8 +21,8 @@ export function ActionGuide({ character }: { character: Character }) {
|
||||
))}
|
||||
</ul>
|
||||
{guide.upgradeAt && character.level < guide.upgradeAt.level && (
|
||||
<p className="mt-2 rounded-md border border-accent/30 bg-accent/5 px-2 py-1 text-xs text-accent">
|
||||
↑ {guide.upgradeAt.note}
|
||||
<p className="mt-2 flex items-center gap-1 rounded-md border border-accent/30 bg-accent/5 px-2 py-1 text-xs text-accent">
|
||||
<ArrowUp size={12} aria-hidden /> {guide.upgradeAt.note}
|
||||
</p>
|
||||
)}
|
||||
{guide.upgradeAt && character.level >= guide.upgradeAt.level && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Sparkles, BrainCircuit } from 'lucide-react';
|
||||
import { Sparkles, BrainCircuit, Minus, Plus, X } from 'lucide-react';
|
||||
import type { Character, Defenses } from '@/lib/schemas';
|
||||
import { castSpell, dropConcentration } from '@/lib/mechanics';
|
||||
import { ActionGuide } from './ActionGuide';
|
||||
@@ -55,8 +55,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
||||
<Button size="sm" variant="primary" onClick={() => setHp(c.hp.current + 5)}>+5</Button>
|
||||
<span className="mx-1 h-4 w-px bg-line" />
|
||||
<span className="text-xs text-muted">Temp</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp - 1)}>−</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp + 1)}>+</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp - 1)}><Minus size={14} aria-hidden /></Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp + 1)}><Plus size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
<Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
|
||||
<Pips label="Death saves ✗" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
|
||||
<Pips label="Death saves (fails)" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
|
||||
<div className="flex items-center justify-between">
|
||||
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} />
|
||||
<label className="flex items-center gap-1"><input type="checkbox" checked={c.defenses.inspiration} onChange={(e) => setDef({ inspiration: e.target.checked })} /> Inspiration</label>
|
||||
@@ -155,9 +155,9 @@ function Counter({ label, value, min, max, onChange, suffix }: { label: string;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{label && <span className="flex-1 text-xs text-muted">{label}</span>}
|
||||
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value - 1, min, max))}>−</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value - 1, min, max))}><Minus size={14} aria-hidden /></Button>
|
||||
<span className="w-8 text-center tabular-nums text-ink">{value}{suffix ?? ''}</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value + 1, min, max))}>+</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value + 1, min, max))}><Plus size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,8 +191,8 @@ function ConditionsBox({ character: c, onPatch }: { character: Character; onPatc
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{c.conditions.length === 0 && <span className="text-xs text-muted">None.</span>}
|
||||
{c.conditions.map((cond, i) => (
|
||||
<button key={i} onClick={() => remove(i)} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:bg-danger/20 hover:text-danger" title="Remove">
|
||||
{cond.name}{cond.value ? ` ${cond.value}` : ''} ✕
|
||||
<button key={i} onClick={() => remove(i)} className="inline-flex items-center gap-1 rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:bg-danger/20 hover:text-danger" title="Remove">
|
||||
{cond.name}{cond.value ? ` ${cond.value}` : ''} <X size={11} aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle } from 'lucide-react';
|
||||
import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle, Play } from 'lucide-react';
|
||||
import type { SystemId } from '@/lib/rules';
|
||||
import type { Snapshot } from '@/lib/sync/messages';
|
||||
import type { PlayerMap } from '@/lib/map';
|
||||
@@ -104,7 +104,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
||||
<li key={c.id} className={cn('flex items-center gap-3 rounded-xl border px-3 py-2', c.isCurrent ? 'border-accent bg-accent-glow ring-1 ring-accent/40' : 'border-line bg-panel')}>
|
||||
<span className="w-8 text-center font-mono text-lg font-semibold text-accent-deep">{c.initiative}</span>
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
|
||||
<span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent">◀ turn</span>}</span>
|
||||
<span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 inline-flex items-center gap-0.5 text-xs text-accent"><Play size={11} aria-hidden /> turn</span>}</span>
|
||||
{c.conditions.map((cond, i) => (
|
||||
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
|
||||
))}
|
||||
@@ -137,7 +137,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
||||
<ul className="mt-1.5 space-y-0.5 pl-1 text-sm">
|
||||
{q.objectives.map((o, oi) => (
|
||||
<li key={oi} className={cn('flex items-start gap-1.5', o.done ? 'text-muted line-through' : 'text-ink')}>
|
||||
<span className="mt-0.5 text-xs text-faint">{o.done ? '☑' : '☐'}</span>
|
||||
{o.done ? <CheckCircle2 size={13} className="mt-0.5 shrink-0 text-success" aria-hidden /> : <Circle size={13} className="mt-0.5 shrink-0 text-faint" aria-hidden />}
|
||||
<span>{o.text}</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -219,8 +219,8 @@ function CloudSync() {
|
||||
{user ? (
|
||||
<div className="space-y-3 pt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="primary" disabled={busy} onClick={push}>⬆ Back up to cloud</Button>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}>⬇ Restore from cloud</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={push}><Upload size={14} aria-hidden /> Back up to cloud</Button>
|
||||
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}><Download size={14} aria-hidden /> Restore from cloud</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={signOut}>Sign out</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { Calendar, Campaign } from '@/lib/schemas';
|
||||
import { calendarRepo } from '@/lib/db/repositories';
|
||||
import { newId } from '@/lib/ids';
|
||||
@@ -55,7 +56,7 @@ function CalendarView({ campaign }: { campaign: Campaign }) {
|
||||
<li key={ev.id} className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 text-sm">
|
||||
<span className={'w-16 shrink-0 font-mono ' + (ev.day === calendar.currentDay ? 'text-accent' : 'text-muted')}>Day {ev.day}</span>
|
||||
<span className="flex-1 text-ink">{ev.title}</span>
|
||||
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}>✕</button>
|
||||
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}><X size={13} aria-hidden /></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { Campaign, Homebrew, HomebrewKind } from '@/lib/schemas';
|
||||
import { homebrewSchema } from '@/lib/schemas';
|
||||
import { homebrewRepo } from '@/lib/db/repositories';
|
||||
@@ -102,7 +104,7 @@ function HomebrewCard({ hb }: { hb: Homebrew }) {
|
||||
<div className="rounded-lg border border-line bg-panel p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="font-display font-semibold" value={h.name} onChange={(e) => update({ name: e.target.value })} aria-label="Homebrew name" />
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}>✕</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}><X size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
{HOMEBREW_FIELDS[hb.kind].length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Wand2 } from 'lucide-react';
|
||||
import { Wand2, X } from 'lucide-react';
|
||||
import type { Campaign, Npc } from '@/lib/schemas';
|
||||
import { npcsRepo } from '@/lib/db/repositories';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
@@ -89,7 +89,7 @@ function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
|
||||
<Button size="sm" variant="ghost" disabled={genState === 'loading'} onClick={() => void generateDetails()} title="Fill description with generated details" aria-label="Generate NPC details">
|
||||
<Wand2 size={13} aria-hidden />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}>✕</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}><X size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { Campaign, Quest, Objective } from '@/lib/schemas';
|
||||
import { questsRepo } from '@/lib/db/repositories';
|
||||
import { newId } from '@/lib/ids';
|
||||
@@ -108,7 +110,7 @@ function QuestCard({ quest }: { quest: Quest }) {
|
||||
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
|
||||
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}>✕</Button>
|
||||
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
@@ -136,7 +138,7 @@ function QuestCard({ quest }: { quest: Quest }) {
|
||||
value={o.text}
|
||||
onChange={(e) => setObjective(o.id, { text: e.target.value })}
|
||||
/>
|
||||
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective">✕</button>
|
||||
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective"><X size={13} aria-hidden /></button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Move, ScanEye, EyeOff, Ruler, Cloud, PenTool, Crosshair, Spline, CirclePlus,
|
||||
ChevronRight, type LucideIcon,
|
||||
ChevronRight, ChevronLeft, Play, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
|
||||
import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
|
||||
@@ -329,9 +329,9 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
||||
{activeEncounter && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-accent-glow p-2 text-sm">
|
||||
<span className="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</span>
|
||||
<span className="font-display font-semibold text-ink">▶ {activeCombatant?.name ?? '—'}</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}>‹ Prev</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn ›</Button>
|
||||
<span className="flex items-center gap-1 font-display font-semibold text-ink"><Play size={14} aria-hidden /> {activeCombatant?.name ?? '—'}</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}><ChevronLeft size={14} aria-hidden /> Prev</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn <ChevronRight size={14} aria-hidden /></Button>
|
||||
{activeTokenId ? <span className="text-xs text-muted">— their token glows on the map</span> : <span className="text-xs text-muted">— place their token from the palette</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
|
||||
<aside className="flex h-full min-h-0 flex-col gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Tokens</h3>
|
||||
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette">‹ Hide</button>
|
||||
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette">Hide</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
|
||||
@@ -200,6 +200,60 @@ const TIPS_PF2E: Record<string, ClassTip> = {
|
||||
skillSuggestions: 'Arcana, Occultism, Society, History',
|
||||
abilityTip: 'Intelligence first, always. Constitution second for concentration spells. Dexterity for AC matters early when you have no armour.',
|
||||
},
|
||||
animist: {
|
||||
role: 'A divine/spirit caster who channels apparitions for shifting spell options. Versatile but bookkeeping-heavy.',
|
||||
primaryAbilities: 'Wisdom (spells, focus) · Constitution (durability)',
|
||||
beginner: false,
|
||||
skillSuggestions: 'Religion, Nature, Occultism, Medicine',
|
||||
},
|
||||
exemplar: {
|
||||
role: 'A mythic melee striker who channels divine sparks (Ikons) into legendary feats of arms. Big, cinematic hits.',
|
||||
primaryAbilities: 'Strength (attacks) · Constitution (durability)',
|
||||
beginner: false,
|
||||
skillSuggestions: 'Athletics, Religion, Intimidation, Diplomacy',
|
||||
},
|
||||
gunslinger: {
|
||||
role: 'A firearms and crossbow specialist with devastating first-shot damage and unique reload tactics.',
|
||||
primaryAbilities: 'Dexterity (attacks, AC) · Perception (initiative, deadly aim)',
|
||||
beginner: false,
|
||||
skillSuggestions: 'Crafting, Acrobatics, Stealth, Intimidation',
|
||||
},
|
||||
inventor: {
|
||||
role: 'A gadgeteer who builds an innovation (armour, weapon, or construct) and Overdrives it for risky power spikes.',
|
||||
primaryAbilities: 'Intelligence (key checks) · Constitution or Dexterity (defence)',
|
||||
beginner: false,
|
||||
skillSuggestions: 'Crafting, Arcana, Society, Athletics',
|
||||
},
|
||||
kineticist: {
|
||||
role: 'An elemental blaster who channels kinetic gates for at-will impulses — no spell slots to track. Easy to play, hard to run dry.',
|
||||
primaryAbilities: 'Constitution (impulse DC, durability)',
|
||||
beginner: true,
|
||||
skillSuggestions: 'Nature, Athletics, Acrobatics, Crafting',
|
||||
},
|
||||
magus: {
|
||||
role: 'A spell-and-sword duelist who fuses a spell into a strike with Spellstrike. Bursty; manage your spell slots carefully.',
|
||||
primaryAbilities: 'Strength or Dexterity (attacks) · Intelligence (spells, DC)',
|
||||
beginner: false,
|
||||
skillSuggestions: 'Arcana, Athletics, Acrobatics, Society',
|
||||
},
|
||||
psychic: {
|
||||
role: 'An occult spontaneous caster with amped cantrips and a psyche you unleash for big turns. Few spells, huge cantrips.',
|
||||
primaryAbilities: 'Charisma or Intelligence (spells, DC) · Constitution (durability)',
|
||||
beginner: false,
|
||||
skillSuggestions: 'Occultism, Arcana, Society, Intimidation',
|
||||
},
|
||||
summoner: {
|
||||
role: 'You and your eidolon act as one — a powerful summoned ally you command. Two bodies, one shared action economy.',
|
||||
primaryAbilities: 'Charisma (spells, eidolon) · Constitution (shared HP matters)',
|
||||
beginner: false,
|
||||
skillSuggestions: 'Arcana, Nature, Athletics, Diplomacy',
|
||||
},
|
||||
thaumaturge: {
|
||||
role: 'An occult investigator who exploits monster weaknesses with esoteric implements and a tome of lore. Skill-rich and adaptable.',
|
||||
primaryAbilities: 'Charisma (key checks, Exploit Vulnerability) · Constitution or Dexterity (defence)',
|
||||
beginner: false,
|
||||
skillSuggestions: 'Occultism, Religion, Arcana, Diplomacy',
|
||||
},
|
||||
};
|
||||
|
||||
export function getClassTip(system: string, classSlug: string): ClassTip | undefined {
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('HP transitions', () => {
|
||||
});
|
||||
|
||||
it('applies typed-damage resistances when defenses are present', () => {
|
||||
const base = { ...mk('a', 0, 20), damageDefenses: { resist: ['fire'], immune: ['poison'], vulnerable: ['cold'], conditionImmune: [], notes: [] } };
|
||||
const base = { ...mk('a', 0, 20), damageDefenses: { resist: ['fire'], immune: ['poison'], vulnerable: ['cold'], conditionImmune: [], notes: [], resistFlat: [], weakness: [] } };
|
||||
expect(applyDamage(base, 10, 'fire').hp.current).toBe(15); // resisted → 5
|
||||
expect(applyDamage(base, 10, 'poison').hp.current).toBe(20); // immune → 0
|
||||
expect(applyDamage(base, 10, 'cold').hp.current).toBe(0); // vulnerable → 20
|
||||
@@ -174,6 +174,14 @@ describe('HP transitions', () => {
|
||||
it('typeless damage is unchanged when no defenses are set', () => {
|
||||
expect(applyDamage(mk('a', 0, 10), 4, 'fire').hp.current).toBe(6);
|
||||
});
|
||||
|
||||
it('applies pf2e flat weakness (adds) and resistance (subtracts)', () => {
|
||||
const wk = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: [], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [], weakness: [{ type: 'fire', amount: 5 }] } };
|
||||
expect(applyDamage(wk, 10, 'fire').hp.current).toBe(15); // 10 + 5 weakness
|
||||
const rs = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: [], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [{ type: 'all', amount: 5 }], weakness: [] } };
|
||||
expect(applyDamage(rs, 8, 'cold').hp.current).toBe(27); // 30 - (8 - 5 'all') = 27
|
||||
expect(applyDamage(rs, 4, 'cold').hp.current).toBe(30); // 4 - 5 → 0, no change
|
||||
});
|
||||
});
|
||||
|
||||
describe('endEncounter', () => {
|
||||
|
||||
@@ -219,9 +219,15 @@ export function applyDamage(c: Combatant, amount: number, type?: string): Combat
|
||||
let dmg = amount;
|
||||
const def = c.damageDefenses;
|
||||
if (type && def) {
|
||||
if (def.immune.includes(type)) dmg = 0;
|
||||
else if (def.vulnerable.includes(type)) dmg = amount * 2;
|
||||
if (def.immune.includes(type)) return c; // immune — no change
|
||||
// 5e: halve / double
|
||||
if (def.vulnerable.includes(type)) dmg = amount * 2;
|
||||
else if (def.resist.includes(type)) dmg = Math.floor(amount / 2);
|
||||
// pf2e: flat weakness adds, then flat resistance subtracts ('all' matches any type)
|
||||
const wk = def.weakness?.find((w) => w.type === type || w.type === 'all');
|
||||
if (wk) dmg += wk.amount;
|
||||
const rs = def.resistFlat?.find((r) => r.type === type || r.type === 'all');
|
||||
if (rs) dmg = Math.max(0, dmg - rs.amount);
|
||||
}
|
||||
if (dmg <= 0) return c; // fully resisted / immune — no change
|
||||
|
||||
|
||||
@@ -30,4 +30,28 @@ describe('backup round-trip', () => {
|
||||
await expect(restoreBackup('{"foo":1}')).rejects.toThrow();
|
||||
await expect(restoreBackup('not json')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('backfills new fields on a character from an older backup (no missing-field crash)', async () => {
|
||||
const c = await campaignsRepo.create({ name: 'Old Save', system: '5e', description: '' });
|
||||
// Simulate a pre-v15 character row: no feats/concentration/equippedArmor.
|
||||
const legacy = {
|
||||
format: 'ttrpg-manager:backup', version: 1,
|
||||
data: {
|
||||
campaigns: [{ id: c.id, name: 'Old Save', system: '5e', description: '', createdAt: 't', updatedAt: 't' }],
|
||||
characters: [{
|
||||
id: 'old1', campaignId: c.id, system: '5e', kind: 'pc', name: 'Legacy', ancestry: '', className: 'Fighter', level: 3,
|
||||
abilities: { str: 14, dex: 12, con: 13, int: 10, wis: 11, cha: 9 }, hp: { current: 20, max: 20, temp: 0 },
|
||||
speed: 30, armorBonus: 0, skillRanks: {}, saveRanks: {}, perceptionRank: 'trained', notes: '', createdAt: 't', updatedAt: 't',
|
||||
}],
|
||||
},
|
||||
};
|
||||
await clearAllData();
|
||||
await restoreBackup(JSON.stringify(legacy));
|
||||
const [restored] = await charactersRepo.listByCampaign(c.id);
|
||||
expect(restored!.name).toBe('Legacy');
|
||||
// The fields the sheet reads must exist (default-backfilled), not be undefined.
|
||||
expect(restored!.feats).toEqual([]);
|
||||
expect(restored!.concentration).toBeNull();
|
||||
expect(restored!.spellcasting.spells).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+22
-1
@@ -1,4 +1,9 @@
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
import { db } from '@/lib/db/db';
|
||||
import {
|
||||
campaignSchema, characterSchema, encounterSchema, diceRollSchema,
|
||||
noteSchema, npcSchema, questSchema, calendarSchema, battleMapSchema, homebrewSchema,
|
||||
} from '@/lib/schemas';
|
||||
import { downloadJson } from './file';
|
||||
|
||||
const TABLES = [
|
||||
@@ -6,6 +11,14 @@ const TABLES = [
|
||||
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew',
|
||||
] as const;
|
||||
|
||||
/** Schema per table, so restored rows are validated + backfilled with defaults
|
||||
* (a backup from an older app version is missing newer fields, e.g. `feats`). */
|
||||
const SCHEMA: Record<(typeof TABLES)[number], ZodTypeAny> = {
|
||||
campaigns: campaignSchema, characters: characterSchema, encounters: encounterSchema,
|
||||
diceRolls: diceRollSchema, notes: noteSchema, npcs: npcSchema, quests: questSchema,
|
||||
calendars: calendarSchema, maps: battleMapSchema, homebrew: homebrewSchema,
|
||||
};
|
||||
|
||||
const FORMAT = 'ttrpg-manager:backup';
|
||||
|
||||
export class BackupError extends Error {}
|
||||
@@ -40,7 +53,15 @@ export async function restoreBackup(text: string): Promise<void> {
|
||||
for (const t of TABLES) {
|
||||
await db.table(t).clear();
|
||||
const rows = data[t];
|
||||
if (Array.isArray(rows) && rows.length) await db.table(t).bulkAdd(rows);
|
||||
if (!Array.isArray(rows) || !rows.length) continue;
|
||||
// Validate + backfill defaults so a backup from an older app version (or an
|
||||
// edited/foreign file) can't land rows missing newer fields and crash the UI.
|
||||
const valid: unknown[] = [];
|
||||
for (const row of rows) {
|
||||
const r = SCHEMA[t].safeParse(row);
|
||||
if (r.success) valid.push(r.data);
|
||||
}
|
||||
if (valid.length) await db.table(t).bulkAdd(valid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export * from './types';
|
||||
export type { EnforceResult } from './result';
|
||||
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
|
||||
export { normalizeArmor5e } from './normalize/armor';
|
||||
export { normalizeMonsterDefenses } from './normalize/monsterDefenses';
|
||||
export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefenses } from './normalize/monsterDefenses';
|
||||
export { castSpell, dropConcentration, concentrationDC } from './cast';
|
||||
export { spendResource, regainResource } from './resources';
|
||||
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
|
||||
|
||||
@@ -40,6 +40,49 @@ function parseConditionList(raw: string | undefined): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Normalize a pf2e damage/condition word ("non-magical attacks", "cold_iron"). */
|
||||
function pf2eType(s: string): DamageType | undefined {
|
||||
return asDamageType(s.replace(/_/g, ' ').trim());
|
||||
}
|
||||
|
||||
/** pf2e creature flat-defense shape, ready to attach to a combatant. */
|
||||
export interface Pf2eDefenses {
|
||||
immune: DamageType[];
|
||||
conditionImmune: string[];
|
||||
resistFlat: { type: string; amount: number }[];
|
||||
weakness: { type: string; amount: number }[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* pf2e creature → flat resistances/weaknesses + immunities. pf2e uses flat
|
||||
* amounts (resistance 5, weakness 5) rather than 5e's halve/double, and 'all'
|
||||
* applies to every type. Immunities mix damage types and conditions.
|
||||
*/
|
||||
export function normalizeMonsterDefensesPf2e(raw: {
|
||||
immunity?: unknown; resistance?: unknown; weakness?: unknown;
|
||||
}): Pf2eDefenses {
|
||||
const immune: DamageType[] = [];
|
||||
const conditionImmune: string[] = [];
|
||||
for (const i of Array.isArray(raw.immunity) ? (raw.immunity as string[]) : []) {
|
||||
const t = pf2eType(String(i));
|
||||
if (t) immune.push(t);
|
||||
else conditionImmune.push(String(i).trim().toLowerCase());
|
||||
}
|
||||
const flat = (obj: unknown): { type: string; amount: number }[] => {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
const out: { type: string; amount: number }[] = [];
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const amount = Number(v);
|
||||
if (!Number.isFinite(amount) || amount <= 0) continue;
|
||||
const key = k.toLowerCase() === 'all' ? 'all' : pf2eType(k);
|
||||
if (key) out.push({ type: key, amount });
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return { immune, conditionImmune, resistFlat: flat(raw.resistance), weakness: flat(raw.weakness), notes: [] };
|
||||
}
|
||||
|
||||
/** Open5e monster → structured damage/condition defenses. */
|
||||
export function normalizeMonsterDefenses(raw: Monster): DamageDefenses {
|
||||
const resist = parseDamageList(raw.damage_resistances);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeSpell5e, normalizeSpellPf2e } from './spell';
|
||||
import { normalizeArmor5e } from './armor';
|
||||
import { normalizeMonsterDefenses } from './monsterDefenses';
|
||||
import { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e } from './monsterDefenses';
|
||||
import { effectiveArmorClass } from '../types';
|
||||
|
||||
describe('normalizeSpell5e', () => {
|
||||
@@ -103,3 +103,23 @@ describe('normalizeMonsterDefenses', () => {
|
||||
expect(d.notes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeMonsterDefensesPf2e', () => {
|
||||
it('splits immunities into damage types vs conditions and reads flat resist/weakness', () => {
|
||||
const d = normalizeMonsterDefensesPf2e({
|
||||
immunity: ['fire', 'poison', 'paralysis', 'unconscious', 'cold_iron'],
|
||||
resistance: { cold: 5, bludgeoning: 5, lawful: 5, all: 3 },
|
||||
weakness: { fire: 10 },
|
||||
});
|
||||
expect(d.immune).toEqual(['fire', 'poison']); // damage types only
|
||||
expect(d.conditionImmune).toEqual(['paralysis', 'unconscious', 'cold_iron']); // non-damage immunities
|
||||
expect(d.resistFlat).toContainEqual({ type: 'cold', amount: 5 });
|
||||
expect(d.resistFlat).toContainEqual({ type: 'all', amount: 3 });
|
||||
expect(d.resistFlat.find((r) => r.type === 'lawful')).toBeUndefined(); // alignment, not damage
|
||||
expect(d.weakness).toEqual([{ type: 'fire', amount: 10 }]);
|
||||
});
|
||||
|
||||
it('tolerates missing/empty fields', () => {
|
||||
expect(normalizeMonsterDefensesPf2e({})).toEqual({ immune: [], conditionImmune: [], resistFlat: [], weakness: [], notes: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,19 +8,28 @@ import type { ClassDef } from '../progression';
|
||||
*/
|
||||
export const PF2E_CLASSES: ClassDef[] = [
|
||||
cls('Alchemist', 8, ['int'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'trained', 3, 'none'),
|
||||
cls('Animist', 8, ['wis'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'wis'),
|
||||
cls('Barbarian', 12, ['str'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'expert', 3, 'none'),
|
||||
cls('Bard', 8, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'expert', 4, 'full', 'cha'),
|
||||
cls('Champion', 10, ['str'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'trained', 2, 'none'),
|
||||
cls('Cleric', 8, ['wis'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'wis'),
|
||||
cls('Druid', 8, ['wis'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'trained', 2, 'full', 'wis'),
|
||||
cls('Exemplar', 10, ['str'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'trained', 3, 'none'),
|
||||
cls('Fighter', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'expert', 3, 'none'),
|
||||
cls('Gunslinger', 8, ['dex'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'expert', 3, 'none'),
|
||||
cls('Inventor', 8, ['int'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'trained', 3, 'none'),
|
||||
cls('Investigator', 8, ['int'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 4, 'none'),
|
||||
cls('Kineticist', 8, ['con'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'trained', 3, 'none'),
|
||||
cls('Magus', 8, ['str', 'dex', 'int'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'trained', 3, 'full', 'int'),
|
||||
cls('Monk', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'expert' }, 'trained', 4, 'none'),
|
||||
cls('Oracle', 8, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'cha'),
|
||||
cls('Psychic', 6, ['cha', 'int'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'cha'),
|
||||
cls('Ranger', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'expert', 4, 'none'),
|
||||
cls('Rogue', 8, ['dex'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 7, 'none'),
|
||||
cls('Sorcerer', 6, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'cha'),
|
||||
cls('Summoner', 10, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'cha'),
|
||||
cls('Swashbuckler', 10, ['dex'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 4, 'none'),
|
||||
cls('Thaumaturge', 8, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'none'),
|
||||
cls('Witch', 6, ['int'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'int'),
|
||||
cls('Wizard', 6, ['int'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'int'),
|
||||
];
|
||||
|
||||
@@ -5,12 +5,17 @@ export const combatantKindSchema = z.enum(['pc', 'npc', 'monster']);
|
||||
|
||||
/** Damage/condition defenses snapshotted from the bestiary at add time, so the
|
||||
* combat engine can apply resistances without an async compendium load. */
|
||||
const flatDefenseSchema = z.object({ type: z.string(), amount: int.nonnegative() });
|
||||
export const damageDefensesSchema = z.object({
|
||||
// 5e: halve / double / negate by type
|
||||
resist: z.array(z.string()).default([]),
|
||||
immune: z.array(z.string()).default([]),
|
||||
vulnerable: z.array(z.string()).default([]),
|
||||
conditionImmune: z.array(z.string()).default([]),
|
||||
notes: z.array(z.string()).default([]),
|
||||
// pf2e: flat resistance subtracts, weakness adds (type 'all' applies to any)
|
||||
resistFlat: z.array(flatDefenseSchema).default([]),
|
||||
weakness: z.array(flatDefenseSchema).default([]),
|
||||
});
|
||||
export type CombatantDamageDefenses = z.infer<typeof damageDefensesSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user