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:
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user