3f524ce82c
- 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>
89 lines
3.8 KiB
TypeScript
89 lines
3.8 KiB
TypeScript
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>
|
||
);
|
||
}
|