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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user