Phase 1: player character depth
- Inventory + currency + encumbrance vs carrying capacity - Class resources with correct short/long/daily rest recovery (applyRest) - Spellcasting: slots (+pact), known/prepared spells, derived DC + attack - Defenses: 5e death saves/inspiration/exhaustion, pf2e dying/wounded/hero points - Derived weapon attacks + passive perception via extended RulesSystem - Dexie v2 migration backfills new fields; debounced save flushes on pagehide - 9 new unit tests, new character-depth e2e; all green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Campaign } from '@/lib/schemas/campaign';
|
||||
import type { Character } from '@/lib/schemas/character';
|
||||
import type { Encounter } from '@/lib/schemas/encounter';
|
||||
import type { DiceRoll } from '@/lib/schemas/dice';
|
||||
import { characterDefaults } from '@/lib/schemas/character';
|
||||
|
||||
/**
|
||||
* Single Dexie instance for the whole app. Dexie wraps IndexedDB transactions
|
||||
@@ -26,6 +27,18 @@ export class TtrpgDatabase extends Dexie {
|
||||
encounters: 'id, campaignId, status',
|
||||
diceRolls: 'id, campaignId, createdAt',
|
||||
});
|
||||
|
||||
// v2 — Phase 1 character depth: backfill new fields on existing characters.
|
||||
this.version(2).stores({}).upgrade(async (tx) => {
|
||||
await tx
|
||||
.table('characters')
|
||||
.toCollection()
|
||||
.modify((c: Record<string, unknown>) => {
|
||||
for (const [key, value] of Object.entries(characterDefaults())) {
|
||||
if (c[key] === undefined) c[key] = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,13 @@ export function defaultAbilityScores(): AbilityScores {
|
||||
return { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
||||
}
|
||||
|
||||
/** Compose a damage expression from dice and a flat modifier, e.g. ("1d8", 3) -> "1d8+3". */
|
||||
export function formatDamage(dice: string | undefined, mod: number): string {
|
||||
const base = dice && dice.trim() !== '' ? dice.trim() : '0';
|
||||
if (mod === 0) return base;
|
||||
return mod > 0 ? `${base}+${mod}` : `${base}${mod}`;
|
||||
}
|
||||
|
||||
export const ABILITY_LABELS: Record<AbilityKey, string> = {
|
||||
str: 'Strength',
|
||||
dex: 'Dexterity',
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { dnd5e } from './dnd5e';
|
||||
import { pf2e } from './pf2e';
|
||||
import { applyRest } from './rest';
|
||||
import type { CharacterRulesInput } from './types';
|
||||
import type { Character } from '@/lib/schemas/character';
|
||||
import { characterDefaults } from '@/lib/schemas/character';
|
||||
|
||||
const fighter5e: CharacterRulesInput = {
|
||||
level: 5,
|
||||
abilities: { str: 18, dex: 14, con: 16, int: 10, wis: 12, cha: 8 },
|
||||
skillRanks: { perception: 'trained' },
|
||||
};
|
||||
|
||||
describe('5e derived combat math', () => {
|
||||
it('weapon attack: ability + proficiency + item, ability added to damage', () => {
|
||||
const r = dnd5e.weaponAttack(fighter5e, { ability: 'str', rank: 'trained', damageDice: '1d8', itemBonus: 1 });
|
||||
expect(r.toHit).toBe(4 + 3 + 1); // STR +4, PB +3, item +1
|
||||
expect(r.damage).toBe('1d8+5'); // STR +4 + item +1
|
||||
});
|
||||
|
||||
it('off-hand damage omits the ability modifier', () => {
|
||||
const r = dnd5e.weaponAttack(fighter5e, { ability: 'str', rank: 'trained', damageDice: '1d6', addAbilityToDamage: false });
|
||||
expect(r.damage).toBe('1d6');
|
||||
});
|
||||
|
||||
it('spell attack and passive perception', () => {
|
||||
expect(dnd5e.spellAttackBonus(fighter5e, 'wis')).toBe(3 + 1);
|
||||
expect(dnd5e.passivePerception(fighter5e)).toBe(10 + 1 + 3); // 10 + WIS + PB
|
||||
});
|
||||
|
||||
it('carrying capacity is STR*15', () => {
|
||||
expect(dnd5e.carryingCapacity(fighter5e)).toEqual({ encumbered: 90, max: 270, unit: 'lb' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pf2e derived combat math', () => {
|
||||
const pcg: CharacterRulesInput = {
|
||||
level: 3,
|
||||
abilities: { str: 18, dex: 12, con: 14, int: 10, wis: 14, cha: 10 },
|
||||
perceptionRank: 'expert',
|
||||
spellcastingRank: 'trained',
|
||||
};
|
||||
it('weapon attack adds level + rank bonus', () => {
|
||||
const r = pf2e.weaponAttack(pcg, { ability: 'str', rank: 'trained', damageDice: '1d8' });
|
||||
expect(r.toHit).toBe(4 + (3 + 2)); // STR +4, trained = level3 + 2
|
||||
expect(r.damage).toBe('1d8+4');
|
||||
});
|
||||
it('passive perception uses perception proficiency', () => {
|
||||
expect(pf2e.passivePerception(pcg)).toBe(10 + 2 + (3 + 4)); // 10 + WIS + (level + expert)
|
||||
});
|
||||
it('carrying capacity is Bulk', () => {
|
||||
expect(pf2e.carryingCapacity(pcg)).toEqual({ encumbered: 5 + 4, max: 10 + 4, unit: 'Bulk' });
|
||||
});
|
||||
});
|
||||
|
||||
function makeCharacter(): Character {
|
||||
return {
|
||||
id: 'c', campaignId: 'camp', system: '5e', kind: 'pc',
|
||||
name: 'Test', ancestry: '', className: 'Wizard', level: 5,
|
||||
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
|
||||
hp: { current: 4, max: 30, temp: 0 }, speed: 30, armorBonus: 0,
|
||||
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||
...characterDefaults(),
|
||||
resources: [
|
||||
{ id: 'r1', name: 'Sorcery Points', current: 0, max: 5, recovery: 'long' },
|
||||
{ id: 'r2', name: 'Channel Divinity', current: 0, max: 1, recovery: 'short' },
|
||||
],
|
||||
spellcasting: {
|
||||
slots: [{ level: 1, max: 4, current: 0 }, { level: 2, max: 3, current: 1 }],
|
||||
pact: { level: 1, max: 2, current: 0 },
|
||||
spells: [],
|
||||
},
|
||||
defenses: { ...characterDefaults().defenses, exhaustion: 2 },
|
||||
notes: '', createdAt: '', updatedAt: '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyRest', () => {
|
||||
it('short rest restores short resources + pact, not HP/slots/long resources', () => {
|
||||
const c = makeCharacter();
|
||||
const short = dnd5e.restOptions.find((o) => o.id === 'short')!;
|
||||
const patch = applyRest(c, short);
|
||||
expect(patch.hp).toBeUndefined();
|
||||
expect(patch.resources!.find((r) => r.id === 'r2')!.current).toBe(1); // short resource restored
|
||||
expect(patch.resources!.find((r) => r.id === 'r1')!.current).toBe(0); // long resource untouched
|
||||
expect(patch.spellcasting!.pact!.current).toBe(2); // pact restored
|
||||
expect(patch.spellcasting!.slots[0]!.current).toBe(0); // slots NOT restored on short rest
|
||||
});
|
||||
|
||||
it('long rest restores HP, all slots, all resources, steps exhaustion down, clears death saves', () => {
|
||||
const c = makeCharacter();
|
||||
const long = dnd5e.restOptions.find((o) => o.id === 'long')!;
|
||||
const patch = applyRest(c, long);
|
||||
expect(patch.hp!.current).toBe(30);
|
||||
expect(patch.resources!.every((r) => r.current === r.max)).toBe(true);
|
||||
expect(patch.spellcasting!.slots.every((s) => s.current === s.max)).toBe(true);
|
||||
expect(patch.defenses!.exhaustion).toBe(1);
|
||||
expect(patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CharacterRulesInput,
|
||||
DerivedSkill,
|
||||
ProficiencyRank,
|
||||
RestOption,
|
||||
RulesSystem,
|
||||
} from '../types';
|
||||
import { ABILITY_KEYS } from '../types';
|
||||
@@ -11,9 +12,15 @@ import {
|
||||
ABILITY_LABELS,
|
||||
abilityModifier,
|
||||
defaultAbilityScores,
|
||||
formatDamage,
|
||||
} from '../abilities';
|
||||
import { DND5E_SKILLS } from './skills';
|
||||
|
||||
const DND5E_RESTS: readonly RestOption[] = [
|
||||
{ id: 'short', label: 'Short Rest', restoresHp: false, restoresSlots: false, restoresPact: true, recovers: ['short'] },
|
||||
{ id: 'long', label: 'Long Rest', restoresHp: true, restoresSlots: true, restoresPact: true, recovers: ['short', 'long'], reduceExhaustion: true },
|
||||
];
|
||||
|
||||
/** Proficiency bonus by character level (PHB table): +2 at 1, +6 at 17. */
|
||||
export function proficiencyBonus(level: number): number {
|
||||
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
|
||||
@@ -79,6 +86,30 @@ export const dnd5e: RulesSystem = {
|
||||
spellSaveDc(input, ability) {
|
||||
return 8 + proficiencyBonus(input.level) + abilityModifier(input.abilities[ability]);
|
||||
},
|
||||
|
||||
spellAttackBonus(input, ability) {
|
||||
return proficiencyBonus(input.level) + abilityModifier(input.abilities[ability]);
|
||||
},
|
||||
|
||||
passivePerception(input) {
|
||||
const rank = input.skillRanks?.['perception'] ?? 'untrained';
|
||||
return 10 + abilityModifier(input.abilities.wis) + proficiencyBonus(input.level) * profMultiplier(rank);
|
||||
},
|
||||
|
||||
weaponAttack(input, weapon) {
|
||||
const abilityMod = abilityModifier(input.abilities[weapon.ability]);
|
||||
const item = weapon.itemBonus ?? 0;
|
||||
const toHit = abilityMod + proficiencyBonus(input.level) * profMultiplier(weapon.rank) + item;
|
||||
const dmgMod = (weapon.addAbilityToDamage !== false ? abilityMod : 0) + item;
|
||||
return { toHit, damage: formatDamage(weapon.damageDice, dmgMod) };
|
||||
},
|
||||
|
||||
carryingCapacity(input) {
|
||||
const str = input.abilities.str;
|
||||
return { encumbered: str * 5, max: str * 15, unit: 'lb' };
|
||||
},
|
||||
|
||||
restOptions: DND5E_RESTS,
|
||||
};
|
||||
|
||||
export { ABILITY_ABBR };
|
||||
|
||||
@@ -17,4 +17,5 @@ export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [
|
||||
];
|
||||
|
||||
export * from './types';
|
||||
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS } from './abilities';
|
||||
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
||||
export { applyRest } from './rest';
|
||||
|
||||
@@ -3,12 +3,20 @@ import type {
|
||||
CharacterRulesInput,
|
||||
DerivedSkill,
|
||||
ProficiencyRank,
|
||||
RestOption,
|
||||
RulesSystem,
|
||||
} from '../types';
|
||||
import { ABILITY_KEYS } from '../types';
|
||||
import { ABILITY_LABELS, abilityModifier, defaultAbilityScores } from '../abilities';
|
||||
import { ABILITY_LABELS, abilityModifier, defaultAbilityScores, formatDamage } from '../abilities';
|
||||
import { PF2E_SKILLS } from './skills';
|
||||
|
||||
const PF2E_RESTS: readonly RestOption[] = [
|
||||
// Refocus restores 1 Focus Point (short-recovery resources).
|
||||
{ id: 'refocus', label: 'Refocus', restoresHp: false, restoresSlots: false, restoresPact: false, recovers: ['short'] },
|
||||
// A full night's rest restores HP, spell slots, and daily resources.
|
||||
{ id: 'rest', label: 'Rest for the Night', restoresHp: true, restoresSlots: true, restoresPact: true, recovers: ['long', 'daily'] },
|
||||
];
|
||||
|
||||
const RANK_BONUS: Record<ProficiencyRank, number> = {
|
||||
untrained: 0,
|
||||
trained: 2,
|
||||
@@ -75,4 +83,28 @@ export const pf2e: RulesSystem = {
|
||||
spellSaveDc(input, ability) {
|
||||
return 10 + abilityModifier(input.abilities[ability]) + pf2eProficiency(input.level, input.spellcastingRank ?? 'trained');
|
||||
},
|
||||
|
||||
spellAttackBonus(input, ability) {
|
||||
return abilityModifier(input.abilities[ability]) + pf2eProficiency(input.level, input.spellcastingRank ?? 'trained');
|
||||
},
|
||||
|
||||
passivePerception(input) {
|
||||
return 10 + abilityModifier(input.abilities.wis) + pf2eProficiency(input.level, input.perceptionRank ?? 'trained');
|
||||
},
|
||||
|
||||
weaponAttack(input, weapon) {
|
||||
const abilityMod = abilityModifier(input.abilities[weapon.ability]);
|
||||
const item = weapon.itemBonus ?? 0;
|
||||
const toHit = abilityMod + pf2eProficiency(input.level, weapon.rank) + item;
|
||||
const dmgMod = (weapon.addAbilityToDamage !== false ? abilityMod : 0) + item;
|
||||
return { toHit, damage: formatDamage(weapon.damageDice, dmgMod) };
|
||||
},
|
||||
|
||||
carryingCapacity(input) {
|
||||
// PF2e uses Bulk: encumbered at 5 + STR mod, max 10 + STR mod.
|
||||
const strMod = abilityModifier(input.abilities.str);
|
||||
return { encumbered: 5 + strMod, max: 10 + strMod, unit: 'Bulk' };
|
||||
},
|
||||
|
||||
restOptions: PF2E_RESTS,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Character } from '@/lib/schemas/character';
|
||||
import type { RestOption } from './types';
|
||||
|
||||
/**
|
||||
* Compute the character changes a rest produces. Pure — returns a partial patch
|
||||
* the caller persists. Correctly distinguishes short vs long recovery, refreshes
|
||||
* spell slots / pact magic / resources by their recovery tag, and resets 5e death
|
||||
* saves + steps down exhaustion on a full rest. (The old app's short rest just
|
||||
* full-healed, ignoring all of this.)
|
||||
*/
|
||||
export function applyRest(c: Character, opt: RestOption): Partial<Character> {
|
||||
const patch: Partial<Character> = {};
|
||||
|
||||
if (opt.restoresHp) {
|
||||
patch.hp = { ...c.hp, current: c.hp.max };
|
||||
}
|
||||
|
||||
// Resources refresh when their recovery tag is covered by this rest.
|
||||
patch.resources = c.resources.map((r) =>
|
||||
opt.recovers.includes(r.recovery) ? { ...r, current: r.max } : r,
|
||||
);
|
||||
|
||||
if (opt.restoresSlots || opt.restoresPact) {
|
||||
patch.spellcasting = {
|
||||
...c.spellcasting,
|
||||
slots: opt.restoresSlots
|
||||
? c.spellcasting.slots.map((s) => ({ ...s, current: s.max }))
|
||||
: c.spellcasting.slots,
|
||||
...(c.spellcasting.pact
|
||||
? {
|
||||
pact: opt.restoresPact
|
||||
? { ...c.spellcasting.pact, current: c.spellcasting.pact.max }
|
||||
: c.spellcasting.pact,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const exhaustion =
|
||||
opt.reduceExhaustion && c.defenses.exhaustion > 0
|
||||
? c.defenses.exhaustion - 1
|
||||
: c.defenses.exhaustion;
|
||||
|
||||
if (opt.restoresHp || exhaustion !== c.defenses.exhaustion) {
|
||||
patch.defenses = {
|
||||
...c.defenses,
|
||||
exhaustion,
|
||||
// recovering from 0 HP clears death saves (5e)
|
||||
...(opt.restoresHp ? { deathSaves: { successes: 0, failures: 0 } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
@@ -21,6 +21,48 @@ export interface DerivedSkill {
|
||||
modifier: number;
|
||||
}
|
||||
|
||||
/** Input describing a single weapon/attack for derived to-hit + damage. */
|
||||
export interface WeaponInput {
|
||||
/** ability the attack uses (str for melee, dex for finesse/ranged) */
|
||||
ability: AbilityKey;
|
||||
/** proficiency in the weapon */
|
||||
rank: ProficiencyRank;
|
||||
/** flat magic/enhancement bonus to hit and damage */
|
||||
itemBonus?: number;
|
||||
/** damage dice, e.g. "1d8" */
|
||||
damageDice?: string;
|
||||
/** add the ability modifier to damage (off-hand/some ranged turn this off) */
|
||||
addAbilityToDamage?: boolean;
|
||||
}
|
||||
|
||||
export interface WeaponResult {
|
||||
toHit: number;
|
||||
/** ready-to-roll damage expression, e.g. "1d8+3" */
|
||||
damage: string;
|
||||
}
|
||||
|
||||
export interface CarryingCapacity {
|
||||
/** at/over this, the character is encumbered */
|
||||
encumbered: number;
|
||||
/** absolute maximum carry */
|
||||
max: number;
|
||||
unit: 'lb' | 'Bulk';
|
||||
}
|
||||
|
||||
/** A rest/recovery option and what it restores. */
|
||||
export interface RestOption {
|
||||
id: string;
|
||||
label: string;
|
||||
restoresHp: boolean;
|
||||
restoresSlots: boolean;
|
||||
restoresPact: boolean;
|
||||
/** resource.recovery tags that refresh */
|
||||
recovers: readonly Recovery[];
|
||||
reduceExhaustion?: boolean;
|
||||
}
|
||||
|
||||
export type Recovery = 'short' | 'long' | 'daily' | 'none';
|
||||
|
||||
/**
|
||||
* The contract every supported game system implements. The UI and stores talk
|
||||
* to this interface only — no `if (system === '5e')` scattered through features.
|
||||
@@ -55,6 +97,21 @@ export interface RulesSystem {
|
||||
|
||||
/** Spellcasting save DC for the given casting ability, if any. */
|
||||
spellSaveDc?(input: CharacterRulesInput, ability: AbilityKey): number;
|
||||
|
||||
/** Spell attack bonus for the given casting ability. */
|
||||
spellAttackBonus(input: CharacterRulesInput, ability: AbilityKey): number;
|
||||
|
||||
/** Passive Perception (5e) / Perception DC basis (PF2e): 10 + perception mod. */
|
||||
passivePerception(input: CharacterRulesInput): number;
|
||||
|
||||
/** To-hit + damage for a weapon attack. */
|
||||
weaponAttack(input: CharacterRulesInput, weapon: WeaponInput): WeaponResult;
|
||||
|
||||
/** Carry limits, in the system's native unit. */
|
||||
carryingCapacity(input: CharacterRulesInput): CarryingCapacity;
|
||||
|
||||
/** Rest/recovery options this system offers (short/long, refocus/rest, …). */
|
||||
restOptions: readonly RestOption[];
|
||||
}
|
||||
|
||||
/** Minimal character shape the rules layer needs to compute derived values. */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
abilityScoresSchema,
|
||||
conditionSchema,
|
||||
currencySchema,
|
||||
hpSchema,
|
||||
int,
|
||||
proficiencyRankSchema,
|
||||
@@ -9,6 +11,86 @@ import {
|
||||
|
||||
export const characterKindSchema = z.enum(['pc', 'npc']);
|
||||
|
||||
export const inventoryItemSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(120),
|
||||
quantity: int.min(0).default(1),
|
||||
/** weight per unit, in lbs (5e) / Bulk*10 abstracted (pf2e) */
|
||||
weight: z.number().finite().nonnegative().default(0),
|
||||
equipped: z.boolean().default(false),
|
||||
attuned: z.boolean().default(false),
|
||||
description: z.string().max(4000).default(''),
|
||||
/** link back to a compendium item slug */
|
||||
compendiumRef: z.string().optional(),
|
||||
});
|
||||
export type InventoryItem = z.infer<typeof inventoryItemSchema>;
|
||||
|
||||
/** When a tracked resource refreshes. */
|
||||
export const recoverySchema = z.enum(['short', 'long', 'daily', 'none']);
|
||||
|
||||
export const resourceSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(80),
|
||||
current: int.min(0).default(0),
|
||||
max: int.min(0).default(0),
|
||||
recovery: recoverySchema.default('long'),
|
||||
});
|
||||
export type CharacterResource = z.infer<typeof resourceSchema>;
|
||||
|
||||
export const spellEntrySchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(120),
|
||||
/** 0 = cantrip */
|
||||
level: int.min(0).max(10).default(0),
|
||||
prepared: z.boolean().default(false),
|
||||
compendiumRef: z.string().optional(),
|
||||
notes: z.string().max(2000).default(''),
|
||||
});
|
||||
export type SpellEntry = z.infer<typeof spellEntrySchema>;
|
||||
|
||||
export const spellSlotSchema = z.object({
|
||||
level: int.min(1).max(10),
|
||||
max: int.min(0).default(0),
|
||||
current: int.min(0).default(0),
|
||||
});
|
||||
|
||||
export const spellcastingSchema = z.object({
|
||||
slots: z.array(spellSlotSchema).default([]),
|
||||
/** warlock-style pact magic, refreshes on short rest */
|
||||
pact: spellSlotSchema.optional(),
|
||||
spells: z.array(spellEntrySchema).default([]),
|
||||
});
|
||||
export type Spellcasting = z.infer<typeof spellcastingSchema>;
|
||||
|
||||
export const abilityKeySchema = z.enum(['str', 'dex', 'con', 'int', 'wis', 'cha']);
|
||||
|
||||
export const attackSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(120),
|
||||
ability: abilityKeySchema.default('str'),
|
||||
rank: proficiencyRankSchema.default('trained'),
|
||||
damageDice: z.string().max(40).default('1d6'),
|
||||
damageType: z.string().max(40).default(''),
|
||||
itemBonus: int.default(0),
|
||||
addAbilityToDamage: z.boolean().default(true),
|
||||
});
|
||||
export type Attack = z.infer<typeof attackSchema>;
|
||||
|
||||
export const defensesSchema = z.object({
|
||||
// 5e
|
||||
deathSaves: z
|
||||
.object({ successes: int.min(0).max(3).default(0), failures: int.min(0).max(3).default(0) })
|
||||
.default({ successes: 0, failures: 0 }),
|
||||
inspiration: z.boolean().default(false),
|
||||
exhaustion: int.min(0).max(6).default(0),
|
||||
// pf2e
|
||||
dying: int.min(0).max(4).default(0),
|
||||
wounded: int.min(0).default(0),
|
||||
doomed: int.min(0).default(0),
|
||||
heroPoints: int.min(0).default(0),
|
||||
});
|
||||
export type Defenses = z.infer<typeof defensesSchema>;
|
||||
|
||||
export const characterSchema = z.object({
|
||||
id: z.string(),
|
||||
campaignId: z.string(),
|
||||
@@ -33,6 +115,23 @@ export const characterSchema = z.object({
|
||||
spellcastingRank: proficiencyRankSchema.optional(),
|
||||
spellcastingAbility: z.enum(['int', 'wis', 'cha']).optional(),
|
||||
|
||||
// --- Phase 1: character depth ---
|
||||
currency: currencySchema.default({ cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 }),
|
||||
inventory: z.array(inventoryItemSchema).default([]),
|
||||
attacks: z.array(attackSchema).default([]),
|
||||
resources: z.array(resourceSchema).default([]),
|
||||
spellcasting: spellcastingSchema.default({ slots: [], spells: [] }),
|
||||
defenses: defensesSchema.default({
|
||||
deathSaves: { successes: 0, failures: 0 },
|
||||
inspiration: false,
|
||||
exhaustion: 0,
|
||||
dying: 0,
|
||||
wounded: 0,
|
||||
doomed: 0,
|
||||
heroPoints: 0,
|
||||
}),
|
||||
conditions: z.array(conditionSchema).default([]),
|
||||
|
||||
notes: z.string().max(20000).default(''),
|
||||
|
||||
createdAt: z.string(),
|
||||
@@ -49,3 +148,27 @@ export const characterDraftSchema = characterSchema.pick({
|
||||
level: true,
|
||||
});
|
||||
export type CharacterDraft = z.infer<typeof characterDraftSchema>;
|
||||
|
||||
/** Default values for the Phase-1 depth fields. Used by create + DB migration. */
|
||||
export function characterDefaults(): Pick<
|
||||
Character,
|
||||
'currency' | 'inventory' | 'attacks' | 'resources' | 'spellcasting' | 'defenses' | 'conditions'
|
||||
> {
|
||||
return {
|
||||
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
|
||||
inventory: [],
|
||||
attacks: [],
|
||||
resources: [],
|
||||
spellcasting: { slots: [], spells: [] },
|
||||
defenses: {
|
||||
deathSaves: { successes: 0, failures: 0 },
|
||||
inspiration: false,
|
||||
exhaustion: 0,
|
||||
dying: 0,
|
||||
wounded: 0,
|
||||
doomed: 0,
|
||||
heroPoints: 0,
|
||||
},
|
||||
conditions: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,19 @@ export const conditionSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
/** stacking value, e.g. PF2e frightened 2 or 5e exhaustion level */
|
||||
value: int.positive().optional(),
|
||||
/** rounds remaining; undefined = indefinite (used by combat auto-expiry) */
|
||||
duration: int.positive().optional(),
|
||||
});
|
||||
|
||||
/** Coins. 5e uses cp/sp/ep/gp/pp; PF2e cp/sp/gp/pp (ep stays 0). */
|
||||
export const currencySchema = z.object({
|
||||
cp: int.nonnegative().default(0),
|
||||
sp: int.nonnegative().default(0),
|
||||
ep: int.nonnegative().default(0),
|
||||
gp: int.nonnegative().default(0),
|
||||
pp: int.nonnegative().default(0),
|
||||
});
|
||||
|
||||
export type HitPoints = z.infer<typeof hpSchema>;
|
||||
export type Condition = z.infer<typeof conditionSchema>;
|
||||
export type Currency = z.infer<typeof currencySchema>;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Returns a debounced wrapper around `fn` that also flushes on unmount, so the
|
||||
* last edit is never lost when navigating away (the old app silently dropped
|
||||
* unsaved character changes on navigation).
|
||||
* Returns a debounced wrapper around `fn` that also flushes on unmount AND on
|
||||
* page hide (tab close / refresh), so the last edit is never lost — the old app
|
||||
* silently dropped unsaved character changes on navigation.
|
||||
*/
|
||||
export function useDebouncedCallback<A extends unknown[]>(
|
||||
fn: (...args: A) => void,
|
||||
@@ -25,7 +25,14 @@ export function useDebouncedCallback<A extends unknown[]>(
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => flush, []); // flush on unmount
|
||||
useEffect(() => {
|
||||
const onHide = () => flush();
|
||||
window.addEventListener('pagehide', onHide);
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', onHide);
|
||||
flush(); // flush on unmount
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user