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);
|
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)', () => {
|
describe('realtime server (integration)', () => {
|
||||||
it('host → join → snapshot flow; players cannot push state', async () => {
|
it('host → join → snapshot flow; players cannot push state', async () => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function fake(): Sender & { msgs: ServerMessage[] } {
|
|||||||
const msgs: ServerMessage[] = [];
|
const msgs: ServerMessage[] = [];
|
||||||
return { msgs, send: (m) => msgs.push(m) };
|
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({
|
const char: Character = characterSchema.parse({
|
||||||
id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia',
|
id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia',
|
||||||
abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 13, cha: 8 },
|
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>
|
<p className="mb-2 text-sm text-ink">{suggestion.reasoning}</p>
|
||||||
<ul className="mb-2 text-sm text-ink">
|
<ul className="mb-2 text-sm text-ink">
|
||||||
{suggestion.add.map((a) => (
|
{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>
|
</ul>
|
||||||
<div className="flex gap-2">
|
<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>
|
<Button size="sm" variant="ghost" onClick={run}>Regenerate</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { encountersRepo } from '@/lib/db/repositories';
|
|||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { rollDice } from '@/lib/dice/notation';
|
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 { computeBudget } from '@/lib/combat/budget';
|
||||||
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
import { loadMonsters, loadPf2e } from '@/lib/compendium';
|
||||||
import { complete } from '@/lib/llm/client';
|
import { complete } from '@/lib/llm/client';
|
||||||
@@ -162,6 +162,16 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
|
|||||||
if (!suggestion) return;
|
if (!suggestion) return;
|
||||||
await encountersRepo.mutate(encounter.id, (e) => {
|
await encountersRepo.mutate(encounter.id, (e) => {
|
||||||
let next = 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) {
|
for (const a of suggestion.add) {
|
||||||
// Prefer cloning a creature already in the fight (handles "add another goblin"
|
// Prefer cloning a creature already in the fight (handles "add another goblin"
|
||||||
// and works even for custom/homebrew combatants); otherwise pull from the pool.
|
// 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 { cn } from '@/lib/cn';
|
||||||
import { Page } from '@/components/ui/Page';
|
import { Page } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Badge, Meter } from '@/components/ui/Codex';
|
import { Badge, Meter } from '@/components/ui/Codex';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
@@ -43,6 +44,9 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
const [levelUp, setLevelUp] = useState(false);
|
const [levelUp, setLevelUp] = useState(false);
|
||||||
const [genScores, setGenScores] = useState(false);
|
const [genScores, setGenScores] = useState(false);
|
||||||
const [shared, setShared] = 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 onPortrait = async (file: File) => {
|
||||||
const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
|
const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
|
||||||
@@ -52,9 +56,27 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
const shareWithPlayer = async () => {
|
const shareWithPlayer = async () => {
|
||||||
const campaign = await campaignsRepo.get(c.campaignId);
|
const campaign = await campaignsRepo.get(c.campaignId);
|
||||||
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`;
|
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 */ }
|
// Prefer the native share sheet (best on iOS/iPadOS), then the clipboard,
|
||||||
setShared(true);
|
// then a selectable dialog so the link is always recoverable.
|
||||||
setTimeout(() => setShared(false), 1500);
|
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) => {
|
const save = useDebouncedCallback((next: Character) => {
|
||||||
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
||||||
@@ -281,6 +303,24 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
{levelUp && (
|
{levelUp && (
|
||||||
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
|
<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>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Snapshot } from '@/lib/sync/messages';
|
|||||||
import { pushSnapshot, pushImage } from '@/lib/sync/wsSync';
|
import { pushSnapshot, pushImage } from '@/lib/sync/wsSync';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
import { useEncounters } from '@/features/combat/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
|
* 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 encounters = useEncounters(cid);
|
||||||
const maps = useMaps(cid);
|
const maps = useMaps(cid);
|
||||||
const calendar = useCalendar(cid);
|
const calendar = useCalendar(cid);
|
||||||
|
const quests = useQuests(cid);
|
||||||
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||||
const activeMapId = useUiStore((s) => s.activeMapId);
|
const activeMapId = useUiStore((s) => s.activeMapId);
|
||||||
const activeHandout = useUiStore((s) => s.activeHandout);
|
const activeHandout = useUiStore((s) => s.activeHandout);
|
||||||
@@ -31,8 +32,8 @@ export function useSessionBroadcaster(campaign: Campaign | null): void {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (role !== 'gm' || status !== 'connected' || !campaign) return;
|
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);
|
debouncedPush(snapshot);
|
||||||
for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl);
|
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 { Snapshot } from '@/lib/sync/messages';
|
||||||
import type { PlayerMap } from '@/lib/map';
|
import type { PlayerMap } from '@/lib/map';
|
||||||
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
|
||||||
@@ -10,6 +10,7 @@ import { PlayerMapView } from './PlayerMapView';
|
|||||||
/** Presentational player view: battle map + party + initiative, from a snapshot. */
|
/** Presentational player view: battle map + party + initiative, from a snapshot. */
|
||||||
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
|
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
|
||||||
const { party, encounter, map, handout } = snapshot;
|
const { party, encounter, map, handout } = snapshot;
|
||||||
|
const quests = snapshot.quests ?? [];
|
||||||
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
|
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
|
||||||
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
|
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
|
||||||
return (
|
return (
|
||||||
@@ -113,6 +114,34 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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)', () => {
|
it('returns null for an empty encounter (caller builds fresh instead)', () => {
|
||||||
expect(suggestReinforcements('5e', [3, 3], [], pool, 'Hard')).toBeNull();
|
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 {
|
export interface ReinforcePlan {
|
||||||
reasoning: string;
|
reasoning: string;
|
||||||
add: { name: string; count: number }[];
|
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;
|
targetDifficulty: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,18 @@ export function suggestReinforcements(
|
|||||||
const target = thresholds.find((t) => t.label.toLowerCase() === targetDifficulty.toLowerCase())?.value ?? 0;
|
const target = thresholds.find((t) => t.label.toLowerCase() === targetDifficulty.toLowerCase())?.value ?? 0;
|
||||||
if (target <= 0) return null;
|
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 => {
|
const minCountToReach = (rating: number): number | null => {
|
||||||
for (let n = 1; n <= maxAdds; n++) {
|
for (let n = 1; n <= maxAdds; n++) {
|
||||||
const extra = Array.from({ length: n }, () => (system === '5e' ? { cr: rating } : { level: rating }));
|
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}.`;
|
: `Add ${best.count}${more} ${best.name} to push this fight toward ${targetDifficulty}.`;
|
||||||
return { reasoning, add: [{ name: best.name, count: best.count }], 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),
|
count: z.number().int().min(1).max(8),
|
||||||
ref: z.string().optional(),
|
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(),
|
targetDifficulty: z.string(),
|
||||||
});
|
});
|
||||||
export type BalanceSuggestion = z.infer<typeof balanceSuggestionSchema>;
|
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 });
|
const r = await complete(base, { system: 's', user: 'u', schema });
|
||||||
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
|
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 () => {
|
it('returns parse error on malformed JSON', async () => {
|
||||||
mockFetch(() => anthropicReply('not json at all'));
|
mockFetch(() => anthropicReply('not json at all'));
|
||||||
const r = await complete(base, { system: 's', user: 'u', schema });
|
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.';
|
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 {
|
function tryParseJson(text: string): unknown {
|
||||||
const trimmed = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
const unfenced = stripCodeFences(text).trim();
|
||||||
const start = trimmed.indexOf('{');
|
const candidate = extractFirstJsonObject(unfenced) ?? unfenced;
|
||||||
const end = trimmed.lastIndexOf('}');
|
|
||||||
const candidate = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed;
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(candidate);
|
return JSON.parse(candidate);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import { clientMessageSchema, serverMessageSchema, snapshotSchema, partialCharacterDiffSchema, type Snapshot } from './messages';
|
import { clientMessageSchema, serverMessageSchema, snapshotSchema, partialCharacterDiffSchema, type Snapshot } from './messages';
|
||||||
import { characterSchema, type Character } from '@/lib/schemas';
|
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({
|
const char: Character = characterSchema.parse({
|
||||||
id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia',
|
id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia',
|
||||||
@@ -51,7 +51,7 @@ describe('sync protocol', () => {
|
|||||||
encounter: { id: 'e', name: 'Fight', round: 1, combatants: [
|
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 }] },
|
{ 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);
|
expect(snapshotSchema.safeParse(full).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ export const handoutSchema = z.object({
|
|||||||
imageId: z.string().nullable(),
|
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({
|
export const snapshotSchema = z.object({
|
||||||
campaignName: z.string(),
|
campaignName: z.string(),
|
||||||
calendarDay: int.nullable(),
|
calendarDay: int.nullable(),
|
||||||
@@ -71,6 +77,7 @@ export const snapshotSchema = z.object({
|
|||||||
map: playerMapSchema.nullable(),
|
map: playerMapSchema.nullable(),
|
||||||
mapImageId: z.string().nullable(),
|
mapImageId: z.string().nullable(),
|
||||||
handout: handoutSchema.nullable().optional(),
|
handout: handoutSchema.nullable().optional(),
|
||||||
|
quests: z.array(playerQuestSchema).default([]),
|
||||||
});
|
});
|
||||||
export type Snapshot = z.infer<typeof snapshotSchema>;
|
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 { getSystem } from '@/lib/rules';
|
||||||
import { toPlayerEncounter } from '@/lib/combat/playerProjection';
|
import { toPlayerEncounter } from '@/lib/combat/playerProjection';
|
||||||
import { toPlayerProjection } from '@/lib/map';
|
import { toPlayerProjection } from '@/lib/map';
|
||||||
@@ -25,8 +25,9 @@ export function buildSnapshot(input: {
|
|||||||
activeEncounterId: string | null;
|
activeEncounterId: string | null;
|
||||||
activeMapId: string | null;
|
activeMapId: string | null;
|
||||||
handout?: { title: string; body: string; image?: string } | null;
|
handout?: { title: string; body: string; image?: string } | null;
|
||||||
|
quests?: Quest[];
|
||||||
}): SnapshotResult {
|
}): 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 sys = getSystem(campaign.system);
|
||||||
const images: Record<string, string> = {};
|
const images: Record<string, string> = {};
|
||||||
const byId = new Map(characters.map((c) => [c.id, c]));
|
const byId = new Map(characters.map((c) => [c.id, c]));
|
||||||
@@ -86,6 +87,13 @@ export function buildSnapshot(input: {
|
|||||||
map: projection,
|
map: projection,
|
||||||
mapImageId: activeMap && activeMap.image ? activeMap.id : null,
|
mapImageId: activeMap && activeMap.image ? activeMap.id : null,
|
||||||
handout: handoutOut,
|
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,
|
images,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user