Tokens
- ‹ Hide
+ Hide
diff --git a/src/lib/assistant/builder.ts b/src/lib/assistant/builder.ts
index 22ba3f3..0523c38 100644
--- a/src/lib/assistant/builder.ts
+++ b/src/lib/assistant/builder.ts
@@ -200,6 +200,60 @@ const TIPS_PF2E: Record = {
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 {
diff --git a/src/lib/combat/engine.test.ts b/src/lib/combat/engine.test.ts
index 5c4f46a..fcfd7ca 100644
--- a/src/lib/combat/engine.test.ts
+++ b/src/lib/combat/engine.test.ts
@@ -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', () => {
diff --git a/src/lib/combat/engine.ts b/src/lib/combat/engine.ts
index 83c280c..ac63e49 100644
--- a/src/lib/combat/engine.ts
+++ b/src/lib/combat/engine.ts
@@ -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
diff --git a/src/lib/io/backup.test.ts b/src/lib/io/backup.test.ts
index 1adbf95..3ca379c 100644
--- a/src/lib/io/backup.test.ts
+++ b/src/lib/io/backup.test.ts
@@ -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([]);
+ });
});
diff --git a/src/lib/io/backup.ts b/src/lib/io/backup.ts
index 2774e53..8b42205 100644
--- a/src/lib/io/backup.ts
+++ b/src/lib/io/backup.ts
@@ -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 {
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);
}
});
}
diff --git a/src/lib/mechanics/index.ts b/src/lib/mechanics/index.ts
index f8e2c56..70837c9 100644
--- a/src/lib/mechanics/index.ts
+++ b/src/lib/mechanics/index.ts
@@ -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';
diff --git a/src/lib/mechanics/normalize/monsterDefenses.ts b/src/lib/mechanics/normalize/monsterDefenses.ts
index 68161e2..40f54a5 100644
--- a/src/lib/mechanics/normalize/monsterDefenses.ts
+++ b/src/lib/mechanics/normalize/monsterDefenses.ts
@@ -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)) {
+ 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);
diff --git a/src/lib/mechanics/normalize/normalize.test.ts b/src/lib/mechanics/normalize/normalize.test.ts
index db2bd88..d96e9d5 100644
--- a/src/lib/mechanics/normalize/normalize.test.ts
+++ b/src/lib/mechanics/normalize/normalize.test.ts
@@ -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: [] });
+ });
+});
diff --git a/src/lib/rules/pf2e/progression.ts b/src/lib/rules/pf2e/progression.ts
index e64fc55..1a7b737 100644
--- a/src/lib/rules/pf2e/progression.ts
+++ b/src/lib/rules/pf2e/progression.ts
@@ -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'),
];
diff --git a/src/lib/schemas/encounter.ts b/src/lib/schemas/encounter.ts
index 64adcb6..393800d 100644
--- a/src/lib/schemas/encounter.ts
+++ b/src/lib/schemas/encounter.ts
@@ -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;