Files
ttrpg_manager/src/features/assistant/EncounterTipCard.tsx
T
NilsBriggen 3f524ce82c 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>
2026-06-08 21:56:10 +02:00

89 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Campaign, Encounter } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { useEncounterAdvisor } from './useEncounterAdvisor';
/**
* Contextual encounter-balancing tip. Shows the detected difficulty tendency and,
* on demand, a grounded recommendation (AI when configured, deterministic otherwise)
* with one-click apply. Renders nothing when there's no tendency to report.
*/
export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign; encounter: Encounter }) {
const { theme, hasMonsters, currentDifficulty, state, suggestion, source, message, canUseLlm, run, apply } =
useEncounterAdvisor(campaign, encounter);
// Surface when there's a detected tendency, an encounter with monsters to balance,
// or an in-progress/ready suggestion. Stay out of the way otherwise.
if (!theme && !hasMonsters && state === 'idle') return null;
return (
<div
className={cn('mb-4 rounded-lg border p-3', theme ? 'border-warning/40 bg-warning/5' : 'border-line bg-panel')}
data-testid="encounter-tip"
>
<div className="flex items-start gap-3">
<span className="text-lg" aria-hidden>🧠</span>
<div className="min-w-0 flex-1">
{theme ? (
<>
<div className="text-sm font-medium text-ink">{theme.tendency}</div>
<div className="text-xs text-muted">{theme.evidence}</div>
</>
) : (
<>
<div className="text-sm font-medium text-ink">Encounter balance</div>
<div className="text-xs text-muted">
{currentDifficulty ? (
<>Currently <span className="capitalize">{currentDifficulty}</span> for the party.</>
) : (
'Add a creature suggestion for your party.'
)}
</div>
</>
)}
{state === 'ready' && suggestion && (
<div className="mt-2 rounded-md border border-line bg-surface p-2">
<div className="mb-1 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
{source === 'llm' ? 'AI suggestion' : 'Suggested'}
</span>
<span className="text-xs text-muted"> {suggestion.targetDifficulty}</span>
</div>
<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={`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">
{(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>
)}
{message && <p className="mt-1 text-xs text-muted">{message}</p>}
{state !== 'ready' && (
<div className="mt-2">
<Button size="sm" variant="secondary" onClick={run} disabled={state === 'loading'}>
{state === 'loading' ? 'Thinking…' : canUseLlm ? 'Suggest a fix (AI)' : 'Suggest a fix'}
</Button>
</div>
)}
</div>
</div>
</div>
);
}