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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user