diff --git a/server/src/integration.test.ts b/server/src/integration.test.ts index 0e8c09e..4f885c8 100644 --- a/server/src/integration.test.ts +++ b/server/src/integration.test.ts @@ -29,7 +29,7 @@ function next(ws: WebSocket, t: ServerMessage['t']): Promise { ws.on('message', onMsg); }); } -const snap = { campaignName: 'Live', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null }; +const snap = { campaignName: 'Live', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null, quests: [] }; describe('realtime server (integration)', () => { it('host → join → snapshot flow; players cannot push state', async () => { diff --git a/server/src/rooms.test.ts b/server/src/rooms.test.ts index 2d10543..448181a 100644 --- a/server/src/rooms.test.ts +++ b/server/src/rooms.test.ts @@ -7,7 +7,7 @@ function fake(): Sender & { msgs: ServerMessage[] } { const msgs: ServerMessage[] = []; return { msgs, send: (m) => msgs.push(m) }; } -const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null }; +const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null, quests: [] }; const char: Character = characterSchema.parse({ id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia', abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 13, cha: 8 }, diff --git a/src/features/assistant/EncounterTipCard.tsx b/src/features/assistant/EncounterTipCard.tsx index 83f5c30..c5105cc 100644 --- a/src/features/assistant/EncounterTipCard.tsx +++ b/src/features/assistant/EncounterTipCard.tsx @@ -53,11 +53,20 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;

{suggestion.reasoning}

- + {(suggestion.add.length > 0 || (suggestion.remove?.length ?? 0) > 0) && ( + + )}
diff --git a/src/features/assistant/useEncounterAdvisor.ts b/src/features/assistant/useEncounterAdvisor.ts index 83e53fd..f3529c6 100644 --- a/src/features/assistant/useEncounterAdvisor.ts +++ b/src/features/assistant/useEncounterAdvisor.ts @@ -4,7 +4,7 @@ import { encountersRepo } from '@/lib/db/repositories'; import { newId } from '@/lib/ids'; import { createRng } from '@/lib/rng'; import { rollDice } from '@/lib/dice/notation'; -import { addCombatant } from '@/lib/combat/engine'; +import { addCombatant, removeCombatant } from '@/lib/combat/engine'; import { computeBudget } from '@/lib/combat/budget'; import { loadMonsters, loadPf2e } from '@/lib/compendium'; import { complete } from '@/lib/llm/client'; @@ -162,6 +162,16 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) { if (!suggestion) return; await encountersRepo.mutate(encounter.id, (e) => { let next = e; + // Over-tuned fights: take out the suggested monsters (newest copies first). + for (const r of suggestion.remove ?? []) { + for (let i = 0; i < r.count; i++) { + const victim = [...next.combatants].reverse().find( + (c) => c.kind === 'monster' && baseName(c.name) === r.name, + ); + if (!victim) break; + next = removeCombatant(next, victim.id); + } + } for (const a of suggestion.add) { // Prefer cloning a creature already in the fight (handles "add another goblin" // and works even for custom/homebrew combatants); otherwise pull from the pool. diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index 103bd1f..839d661 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -13,6 +13,7 @@ import { rollCheck } from '@/lib/useRoll'; import { cn } from '@/lib/cn'; import { Page } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; import { Badge, Meter } from '@/components/ui/Codex'; import { Input, Select } from '@/components/ui/Input'; import { NumberField } from '@/components/ui/NumberField'; @@ -43,6 +44,9 @@ export function CharacterSheet({ character }: { character: Character }) { const [levelUp, setLevelUp] = useState(false); const [genScores, setGenScores] = useState(false); const [shared, setShared] = useState(false); + // Manual fallback: holds the link to show in a selectable dialog when neither + // Web Share nor Clipboard is available (e.g. iPad without a secure context). + const [shareLink, setShareLink] = useState(null); const onPortrait = async (file: File) => { const thumb = await squareThumbnail(await fileToDataUrl(file), 256); @@ -52,9 +56,27 @@ export function CharacterSheet({ character }: { character: Character }) { const shareWithPlayer = async () => { const campaign = await campaignsRepo.get(c.campaignId); const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`; - try { await navigator.clipboard?.writeText(link); } catch { /* clipboard blocked */ } - setShared(true); - setTimeout(() => setShared(false), 1500); + // Prefer the native share sheet (best on iOS/iPadOS), then the clipboard, + // then a selectable dialog so the link is always recoverable. + if (typeof navigator.share === 'function') { + try { + await navigator.share({ title: `${c.name} — character link`, url: link }); + return; + } catch (err) { + // User dismissed the share sheet: respect that and do nothing further. + if (err instanceof DOMException && err.name === 'AbortError') return; + // Otherwise fall through to clipboard / manual fallback. + } + } + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(link); + setShared(true); + setTimeout(() => setShared(false), 1500); + return; + } + } catch { /* clipboard blocked (e.g. insecure context on iPad) */ } + setShareLink(link); }; const save = useDebouncedCallback((next: Character) => { // Persist everything except identity/timestamps (update() stamps updatedAt). @@ -281,6 +303,24 @@ export function CharacterSheet({ character }: { character: Character }) { {levelUp && ( setLevelUp(false)} onApply={(patch) => update(patch)} /> )} + {shareLink !== null && ( + setShareLink(null)} + title="Share with player" + footer={} + > +

Copy this link and send it to the player:

+ e.currentTarget.select()} + /> +
+ )} ); } diff --git a/src/features/play/useSessionBroadcaster.ts b/src/features/play/useSessionBroadcaster.ts index 70e0b73..b0a28fa 100644 --- a/src/features/play/useSessionBroadcaster.ts +++ b/src/features/play/useSessionBroadcaster.ts @@ -8,7 +8,7 @@ import type { Snapshot } from '@/lib/sync/messages'; import { pushSnapshot, pushImage } from '@/lib/sync/wsSync'; import { useCharacters } from '@/features/characters/hooks'; import { useEncounters } from '@/features/combat/hooks'; -import { useMaps, useCalendar } from '@/features/world/hooks'; +import { useMaps, useCalendar, useQuests } from '@/features/world/hooks'; /** * While the GM is hosting, mirror local state to the room: a debounced @@ -23,6 +23,7 @@ export function useSessionBroadcaster(campaign: Campaign | null): void { const encounters = useEncounters(cid); const maps = useMaps(cid); const calendar = useCalendar(cid); + const quests = useQuests(cid); const activeEncounterId = useUiStore((s) => s.activeEncounterId); const activeMapId = useUiStore((s) => s.activeMapId); const activeHandout = useUiStore((s) => s.activeHandout); @@ -31,8 +32,8 @@ export function useSessionBroadcaster(campaign: Campaign | null): void { useEffect(() => { if (role !== 'gm' || status !== 'connected' || !campaign) return; - const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId, handout: activeHandout }); + const { snapshot, images } = buildSnapshot({ campaign, characters, encounters, maps, calendar: calendar ?? null, activeEncounterId, activeMapId, handout: activeHandout, quests }); debouncedPush(snapshot); for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl); - }, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, activeHandout, debouncedPush]); + }, [role, status, campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, activeHandout, quests, debouncedPush]); } diff --git a/src/features/player/PlayerBoards.tsx b/src/features/player/PlayerBoards.tsx index 522b1bc..fc86841 100644 --- a/src/features/player/PlayerBoards.tsx +++ b/src/features/player/PlayerBoards.tsx @@ -1,4 +1,4 @@ -import { Mail, Eye, Map as MapIcon, Users, Swords } from 'lucide-react'; +import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle } from 'lucide-react'; import type { Snapshot } from '@/lib/sync/messages'; import type { PlayerMap } from '@/lib/map'; import { usePlayerSessionStore } from '@/stores/playerSessionStore'; @@ -10,6 +10,7 @@ import { PlayerMapView } from './PlayerMapView'; /** Presentational player view: battle map + party + initiative, from a snapshot. */ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record }) { const { party, encounter, map, handout } = snapshot; + const quests = snapshot.quests ?? []; const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined; const privateHandout = usePlayerSessionStore((s) => s.privateHandout); return ( @@ -113,6 +114,34 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; )} + + {quests.length > 0 && ( +
+

+ Quest log +

+
+ {quests.map((q, qi) => ( +
+
+ {q.completed ? : } + {q.title} +
+ {q.objectives.length > 0 && ( +
    + {q.objectives.map((o, oi) => ( +
  • + {o.done ? '☑' : '☐'} + {o.text} +
  • + ))} +
+ )} +
+ ))} +
+
+ )} ); } diff --git a/src/lib/assistant/advisors.test.ts b/src/lib/assistant/advisors.test.ts index 0adb277..69314a6 100644 --- a/src/lib/assistant/advisors.test.ts +++ b/src/lib/assistant/advisors.test.ts @@ -94,4 +94,38 @@ describe('suggestReinforcements', () => { it('returns null for an empty encounter (caller builds fresh instead)', () => { expect(suggestReinforcements('5e', [3, 3], [], pool, 'Hard')).toBeNull(); }); + + it('suggests removing monsters when the fight is already over the target', () => { + // 3 Ogres (cr 2) vs two level-3 PCs is far above Deadly; target Deadly should + // ease the fight DOWN rather than add more. + const existing = [ + { name: 'Ogre', cr: 2 }, { name: 'Ogre 2', cr: 2 }, { name: 'Ogre 3', cr: 2 }, + ]; + const plan = suggestReinforcements('5e', [3, 3], existing, pool, 'Deadly'); + expect(plan).not.toBeNull(); + expect(plan!.add).toEqual([]); + expect(plan!.remove).toBeDefined(); + expect(plan!.remove![0]!.name).toBe('Ogre'); + expect(plan!.remove![0]!.count).toBeGreaterThanOrEqual(1); + expect(plan!.reasoning).toMatch(/remove/i); + expect(plan!.reasoning).not.toMatch(/add/i); + }); + + it('suggests a swap when a single creature alone exceeds the target', () => { + // One Ogre vs two level-3 PCs already beats Medium; nothing left to remove. + const plan = suggestReinforcements('5e', [3, 3], [{ name: 'Ogre', cr: 2 }], pool, 'Medium'); + expect(plan).not.toBeNull(); + expect(plan!.add).toEqual([]); + expect(plan!.remove).toBeUndefined(); + expect(plan!.reasoning).toMatch(/swap/i); + expect(plan!.reasoning).toMatch(/ogre/i); + }); + + it('still adds when the fight is below the target', () => { + const existing = [{ name: 'Goblin', cr: 0.25 }, { name: 'Goblin 2', cr: 0.25 }, { name: 'Goblin 3', cr: 0.25 }]; + const plan = suggestReinforcements('5e', [3, 3], existing, pool, 'Hard'); + expect(plan!.add.length).toBeGreaterThan(0); + expect(plan!.remove).toBeUndefined(); + expect(plan!.reasoning).toMatch(/add/i); + }); }); diff --git a/src/lib/assistant/encounter.ts b/src/lib/assistant/encounter.ts index b881afe..0b0d04d 100644 --- a/src/lib/assistant/encounter.ts +++ b/src/lib/assistant/encounter.ts @@ -62,6 +62,8 @@ export interface ReinforceMonster { export interface ReinforcePlan { reasoning: string; add: { name: string; count: number }[]; + /** Monsters to take out when the fight is already at/above the target (over-tuned). */ + remove?: { name: string; count: number }[]; targetDifficulty: string; } @@ -98,6 +100,18 @@ export function suggestReinforcements( const target = thresholds.find((t) => t.label.toLowerCase() === targetDifficulty.toLowerCase())?.value ?? 0; if (target <= 0) return null; + // If the fight is already AT or ABOVE the target, it's tuned correctly or + // over-tuned — adding more would only make it harder. Suggest softening it + // instead of reinforcing it. + const currentXp = computeBudget( + system, + partyLevels, + existing.map((m) => ({ cr: m.cr, level: m.level })), + ).ratingXp; + if (currentXp >= target) { + return easeReinforcements(system, partyLevels, existing, target, targetDifficulty); + } + const minCountToReach = (rating: number): number | null => { for (let n = 1; n <= maxAdds; n++) { const extra = Array.from({ length: n }, () => (system === '5e' ? { cr: rating } : { level: rating })); @@ -157,3 +171,59 @@ export function suggestReinforcements( : `Add ${best.count}${more} ${best.name} to push this fight toward ${targetDifficulty}.`; return { reasoning, add: [{ name: best.name, count: best.count }], targetDifficulty }; } + +/** + * The fight already meets or exceeds the target difficulty. Suggest taking out the + * fewest bodies that drops it back UNDER the target so it's no harder than intended. + * Removing creatures also shrinks the 5e encounter multiplier, so the budget is + * recomputed after each removal rather than subtracted linearly. We never empty the + * encounter: if even leaving a single creature stays at/above target, we say so and + * point at swapping for something weaker. + */ +function easeReinforcements( + system: SystemId, + partyLevels: number[], + existing: ReinforceMonster[], + target: number, + targetDifficulty: string, +): ReinforcePlan { + // Strongest first: dropping the heaviest hitters eases the fight with the fewest + // bodies removed. Stable on equal ratings so ties resolve deterministically. + const order = existing + .map((m, i) => ({ m, i, rating: ratingOf(system, m) ?? -Infinity })) + .sort((a, b) => b.rating - a.rating || a.i - b.i); + + const removedCounts = new Map(); + const keep = [...order]; + // Take from the front (strongest) but always leave at least one creature behind. + while (keep.length > 1) { + const dropped = keep.shift()!; + const bn = baseName(dropped.m.name); + removedCounts.set(bn, (removedCounts.get(bn) ?? 0) + 1); + const remaining = keep.map((e) => ({ cr: e.m.cr, level: e.m.level })); + if (computeBudget(system, partyLevels, remaining).ratingXp < target) break; + } + + const remove = [...removedCounts].map(([name, count]) => ({ name, count })); + const totalRemoved = remove.reduce((s, r) => s + r.count, 0); + + if (totalRemoved === 0) { + // Couldn't trim anything (a single over-budget creature). Suggest a swap instead. + const solo = baseName(existing[0]!.name); + return { + reasoning: + `This fight is already at or above ${targetDifficulty}. ` + + `Swap the ${solo} for a weaker creature to soften it.`, + add: [], + targetDifficulty, + }; + } + + const phrase = remove + .map((r) => `${r.count} ${r.name}`) + .join(' and '); + const reasoning = + `This fight is already at or above ${targetDifficulty}. ` + + `Remove ${phrase} to ease it back toward ${targetDifficulty}.`; + return { reasoning, add: [], remove, targetDifficulty }; +} diff --git a/src/lib/assistant/prompts.ts b/src/lib/assistant/prompts.ts index 40c1a78..7cacd0f 100644 --- a/src/lib/assistant/prompts.ts +++ b/src/lib/assistant/prompts.ts @@ -9,6 +9,11 @@ export const balanceSuggestionSchema = z.object({ count: z.number().int().min(1).max(8), ref: z.string().optional(), })), + /** Monsters to take out when the fight is over-tuned (already at/above target). */ + remove: z.array(z.object({ + name: z.string(), + count: z.number().int().min(1).max(8), + })).optional(), targetDifficulty: z.string(), }); export type BalanceSuggestion = z.infer; diff --git a/src/lib/llm/client.test.ts b/src/lib/llm/client.test.ts index e75a5c8..2f11b0f 100644 --- a/src/lib/llm/client.test.ts +++ b/src/lib/llm/client.test.ts @@ -76,6 +76,28 @@ describe('complete — structured output', () => { const r = await complete(base, { system: 's', user: 'u', schema }); expect(r).toEqual({ ok: true, data: { name: 'Goblin' } }); }); + it('parses fenced JSON surrounded by prose (DeepSeek-style)', async () => { + const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' }; + mockFetch(() => openaiReply('Here is the encounter:\n```json\n{"name":"Goblin"}\n```\nHope that helps!')); + const r = await complete(cfg, { system: 's', user: 'u', schema }); + expect(r).toEqual({ ok: true, data: { name: 'Goblin' } }); + }); + it('extracts the first JSON object from surrounding prose without fences', async () => { + mockFetch(() => anthropicReply('Sure! {"name":"Goblin"} — let me know if you need more.')); + const r = await complete(base, { system: 's', user: 'u', schema }); + expect(r).toEqual({ ok: true, data: { name: 'Goblin' } }); + }); + it('handles braces appearing in trailing prose', async () => { + mockFetch(() => anthropicReply('{"name":"Goblin"}\nUse {placeholders} as needed.')); + const r = await complete(base, { system: 's', user: 'u', schema }); + expect(r).toEqual({ ok: true, data: { name: 'Goblin' } }); + }); + it('tolerates braces inside JSON string values', async () => { + const nested = z.object({ reasoning: z.string(), name: z.string() }); + mockFetch(() => anthropicReply('```\n{"reasoning":"add {2} goblins","name":"Goblin"}\n```')); + const r = await complete(base, { system: 's', user: 'u', schema: nested }); + expect(r).toEqual({ ok: true, data: { reasoning: 'add {2} goblins', name: 'Goblin' } }); + }); it('returns parse error on malformed JSON', async () => { mockFetch(() => anthropicReply('not json at all')); const r = await complete(base, { system: 's', user: 'u', schema }); diff --git a/src/lib/llm/client.ts b/src/lib/llm/client.ts index 4b8f914..e8945db 100644 --- a/src/lib/llm/client.ts +++ b/src/lib/llm/client.ts @@ -15,12 +15,48 @@ function jsonInstruction(): string { return '\n\nRespond with ONLY a single valid JSON object — no prose, no markdown code fences — matching the requested shape exactly.'; } -/** Best-effort extraction of a JSON object from a model response. */ +/** Strip Markdown code fences (```json … ```) from anywhere in the text. */ +function stripCodeFences(text: string): string { + // Remove an opening fence (with optional language tag) and its matching closing + // fence, even when surrounded by prose. Falls back to dropping stray fence markers. + const fenced = text.match(/```[ \t]*(?:json|jsonc|json5)?[ \t]*\r?\n([\s\S]*?)```/i); + if (fenced?.[1] !== undefined) return fenced[1]; + return text.replace(/```[ \t]*[a-z0-9]*[ \t]*/gi, ''); +} + +/** Scan for the first complete, brace-balanced JSON object, ignoring braces in strings. */ +function extractFirstJsonObject(text: string): string | undefined { + const start = text.indexOf('{'); + if (start < 0) return undefined; + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < text.length; i++) { + const ch = text[i]; + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return text.slice(start, i + 1); + } + } + return undefined; +} + +/** + * Best-effort extraction of a JSON object from a model response. Tolerates Markdown + * code fences and prose around the JSON (as DeepSeek and other models often emit), + * extracting the first brace-balanced object before parsing. + */ function tryParseJson(text: string): unknown { - const trimmed = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim(); - const start = trimmed.indexOf('{'); - const end = trimmed.lastIndexOf('}'); - const candidate = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed; + const unfenced = stripCodeFences(text).trim(); + const candidate = extractFirstJsonObject(unfenced) ?? unfenced; try { return JSON.parse(candidate); } catch { diff --git a/src/lib/sync/messages.test.ts b/src/lib/sync/messages.test.ts index 58280e3..91d7c65 100644 --- a/src/lib/sync/messages.test.ts +++ b/src/lib/sync/messages.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { clientMessageSchema, serverMessageSchema, snapshotSchema, partialCharacterDiffSchema, type Snapshot } from './messages'; import { characterSchema, type Character } from '@/lib/schemas'; -const snap: Snapshot = { campaignName: 'C', calendarDay: 3, party: [], encounter: null, map: null, mapImageId: null }; +const snap: Snapshot = { campaignName: 'C', calendarDay: 3, party: [], encounter: null, map: null, mapImageId: null, quests: [] }; const char: Character = characterSchema.parse({ id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia', @@ -51,7 +51,7 @@ describe('sync protocol', () => { encounter: { id: 'e', name: 'Fight', round: 1, combatants: [ { id: 'm', name: 'Goblin', kind: 'monster', initiative: 12, isCurrent: false, status: { label: 'Bloodied', cls: 'text-warning' }, conditions: [{ name: 'frightened', value: 1 }] }, ] }, - map: null, mapImageId: null, + map: null, mapImageId: null, quests: [], }; expect(snapshotSchema.safeParse(full).success).toBe(true); }); diff --git a/src/lib/sync/messages.ts b/src/lib/sync/messages.ts index 10a7ed3..787f00e 100644 --- a/src/lib/sync/messages.ts +++ b/src/lib/sync/messages.ts @@ -63,6 +63,12 @@ export const handoutSchema = z.object({ imageId: z.string().nullable(), }); +export const playerQuestSchema = z.object({ + title: z.string(), + completed: z.boolean(), + objectives: z.array(z.object({ text: z.string(), done: z.boolean() })), +}); + export const snapshotSchema = z.object({ campaignName: z.string(), calendarDay: int.nullable(), @@ -71,6 +77,7 @@ export const snapshotSchema = z.object({ map: playerMapSchema.nullable(), mapImageId: z.string().nullable(), handout: handoutSchema.nullable().optional(), + quests: z.array(playerQuestSchema).default([]), }); export type Snapshot = z.infer; diff --git a/src/lib/sync/snapshot.ts b/src/lib/sync/snapshot.ts index f0662b7..dabba85 100644 --- a/src/lib/sync/snapshot.ts +++ b/src/lib/sync/snapshot.ts @@ -1,4 +1,4 @@ -import type { BattleMap, Calendar, Campaign, Character, Encounter } from '@/lib/schemas'; +import type { BattleMap, Calendar, Campaign, Character, Encounter, Quest } from '@/lib/schemas'; import { getSystem } from '@/lib/rules'; import { toPlayerEncounter } from '@/lib/combat/playerProjection'; import { toPlayerProjection } from '@/lib/map'; @@ -25,8 +25,9 @@ export function buildSnapshot(input: { activeEncounterId: string | null; activeMapId: string | null; handout?: { title: string; body: string; image?: string } | null; + quests?: Quest[]; }): SnapshotResult { - const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, handout } = input; + const { campaign, characters, encounters, maps, calendar, activeEncounterId, activeMapId, handout, quests } = input; const sys = getSystem(campaign.system); const images: Record = {}; const byId = new Map(characters.map((c) => [c.id, c])); @@ -86,6 +87,13 @@ export function buildSnapshot(input: { map: projection, mapImageId: activeMap && activeMap.image ? activeMap.id : null, handout: handoutOut, + quests: (quests ?? []) + .filter((q) => q.status !== 'failed') + .map((q) => ({ + title: q.title, + completed: q.status === 'completed', + objectives: q.objectives.map((o) => ({ text: o.text, done: o.done })), + })), }, images, };