dd694477b2
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>
99 lines
3.8 KiB
TypeScript
99 lines
3.8 KiB
TypeScript
import type { Monster } from '@/lib/compendium/types';
|
|
import { asDamageType, type DamageDefenses, type DamageType } from '../types';
|
|
|
|
/**
|
|
* Parse a free-text defense string like "cold, fire" or
|
|
* "bludgeoning, piercing, and slashing from nonmagical attacks".
|
|
*
|
|
* We deliberately only enforce *clean* lists: a clause that carries a qualifier
|
|
* ("from nonmagical attacks", "that is silvered", etc.) is dropped into `notes`
|
|
* for the GM to read, never auto-applied — matching the "don't over-promise
|
|
* automation" rule. Returns the clean damage types plus any leftover notes.
|
|
*/
|
|
function parseDamageList(raw: string | undefined): { types: DamageType[]; notes: string[] } {
|
|
const types: DamageType[] = [];
|
|
const notes: string[] = [];
|
|
const text = (raw ?? '').trim();
|
|
if (!text) return { types, notes };
|
|
|
|
// Split on ';' first (distinct clauses), then commas/"and" within a clause.
|
|
for (const clause of text.split(/;/)) {
|
|
const c = clause.trim();
|
|
if (!c) continue;
|
|
// A qualifier ("from", "that aren't", "except") means it's conditional — note it.
|
|
if (/\b(from|that|except|unless|nonmagical|magical|silvered|adamantine)\b/i.test(c)) {
|
|
notes.push(c);
|
|
continue;
|
|
}
|
|
for (const word of c.split(/,|\band\b/)) {
|
|
const t = asDamageType(word);
|
|
if (t && !types.includes(t)) types.push(t);
|
|
}
|
|
}
|
|
return { types, notes };
|
|
}
|
|
|
|
function parseConditionList(raw: string | undefined): string[] {
|
|
return (raw ?? '')
|
|
.split(/,|\band\b|;/)
|
|
.map((s) => s.trim().toLowerCase())
|
|
.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);
|
|
const immune = parseDamageList(raw.damage_immunities);
|
|
const vulnerable = parseDamageList(raw.damage_vulnerabilities);
|
|
return {
|
|
resist: resist.types,
|
|
immune: immune.types,
|
|
vulnerable: vulnerable.types,
|
|
conditionImmune: parseConditionList(raw.condition_immunities),
|
|
notes: [...resist.notes, ...immune.notes, ...vulnerable.notes],
|
|
};
|
|
}
|