Fixes batch 2: player quests, AI parse, advisor, iPad share

- Player view: a Quest log now syncs to players (snapshot += quests; broadcaster
  passes campaign quests; PlayerBoards renders titles + objective checkboxes).
- AI (DeepSeek etc.): robust JSON extraction — strip code fences anywhere + a
  brace-balanced scanner that ignores prose/braces in strings. Fixes "AI unavailable
  (parse)" with OpenAI-compatible providers. +4 tests.
- Encounter advisor: now bidirectional — for an over-tuned (too-hard) fight it
  suggests REMOVING/swapping monsters instead of only ever adding. +3 tests.
- Character share: works on iPad — native share sheet → clipboard → a copyable
  link modal fallback (was a silently-failing clipboard write).

230 unit + 34 e2e + 2 realtime green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:56:10 +02:00
parent 426824fd78
commit 3f524ce82c
15 changed files with 292 additions and 21 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ function next(ws: WebSocket, t: ServerMessage['t']): Promise<ServerMessage> {
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 () => {
+1 -1
View File
@@ -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 },
+11 -2
View File
@@ -53,11 +53,20 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
<p className="mb-2 text-sm text-ink">{suggestion.reasoning}</p>
<ul className="mb-2 text-sm text-ink">
{suggestion.add.map((a) => (
<li key={a.name}> {a.count}× {a.name}</li>
<li key={`add-${a.name}`}> Add {a.count}× {a.name}</li>
))}
{(suggestion.remove ?? []).map((r) => (
<li key={`remove-${r.name}`}> Remove {r.count}× {r.name}</li>
))}
</ul>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={apply}>Add to encounter</Button>
{(suggestion.add.length > 0 || (suggestion.remove?.length ?? 0) > 0) && (
<Button size="sm" variant="primary" onClick={apply}>
{suggestion.add.length === 0 && (suggestion.remove?.length ?? 0) > 0
? 'Apply to encounter'
: 'Add to encounter'}
</Button>
)}
<Button size="sm" variant="ghost" onClick={run}>Regenerate</Button>
</div>
</div>
+11 -1
View File
@@ -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.
+43 -3
View File
@@ -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<string | null>(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 && (
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
)}
{shareLink !== null && (
<Modal
open
onClose={() => setShareLink(null)}
title="Share with player"
footer={<Button size="sm" variant="ghost" onClick={() => setShareLink(null)}>Done</Button>}
>
<p className="mb-2 text-sm text-muted">Copy this link and send it to the player:</p>
<Input
data-autofocus
readOnly
value={shareLink}
aria-label="Player link"
className="font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
</Modal>
)}
</Page>
);
}
+4 -3
View File
@@ -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]);
}
+30 -1
View File
@@ -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<string, string> }) {
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;
)}
</section>
</div>
{quests.length > 0 && (
<section className="mt-6">
<h2 className="smallcaps mb-2 flex items-center gap-1.5 text-muted">
<ScrollText size={13} aria-hidden /> Quest log
</h2>
<div className="grid gap-2 sm:grid-cols-2">
{quests.map((q, qi) => (
<div key={qi} className={cn('rounded-xl border border-line bg-panel p-3', q.completed && 'opacity-70')}>
<div className="flex items-center gap-2">
{q.completed ? <CheckCircle2 size={15} className="text-success" aria-hidden /> : <Circle size={15} className="text-accent" aria-hidden />}
<span className={cn('font-display font-semibold text-ink', q.completed && 'line-through')}>{q.title}</span>
</div>
{q.objectives.length > 0 && (
<ul className="mt-1.5 space-y-0.5 pl-1 text-sm">
{q.objectives.map((o, oi) => (
<li key={oi} className={cn('flex items-start gap-1.5', o.done ? 'text-muted line-through' : 'text-ink')}>
<span className="mt-0.5 text-xs text-faint">{o.done ? '☑' : '☐'}</span>
<span>{o.text}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
</section>
)}
</>
);
}
+34
View File
@@ -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);
});
});
+70
View File
@@ -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<string, number>();
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 };
}
+5
View File
@@ -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<typeof balanceSuggestionSchema>;
+22
View File
@@ -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 });
+41 -5
View File
@@ -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 {
+2 -2
View File
@@ -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);
});
+7
View File
@@ -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<typeof snapshotSchema>;
+10 -2
View File
@@ -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<string, string> = {};
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,
};