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.