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
+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>
)}
</>
);
}