19 Commits

Author SHA1 Message Date
NilsBriggen 45d74ce5d7 Cleanup: pf2e subclasses + all native dialogs → themed Modals
- pf2e classes now show the defining 1st-level choice (instinct / doctrine /
  bloodline / mystery / racket / implement / thesis / …) in the wizard's
  subclass picker, like 5e. Curated PF2E_SUBCLASSES in pf2e/progression.ts,
  merged into the class during the wizard's pf2e enrich step.
- replaced every remaining native dialog with a themed Modal:
  - PlayerView session-password prompt → password Modal
  - Settings cloud-conflict confirm → "Use cloud / Keep this device" Modal
  - SessionControl host-password prompt → host Modal (optional password)
  - MapEditor text-label prompt → label Modal (captures the click point)
  No window.confirm / window.prompt / window.alert remain in the app.

Realtime e2e updated for the host Modal (Host → Start hosting).
Gate: 293 unit + 35 e2e + 2 realtime; tsc + app/server build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:50:10 +02:00
NilsBriggen 895807d6c9 Polish sprint 2/2: layouts, UX consistency, pf2e conditions
Layout:
- CombatantRow: action controls (damage/AC/move/remove) grouped into one
  right-aligned wrapper so they wrap together instead of scattering on narrow
  widths.
- top bar: the campaign switcher now shrinks/truncates instead of overflowing
  on tablet widths.
- character sheet header: stat coins go full-width grid on mobile; the name
  truncates and scales down on small screens.

UX consistency:
- player Cast and resource Spend/Regain no longer fail silently — Cast shows the
  reason, resource −/+ disable at their bounds.
- the AI encounter builder now catches load failures and reports them; level-up
  advisor shows the human error message, not the error-kind enum.
- section headers standardized on the .smallcaps design token across 16 files
  (replacing an ad-hoc uppercase class).
- player HP bar uses the shared Meter primitive.

pf2e depth: condition-effects table gains drained, dazzled, enfeebled, fatigued.
FeatCard: dropped a dead text-xl wrapper left from the emoji purge.

Left as documented (functional, off-theme, refactor-risky): the native
confirm/prompt in Settings cloud-conflict and the session-password flow.

Gate: 293 unit + 35 e2e + 2 realtime; tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:38:24 +02:00
NilsBriggen dd694477b2 Polish sprint 1/2: restore crash fix, pf2e parity, full glyph purge
Crash fix:
- restoreBackup (file + cloud pull) now validates each row through its Zod
  schema, backfilling new fields — an older backup no longer lands characters
  missing feats/concentration/etc and crashing the sheet. + test.

pf2e parity (closing feature gaps vs 5e):
- +9 missing classes (Animist, Exemplar, Gunslinger, Inventor, Kineticist,
  Magus, Psychic, Summoner, Thaumaturge) with data + class tips.
- the wizard now enriches pf2e classes with their caster ability, so pf2e
  spellcasters get the Spells step + "Caster" tag like 5e.
- editable Perception rank and spellcasting proficiency on the pf2e sheet
  (fixes wrong initiative / spell DC at higher levels); rank-10 spell slots.
- pf2e monster resistances/weaknesses/immunities now apply in combat (flat
  amounts: resistance subtracts, weakness adds, 'all' matches any type). + tests.

Glyph purge (finishing the emoji→Lucide migration): replaced ~40 leftover
text-glyphs (✕ − + ✦ 🤫 ⬆ ⬇ ☑ ☐ ▶ ◀ ‹ › ★ ↻ ▸ ↑ ●) with Lucide icons across
Modal (every dialog), the sheet sections, dice, settings, player panels, world
pages, map editor, roll tray, and session UI.

Gate: 293 unit + e2e green; tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:29:36 +02:00
NilsBriggen f75b50adb8 pf2e character creator: brief, readable overviews matching 5e
The pf2e creator showed long, truncated run-ons where 5e shows clean, brief
overviews: classes were cut mid-word at 400 chars, backgrounds rendered the raw
Foundry "<Name> Source Core Rulebook pg. N …" citation prefix, and ancestry
summaries ended in a stray ellipsis.

- new src/features/characters/builder/overview.ts: briefOverview() strips the
  Foundry citation prefix, drops a trailing ellipsis, and keeps the first clean
  sentence (or a word-boundary trim — never mid-word). A no-op on already-short
  text, so 5e is unchanged. Applied to pf2e class/ancestry/background overviews.
- dedupeByName(): the pf2e data carries reprints under duplicate names (94
  ancestries, 612 backgrounds) which duplicated options and triggered React
  duplicate-key warnings — now de-duplicated at load.
- 6 unit tests for both helpers.

Verified live: pf2e class cards now read as clean single sentences; no citation
junk; no duplicate-key warnings. Gate: 289 unit + 35 e2e + 2 realtime, build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:51:28 +02:00
NilsBriggen ba5ae37111 V2 P5-backend + P7 + P8: conflict-safe sync, revocable invites, feats, multiclass, tests
P5 backend (the deferred items):
- conflict-aware cloud sync via optimistic concurrency: the server versions each
  backup blob (file mtime) and rejects a push whose base version is stale (409),
  so a second device can't silently overwrite a newer cloud copy. The client
  tracks its last-synced version; the SyncStatusIndicator surfaces a "Sync
  conflict" with one-click resolution (Use cloud / Keep this device), and the
  Settings push offers the same choice.
- revocable invites: owner can rotate a campaign's invite code (kills the old
  one) and remove a member (drops them + deletes their published characters).
  (Skipped editor/player roles — no enforcement target in the current model.)
- image storage: deliberately deferred (live images already stream de-duped on a
  separate channel; a backup blob-store is infra with no correctness gain).

P7 — subclass/feat/multiclass depth:
- feats are now first-class tracked records (name + effect text) in a Feats &
  Features sheet section, not buried in notes. Dexie v15 migration.
- 5e multiclass spell-slot calculator: a pure multiclassSlots() (full ×1, half
  ÷2, third ÷3 → full-caster table) + a sheet control to fill the slot table for
  multiclass casters. (Honest scope: tracking + slots, not per-subclass feature
  automation.)

P8 — test hardening: v2-surfaces e2e (feats, multiclass calc, signals bell,
World nav) + server tests (blob versioning, invite rotation, member removal) +
multiclass unit tests.

Gate green: 283 unit + 35 e2e + 2 realtime. App + server build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:34:29 +02:00
NilsBriggen dc0e8f701c V2 Phase 5+6: connectivity, presence, navigation & friction
Phase 5 — live-session & multi-device hardening:
- connectivityStore + SyncStatusIndicator in the shell: shows "Offline" when the
  browser is offline and the cloud autosave state (saving / saved / failed) when
  signed in; useCloudAutosave now reports its status. Owns online/offline events.
- server presence heartbeat: protocol-level ping/pong per socket terminates a
  vanished connection (~30s), so a player who closes their laptop drops out of
  the GM's roster instead of lingering. No client changes needed.

Phase 6 — onboarding & friction:
- navigation: surfaced the previously orphaned routes in the rail — a new
  "World" group (Notes, NPCs, Quests, Calendar) and Homebrew + Assistant under
  Reference. (Empty-state hints and map rename already existed.)
- combat keyboard control: n/→ next turn, p/← previous turn, guarded so it never
  fires while typing; button tooltips document it.

Test hygiene: scoped now-ambiguous nav links in e2e to the Primary landmark, and
fixed pre-existing stale emoji selectors in the realtime suite (📡 Host → Host,
📨 Just for you → Just for you) left over from the Living Codex emoji purge.

Gate green: tsc + 277 unit + 34 e2e + 2 realtime. App + server build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:51:03 +02:00
NilsBriggen 3d2c68a1a1 V2 Phase 4: Signals engine — proactive, app-wide one-click assists
The "buttons, not chatbot" core: the app recognizes a situation and offers the
fix; the user never has to formulate a request.

- src/lib/assistant/signals.ts: buildSignals() aggregates the existing advisor
  detectors with new kernel-aware ones — quest-complete (all objectives done →
  "Mark complete"), caster out-of-slots → "Short rest", concentrating-while-
  bloodied warning — deduped and severity-sorted. 6 unit tests.
- extended SuggestAction with shortRest + completeQuest; one shared dispatcher
  (useSignalAction) used by both surfaces so behaviour can't drift.
- SignalsBell: an ambient bell + popover in the top bar, visible from every
  screen, with a count badge, one-click actions, and per-signal/clear-all
  dismiss. AssistantPage refactored onto the same buildSignals + dispatcher.
- the AI cards were already actionable (NpcGen → npcsRepo, EncounterTip → add
  to encounter, Assistant encounter builder → combat).

Also fixed a pre-existing invalid-HTML bug: ConditionPicker rendered a <Check>
SVG inside <option> (React hydration error spam) → plain "✓" text.

Build + 277 unit tests green; bell verified live (popover, actions, no errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 08:30:26 +02:00
NilsBriggen 8ba38d893d V2 Phase 3: combat rules automation (conditions, damage types, reminders)
Completes the deployable Phase 1-3 chunk: the combat tracker now runs the math.

- condition-effects engine: declarative CONDITION_EFFECTS_5E/PF2E table +
  deriveState(system, speed, conditions) → effective speed / advantage state /
  incapacitation / pf2e status penalty. Tracker shows effect badges
  ("Speed 0", "Disadv. attacks", "−2 status"); pure + 10 unit tests.
- damage types: applyDamage(c, amount, type?) consults snapshotted monster
  DamageDefenses (immune→0, resist→halve, vulnerable→double), back-compatible.
  Monster add snapshots damageDefenses; tracker shows a type picker only when a
  creature has typed defenses, and the log notes resisted/vulnerable amounts.
- concentration + death saves are surfaced as REMINDERS, never auto-rolled:
  damaging a concentrating PC logs "roll a DC N Con save…"; a downed PC's turn
  logs "make a death saving throw." The player rolls and resolves via the Drop
  button / death-save pips they already own. (Per user: no auto dice rolls —
  it removes the player from their character.)

Build + 271 unit tests green. Verified live: grapple → Speed 0 badge;
fire-immune creature shows the immune-labelled damage picker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:23:52 +02:00
NilsBriggen 2651ac03b7 V2 Phase 2: spell + resource enforcement (sheet → combat → live)
The Slice-0 vertical that proves the enforcement architecture end-to-end.

- kernel: castSpell (slot consumption + concentration set/replace, upcasting,
  warlock pact slots), spendResource/regainResource, rollDeathSave — pure
  functions returning Partial<Character> patches. 13 unit tests.
- applyRest now ends concentration on any rest (+ test).
- character sheet SpellcastingSection: per-spell Cast button, concentration
  banner with Drop, concentration markers; ResourcesSection −/+ routed through
  the kernel.
- player MyCharacterPanel: castable spell list + concentration banner, so a
  seated player casts and it broadcasts via the existing playerPatch flow.
- live sync: partialCharacterDiffSchema carries concentration; GM applies it;
  playerCharacterSchema mirrors slots/concentration/resources (PC-only) and the
  player-facing AC now respects equippedArmor. Wire round-trip test added.

Verified live: casting decrements the right slot, switching concentration logs
"Concentration on X ends", banner + Drop render. Build + 258 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:10:07 +02:00
NilsBriggen 026927b5f5 V2 Phase 1: mechanics kernel + compendium→sheet integration
Lay the foundation for full rules enforcement: a new pure, testable
mechanics kernel (src/lib/mechanics/) that derives structured, enforceable
game data from the loosely-typed compendium, plus the first user-facing
payoff (accurate armor AC + spell mechanics on the sheet).

- normalizers: spell (5e + pf2e), armor, monster defenses → typed
  SpellMechanics / ArmorMechanics / DamageDefenses, with memoized derivers
  wrapping the existing lazy compendium loaders (no new assets). 11 unit tests.
- character schema: spell entries snapshot concentration/save/damage at
  compendium-link time; new top-level `concentration` slot and structured
  `equippedArmor` (additive, defaulted). Dexie v14 migration backfills.
- AC model: baseArmorClass now consults equippedArmor (heavy = flat, medium =
  Dex-capped) and falls back to the old 10+Dex formula when unarmored. Threaded
  through every AC call site; armor picker added to the sheet (5e).
- compendium "Add spell to character" snapshots real mechanics via the kernel.

Backward-compatible: un-enriched spells/characters behave exactly as before.
Build + 244 unit tests green; armor→AC verified live in preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:58:31 +02:00
NilsBriggen 1b275ed5e6 Give reasoning models room; skip the advisor's self-contradictory ask
DeepSeek is a reasoning model — its chain-of-thought counts against the
completion budget. The 1024 max_tokens default let it burn the whole budget
"thinking" and get cut off (finish_reason: length) with an empty content, which
read as a parse failure. Raise DEFAULT_MAX_TOKENS to 4096 so it can finish
reasoning AND emit the JSON. Also return a clear error on empty responses.

The spiral was triggered by a contradictory prompt: the fight was already
"deadly" yet the advisor still asked the model to "add creatures to reach
Deadly." Skip the LLM when the target tier isn't actually harder than the
current one and let the deterministic path ease the over-tuned fight instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:14:49 +02:00
NilsBriggen 53854deb81 Find the advisor additions array by shape, not a fixed alias list
DeepSeek keeps inventing its own key for the additions array — first
"creatures", then "addCreatures". Chasing named aliases is whack-a-mole, so
detect it structurally: a known synonym first, else any top-level array of
{name, …} objects that isn't the "remove" list. Any invented key now resolves
to "add".

Tests cover creatures/addCreatures/monsters/an invented key, and that a lone
"remove" list is not mistaken for additions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:59:26 +02:00
NilsBriggen bde21992bd Let complete() accept coerce/preprocess schemas (fix tsc -b build)
The "creatures" alias fix wraps the balance schema in z.preprocess, whose input
type is `unknown`. complete()'s `schema?: ZodType<T>` pinned input=output=T, so
T inferred as unknown and `res.data.add` failed under tsc -b (project build).

Loosen schema input to `ZodType<T, ZodTypeDef, any>` — T stays pinned to the
schema output, but coerce/preprocess/transform schemas (input ≠ T) are accepted.
Pin the advisor call's type param to BalanceSuggestion, matching the other
complete<...> call sites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:52:51 +02:00
NilsBriggen 71bca6e70a Fix combat advisor schema mismatch — accept "creatures" alias for add
The real cause of the advisor falling back to deterministic: DeepSeek returns
the additions array under the key "creatures" (nudged by the prompt wording
"the creatures to add"), but the schema requires "add" — so safeParse failed.

- Spell out the exact JSON keys in the prompt ("add", not "creatures"/"monsters").
- Normalize creatures/monsters/additions aliases to "add" before validation, so
  a correct suggestion isn't discarded over a key-name synonym.
- Regression test using DeepSeek's exact wild payload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:48:39 +02:00
NilsBriggen 9a81c04199 Fix combat advisor "AI unavailable (parse)" — coerce numeric counts
The encounter-balance schema was the only AI schema with a numeric field.
LLMs routinely emit numbers as JSON strings ("count": "2"), which z.number()
rejects, failing the whole safeParse and falling back to deterministic with a
"parse" error. The all-string NPC/session/quest schemas never hit this.

- Coerce monster counts (z.coerce.number) and widen the upper bound 8->12 so a
  string or slightly over-eager count is accepted, not rejected. min(1) stays.
- Tell the model in the prompt that count is a plain integer and reasoning /
  targetDifficulty are required.
- Surface the real failure message in the advisor instead of the opaque kind.
- Add a regression test that string counts coerce to numbers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:43:03 +02:00
NilsBriggen 740cf20b93 UI/UX "Living Codex" 4/n: assistant features + emoji purge
- Replace all emoji icons (~26 instances) with Lucide icons across the app
  (DashboardPage, SessionSidebar, PlayerBoards, MyCharacterPanel,
  PlayerViewPage, EncounterTracker, CampaignsPage, HandoutControl,
  SessionControl, CharacterSheet, EncounterTipCard, ActionGuide)
- Expose real 5e race data in wizard (ASI/vision/traits instead of stub desc);
  add vision field to loadRaces5e return type
- Surface subclass descriptions after picking a subclass in the wizard
- Add per-class tips, race synergy notes, and ability/skill guidance in wizard
- New ActionGuide component: collapsible "What can I do on my turn?" in player view
- New SessionPrepCard: AI + fallback session hook generator on assistant page
- New NpcGenCard: AI + fallback quick NPC generator with "Add to campaign" action
- Inline NPC detail generator (wand button) on each NPC card in NpcsPage
- Quest hook generator button in QuestsPage header
- Condition tooltips in player party view (useConditionGlossary)
- Pass campaign.system through session snapshot so player view is system-aware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:29:06 +02:00
NilsBriggen 235cdecb53 Fixes batch 3: autosave, campaign-agnostic PCs, live push, builder
- Autosave: while signed in, a debounced full backup pushes whenever durable data
  changes (useCloudAutosave watches a fingerprint of the main tables; high-churn
  tables excluded). No-op signed out.
- Campaign-agnostic characters: PCs are now global — the Characters roster shows
  every player character regardless of active campaign, and any PC can be added to
  a fight. NPCs stay per-campaign. (charactersRepo.listAllPcs / useAllPcs; difficulty
  fallback stays campaign-scoped.)
- Live "bring your own character": a joining player can push one of their own local
  characters; the GM's grant inserts it into the campaign and seats them.
- Character builder: a system picker (5e / PF2e, defaults to the campaign) so creation
  no longer assumes 5e; PF2e skill wording ("Trained" vs "proficient"); spell step now
  explains how many to pick + that they're known spells prepared later.

230 unit + 34 e2e + 2 realtime green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:12:18 +02:00
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
NilsBriggen 426824fd78 Fixes batch 1: modal focus, campaign system, combat difficulty, origin desc
- Modal: focus into the dialog ONCE on open (not on every parent re-render), and
  prefer the first field over the X button — fixes focus jumping to the close
  button on each keystroke in the handout composer (and every other composer).
- Campaign edit: hide the System selector entirely (it's fixed after creation).
- Combat difficulty: rate against the PCs actually in the encounter (falls back to
  the roster) so adding a player updates the reading.
- Character builder: ancestry/background description is now a scrollable panel
  instead of clamped to 3 lines (no longer cut off on small screens).

223 unit + key e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:48:31 +02:00
122 changed files with 4604 additions and 362 deletions
+3 -2
View File
@@ -19,7 +19,8 @@ test('GM hosts a session; a player on another device sees live combat', async ({
await gm.getByLabel('Settings').click();
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
await gm.getByRole('button', { name: '📡 Host' }).click();
await gm.getByRole('button', { name: 'Host' }).click();
await gm.getByRole('button', { name: 'Start hosting' }).click();
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6);
@@ -76,7 +77,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({
await gm.getByRole('button', { name: /Share with/ }).click();
await gm.getByLabel('Share title').fill('Secret passage');
await gm.getByRole('button', { name: /Send to/ }).click();
await expect(player.getByText('📨 Just for you')).toBeVisible();
await expect(player.getByText('Just for you')).toBeVisible();
await expect(player.getByText('Secret passage')).toBeVisible();
await gmCtx.close();
+2 -1
View File
@@ -19,7 +19,8 @@ test('a player claims a seat and manages their character; edits round-trip to th
await gm.getByLabel('Settings').click();
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
await gm.getByRole('button', { name: '📡 Host' }).click();
await gm.getByRole('button', { name: 'Host' }).click();
await gm.getByRole('button', { name: 'Start hosting' }).click();
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6);
+2 -1
View File
@@ -33,7 +33,8 @@ test('level-up advisor offers routes and expands a chosen one (deterministic)',
test('assistant page renders the campaign insights section', async ({ page }) => {
await page.getByLabel('Settings').click();
await page.getByRole('button', { name: 'Load sample campaign' }).click();
await page.getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible();
// Sample has a single encounter → not enough for a trend yet.
await expect(page.getByText(/No trends detected yet/)).toBeVisible();
+1 -1
View File
@@ -23,7 +23,7 @@ test('assistant flags a bloodied PC and builds an encounter', async ({ page }) =
// Assistant shows a resource suggestion
await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Assistant' }).click();
await page.getByLabel('Primary').getByRole('link', { name: 'Assistant' }).click();
await expect(page.getByRole('heading', { name: 'Assistant' })).toBeVisible();
await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible();
+1 -1
View File
@@ -14,7 +14,7 @@ test('homebrew: create a custom monster and find it in the compendium', async ({
await page.locator('input[data-autofocus]').fill('Brew');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Homebrew' }).click();
await page.getByLabel('Primary').getByRole('link', { name: 'Homebrew' }).click();
// Create a homebrew monster and name it
await page.getByRole('button', { name: /New monster/ }).click();
+42
View File
@@ -0,0 +1,42 @@
import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(async () => {
indexedDB.deleteDatabase('ttrpg-manager');
localStorage.clear();
});
await page.reload();
});
test('v2 surfaces: feats tracking, multiclass slot calculator, signals bell, World nav', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('V2 Test');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click();
await createCharacter(page, 'Vex');
// Feats: add one and confirm it becomes a tracked record.
await page.getByPlaceholder(/Great Weapon Master/).fill('Sentinel');
await page.getByPlaceholder(/Great Weapon Master/).press('Enter');
await expect(page.getByLabel('Feat name')).toHaveValue('Sentinel');
// Multiclass slot calculator: 5 full-caster levels → standard 4/3/2 table.
await page.getByText('Multiclass slot calculator').click();
await page.getByLabel('Full caster levels').fill('5');
await page.getByRole('button', { name: 'Apply slots' }).click();
await expect(page.getByLabel('Level 1 max slots')).toHaveValue('4');
await expect(page.getByLabel('Level 3 max slots')).toHaveValue('2');
// Ambient Signals bell opens and renders its panel.
await page.getByRole('button', { name: /Signals/ }).click();
await expect(page.getByText('Signals', { exact: true })).toBeVisible();
await page.keyboard.press('Escape');
// The previously-orphaned routes are now reachable from the rail's World group.
await page.getByLabel('Primary').getByRole('link', { name: 'Notes' }).click();
await expect(page).toHaveURL(/\/notes/);
await page.getByLabel('Primary').getByRole('link', { name: 'Quests' }).click();
await expect(page).toHaveURL(/\/quests/);
});
+3 -3
View File
@@ -30,13 +30,13 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
// NPC
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'NPCs' }).click();
await page.getByLabel('Primary').getByRole('link', { name: 'NPCs' }).click();
await page.getByRole('button', { name: '+ New NPC' }).first().click();
await expect(page.getByLabel('NPC name')).toBeVisible();
// Quest with an objective
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Quests' }).click();
await page.getByLabel('Primary').getByRole('link', { name: 'Quests' }).click();
await page.getByRole('button', { name: '+ New quest' }).first().click();
await page.getByPlaceholder('Add objective…').fill('Find the Tome');
await page.getByRole('button', { name: 'Add', exact: true }).click();
@@ -44,7 +44,7 @@ test('worldbuilding: dashboard, note with wiki link, npc, quest, calendar', asyn
// Calendar advances days
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Calendar' }).click();
await page.getByLabel('Primary').getByRole('link', { name: 'Calendar' }).click();
await page.getByRole('button', { name: '+1 week' }).click();
await expect(page.getByText('7', { exact: true })).toBeVisible();
});
+5 -1
View File
@@ -23,8 +23,12 @@ describe('AccountStore', () => {
if (!r.ok) throw new Error('register failed');
const u = await store.userByToken(r.token);
expect(u).toBeTruthy();
await store.saveBlob(u!.id, '{"x":1}');
const ver = await store.saveBlob(u!.id, '{"x":1}');
expect(typeof ver).toBe('number');
expect(await store.loadBlob(u!.id)).toBe('{"x":1}');
// the version token round-trips and is null for a user with no blob
expect(await store.blobSavedAt(u!.id)).toBe(ver);
expect(await store.blobSavedAt('no-such-user')).toBeNull();
expect(await store.userByToken('garbage-token')).toBeNull();
});
+10 -1
View File
@@ -132,16 +132,25 @@ export class AccountStore {
await this.persist();
}
async saveBlob(id: string, blob: string): Promise<void> {
/** Write the backup blob and return its version token (the file mtime, ms). */
async saveBlob(id: string, blob: string): Promise<number> {
await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true });
const tmp = `${this.blobFile(id)}.tmp`;
await fs.writeFile(tmp, blob);
await fs.rename(tmp, this.blobFile(id));
return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs);
}
async loadBlob(id: string): Promise<string | null> {
try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; }
}
/** Version token of the stored blob (file mtime, ms), or null if none. Used for
* optimistic-concurrency conflict detection so a second device can't silently
* overwrite a newer cloud copy. */
async blobSavedAt(id: string): Promise<number | null> {
try { return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs); } catch { return null; }
}
userCount(): number { return this.users.size; }
}
+21
View File
@@ -55,4 +55,25 @@ describe('CloudStore', () => {
expect((await store2.listForUser('gm1'))[0]?.id).toBe(c.id);
expect(await store2.listCharacters(c.id)).toHaveLength(0);
});
it('rotates the invite code (owner only) so the old code stops working', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
const old = c.inviteCode;
expect(await store.rotateInvite('p1', c.id)).toBeNull(); // non-owner can't
const fresh = await store.rotateInvite('gm1', c.id);
expect(fresh).toBeTruthy();
expect(fresh).not.toBe(old);
expect(await store.joinByInvite('p1', old)).toBeNull(); // old code is dead
expect((await store.joinByInvite('p1', fresh!))?.id).toBe(c.id); // new code works
});
it('removes a member and their characters (owner only)', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{}' });
expect(await store.removeMember('p1', c.id, 'p1')).toBe(false); // non-owner can't
expect(await store.removeMember('gm1', c.id, 'p1')).toBe(true);
expect(store.isMember(c.id, 'p1')).toBe(false);
expect(await store.listCharacters(c.id)).toHaveLength(0); // their char is gone
});
});
+26
View File
@@ -83,6 +83,32 @@ export class CloudStore {
return c;
}
/** Owner-only: invalidate the current invite code and mint a new one, so a
* leaked/old code can no longer be used to join. Returns the new code. */
async rotateInvite(ownerUserId: string, campaignId: string): Promise<string | null> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c || c.ownerUserId !== ownerUserId) return null;
this.byInvite.delete(c.inviteCode);
c.inviteCode = this.mintInvite();
c.updatedAt = this.now();
this.byInvite.set(c.inviteCode, c.id);
await this.persist();
return c.inviteCode;
}
/** Owner-only: remove a member and all of their published characters. */
async removeMember(ownerUserId: string, campaignId: string, memberUserId: string): Promise<boolean> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c || c.ownerUserId !== ownerUserId || memberUserId === ownerUserId) return false;
c.members = c.members.filter((m) => m !== memberUserId);
c.updatedAt = this.now();
for (const [id, ch] of this.characters) if (ch.campaignId === campaignId && ch.ownerUserId === memberUserId) this.characters.delete(id);
await this.persist();
return true;
}
isMember(campaignId: string, userId: string): boolean {
const c = this.campaigns.get(campaignId);
return !!c && (c.ownerUserId === userId || c.members.includes(userId));
+38 -6
View File
@@ -23,6 +23,10 @@ const bearer = (req: FastifyRequest): string | undefined => {
// per-socket token bucket: 80 messages / 10s
const RATE = { capacity: 80, refillMs: 10_000 };
// Protocol-level keepalive: ping each socket on an interval and terminate one
// that misses a pong, so a vanished player (closed laptop, dead network) is
// detected and drops out of the GM's roster within ~one interval.
const HEARTBEAT_MS = 30_000;
export function buildServer() {
const app = Fastify({ bodyLimit: BODY_LIMIT });
@@ -61,17 +65,25 @@ export function buildServer() {
app.put('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const blob = (req.body as { blob?: string } | undefined)?.blob;
if (typeof blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
await accounts.saveBlob(u.id, blob);
return { ok: true, size: blob.length };
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
// Optimistic concurrency: if the caller names the version it last synced and
// the cloud has since moved on (another device pushed), refuse — no silent
// overwrite. The client then resolves (pull, or force-push).
const current = await accounts.blobSavedAt(u.id);
if (current !== null && body.baseSavedAt !== undefined && body.baseSavedAt !== null && body.baseSavedAt !== current) {
return reply.code(409).send({ error: 'conflict', savedAt: current });
}
const savedAt = await accounts.saveBlob(u.id, body.blob);
return { ok: true, size: body.blob.length, savedAt };
});
app.get('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const blob = await accounts.loadBlob(u.id);
if (blob === null) return reply.code(404).send({ error: 'no-save' });
return reply.header('content-type', 'application/json').send(blob);
const savedAt = await accounts.blobSavedAt(u.id);
return reply.header('content-type', 'application/json').header('x-saved-at', String(savedAt ?? '')).send(blob);
});
// ---- shared cloud campaigns + member-owned characters ----
@@ -94,6 +106,17 @@ export function buildServer() {
if (!c) return reply.code(404).send({ error: 'no-campaign', message: 'No campaign with that invite code.' });
return { id: c.id, name: c.name, system: c.system, role: c.ownerUserId === u.id ? 'owner' : 'member' };
});
app.post('/api/campaigns/:id/invite', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const code = await cloud.rotateInvite(u.id, (req.params as { id: string }).id);
return code ? { inviteCode: code } : reply.code(403).send({ error: 'forbidden', message: 'Only the campaign owner can rotate the invite.' });
});
app.delete('/api/campaigns/:id/members/:userId', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const p = req.params as { id: string; userId: string };
const ok = await cloud.removeMember(u.id, p.id, p.userId);
return ok ? { ok: true } : reply.code(403).send({ error: 'forbidden' });
});
app.put('/api/characters', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } };
@@ -155,6 +178,15 @@ export function buildServer() {
let tokens = RATE.capacity;
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
// Heartbeat: a socket that doesn't answer the previous ping is dead → drop it.
let alive = true;
socket.on('pong', () => { alive = true; });
const heartbeat = setInterval(() => {
if (!alive) { try { socket.terminate(); } catch { /* already gone */ } return; }
alive = false;
try { socket.ping(); } catch { /* closed */ }
}, HEARTBEAT_MS);
socket.on('message', (raw: Buffer) => {
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
let parsed;
@@ -177,7 +209,7 @@ export function buildServer() {
case 'setName': hub.setName(sender, m.name); break;
}
});
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); });
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); });
});
});
+1 -1
View File
@@ -29,7 +29,7 @@ function next(ws: WebSocket, t: ServerMessage['t']): Promise<ServerMessage> {
ws.on('message', onMsg);
});
}
const snap = { campaignName: 'Live', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null };
const snap = { campaignName: 'Live', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null, quests: [] };
describe('realtime server (integration)', () => {
it('host → join → snapshot flow; players cannot push state', async () => {
+1 -1
View File
@@ -7,7 +7,7 @@ function fake(): Sender & { msgs: ServerMessage[] } {
const msgs: ServerMessage[] = [];
return { msgs, send: (m) => msgs.push(m) };
}
const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null };
const snap: Snapshot = { campaignName: 'C', calendarDay: null, party: [], encounter: null, map: null, mapImageId: null, quests: [] };
const char: Character = characterSchema.parse({
id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia',
abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 13, cha: 8 },
+16 -2
View File
@@ -3,6 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import {
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles,
type LucideIcon,
} from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
@@ -17,11 +18,14 @@ import { SessionControl } from '@/features/play/SessionControl';
import { HandoutControl } from '@/features/play/HandoutControl';
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
import { usePlayerConnection } from '@/features/play/usePlayerConnection';
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
import { SignalsBell } from '@/features/assistant/SignalsBell';
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
import { SessionSidebar } from '@/features/play/SessionSidebar';
import { useSessionStore } from '@/stores/sessionStore';
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'reference' };
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' };
const NAV: NavItem[] = [
{ to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' },
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
@@ -29,12 +33,19 @@ const NAV: NavItem[] = [
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
{ to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' },
{ to: '/npcs', label: 'NPCs', icon: Drama, exact: false, group: 'world' },
{ to: '/quests', label: 'Quests', icon: Target, exact: false, group: 'world' },
{ to: '/calendar', label: 'Calendar', icon: CalendarDays, exact: false, group: 'world' },
{ to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' },
{ to: '/homebrew', label: 'Homebrew', icon: FlaskConical, exact: false, group: 'reference' },
{ to: '/assistant', label: 'Assistant', icon: Sparkles, exact: false, group: 'reference' },
{ to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' },
];
const GROUPS: { id: NavItem['group']; label: string | null }[] = [
{ id: 'top', label: null },
{ id: 'play', label: 'At the Table' },
{ id: 'world', label: 'World' },
{ id: 'reference', label: 'Reference' },
];
@@ -49,7 +60,7 @@ function CampaignSwitcher() {
return (
<Select
aria-label="Active campaign"
className="w-auto min-w-44"
className="w-auto min-w-0 max-w-[34vw] shrink truncate sm:min-w-44"
value={active?.id ?? ''}
onChange={(e) => setActive(e.target.value || null)}
>
@@ -87,6 +98,7 @@ export function RootLayout() {
// While the GM is hosting, mirror state to players (inert unless hosting).
useSessionBroadcaster(activeCampaign ?? null);
usePlayerConnection();
useCloudAutosave();
// Global Ctrl/Cmd+K opens the command palette.
useEffect(() => {
@@ -203,6 +215,8 @@ export function RootLayout() {
</Button>
{activeCampaign && <HandoutControl />}
<SessionControl />
<SyncStatusIndicator />
<SignalsBell />
<ThemeToggle />
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
<SettingsIcon size={18} aria-hidden />
+22 -8
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Button } from './Button';
@@ -15,17 +16,33 @@ interface ModalProps {
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Focus into the dialog ONCE when it opens (not on every parent re-render —
// that was stealing focus to the close button on each keystroke). Prefer the
// first real field over the X button so composers land in their input.
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current;
panel?.querySelector<HTMLElement>('[data-autofocus], input, button, textarea, select')?.focus();
const target =
panel?.querySelector<HTMLElement>('[data-autofocus]') ??
panel?.querySelector<HTMLElement>('input:not([type="hidden"]), textarea, select') ??
panel?.querySelector<HTMLElement>('a[href], button:not([disabled])');
target?.focus();
return () => { previouslyFocused.current?.focus(); };
}, [open]);
// Escape to close + focus trap. Reads onClose via a ref so a changing
// onClose identity never re-binds (and never re-triggers the focus effect).
useEffect(() => {
if (!open) return;
const panel = panelRef.current;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
onCloseRef.current();
return;
}
if (e.key === 'Tab' && panel) {
@@ -45,11 +62,8 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
}
}
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
if (!open) return null;
@@ -69,7 +83,7 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
<div className="flex items-center justify-between border-b border-line px-5 py-3">
<h2 className="text-lg font-display font-semibold text-ink">{title}</h2>
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog">
<X size={16} aria-hidden />
</Button>
</div>
<div className="px-5 py-4">{children}</div>
+4 -3
View File
@@ -1,3 +1,4 @@
import { Sparkles, Skull, X } from 'lucide-react';
import { useRollStore, type TrayRoll } from '@/stores/rollStore';
import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
import { naturalD20 } from '@/lib/dice/notation';
@@ -35,8 +36,8 @@ export function RollTray() {
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
{last.label && <div className="truncate text-xs font-medium text-muted">{last.label}</div>}
{crit === 'crit' && <div className="text-xs font-bold uppercase tracking-wide text-warning"> Critical </div>}
{crit === 'fumble' && <div className="text-xs font-bold uppercase tracking-wide text-danger"> Fumble </div>}
{crit === 'crit' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-warning"><Sparkles size={12} aria-hidden /> Critical <Sparkles size={12} aria-hidden /></div>}
{crit === 'fumble' && <div className="flex items-center gap-1 text-xs font-bold uppercase tracking-wide text-danger"><Skull size={12} aria-hidden /> Fumble</div>}
{last.degree && (
<div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}>
{DEGREE_LABEL[last.degree]}
@@ -44,7 +45,7 @@ export function RollTray() {
</div>
)}
</div>
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink"></button>
<button onClick={dismiss} aria-label="Dismiss roll" className="text-muted hover:text-ink"><X size={14} aria-hidden /></button>
</div>
<div className={cn('mt-1 font-display text-4xl font-bold', crit === 'crit' ? 'text-warning' : crit === 'fumble' ? 'text-danger' : 'text-accent')}>{last.result.total}</div>
<div className="mt-1 font-mono text-xs text-muted">{last.result.breakdown}</div>
+21 -24
View File
@@ -1,14 +1,15 @@
import { useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas';
import { charactersRepo, encountersRepo, notesRepo } from '@/lib/db/repositories';
import { getSystem, applyRest } from '@/lib/rules';
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 { loadMonsters, loadPf2e } from '@/lib/compendium';
import { resourceSuggestions, planningSuggestions, combatSuggestions, type Suggestion, type SuggestAction } from '@/lib/assistant/advisors';
import { type Suggestion, type SuggestAction } from '@/lib/assistant/advisors';
import { buildSignals } from '@/lib/assistant/signals';
import { useSignalAction } from './useSignalAction';
import { buildSuggestedEncounter } from '@/lib/assistant/encounter';
import { useUiStore } from '@/stores/uiStore';
import { useCharacters } from '@/features/characters/hooks';
@@ -18,6 +19,8 @@ import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { CampaignInsights } from './CampaignInsights';
import { SessionPrepCard } from './SessionPrepCard';
import { NpcGenCard } from './NpcGenCard';
export function AssistantPage() {
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
@@ -41,25 +44,11 @@ function Assistant({ campaign }: { campaign: Campaign }) {
const [msg, setMsg] = useState<string | null>(null);
const suggestions = useMemo<Suggestion[]>(
() => [...combatSuggestions(activeEnc), ...resourceSuggestions(characters), ...planningSuggestions(notes, quests)],
() => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }),
[activeEnc, characters, notes, quests],
);
const runAction = async (a: SuggestAction) => {
if (a.type === 'goto') { void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to }); return; }
if (a.type === 'longRest') {
const c = await charactersRepo.get(a.characterId);
if (!c) return;
const opt = getSystem(c.system).restOptions.find((o) => o.restoresHp);
if (opt) await charactersRepo.update(c.id, applyRest(c, opt));
setMsg('Applied a long rest.');
return;
}
if (a.type === 'createNote') {
await notesRepo.create(campaign.id, a.title);
void navigate({ to: '/notes' });
}
};
const runAction = useSignalAction(campaign.id, setMsg);
const buildEncounter = async (difficulty: string) => {
setBusy(true);
@@ -93,6 +82,8 @@ function Assistant({ campaign }: { campaign: Campaign }) {
await encountersRepo.save(enc);
setActiveEncounter(enc.id);
void navigate({ to: '/combat' });
} catch {
setMsg('Could not build an encounter — the monster data failed to load. Try again.');
} finally {
setBusy(false);
}
@@ -107,7 +98,7 @@ function Assistant({ campaign }: { campaign: Campaign }) {
<div className="grid gap-6 lg:grid-cols-2">
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Encounter builder</h2>
<h2 className="mb-2 smallcaps">Encounter builder</h2>
<div className="rounded-lg border border-line bg-panel p-4">
<p className="mb-3 text-sm text-muted">Build a balanced encounter for your party and jump into combat.</p>
<div className="flex flex-wrap gap-2">
@@ -117,18 +108,24 @@ function Assistant({ campaign }: { campaign: Campaign }) {
</div>
</div>
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Combat</h2>
<h2 className="mb-2 mt-6 smallcaps">Combat</h2>
<SuggestionList items={byCat('combat')} onAction={runAction} empty="No active combat." />
</section>
<section>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Party resources</h2>
<h2 className="mb-2 smallcaps">Party resources</h2>
<SuggestionList items={byCat('resources')} onAction={runAction} empty="The party looks ready." />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Session prep</h2>
<h2 className="mb-2 mt-6 smallcaps">Session prep</h2>
<SuggestionList items={byCat('planning')} onAction={runAction} empty="Nothing flagged." />
<h2 className="mb-2 mt-6 text-xs font-semibold uppercase tracking-wide text-muted">Campaign insights</h2>
<h2 className="mb-2 mt-6 smallcaps">Session hooks</h2>
<SessionPrepCard campaign={campaign} characters={characters} notes={notes} quests={quests} />
<h2 className="mb-2 mt-6 smallcaps">Quick NPC</h2>
<NpcGenCard campaign={campaign} />
<h2 className="mb-2 mt-6 smallcaps">Campaign insights</h2>
<CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
</section>
</div>
+14 -4
View File
@@ -1,3 +1,4 @@
import { Brain } from 'lucide-react';
import type { Campaign, Encounter } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
@@ -22,7 +23,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
data-testid="encounter-tip"
>
<div className="flex items-start gap-3">
<span className="text-lg" aria-hidden>🧠</span>
<Brain size={18} className="shrink-0 text-muted" aria-hidden />
<div className="min-w-0 flex-1">
{theme ? (
<>
@@ -45,7 +46,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
{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">
<span className="smallcaps">
{source === 'llm' ? 'AI suggestion' : 'Suggested'}
</span>
<span className="text-xs text-muted"> {suggestion.targetDifficulty}</span>
@@ -53,11 +54,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>
+112
View File
@@ -0,0 +1,112 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
import { getSystem } from '@/lib/rules';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
interface Props {
campaign: Campaign;
}
export function NpcGenCard({ campaign }: Props) {
const navigate = useNavigate();
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const [hint, setHint] = useState('');
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [npc, setNpc] = useState<NpcQuick | null>(null);
const [message, setMessage] = useState<string | null>(null);
const ctx = {
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
systemLabel: getSystem(campaign.system).label,
questsSummary: 'No active quests.',
notesSummary: 'No notes yet.',
};
const generate = async () => {
setState('loading');
setMessage(null);
if (canUseLlm) {
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
const result = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
if (result.ok && 'data' in result) {
setNpc(result.data);
setState('ready');
return;
}
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template NPC instead.`);
}
setNpc(fallbackNpc());
setState('ready');
};
const addToCampaign = async () => {
if (!npc) return;
const created = await npcsRepo.create(campaign.id, npc.name);
await npcsRepo.update(created.id, {
role: npc.role,
description: `Motivation: ${npc.motivation}\n\nSecret: ${npc.secret}\n\nIntro hook: ${npc.hook}`,
});
void navigate({ to: '/npcs' });
};
return (
<div className="rounded-lg border border-line bg-panel p-4">
<p className="mb-3 text-sm text-muted">
Generate a ready-to-use NPC with motivation, secret, and an intro hook for the table.
</p>
<div className="flex gap-2">
<Input
value={hint}
onChange={(e) => setHint(e.target.value)}
placeholder={`Optional: "a nervous blacksmith" or "a corrupt city guard"`}
className="flex-1 text-sm"
aria-label="NPC hint"
/>
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
{state === 'loading' ? 'Thinking…' : canUseLlm ? 'Generate (AI)' : 'Generate'}
</Button>
</div>
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
{state === 'ready' && npc && (
<div className="mt-4 rounded-md border border-line bg-surface-2 p-3 text-sm">
<div className="mb-2 flex items-start justify-between gap-2">
<div>
<span className="font-display text-lg font-semibold text-ink">{npc.name}</span>
<span className="ml-2 text-xs text-muted">{npc.role}</span>
</div>
<Button size="sm" variant="primary" onClick={() => void addToCampaign()}>
Add to campaign
</Button>
</div>
<dl className="space-y-1.5 text-xs">
<NpcField label="Motivation" value={npc.motivation} />
<NpcField label="Secret" value={npc.secret} />
<NpcField label="Intro hook" value={npc.hook} />
</dl>
</div>
)}
</div>
);
}
function NpcField({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="inline font-semibold text-ink">{label}: </dt>
<dd className="inline text-muted">{value}</dd>
</div>
);
}
@@ -0,0 +1,88 @@
import { useState } from 'react';
import { Copy, Check } from 'lucide-react';
import type { Campaign, Character, Note, Quest } from '@/lib/schemas';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildCampaignContext } from '@/lib/assistant/context';
import { buildSessionHookPrompt, sessionHookSchema, fallbackSessionHooks, type SessionHook } from '@/lib/assistant/session';
import { Button } from '@/components/ui/Button';
interface Props {
campaign: Campaign;
characters: Character[];
notes: Note[];
quests: Quest[];
}
export function SessionPrepCard({ campaign, characters, notes, quests }: Props) {
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const [state, setState] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle');
const [hooks, setHooks] = useState<SessionHook[]>([]);
const [message, setMessage] = useState<string | null>(null);
const generate = async () => {
setState('loading');
setMessage(null);
const ctx = buildCampaignContext({ campaign, characters, encounters: [], quests, notes });
if (canUseLlm) {
const { system, user } = buildSessionHookPrompt(ctx);
const result = await complete<{ hooks: SessionHook[] }>(getLlmConfig(), { system, user, schema: sessionHookSchema });
if (result.ok && 'data' in result) {
setHooks(result.data.hooks);
setState('ready');
return;
}
setMessage(`AI unavailable${!result.ok ? ` (${result.error})` : ''}. Showing template hooks instead.`);
}
setHooks(fallbackSessionHooks(ctx));
setState('ready');
};
return (
<div className="rounded-lg border border-line bg-panel p-4">
<p className="mb-3 text-sm text-muted">
Generate three distinct session opening hooks grounded in your campaign&apos;s current threads.
</p>
<Button variant="secondary" disabled={state === 'loading'} onClick={() => void generate()}>
{state === 'loading' ? 'Thinking…' : state === 'ready' ? 'Regenerate' : canUseLlm ? 'Generate hooks (AI)' : 'Generate hooks'}
</Button>
{message && <p className="mt-2 text-xs text-muted">{message}</p>}
{state === 'ready' && hooks.length > 0 && (
<div className="mt-4 space-y-3">
{hooks.map((h, i) => (
<HookCard key={i} hook={h} />
))}
</div>
)}
</div>
);
}
function HookCard({ hook }: { hook: SessionHook }) {
const [copied, setCopied] = useState(false);
const copy = () => {
void navigator.clipboard?.writeText(`${hook.title}\n\n${hook.opening}\n\n${hook.tension}`).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<div className="rounded-md border border-line bg-surface-2 p-3 text-sm">
<div className="mb-1 flex items-start justify-between gap-2">
<span className="font-display font-semibold text-ink">{hook.title}</span>
<button onClick={copy} title="Copy hook" className="shrink-0 text-muted hover:text-ink">
{copied ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}
</button>
</div>
<p className="text-muted">{hook.opening}</p>
<p className="mt-1.5 text-xs italic text-faint">{hook.tension}</p>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Bell, X } from 'lucide-react';
import type { Campaign } from '@/lib/schemas';
import { buildSignals } from '@/lib/assistant/signals';
import type { Suggestion } from '@/lib/assistant/advisors';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import { useCharacters } from '@/features/characters/hooks';
import { useNotes, useQuests } from '@/features/world/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
import { useSignalAction } from './useSignalAction';
const DOT: Record<Suggestion['severity'], string> = {
danger: 'bg-danger',
warn: 'bg-warning',
info: 'bg-info',
};
/**
* Ambient, app-wide "what needs my attention" surface. Watches the active
* campaign's state and surfaces one-click signals from anywhere — the proactive
* core of the assistant. Renders nothing without an active campaign.
*/
export function SignalsBell() {
const campaign = useActiveCampaign();
if (!campaign) return null;
return <SignalsBellInner campaign={campaign} />;
}
function SignalsBellInner({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const notes = useNotes(campaign.id);
const quests = useQuests(campaign.id);
const encounters = useEncounters(campaign.id);
const activeEnc = encounters.find((e) => e.status === 'active');
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
const [open, setOpen] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const runAction = useSignalAction(campaign.id, setMsg);
const ref = useRef<HTMLDivElement>(null);
const signals = useMemo(
() => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }).filter((s) => !dismissed.has(s.id)),
[activeEnc, characters, notes, quests, dismissed],
);
// The badge counts only things that genuinely want attention.
const attention = signals.filter((s) => s.severity !== 'info').length;
// Close the popover on an outside click or Escape.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); };
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey); };
}, [open]);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
aria-label={`Signals${attention > 0 ? ` (${attention} need attention)` : ''}`}
title="Signals — things that may need your attention"
className="relative grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink"
>
<Bell size={18} aria-hidden />
{attention > 0 && (
<span className="absolute -right-0.5 -top-0.5 grid min-w-[16px] place-items-center rounded-full bg-danger px-1 text-[10px] font-semibold leading-4 text-white">
{attention}
</span>
)}
</button>
{open && (
<div className="absolute right-0 top-11 z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border border-line bg-panel p-2 shadow-xl">
<div className="flex items-center justify-between px-2 py-1">
<span className="smallcaps">Signals</span>
{signals.length > 0 && (
<button className="text-xs text-faint hover:text-ink" onClick={() => setDismissed(new Set(signals.map((s) => s.id)))}>Clear all</button>
)}
</div>
{msg && <p className="px-2 pb-1 text-xs text-success" aria-live="polite">{msg}</p>}
{signals.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted">Nothing needs your attention.</p>
) : (
<ul className="max-h-[60vh] space-y-1 overflow-y-auto">
{signals.map((s) => (
<li key={s.id} className="flex items-start gap-2 rounded-lg border border-line/70 bg-surface p-2">
<span className={cn('mt-1.5 h-2 w-2 shrink-0 rounded-full', DOT[s.severity])} aria-hidden />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-ink">{s.title}</div>
{s.detail && <div className="text-xs text-muted">{s.detail}</div>}
{s.action && (
<Button size="sm" variant="secondary" className="mt-1.5" onClick={() => { void runAction(s.action!); if (s.action!.type !== 'goto') setMsg(null); setOpen(s.action!.type !== 'goto'); }}>
{s.action.label}
</Button>
)}
</div>
<button className="text-faint hover:text-ink" aria-label="Dismiss" onClick={() => setDismissed((d) => new Set(d).add(s.id))}>
<X size={13} aria-hidden />
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
+19 -4
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';
@@ -108,6 +108,11 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
const existing: ReinforceMonster[] = monsterCombatants.map((c) => ({ name: c.name, cr: c.cr, level: c.level }));
const current = computeBudget(campaign.system, partyLevels, existing.map((m) => ({ cr: m.cr, level: m.level }))).difficulty;
const target = nextTarget(campaign.system, current);
// When the fight is already at (or above) the hardest tier, "add creatures to
// reach <target>" is a contradiction — it makes reasoning models spiral and burn
// their whole token budget. Only ask the AI when the target is genuinely harder;
// otherwise fall through to the deterministic path (which eases an over-tuned fight).
const targetIsHarder = (ORDINAL[target.toLowerCase()] ?? 99) > (ORDINAL[current.toLowerCase()] ?? -1);
// Candidates the AI may choose from: the creatures already in the fight
// (so it can say "add another goblin") plus level-appropriate bestiary picks.
@@ -125,9 +130,9 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
const candidates = [...existingCands, ...poolCands];
if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; }
if (canUseLlm) {
if (canUseLlm && targetIsHarder) {
const prompt = buildBalancePrompt(ctx, { difficulty: current, targetDifficulty: target, candidates });
const res = await complete(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema });
const res = await complete<BalanceSuggestion>(getLlmConfig(), { system: prompt.system, user: prompt.user, schema: balanceSuggestionSchema });
if (res.ok && 'data' in res) {
const valid = res.data.add.filter((a) => candidates.some((c) => c.name === a.name));
if (valid.length) {
@@ -137,7 +142,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
return;
}
} else if (!res.ok) {
setMessage(`AI unavailable (${res.error}); showing a deterministic pick.`);
setMessage(`AI unavailable: ${res.message} Showing a deterministic pick.`);
}
}
@@ -162,6 +167,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.
+1 -1
View File
@@ -43,7 +43,7 @@ export function useLevelUpAdvisor(campaign: Campaign, character: Character) {
setState('ready');
return;
}
if (!res.ok) setMessage(`AI unavailable (${res.error}); showing general routes.`);
if (!res.ok) setMessage(`AI unavailable: ${res.message} Showing general routes.`);
}
setRoutes(deterministicRoutes(campaign.system, character.className));
setSource('deterministic');
+41
View File
@@ -0,0 +1,41 @@
import { useNavigate } from '@tanstack/react-router';
import { charactersRepo, notesRepo, questsRepo } from '@/lib/db/repositories';
import { getSystem, applyRest } from '@/lib/rules';
import type { SuggestAction } from '@/lib/assistant/advisors';
/**
* One place that turns a signal's `SuggestAction` into a real mutation/navigation,
* shared by the ambient signals bell and the Assistant page so behaviour can't
* drift. `onDone` receives a short confirmation message for an aria-live region.
*/
export function useSignalAction(campaignId: string, onDone?: (msg: string) => void) {
const navigate = useNavigate();
return async (a: SuggestAction): Promise<void> => {
switch (a.type) {
case 'goto':
void navigate(a.params ? { to: a.to, params: a.params } : { to: a.to });
return;
case 'longRest':
case 'shortRest': {
const c = await charactersRepo.get(a.characterId);
if (!c) return;
const opts = getSystem(c.system).restOptions;
const opt = a.type === 'longRest'
? opts.find((o) => o.restoresHp) ?? opts.at(-1)
: opts.find((o) => !o.restoresHp) ?? opts[0];
if (opt) await charactersRepo.update(c.id, applyRest(c, opt));
onDone?.(`Applied ${opt?.label ?? 'a rest'} for ${c.name}.`);
return;
}
case 'completeQuest':
await questsRepo.update(a.questId, { status: 'completed' });
onDone?.('Quest marked complete.');
return;
case 'createNote':
await notesRepo.create(campaignId, a.title);
void navigate({ to: '/notes' });
return;
}
};
}
+11 -12
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { type ReactNode, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { ArrowRight, BookOpenText, CalendarDays, ScrollText } from 'lucide-react';
import { ArrowRight, BookOpenText, CalendarDays, Map, RadioTower, ScrollText, Users } from 'lucide-react';
import { cn } from '@/lib/cn';
import { campaignsRepo } from '@/lib/db/repositories';
import { seedSampleCampaign } from '@/lib/sample';
@@ -70,9 +70,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
</p>
<hr className="gilt-rule mx-auto mt-5 max-w-xs" />
<div className="mt-5 grid gap-2 text-left sm:grid-cols-3">
<FeatCard icon="🧙" title="Build characters" desc="A friendly, data-driven builder for new players." />
<FeatCard icon="🗺️" title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
<FeatCard icon="📡" title="Play live" desc="Host a room; players manage their own character." />
<FeatCard icon={<Users size={20} />} title="Build characters" desc="A friendly, data-driven builder for new players." />
<FeatCard icon={<Map size={20} />} title="Run battle maps" desc="Fog, line-of-sight vision, tokens, .dd2vtt import." />
<FeatCard icon={<RadioTower size={20} />} title="Play live" desc="Host a room; players manage their own character." />
</div>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button>
@@ -83,10 +83,10 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
);
}
function FeatCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
function FeatCard({ icon, title, desc }: { icon: ReactNode; title: string; desc: string }) {
return (
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong">
<div className="text-xl" aria-hidden>{icon}</div>
<div className="text-accent" aria-hidden>{icon}</div>
<div className="mt-1 font-display font-semibold text-ink">{title}</div>
<div className="text-xs text-muted">{desc}</div>
</div>
@@ -258,12 +258,10 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
placeholder="Curse of Strahd"
/>
</Field>
{/* System is fixed once a campaign exists (content + characters depend on it). */}
{!campaign && (
<Field label="System">
<Select
value={system}
disabled={!!campaign}
onChange={(e) => setSystem(e.target.value as typeof system)}
>
<Select value={system} onChange={(e) => setSystem(e.target.value as typeof system)}>
{SYSTEM_OPTIONS.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
@@ -271,6 +269,7 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
))}
</Select>
</Field>
)}
<Field label="Description">
<Textarea
value={description}
+98 -10
View File
@@ -1,9 +1,10 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, Shield, Gauge, Footprints, Award } from 'lucide-react';
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { allArmorMechanics5e, type ArmorMechanics } from '@/lib/mechanics';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
@@ -13,6 +14,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';
@@ -22,6 +24,7 @@ import { AttacksSection } from './sheet/AttacksSection';
import { ResourcesSection } from './sheet/ResourcesSection';
import { SpellcastingSection } from './sheet/SpellcastingSection';
import { DefensesSection } from './sheet/DefensesSection';
import { FeatsSection } from './sheet/FeatsSection';
import { AbilityGenModal } from './sheet/AbilityGenModal';
import { LevelUpModal } from './sheet/LevelUpModal';
@@ -43,6 +46,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 +58,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 */ }
// 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).
@@ -77,6 +101,7 @@ export function CharacterSheet({ character }: { character: Character }) {
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
armorBonus: c.armorBonus,
...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}),
perceptionRank: c.perceptionRank,
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
};
@@ -110,7 +135,7 @@ export function CharacterSheet({ character }: { character: Character }) {
<div className="min-w-0 flex-1">
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
<Input
className="mt-0.5 max-w-md border-transparent bg-transparent px-0 font-display text-3xl font-semibold leading-tight tracking-tight focus-visible:border-line"
className="mt-0.5 w-full max-w-md truncate border-transparent bg-transparent px-0 font-display text-2xl font-semibold leading-tight tracking-tight focus-visible:border-line sm:text-3xl"
value={c.name}
aria-label="Character name"
onChange={(e) => update({ name: e.target.value })}
@@ -124,13 +149,13 @@ export function CharacterSheet({ character }: { character: Character }) {
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex w-full items-center gap-2 sm:w-auto">
{[
{ icon: Shield, value: ac, label: 'Armor' },
{ icon: Gauge, value: formatModifier(initiative), label: 'Init' },
{ icon: Footprints, value: c.speed, label: 'Speed' },
].map(({ icon: Ic, value, label }) => (
<div key={label} className="flex w-[4.5rem] flex-col items-center gap-1 rounded-xl border border-line bg-surface-2 px-3 py-2.5 text-center">
<div key={label} className="flex flex-1 flex-col items-center gap-1 rounded-xl border border-line bg-surface-2 px-3 py-2.5 text-center sm:w-[4.5rem] sm:flex-none">
<Ic className="h-4 w-4 text-accent-deep" aria-hidden />
<span className="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
<span className="smallcaps text-[8.5px]">{label}</span>
@@ -139,7 +164,7 @@ export function CharacterSheet({ character }: { character: Character }) {
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? 'Link copied ✓' : 'Share with player'}</Button>}
{c.kind === 'pc' && <Button size="sm" variant="ghost" onClick={() => void shareWithPlayer()} title="Copy a link the player opens to manage this character">{shared ? <><Check size={12} aria-hidden /> Link copied</> : 'Share with player'}</Button>}
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
</div>
@@ -162,11 +187,14 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Vital stats */}
<div className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard label="Armor Class" value={ac} hint={`10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-muted">
<span>Armor/shield bonus</span>
<StatCard label="Armor Class" value={ac} hint={c.equippedArmor ? `${c.equippedArmor.name}${c.armorBonus ? ` + ${c.armorBonus}` : ''}` : `10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
<div className="mt-2 flex flex-col items-center gap-2 text-xs text-muted">
{c.system === '5e' && <ArmorPicker c={c} update={update} />}
<div className="flex items-center gap-2">
<span>Shield/misc bonus</span>
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
</div>
</div>
</StatCard>
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
@@ -201,6 +229,23 @@ export function CharacterSheet({ character }: { character: Character }) {
</div>
</section>
{/* Perception (pf2e core proficiency — advances to legendary; drives initiative) */}
{c.system === 'pf2e' && (
<section className="mb-6">
<SectionTitle>Perception</SectionTitle>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
<RankRow
label="Perception (WIS)"
modifier={sys.passivePerception(rulesInput) - 10}
rank={c.perceptionRank}
ranks={ranks}
onRank={(rank) => update({ perceptionRank: rank })}
system={c.system}
/>
</div>
</section>
)}
{/* Saves */}
<section className="mb-6">
<SectionTitle>Saving Throws</SectionTitle>
@@ -261,6 +306,9 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Class resources + rest */}
<ResourcesSection c={c} update={update} />
{/* Feats & features */}
<FeatsSection c={c} update={update} />
{/* Inventory, currency, encumbrance */}
<InventorySection c={c} update={update} />
@@ -281,6 +329,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>
);
}
@@ -388,6 +454,28 @@ function StatCard({ label, value, hint, children }: { label: string; value: stri
);
}
function ArmorPicker({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
const [armors, setArmors] = useState<ArmorMechanics[]>([]);
useEffect(() => { void allArmorMechanics5e().then(setArmors); }, []);
// Body armor only — shields are handled via the misc bonus field.
const body = armors.filter((a) => a.category !== 'shield');
return (
<Select
className="w-auto py-1 text-xs"
aria-label="Equipped armor"
value={c.equippedArmor?.name ?? ''}
onChange={(e) => {
const name = e.target.value;
const found = body.find((a) => a.name === name);
update({ equippedArmor: found ? { name: found.name, category: found.category, baseAc: found.baseAc, dexCap: found.dexCap, stealthDisadvantage: found.stealthDisadvantage } : null });
}}
>
<option value="">Unarmored</option>
{body.map((a) => <option key={a.name} value={a.name}>{a.name} (AC {a.baseAc}{a.dexCap === 0 ? '' : a.dexCap === null ? ' + Dex' : ` + Dex≤${a.dexCap}`})</option>)}
</Select>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
}
+6 -6
View File
@@ -6,7 +6,7 @@ import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules';
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
import { pickTextFile } from '@/lib/io/file';
import { useCharacters } from './hooks';
import { useCharacters, useAllPcs } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Avatar, Badge } from '@/components/ui/Codex';
@@ -18,11 +18,11 @@ export function CharactersPage() {
}
function CharactersList({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
// PCs are campaign-agnostic — show every player character; NPCs stay per-campaign.
const pcs = useAllPcs();
const npcs = useCharacters(campaign.id).filter((c) => c.kind === 'npc');
const [creating, setCreating] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const pcs = characters.filter((c) => c.kind === 'pc');
const npcs = characters.filter((c) => c.kind === 'npc');
const importCharacter = async () => {
setImportError(null);
@@ -60,7 +60,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
</p>
)}
{characters.length === 0 ? (
{pcs.length === 0 && npcs.length === 0 ? (
<EmptyState
title="No characters yet"
hint="Add player characters and NPCs, or import a character file."
@@ -98,7 +98,7 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
function CharacterCard({ c }: { c: Character }) {
const [confirming, setConfirming] = useState(false);
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
const ac = getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus, ...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}) });
return (
<div className="paper-grain flex flex-col rounded-xl border border-line bg-panel transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-accent/50 hover:shadow-[0_8px_24px_-12px_var(--app-accent-glow)]">
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import type { Campaign, Character, SpellEntry } from '@/lib/schemas';
import { Lightbulb, RotateCcw, Star } from 'lucide-react';
import { type Campaign, type Character, type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter,
type AbilityKey, type AbilityScores, type ProficiencyRank,
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
} from '@/lib/rules';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
@@ -12,8 +13,12 @@ import type { RulesetClass } from '@/lib/ruleset/normalize';
import { createRng } from '@/lib/rng';
import { newId } from '@/lib/ids';
import { formatModifier } from '@/lib/format';
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
import { briefOverview, dedupeByName } from './overview';
import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Field, Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn';
@@ -33,22 +38,25 @@ function playstyle(c: RulesetClass): string {
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = {
'5e': [
{ label: '🛡️ Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
{ label: '🔥 Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
{ label: '🗡️ Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
{ label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
{ label: 'Stalwart Fighter', hint: 'Tough front-liner. Easy to play.', className: 'Fighter', ability: { str: 15, dex: 13, con: 14, int: 8, wis: 12, cha: 10 } },
{ label: 'Clever Wizard', hint: 'Versatile spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 13, int: 15, wis: 12, cha: 10 } },
{ label: 'Sneaky Rogue', hint: 'Skills + big sneak attacks.', className: 'Rogue', ability: { str: 10, dex: 15, con: 13, int: 12, wis: 14, cha: 8 } },
{ label: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
],
pf2e: [
{ label: '🛡️ Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
{ label: '🔥 Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
{ label: '🗡️ Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
{ label: 'Stalwart Fighter', hint: 'Best weapon proficiency in the game.', className: 'Fighter', ability: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 } },
{ label: 'Clever Wizard', hint: 'Prepared arcane spellcaster.', className: 'Wizard', ability: { str: 8, dex: 14, con: 12, int: 18, wis: 12, cha: 10 } },
{ label: 'Sneaky Rogue', hint: 'Skills, mobility, sneak attack.', className: 'Rogue', ability: { str: 10, dex: 18, con: 12, int: 12, wis: 14, cha: 10 } },
{ label: 'Healing Cleric', hint: 'Divine font of healing.', className: 'Cleric', ability: { str: 12, dex: 10, con: 12, int: 10, wis: 18, cha: 12 } },
],
};
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
const navigate = useNavigate();
const sys = getSystem(campaign.system);
// Characters are campaign-agnostic: the wizard asks which system to build for,
// defaulting to the campaign's. Changing it resets all dependent selections.
const [system, setSystem] = useState<SystemId>(campaign.system);
const sys = getSystem(system);
const allSkillKeys = sys.skills.map((s) => s.key);
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
@@ -58,18 +66,63 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]);
useEffect(() => { let on = true; void loadClasses(campaign.system).then((c) => on && setClasses([...c].sort((a, b) => a.name.localeCompare(b.name)))); return () => { on = false; }; }, [campaign.system]);
useEffect(() => {
let on = true;
if (campaign.system === '5e') {
void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed }))));
void loadClasses(system).then((c) => {
if (!on) return;
// The pf2e class data loads with caster:'none' and no subclasses; merge the
// curated caster ability (so pf2e spellcasters get the Spells step + "Caster"
// tag) and the defining 1st-level choice list (instinct/doctrine/bloodline…).
const enriched = system === 'pf2e'
? c.map((rc) => {
const def = getClassDef('pf2e', rc.name);
const subclasses = PF2E_SUBCLASSES[rc.name] ?? rc.subclasses;
const caster = def && def.caster !== 'none' && def.spellAbility ? def.spellAbility : rc.caster;
return { ...rc, caster, subclasses };
})
: c;
setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name)));
});
return () => { on = false; };
}, [system]);
useEffect(() => {
let on = true;
if (system === '5e') {
void loadRaces5e().then((rs) => {
if (!on) return;
const strip = (s: string) => s.replace(/\*{2,3}[^*]+\*{2,3}\s*/g, '').trim();
setOrigins(rs.map((r) => {
const desc = [r.asi, r.vision, r.traits].filter(Boolean).map(strip).join('\n\n');
const speedFt = /(\d+) feet/.exec(r.speed)?.[1];
const asiMatches = [...r.asi.matchAll(/(\w+) score (?:each )?increases? by (\d+)/g)];
const asiSummary = asiMatches.map(([, ability, n]) => ability && n ? `+${n} ${ability.slice(0, 3).toUpperCase()}` : '').filter(Boolean).join(', ');
const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
return { name: r.name, desc, meta };
}));
});
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
} else {
void loadPf2e('ancestries').then((rs) => on && setOrigins(rs.map((r) => ({ name: String(r.name), desc: String((r.description ?? r.text) ?? ''), ...(typeof r.hp === 'number' ? { hp: r.hp } : {}), ...((r.speed as { land?: number } | undefined)?.land ? { speed: (r.speed as { land: number }).land } : {}) }))));
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') }))));
void loadPf2e('ancestries').then((rs) => {
if (!on) return;
setOrigins(dedupeByName(rs.map((r) => {
const hp = typeof r.hp === 'number' ? r.hp : undefined;
const speed = (r.speed as { land?: number } | undefined)?.land;
const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : [];
const attrSummary = attrArr.map((a) => `+${String(a).slice(0, 3)}`).join('/');
const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
return {
name: String(r.name),
desc: briefOverview(String((r.summary ?? r.text) ?? '')),
...(hp !== undefined ? { hp } : {}),
...(speed ? { speed } : {}),
meta,
};
})));
});
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ name: String(b.name), desc: briefOverview(String((b.description ?? b.text) ?? '')) })))));
}
return () => { on = false; };
}, [campaign.system]);
}, [system]);
// ---- selections ----
const [step, setStep] = useState(0);
@@ -108,7 +161,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int);
const skillCount = selectedClass
? selectedClass.skillCount + (campaign.system === 'pf2e' ? Math.max(0, intMod) : 0)
? selectedClass.skillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
: 2;
const skillOptions = useMemo(() => {
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys;
@@ -127,10 +180,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
useEffect(() => {
if (!isCaster || allSpells.length) return;
let on = true;
if (campaign.system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' }))));
if (system === '5e') void loadSpells().then((ss) => on && setAllSpells((ss as unknown as { name: string; level_int: number; school: string }[]).map((s) => ({ name: s.name, level: s.level_int ?? 0, meta: s.school ?? '' }))));
else void loadPf2e('spells').then((ss) => on && setAllSpells(ss.map((s) => ({ name: String(s.name), level: Number(s.level) || 0, meta: Array.isArray(s.trait) ? (s.trait as string[]).slice(0, 2).join(', ') : '' }))));
return () => { on = false; };
}, [isCaster, campaign.system, allSpells.length]);
}, [isCaster, system, allSpells.length]);
const spellResults = useMemo(() => {
const q = spellQuery.trim().toLowerCase();
const maxLevel = Math.min(9, Math.ceil(level / 2));
@@ -140,15 +193,36 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s]));
// ---- derived build ----
const built = useMemo(() => buildCharacter(campaign.system, {
const built = useMemo(() => buildCharacter(system, {
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
}), [campaign.system, selectedClass, level, abilities, skills, selectedOrigin]);
}), [system, selectedClass, level, abilities, skills, selectedOrigin]);
// Rough "how many should I pick" suggestion for the Spells step. Not enforced —
// just guidance: a few cantrips plus a handful of starting leveled spells.
const leveledSlots = useMemo(() => built.spellcasting.slots.reduce((n, s) => n + (s.level > 0 ? s.max : 0), 0), [built]);
const suggestedSpells = Math.max(4, leveledSlots + 2);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
const changeSystem = (next: SystemId) => {
if (next === system) return;
setSystem(next);
// Reset everything keyed on the system so stale picks don't leak across systems.
// The class/origin/background/spell lists reload from the loaders above.
setClassSlug('');
setSubclass('');
setSkills([]);
setSpellPicks([]);
setSpellQuery('');
setAllSpells([]); // force the spell loader (guarded on length) to refetch for the new system
setAncestry('');
setBackground('');
setStep(0);
};
const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
const c = classes.find((x) => x.name === t.className);
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
@@ -169,7 +243,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const finish = async () => {
if (!selectedClass) return;
const created = await charactersRepo.create(campaign.id, {
system: campaign.system, name: name.trim() || selectedClass.name, kind,
system, name: name.trim() || selectedClass.name, kind,
ancestry: ancestry.trim(), className: selectedClass.name, level,
});
// For classes outside the curated tables (esp. PF2e), fill saves from data.
@@ -177,7 +251,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
}
const spells: SpellEntry[] = spellPicks.map((s) => ({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)), prepared: false, notes: '' }));
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
const notes = [subclass ? `Subclass: ${subclass}` : '', background ? `Background: ${background}` : ''].filter(Boolean).join('\n');
await charactersRepo.update(created.id, {
...built,
@@ -222,17 +296,29 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
<Field label="Level"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field>
</div>
<div>
<div className="mb-1 smallcaps">Game system</div>
<div className="flex gap-1" role="group" aria-label="Game system">
{SYSTEM_OPTIONS.map((opt) => (
<button key={opt.id} type="button" onClick={() => changeSystem(opt.id)} aria-pressed={system === opt.id}
className={cn('rounded-md px-3 py-1.5 text-sm', system === opt.id ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>
{opt.label}
</button>
))}
</div>
</div>
<div className="rounded-lg border border-accent/30 bg-accent/5 p-2">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">New here? Start from a ready-made hero</div>
<div className="mb-1 smallcaps">New here? Start from a ready-made hero</div>
<div className="flex flex-wrap gap-1">
{(TEMPLATES[campaign.system] ?? []).map((t) => (
{(TEMPLATES[system] ?? []).map((t) => (
<button key={t.label} onClick={() => applyTemplate(t)} title={t.hint} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent">{t.label}</button>
))}
</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Choose a class</div>
<div className="mb-1 smallcaps">Choose a class</div>
<div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
{classes.map((c) => (
<button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); }}
@@ -241,30 +327,70 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
<span className="font-display font-semibold text-ink">{c.name}</span>
<span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span>
</div>
<div className="mt-0.5 text-[11px] text-muted">{campaign.system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div>
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{c.description}</p>}
<div className="mt-0.5 text-[11px] text-muted">{system === 'pf2e' ? `${c.hitDie} HP/lvl` : `d${c.hitDie}`}{c.keyAbilities.length ? ` · ${c.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join('/')}` : ''}{c.caster !== 'none' ? ' · caster' : ''}</div>
{c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{briefOverview(c.description)}</p>}
</button>
))}
{classes.length === 0 && <p className="text-sm text-muted">Loading classes</p>}
</div>
</div>
{selectedClass && selectedClass.subclasses.length > 0 && (
<Field label={campaign.system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
if (!tip) return null;
return (
<div className="rounded-lg border border-accent/30 bg-accent/5 p-3 text-sm">
<div className="flex items-start gap-2">
<Lightbulb size={15} className="mt-0.5 shrink-0 text-accent" aria-hidden />
<div>
<div className="font-medium text-ink">{selectedClass.name}</div>
<p className="mt-0.5 text-muted">{tip.role}</p>
<div className="mt-1.5 text-xs text-muted">
<span className="font-medium text-ink">Key abilities:</span> {tip.primaryAbilities}
</div>
{tip.beginner && <Badge tone="verdigris" className="mt-1.5">Good for beginners</Badge>}
</div>
</div>
</div>
);
})()}
{selectedClass && selectedClass.subclasses.length > 0 && (() => {
const sc = selectedClass.subclasses.find((s) => s.name === subclass);
return (
<Field label={system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}>
<Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
<option value=""> decide later </option>
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
</Select>
</Field>
{sc?.desc && (
<p className="mt-1 max-h-28 overflow-y-auto rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">
{sc.desc}
</p>
)}
</Field>
);
})()}
</div>
)}
{stepName === 'Origin' && (
<div className="space-y-3">
<div className="grid gap-4 sm:grid-cols-2">
<OriginPicker title={campaign.system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
</div>
{selectedClass && selectedOrigin && (() => {
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
if (!synergy) return null;
return (
<p className="flex items-center gap-1.5 text-xs text-accent">
<Lightbulb size={12} className="shrink-0" aria-hidden />
{synergy}
</p>
);
})()}
</div>
)}
{stepName === 'Abilities' && (
@@ -274,9 +400,20 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
))}
</div>
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}> Reroll</Button>}
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}><RotateCcw size={13} aria-hidden /> Reroll</Button>}
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
{selectedClass && selectedClass.keyAbilities.length > 0 && <p className="mb-3 text-xs text-muted">Tip: prioritise <span className="text-ink">{selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')}</span> for a {selectedClass.name}.</p>}
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0
? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.`
: null);
return text ? (
<p className="mb-3 flex items-start gap-1.5 text-xs text-muted">
<Lightbulb size={12} className="mt-0.5 shrink-0 text-accent" aria-hidden />
{text}
</p>
) : null;
})()}
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{ABILITIES.map((a, i) => {
@@ -284,7 +421,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const isKey = selectedClass?.keyAbilities.includes(a);
return (
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}{isKey ? ' ★' : ''}</div>
<div className="flex items-center justify-center gap-0.5 smallcaps">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
{usesPool ? (
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}>
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
@@ -302,7 +439,17 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Skills' && (
<div>
<p className="mb-3 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> trained {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<p className="mb-2 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
return tip?.skillSuggestions ? (
<p className="mb-3 flex items-center gap-1.5 text-xs text-muted">
<Lightbulb size={12} className="shrink-0 text-accent" aria-hidden />
{selectedClass.name}s often pick: <span className="ml-1 text-ink">{tip.skillSuggestions}</span>
</p>
) : null;
})()}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillOptions.map((key) => {
const checked = skills.includes(key);
@@ -320,7 +467,11 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
{stepName === 'Spells' && (
<div>
<p className="mb-2 text-sm text-muted">Pick starting spells (search by name). You can add more on the sheet later. {spellPicks.length} selected.</p>
<p className="mb-1 text-sm text-muted">Pick your cantrips and a few starting spells about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. Search by name; you can always adjust on the sheet later.</p>
<p className="mb-1 text-xs text-muted">These are the spells you <span className="text-ink">{system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells youve learned)'}</span>. You prepare or cast them from slots on the character sheet.</p>
<p className={cn('mb-2 text-xs', spellPicks.length > suggestedSpells + 4 ? 'text-warning' : 'text-muted')}>
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — thats quite a few; you can trim some later if you like.' : ''}
</p>
<Input value={spellQuery} onChange={(e) => setSpellQuery(e.target.value)} placeholder="Search spells…" className="mb-2" aria-label="Search spells" />
<div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
{spellResults.map((s) => {
@@ -366,7 +517,7 @@ function OriginPicker({ title, options, value, onPick }: { title: string; option
return (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">{title}</span>
<span className="smallcaps">{title}</span>
{value && <button className="text-[11px] text-muted hover:text-ink" onClick={() => onPick('')}>clear</button>}
</div>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${title.toLowerCase()}`} className="mb-1" aria-label={`Search ${title}`} />
@@ -378,7 +529,7 @@ function OriginPicker({ title, options, value, onPick }: { title: string; option
))}
{options.length === 0 && <p className="text-xs text-muted">Loading</p>}
</div>
{sel?.desc && <p className="mt-1 line-clamp-3 text-xs text-muted">{sel.desc}</p>}
{sel?.desc && <p className="mt-1 max-h-32 overflow-y-auto whitespace-pre-wrap rounded-md border border-line bg-surface-2 p-2 text-xs text-muted">{sel.desc}</p>}
</div>
);
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { briefOverview, dedupeByName } from './overview';
describe('briefOverview', () => {
it('leaves short text untouched (5e feature names)', () => {
expect(briefOverview('Rage')).toBe('Rage');
expect(briefOverview('Fighting Style')).toBe('Fighting Style');
});
it('keeps the first clean sentence of a long run-on, no mid-word cut', () => {
const fighter = 'Fighting for honor, greed, loyalty, or simply the thrill of battle, you are an undisputed master of weaponry and combat techniques. You combine your actions through clever combinations…';
expect(briefOverview(fighter)).toBe('Fighting for honor, greed, loyalty, or simply the thrill of battle, you are an undisputed master of weaponry and combat techniques.');
});
it('strips the Foundry "Name Source … pg. N" citation prefix, then takes the first sentence', () => {
const bg = ' Acolyte Source Core Rulebook pg. 60 You spent your early days in a religious monastery or cloister. You may have traveled out into the world to spread the message of your religion or because you cast away the teachings of your faith.';
expect(briefOverview(bg)).toBe('You spent your early days in a religious monastery or cloister.');
});
it('drops a stray trailing ellipsis from a pre-truncated summary', () => {
expect(briefOverview('Dwarves are a stoic and stern people…')).toBe('Dwarves are a stoic and stern people');
});
it('falls back to a word-boundary trim when the first sentence is very long', () => {
const long = `${'word '.repeat(60)}end.`;
const out = briefOverview(long);
expect(out.endsWith('…')).toBe(true);
expect(out.length).toBeLessThanOrEqual(141);
expect(out).not.toMatch(/wor…$/); // never cuts mid-word
});
});
describe('dedupeByName', () => {
it('keeps the first of each name, case-insensitively', () => {
const out = dedupeByName([{ name: 'Human' }, { name: 'Elf' }, { name: 'human' }, { name: 'Elf' }]);
expect(out.map((x) => x.name)).toEqual(['Human', 'Elf']);
});
});
@@ -0,0 +1,35 @@
/**
* Helpers that make the pf2e creator read as cleanly as the 5e one. The pf2e
* (Foundry) data carries long, citation-prefixed descriptions and reprinted
* entries under duplicate names; these trim and de-duplicate at the edge.
*/
/**
* A short, clean overview from possibly-long text. Strips the
* "<Name> Source Core Rulebook pg. N" citation prefix Foundry entries carry,
* drops a stray trailing ellipsis from prior truncation, then keeps the first
* sentence (or a clean word-boundary trim). A no-op on already-short text.
*/
export function briefOverview(text: string): string {
let t = (text || '').replace(/\s+/g, ' ').trim();
const citation = /^.{0,80}?\bSource\b.*?\bpg\.\s*\d+\s*/i.exec(t);
if (citation) t = t.slice(citation[0].length).trim();
t = t.replace(/…\s*$/, '').trim();
if (t.length <= 140) return t;
const sentence = /^(.{40,180}?[.!?])(?:\s|$)/.exec(t);
if (sentence) return sentence[1]!;
const cut = t.slice(0, 140);
const sp = cut.lastIndexOf(' ');
return `${(sp > 40 ? cut.slice(0, sp) : cut).trim()}`;
}
/** Keep the first entry of each name (case-insensitive) — drops reprints/variants. */
export function dedupeByName<T extends { name: string }>(items: T[]): T[] {
const seen = new Set<string>();
return items.filter((it) => {
const k = it.name.trim().toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
+5
View File
@@ -6,6 +6,11 @@ export function useCharacters(campaignId: string): Character[] {
return useLiveQuery(() => charactersRepo.listByCampaign(campaignId), [campaignId], []);
}
/** All player characters across every campaign — PCs are campaign-agnostic. */
export function useAllPcs(): Character[] {
return useLiveQuery(() => charactersRepo.listAllPcs(), [], []);
}
export function useCharacter(id: string): Character | undefined {
return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
}
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { RotateCcw } from 'lucide-react';
import type { AbilityKey, AbilityScores } from '@/lib/rules';
import { ABILITY_ABBR, abilityModifier } from '@/lib/rules';
import { createRng } from '@/lib/rng';
@@ -75,7 +76,7 @@ export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: Abilit
</div>
{method === 'roll' && (
<Button size="sm" variant="secondary" className="mb-3" onClick={reroll}> Reroll</Button>
<Button size="sm" variant="secondary" className="mb-3" onClick={reroll}><RotateCcw size={13} aria-hidden /> Reroll</Button>
)}
{method === 'pointbuy' && (
<p className={cn('mb-3 text-sm', remaining < 0 ? 'text-danger' : 'text-muted')}>
@@ -89,7 +90,7 @@ export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: Abilit
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
return (
<div key={a} className="rounded-lg border border-line bg-surface p-3 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
<div className="smallcaps">{ABILITY_ABBR[a]}</div>
{usesPool ? (
<Select
className="mt-1"
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
@@ -84,7 +86,7 @@ export function AttacksSection({ c, update }: SectionProps) {
{result.damage}{a.damageType ? ` ${a.damageType}` : ''}
</button>
</span>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}><X size={14} aria-hidden /></Button>
</li>
);
})}
@@ -1,3 +1,5 @@
import { Minus, Plus, Star } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { NumberField } from '@/components/ui/NumberField';
import { SheetSection, type SectionProps } from './common';
@@ -44,7 +46,7 @@ export function DefensesSection({ c, update }: SectionProps) {
</Field>
<Field label="Inspiration">
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
{d.inspiration ? '★ Inspired' : 'Grant inspiration'}
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
</Button>
</Field>
<Field label="Exhaustion (06)">
@@ -64,9 +66,9 @@ export function DefensesSection({ c, update }: SectionProps) {
</Field>
<Field label="Hero Points">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"></Button>
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point"><Minus size={14} aria-hidden /></Button>
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point">+</Button>
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
</div>
</Field>
</>
@@ -79,7 +81,7 @@ export function DefensesSection({ c, update }: SectionProps) {
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="rounded-md border border-line bg-panel px-3 py-2">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
<div className="mb-1 smallcaps">{label}</div>
{children}
</div>
);
@@ -0,0 +1,50 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids';
import type { Feat } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input, Textarea } from '@/components/ui/Input';
import { SheetSection, type SectionProps } from './common';
/** Feats tracked as first-class records (name + effect) rather than buried in notes. */
export function FeatsSection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const add = () => {
if (name.trim() === '') return;
const feat: Feat = { id: newId(), name: name.trim(), source: '', description: '' };
update({ feats: [...c.feats, feat] });
setName('');
};
const patch = (id: string, p: Partial<Feat>) =>
update({ feats: c.feats.map((f) => (f.id === id ? { ...f, ...p } : f)) });
const remove = (id: string) => update({ feats: c.feats.filter((f) => f.id !== id) });
return (
<SheetSection title="Feats & Features">
<div className="mb-3 flex items-end gap-2">
<label className="flex-1 min-w-40 text-xs text-muted">
Add feat / feature
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Great Weapon Master, Oath of Devotion…" />
</label>
<Button variant="primary" onClick={add}>Add</Button>
</div>
{c.feats.length === 0 ? (
<p className="text-sm text-muted">No feats or features tracked yet.</p>
) : (
<ul className="space-y-2">
{c.feats.map((f) => (
<li key={f.id} className="rounded-md border border-line bg-panel p-2">
<div className="flex items-center gap-2">
<Input className="h-8 flex-1 font-medium" value={f.name} onChange={(e) => patch(f.id, { name: e.target.value })} aria-label="Feat name" />
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(f.id)} aria-label={`Remove ${f.name}`}><X size={14} aria-hidden /></Button>
</div>
<Textarea className="mt-1 text-xs" rows={2} value={f.description} onChange={(e) => patch(f.id, { description: e.target.value })} placeholder="What it does — kept handy at the table." aria-label={`${f.name} description`} />
</li>
))}
</ul>
)}
</SheetSection>
);
}
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { InventoryItem } from '@/lib/schemas';
@@ -118,7 +120,7 @@ export function InventorySection({ c, update }: SectionProps) {
Attuned
</label>
)}
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}><X size={14} aria-hidden /></Button>
</li>
))}
</ul>
@@ -28,7 +28,7 @@ export function LevelUpAdvisor({ campaign, character }: { campaign: Campaign; ch
return (
<div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">
<span className="smallcaps">
{source === 'llm' ? 'AI build routes' : 'Build routes'}
</span>
{state === 'loading' && <span className="text-xs text-muted">Thinking</span>}
@@ -89,7 +89,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
<div className="space-y-4">
{/* HP */}
<section>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Hit points</h3>
<h3 className="mb-1 smallcaps">Hit points</h3>
{character.system === '5e' ? (
<div className="flex items-center gap-2">
<Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
@@ -108,7 +108,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
{/* ASI (5e) */}
{asi && (
<section>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability Score Improvement</h3>
<h3 className="mb-1 smallcaps">Ability Score Improvement</h3>
<div className="mb-2 flex gap-1">
{(['asi', 'feat'] as const).map((m) => (
<button key={m} onClick={() => setAsiMode(m)} className={`rounded-md px-3 py-1 text-sm ${asiMode === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink'}`}>
@@ -134,7 +134,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
{/* Boosts (pf2e) */}
{boosts && (
<section>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability boosts (choose 4)</h3>
<h3 className="mb-1 smallcaps">Ability boosts (choose 4)</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<Select key={i} aria-label={`Boost ${i + 1}`} value={boostPicks[i]} onChange={(e) => setBoostPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
@@ -148,7 +148,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
{/* Skill increase (pf2e) */}
{skillInc && (
<section>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Skill increase</h3>
<h3 className="mb-1 smallcaps">Skill increase</h3>
<Select value={skillKey} onChange={(e) => setSkillKey(e.target.value)} aria-label="Skill to increase">
{sys.skills.map((s) => {
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
@@ -1,6 +1,9 @@
import { useState } from 'react';
import { X, Minus, Plus } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem, applyRest } from '@/lib/rules';
import { spendResource, regainResource } from '@/lib/mechanics';
import type { CharacterResource } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
@@ -61,19 +64,19 @@ export function ResourcesSection({ c, update }: SectionProps) {
{c.resources.map((r) => (
<li key={r.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-2">
<Input className="h-8 flex-1" value={r.name} onChange={(e) => patch(r.id, { name: e.target.value })} aria-label="Resource name" />
<Button size="icon" variant="ghost" onClick={() => patch(r.id, { current: Math.max(0, r.current - 1) })} aria-label="Spend"></Button>
<Button size="icon" variant="ghost" disabled={r.current <= 0} onClick={() => { const res = spendResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Spend"><Minus size={14} aria-hidden /></Button>
<span className="w-12 text-center text-sm">
<span className="font-medium text-ink">{r.current}</span>
<span className="text-muted">/{r.max}</span>
</span>
<Button size="icon" variant="ghost" onClick={() => patch(r.id, { current: Math.min(r.max, r.current + 1) })} aria-label="Regain">+</Button>
<Button size="icon" variant="ghost" disabled={r.current >= r.max} onClick={() => { const res = regainResource(c, r.id); if (res.ok) update(res.patch); }} aria-label="Regain"><Plus size={14} aria-hidden /></Button>
<NumberField className="w-14" value={r.max} min={0} onChange={(v) => patch(r.id, { max: v, current: Math.min(v, r.current) })} aria-label="Max" />
<Select className="w-auto py-1 text-xs" value={r.recovery} onChange={(e) => patch(r.id, { recovery: e.target.value as CharacterResource['recovery'] })}>
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
))}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}><X size={14} aria-hidden /></Button>
</li>
))}
</ul>
@@ -1,8 +1,11 @@
import { useState } from 'react';
import { Sparkles, BrainCircuit, X, Minus, Plus } from 'lucide-react';
import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import type { SpellEntry } from '@/lib/schemas';
import { type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics';
import { multiclassSlots } from '@/lib/rules/dnd5e/progression';
import { formatModifier } from '@/lib/format';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
@@ -33,15 +36,17 @@ export function SpellcastingSection({ c, update }: SectionProps) {
const setSlots = (slots: typeof sc.slots) => update({ spellcasting: { ...sc, slots } });
const patchSlot = (level: number, p: Partial<(typeof sc.slots)[number]>) =>
setSlots(sc.slots.map((s) => (s.level === level ? { ...s, ...p } : s)));
// pf2e has 10 spell ranks; 5e tops out at 9.
const maxRank = c.system === 'pf2e' ? 10 : 9;
const addSlotLevel = () => {
const next = (sc.slots.at(-1)?.level ?? 0) + 1;
if (next > 9) return;
if (next > maxRank) return;
setSlots([...sc.slots, { level: next, max: 1, current: 1 }]);
};
const addSpell = () => {
if (spellName.trim() === '') return;
const entry: SpellEntry = { id: newId(), name: spellName.trim(), level: spellLevel, prepared: false, notes: '' };
const entry: SpellEntry = newSpellEntry({ id: newId(), name: spellName.trim(), level: spellLevel });
update({ spellcasting: { ...sc, spells: [...sc.spells, entry] } });
setSpellName('');
};
@@ -50,6 +55,18 @@ export function SpellcastingSection({ c, update }: SectionProps) {
const removeSpell = (id: string) =>
update({ spellcasting: { ...sc, spells: sc.spells.filter((s) => s.id !== id) } });
// Casting a spell enforces slot consumption + concentration via the kernel.
const [castMsg, setCastMsg] = useState<{ text: string; ok: boolean } | null>(null);
const cast = (id: string) => {
const res = castSpell(c, id);
if (res.ok) {
update(res.patch);
setCastMsg({ text: res.log.join(' '), ok: true });
} else {
setCastMsg({ text: res.reason, ok: false });
}
};
const spellsByLevel = [...sc.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
return (
@@ -68,6 +85,21 @@ export function SpellcastingSection({ c, update }: SectionProps) {
))}
</Select>
</label>
{/* pf2e: spell DC/attack scale with spellcasting proficiency (expert/master/legendary). */}
{c.system === 'pf2e' && ability && (
<label className="text-xs text-muted">
Proficiency
<Select
className="ml-2 w-auto"
value={c.spellcastingRank ?? 'trained'}
onChange={(e) => update({ spellcastingRank: e.target.value as ProficiencyRank })}
>
{(['trained', 'expert', 'master', 'legendary'] as const).map((r) => (
<option key={r} value={r}>{r[0]!.toUpperCase() + r.slice(1)}</option>
))}
</Select>
</label>
)}
{ability && (
<div className="flex gap-4 text-sm">
<span className="text-muted">Spell DC <span className="font-display text-lg font-semibold text-accent">{dc}</span></span>
@@ -76,10 +108,22 @@ export function SpellcastingSection({ c, update }: SectionProps) {
)}
</div>
{/* Concentration banner — enforced: one spell at a time. */}
{c.concentration && (
<div className="mb-3 flex items-center gap-2 rounded-md border border-accent/40 bg-accent/5 px-3 py-1.5 text-sm">
<BrainCircuit size={15} className="shrink-0 text-accent" aria-hidden />
<span className="text-ink">Concentrating on <span className="font-medium">{c.concentration.spellName}</span></span>
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => update(dropConcentration(c))}>Drop</Button>
</div>
)}
{castMsg && (
<p className={`mb-3 text-xs ${castMsg.ok ? 'text-success' : 'text-danger'}`} aria-live="polite">{castMsg.text}</p>
)}
{/* Spell slots */}
<div className="mb-4">
<div className="mb-1 flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted">Spell slots</span>
<span className="smallcaps">Spell slots</span>
<Button size="sm" variant="ghost" onClick={addSlotLevel}>+ slot level</Button>
</div>
{sc.slots.length === 0 ? (
@@ -90,14 +134,15 @@ export function SpellcastingSection({ c, update }: SectionProps) {
<div key={s.level} className="rounded-md border border-line bg-panel px-2 py-1 text-center">
<div className="text-[10px] uppercase text-muted">Lv {s.level}</div>
<div className="flex items-center gap-1">
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}></Button>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}><Minus size={14} aria-hidden /></Button>
<span className="text-sm"><span className="font-medium text-ink">{s.current}</span>/<NumberField className="inline-block w-12" value={s.max} min={0} onChange={(v) => patchSlot(s.level, { max: v, current: Math.min(v, s.current) })} aria-label={`Level ${s.level} max slots`} /></span>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}>+</Button>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}><Plus size={14} aria-hidden /></Button>
</div>
</div>
))}
</div>
)}
{c.system === '5e' && <MulticlassSlots onApply={setSlots} />}
</div>
{/* Spell list */}
@@ -110,7 +155,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
Level
<Select className="w-20" value={spellLevel} onChange={(e) => setSpellLevel(Number(e.target.value))}>
<option value={0}>Cantrip</option>
{Array.from({ length: 9 }, (_, i) => i + 1).map((l) => (
{Array.from({ length: maxRank }, (_, i) => i + 1).map((l) => (
<option key={l} value={l}>{l}</option>
))}
</Select>
@@ -124,14 +169,20 @@ export function SpellcastingSection({ c, update }: SectionProps) {
{spellsByLevel.map((s) => (
<li key={s.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
<span className="w-16 text-xs text-muted">{s.level === 0 ? 'Cantrip' : `Lv ${s.level}`}</span>
<span className="flex-1 text-ink">{s.name}</span>
<span className="flex-1 text-ink">
{s.name}
{s.concentration && <BrainCircuit size={12} className="ml-1 inline text-accent" aria-label="Concentration" />}
</span>
{s.level > 0 && (
<label className="flex items-center gap-1 text-xs text-muted">
<input type="checkbox" checked={s.prepared} onChange={(e) => patchSpell(s.id, { prepared: e.target.checked })} />
Prepared
</label>
)}
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}></Button>
<Button size="sm" variant="ghost" onClick={() => cast(s.id)} aria-label={`Cast ${s.name}`}>
<Sparkles size={13} aria-hidden /> Cast
</Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}><X size={14} aria-hidden /></Button>
</li>
))}
</ul>
@@ -139,3 +190,23 @@ export function SpellcastingSection({ c, update }: SectionProps) {
</SheetSection>
);
}
/** PHB multiclass spell-slot calculator: enter your class levels by caster type
* and it fills the slot table correctly (full ×1, half ÷2, third ÷3). */
function MulticlassSlots({ onApply }: { onApply: (slots: { level: number; max: number; current: number }[]) => void }) {
const [full, setFull] = useState(0);
const [half, setHalf] = useState(0);
const [third, setThird] = useState(0);
return (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-muted hover:text-ink">Multiclass slot calculator</summary>
<div className="mt-2 flex flex-wrap items-end gap-2 rounded-md border border-line bg-panel-2 p-2 text-xs text-muted">
<label>Full-caster lvls<NumberField className="ml-1 w-14" value={full} min={0} max={20} onChange={setFull} aria-label="Full caster levels" /></label>
<label>Half-caster lvls<NumberField className="ml-1 w-14" value={half} min={0} max={20} onChange={setHalf} aria-label="Half caster levels" /></label>
<label>Third-caster lvls<NumberField className="ml-1 w-14" value={third} min={0} max={20} onChange={setThird} aria-label="Third caster levels" /></label>
<Button size="sm" variant="secondary" onClick={() => onApply(multiclassSlots({ full, half, third }))}>Apply slots</Button>
<span className="w-full text-[11px] text-faint">Bard/Cleric/Druid/Sorcerer/Wizard = full; Paladin/Ranger = half; Eldritch Knight/Arcane Trickster = third. Warlock pact slots are separate.</span>
</div>
</details>
);
}
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { Cloud, CloudOff, RefreshCw, WifiOff, AlertTriangle } from 'lucide-react';
import { cloudUsername, pushBackup, pullBackup } from '@/lib/cloud/client';
import { useConnectivityStore } from '@/stores/connectivityStore';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn';
/**
* Compact shell indicator for connectivity + cloud sync. Shows "Offline" when
* the browser is offline, otherwise the autosave state (saving / saved / failed)
* while signed into the cloud. Renders nothing when online and not using cloud,
* to keep the bar quiet. Also owns the online/offline event wiring.
*/
export function SyncStatusIndicator() {
const online = useConnectivityStore((s) => s.online);
const cloudSync = useConnectivityStore((s) => s.cloudSync);
const setOnline = useConnectivityStore((s) => s.setOnline);
const signedIn = !!cloudUsername();
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
setOnline(navigator.onLine);
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); };
}, [setOnline]);
if (!online) {
return (
<span className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning" title="You're offline — changes are saved locally and will sync when you reconnect." role="status">
<WifiOff size={14} aria-hidden /> <span className="hidden sm:inline">Offline</span>
</span>
);
}
if (!signedIn) return null;
if (cloudSync === 'conflict') return <ConflictResolver />;
const map = {
idle: null,
conflict: null, // handled above
saving: { Icon: RefreshCw, text: 'Saving…', cls: 'text-muted', spin: true, title: 'Saving your campaign to the cloud…' },
saved: { Icon: Cloud, text: 'Saved', cls: 'text-success', spin: false, title: 'Your campaign is backed up to the cloud.' },
error: { Icon: CloudOff, text: 'Sync failed', cls: 'text-danger', spin: false, title: 'Cloud sync failed — your data is safe locally and will retry on the next change.' },
} as const;
const s = map[cloudSync];
if (!s) return null;
return (
<span className={cn('flex items-center gap-1 rounded-md px-2 py-1 text-xs', s.cls)} title={s.title} role="status" aria-live="polite">
<s.Icon size={14} aria-hidden className={s.spin ? 'animate-spin' : undefined} />
<span className="hidden sm:inline">{s.text}</span>
</span>
);
}
/**
* Cloud changed on another device since this one last synced — resolve in one
* click rather than silently clobbering. "Use cloud" pulls (overwrites local),
* "Keep this device" force-pushes (overwrites cloud).
*/
function ConflictResolver() {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const setCloudSync = useConnectivityStore((s) => s.setCloudSync);
const useCloud = async () => {
setBusy(true);
try { if (await pullBackup()) { setCloudSync('saved'); setTimeout(() => location.reload(), 400); } }
finally { setBusy(false); setOpen(false); }
};
const keepMine = async () => {
setBusy(true);
try { await pushBackup({ force: true }); setCloudSync('saved'); }
finally { setBusy(false); setOpen(false); }
};
return (
<div className="relative">
<button onClick={() => setOpen((o) => !o)} className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning hover:bg-elevated" title="The cloud copy changed on another device — click to resolve.">
<AlertTriangle size={14} aria-hidden /> <span className="hidden sm:inline">Sync conflict</span>
</button>
{open && (
<div className="absolute right-0 top-9 z-50 w-64 rounded-xl border border-line bg-panel p-3 text-sm shadow-xl">
<p className="mb-2 text-muted">The cloud copy was changed on another device since this one last synced.</p>
<div className="flex flex-col gap-1.5">
<Button size="sm" variant="secondary" disabled={busy} onClick={() => void useCloud()}>Use cloud version (reload)</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void keepMine()}>Keep this device (overwrite cloud)</Button>
</div>
</div>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useRef } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db';
import { cloudUsername, pushBackup } from '@/lib/cloud/client';
import { useConnectivityStore } from '@/stores/connectivityStore';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
/**
* Autosave to the cloud while signed in: whenever durable data changes, debounce
* a full backup push. A fingerprint of (id:updatedAt) over the durable tables is
* watched via liveQuery, so it reacts to edits, adds, and deletes alike. High-churn
* tables (dice rolls, the session recap) are excluded so a roll doesn't trigger a
* save. No-op when signed out. Mounted once in the app shell.
*/
export function useCloudAutosave(): void {
const fingerprint = useLiveQuery(async () => {
const stamp = async (rows: Promise<Array<{ id?: string; campaignId?: string; updatedAt?: string }>>) =>
(await rows).map((r) => `${r.id ?? r.campaignId ?? ''}:${r.updatedAt ?? ''}`).join(',');
const [c, ch, e, n, np, q, m, h, cal] = await Promise.all([
stamp(db.campaigns.toArray()),
stamp(db.characters.toArray()),
stamp(db.encounters.toArray()),
stamp(db.notes.toArray()),
stamp(db.npcs.toArray()),
stamp(db.quests.toArray()),
stamp(db.maps.toArray()),
stamp(db.homebrew.toArray()),
stamp(db.calendars.toArray()),
]);
return [c, ch, e, n, np, q, m, h, cal].join('|');
}, [], undefined);
const save = useDebouncedCallback(() => {
if (!cloudUsername()) return;
const conn = useConnectivityStore.getState();
conn.setCloudSync('saving');
void pushBackup()
.then((r) => useConnectivityStore.getState().setCloudSync(r.ok ? 'saved' : 'conflict'))
.catch(() => useConnectivityStore.getState().setCloudSync('error')); // offline / transient — next change retries
}, 8000);
// Skip the initial load; only push once data actually changes after mount.
const prev = useRef<string | undefined>(undefined);
useEffect(() => {
if (fingerprint === undefined) return;
if (prev.current === undefined) { prev.current = fingerprint; return; }
if (prev.current !== fingerprint) { prev.current = fingerprint; save(); }
}, [fingerprint, save]);
}
+107 -10
View File
@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
ArrowDown,
ArrowUp,
@@ -22,7 +22,8 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
import { useCharacters } from '@/features/characters/hooks';
import { deriveState, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useConditionGlossary } from './useConditionGlossary';
import {
addCombatant,
@@ -48,6 +49,9 @@ import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
const characters = useCharacters(campaign.id);
// PCs are campaign-agnostic, so any PC can join a fight; NPCs stay campaign-scoped.
const allPcs = useAllPcs();
const addableChars = [...allPcs, ...characters.filter((c) => c.kind === 'npc')];
const glossary = useConditionGlossary(campaign.system);
const undoStack = useRef<Encounter[]>([]);
@@ -62,6 +66,36 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
if (prev) void encountersRepo.save(prev);
};
/** The Character backing a PC/NPC combatant, if any (for cross-doc rules state). */
const pcCharacter = (combatant: Combatant): Character | undefined =>
combatant.kind !== 'monster' && combatant.characterId
? [...allPcs, ...characters].find((ch) => ch.id === combatant.characterId)
: undefined;
/**
* After a concentrating PC takes damage, the app computes the Constitution
* save DC and surfaces it — but never rolls. The player rolls their own save
* and, on a failure, uses the Drop button on their sheet/panel. (Auto-rolling
* would take the moment away from the player.)
*/
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
if (damage <= 0) return;
const ch = pcCharacter(combatant);
if (!ch?.concentration) return;
const dc = concentrationDC(damage);
void encountersRepo.mutate(encounter.id, (e) =>
logEvent(e, `${ch.name}: roll a DC ${dc} Constitution save or lose concentration on ${ch.concentration!.spellName}.`));
};
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
const advanceTurn = () => {
mutate(nextTurn);
const upcoming = currentCombatant(nextTurn(encounter));
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`));
}
};
const rollAllInitiative = () => {
const rolls: Record<string, number> = {};
for (const c of encounter.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus;
@@ -71,7 +105,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
// Difficulty budget from monster combatants vs the campaign's PCs. Once combat
// has started, rate against the levels captured at that time (snapshot) so the
// reading — and the assistant's history — reflects the party as it was.
const currentLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
// Rate against the PCs actually IN the fight (so adding a player updates the
// difficulty); fall back to the campaign roster before any PC is added.
const encounterPcLevels = encounter.combatants.filter((c) => c.kind === 'pc' && c.level !== undefined).map((c) => c.level as number);
const currentLevels = encounterPcLevels.length > 0 ? encounterPcLevels : characters.filter((c) => c.kind === 'pc').map((c) => c.level);
const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels;
const monsters = encounter.combatants
.filter((c) => c.kind === 'monster')
@@ -84,6 +121,25 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
const current = currentCombatant(encounter);
const isActive = encounter.status === 'active';
// Keyboard turn control for a GM running the table hands-free. Guarded so it
// never fires while typing in a field, and only during an active fight.
const advanceRef = useRef(advanceTurn);
advanceRef.current = advanceTurn;
const prevRef = useRef(() => mutate(previousTurn));
prevRef.current = () => mutate(previousTurn);
useEffect(() => {
if (!isActive) return;
const onKey = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const el = e.target as HTMLElement | null;
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return;
if (e.key === 'n' || e.key === 'ArrowRight') { e.preventDefault(); advanceRef.current(); }
else if (e.key === 'p' || e.key === 'ArrowLeft') { e.preventDefault(); prevRef.current(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isActive]);
return (
<div>
{/* Control bar */}
@@ -138,10 +194,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
)}
{isActive && (
<>
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
<Button variant="secondary" onClick={() => mutate(previousTurn)} title="Previous turn (p or ←)">
<ChevronLeft size={15} aria-hidden /> Prev
</Button>
<Button variant="primary" onClick={() => mutate(nextTurn)}>
<Button variant="primary" onClick={advanceTurn} title="Next turn (n or →)">
Next turn <ChevronRight size={15} aria-hidden />
</Button>
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
@@ -159,7 +215,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
<EncounterTipCard campaign={campaign} encounter={encounter} />
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
<AddCombatantBar encounter={encounter} characters={addableChars} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
{/* Combatant list */}
{encounter.combatants.length === 0 ? (
@@ -178,7 +234,15 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
glossary={glossary}
isCurrent={isActive && idx === encounter.turnIndex}
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
onDamage={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }), `${c.name} takes ${amt} damage`))}
onDamage={(amt, type) => {
const after = applyDamage(c, amt, type);
const hpLoss = (c.hp.current - after.hp.current) + (c.hp.temp - after.hp.temp);
const note = hpLoss === amt
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
mutate((e) => logEvent(updateCombatant(e, c.id, { hp: after.hp }), note));
noteConcentrationCheck(c, hpLoss);
}}
onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))}
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
@@ -276,7 +340,7 @@ function AddCombatantBar({
characterId: ch.id,
initiative: rollInitiativeFor(ch),
initBonus: sys.initiativeModifier({ level: ch.level, abilities: ch.abilities, perceptionRank: ch.perceptionRank }),
ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus }),
ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus, ...(ch.equippedArmor ? { equippedArmor: ch.equippedArmor } : {}) }),
hp: { ...ch.hp },
conditions: [],
notes: '',
@@ -350,12 +414,16 @@ function CombatantRow({
glossary: Map<string, string>;
isCurrent: boolean;
onChange: (patch: Partial<Combatant>) => void;
onDamage: (amt: number) => void;
onDamage: (amt: number, type?: string) => void;
onHeal: (amt: number) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const [delta, setDelta] = useState(0);
const [dmgType, setDmgType] = useState('');
// Only show the damage-type picker when this creature actually has typed defenses.
const def = c.damageDefenses;
const hasDefenses = !!def && (def.resist.length > 0 || def.immune.length > 0 || def.vulnerable.length > 0 || (def.resistFlat?.length ?? 0) > 0 || (def.weakness?.length ?? 0) > 0);
const dead = c.hp.current <= 0;
const foe = c.kind === 'monster';
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
@@ -433,12 +501,40 @@ function CombatantRow({
))}
</div>
)}
{/* Mechanical consequences the conditions impose (enforced read-model). */}
{(() => {
const st = deriveState(system, 30, c.conditions);
return st.badges.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1" aria-label="Condition effects">
{st.badges.map((b) => (
<span key={b} className="rounded border border-line bg-surface px-1.5 py-0.5 text-[10px] font-medium text-muted">{b}</span>
))}
</div>
) : null;
})()}
</div>
{/* Action controls — kept grouped so they wrap together, not scattered, on narrow widths */}
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
{/* HP controls */}
<div className="flex items-center gap-1">
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage">
{hasDefenses && (
<Select className="w-auto py-1 text-xs" value={dmgType} onChange={(e) => setDmgType(e.target.value)} aria-label={`${c.name} damage type`}>
<option value="">untyped</option>
{DAMAGE_TYPES.filter((t) => t !== 'untyped').map((t) => {
const rf = def!.resistFlat?.find((r) => r.type === t);
const wk = def!.weakness?.find((w) => w.type === t);
const tag = def!.immune.includes(t) ? ' (immune)'
: def!.vulnerable.includes(t) ? ' (vuln)'
: def!.resist.includes(t) ? ' (resist)'
: rf ? ` (resist ${rf.amount})`
: wk ? ` (weak ${wk.amount})` : '';
return <option key={t} value={t}>{t}{tag}</option>;
})}
</Select>
)}
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta, dmgType || undefined); setDelta(0); }} title="Apply damage">
<Sword size={14} aria-hidden /> Dmg
</Button>
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
@@ -466,6 +562,7 @@ function CombatantRow({
<X size={15} aria-hidden />
</Button>
</div>
</div>
<div className="mt-2">
<ConditionPicker
+19 -6
View File
@@ -9,7 +9,10 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { addCombatant } from '@/lib/combat/engine';
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
import type { Character, InventoryItem, SpellEntry } from '@/lib/schemas';
import { type Character, type InventoryItem, type SpellEntry, newSpellEntry } from '@/lib/schemas';
import type { Spell, CompendiumEntry } from '@/lib/compendium/types';
import type { CombatantDamageDefenses } from '@/lib/schemas/encounter';
import { normalizeSpell5e, normalizeSpellPf2e } from '@/lib/mechanics';
import { useUiStore } from '@/stores/uiStore';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import { useCharacters } from '@/features/characters/hooks';
@@ -293,7 +296,7 @@ function ActionBar({ children }: { children: React.ReactNode }) {
);
}
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number } }) {
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; damageDefenses?: CombatantDamageDefenses } }) {
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const [msg, setMsg] = useState<string | null>(null);
@@ -310,6 +313,7 @@ function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number;
conditions: [], notes: '',
...(stats.cr !== undefined ? { cr: stats.cr } : {}),
...(stats.level !== undefined ? { level: stats.level } : {}),
...(stats.damageDefenses ? { damageDefenses: stats.damageDefenses } : {}),
}),
);
setMsg(`Added to "${enc.name}" (init ${initiative}).`);
@@ -334,11 +338,20 @@ function AddToCharacter({ kind, entry, system }: { kind: 'spell' | 'item'; entry
const c = await charactersRepo.get(characterId);
if (!c) return;
if (kind === 'spell') {
// Snapshot the spell's mechanics (concentration / save / damage) so combat
// enforcement never needs to re-load the compendium.
const mech = system === '5e'
? normalizeSpell5e(entry as unknown as Spell)
: normalizeSpellPf2e(entry as unknown as CompendiumEntry);
const level = system === '5e' ? Number(entry.level_int ?? 0) : Number(entry.level ?? 0);
const spell: SpellEntry = {
id: newId(), name: entry.name, level: Number.isFinite(level) ? level : 0,
prepared: false, notes: '', ...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
};
const spell: SpellEntry = newSpellEntry({
id: newId(), name: entry.name,
level: mech?.level ?? (Number.isFinite(level) ? level : 0),
concentration: mech?.concentration ?? false,
save: mech?.save ?? null,
damage: mech?.damage ?? [],
...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
});
await charactersRepo.update(c.id, { spellcasting: { ...c.spellcasting, spells: [...c.spellcasting.spells, spell] } });
} else {
const item: InventoryItem = {
+11 -3
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react';
import type { SystemId } from '@/lib/rules';
import { abilityModifier } from '@/lib/rules';
import { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e } from '@/lib/mechanics';
import type { CombatantDamageDefenses } from '@/lib/schemas/encounter';
import type { CompendiumEntry, Monster, Spell, MagicItem } from '@/lib/compendium/types';
import {
loadMonsters,
@@ -49,7 +51,7 @@ export interface CategoryDef {
/** enables "add to character" for spells/items */
linkAs?: 'spell' | 'item';
/** enables "add to combat" for monsters/creatures */
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number };
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number; cr?: number; level?: number; damageDefenses?: CombatantDamageDefenses };
/** optional numeric sort axis (e.g. CR, spell level) in addition to name */
numericSort?: { label: string; get: (e: Entry) => number };
}
@@ -169,6 +171,7 @@ export const CATEGORIES: CategoryDef[] = [
name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1,
initBonus: abilityModifier(m.dexterity ?? 10),
...(m.cr !== undefined ? { cr: m.cr } : {}),
damageDefenses: { ...normalizeMonsterDefenses(m), resistFlat: [], weakness: [] },
};
},
numericSort: { label: 'CR', get: (e) => Number(e.cr) },
@@ -242,13 +245,18 @@ export const CATEGORIES: CategoryDef[] = [
// ---------- Pathfinder 2e ----------
{
...pf2eCategory('creatures', 'Bestiary', 'creatures'),
toCombatant: (e) => ({
toCombatant: (e) => {
const d = normalizeMonsterDefensesPf2e(e as { immunity?: unknown; resistance?: unknown; weakness?: unknown });
const hasDef = d.immune.length || d.conditionImmune.length || d.resistFlat.length || d.weakness.length;
return {
name: e.name,
ac: Number(e.ac) || 10,
hp: Number(e.hp) || 1,
initBonus: Number(e.perception) || 0,
...(e.level !== undefined ? { level: Number(e.level) } : {}),
}),
...(hasDef ? { damageDefenses: { resist: [], vulnerable: [], ...d } } : {}),
};
},
},
classCategory('pf2e'),
pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }),
+4 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { Dices, Sparkles, Skull, History as HistoryIcon } from 'lucide-react';
import { Dices, Sparkles, Skull, EyeOff, X, History as HistoryIcon } from 'lucide-react';
import { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { diceRepo } from '@/lib/db/repositories';
@@ -199,7 +199,7 @@ export function DicePage() {
onClick={toggleSecret}
title="When on, your rolls are NOT shared with the players"
>
🤫 Secret
<EyeOff size={14} aria-hidden /> Secret
</Button>
)}
</div>
@@ -209,7 +209,7 @@ export function DicePage() {
</p>
)}
{isGm && secret && (
<p className="mt-2 text-xs text-warning">🤫 Secret is on your rolls are not shared with the players.</p>
<p className="mt-2 flex items-center gap-1 text-xs text-warning"><EyeOff size={12} aria-hidden /> Secret is on your rolls are not shared with the players.</p>
)}
{/* Saved rolls (macros) */}
@@ -240,7 +240,7 @@ export function DicePage() {
onClick={() => removeMacro(macroKey, m.id)}
aria-label={`Remove ${m.label}`}
>
<X size={13} aria-hidden />
</button>
</span>
))}
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Check, Megaphone, X } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Button } from '@/components/ui/Button';
@@ -21,9 +22,9 @@ export function HandoutControl() {
return (
<>
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players">
{active ? '📣 Handout ✓' : '📣 Handout'}
<Megaphone size={14} aria-hidden /> Handout {active && <Check size={12} aria-hidden />}
</Button>
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"></Button>}
{active && <Button size="sm" variant="ghost" className="px-1 text-danger" onClick={() => setActive(null)} title="Hide handout"><X size={14} aria-hidden /></Button>}
{open && (
<Modal open onClose={() => setOpen(false)} title="Handout for players"
footer={<>
+5 -2
View File
@@ -1,4 +1,5 @@
import { Link } from '@tanstack/react-router';
import { X } from 'lucide-react';
import { useSessionStore } from '@/stores/sessionStore';
import { leaveRoom } from '@/lib/sync/wsSync';
@@ -11,9 +12,11 @@ export function PlayerSessionBadge() {
const label = status === 'connected' ? 'Live' : status === 'connecting' ? 'Connecting…' : status;
return (
<div data-testid="player-live" className="flex items-center gap-1 rounded-md border border-success/40 bg-success/5 px-2 py-1 text-xs">
<span className={status === 'connected' ? 'text-success' : 'text-muted'}> {label}</span>
<span className={`inline-flex items-center gap-1 ${status === 'connected' ? 'text-success' : 'text-muted'}`}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${status === 'connected' ? 'bg-success' : 'bg-muted'}`} aria-hidden />{label}
</span>
<Link to="/play" className="font-mono font-semibold text-accent" title="Open the table">{intent.joinCode}</Link>
<button onClick={leaveRoom} className="px-1 text-danger" title="Leave session"></button>
<button onClick={leaveRoom} className="px-1 text-danger" title="Leave session" aria-label="Leave session"><X size={13} aria-hidden /></button>
</div>
);
}
+28 -11
View File
@@ -1,13 +1,15 @@
import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import { RadioTower, X } from 'lucide-react';
import { Check, Lock, RadioTower, X } from 'lucide-react';
import { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories';
import { cloudUsername } from '@/lib/cloud/client';
import { useActiveCampaign } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
/** Header control: GM hosts a live session, shares the code, and approves seats. */
export function SessionControl() {
@@ -19,6 +21,8 @@ export function SessionControl() {
const seatRequests = useSessionStore((s) => s.seatRequests);
const [copied, setCopied] = useState(false);
const [showSeats, setShowSeats] = useState(false);
const [hostOpen, setHostOpen] = useState(false);
const [hostPw, setHostPw] = useState('');
if (role === 'player') return null; // players don't host
@@ -28,17 +32,24 @@ export function SessionControl() {
if (!cloudUsername()) {
return (
<Button size="sm" variant="ghost" title="Sign in to host a shared session" onClick={() => void navigate({ to: '/settings' })}>
🔒 Sign in to host
<Lock size={14} aria-hidden /> Sign in to host
</Button>
);
}
return (
<Button size="sm" variant="ghost" title="Host a live session for players"
onClick={() => {
const pw = window.prompt('Optional player password (leave blank for none):')?.trim();
hostSession(campaign.id, pw || undefined);
}}
>📡 Host</Button>
<>
<Button size="sm" variant="ghost" title="Host a live session for players" onClick={() => setHostOpen(true)}>
<RadioTower size={14} aria-hidden /> Host
</Button>
<Modal open={hostOpen} onClose={() => setHostOpen(false)} title="Host a live session" className="max-w-sm"
footer={<>
<Button variant="ghost" onClick={() => setHostOpen(false)}>Cancel</Button>
<Button variant="primary" onClick={() => { hostSession(campaign.id, hostPw.trim() || undefined); setHostOpen(false); setHostPw(''); }}>Start hosting</Button>
</>}>
<p className="mb-2 text-sm text-muted">Optionally require a password for players to join. Leave it blank for an open session.</p>
<Input type="password" autoFocus value={hostPw} onChange={(e) => setHostPw(e.target.value)} aria-label="Session password (optional)" placeholder="Password (optional)" />
</Modal>
</>
);
}
@@ -52,7 +63,7 @@ export function SessionControl() {
className="font-mono font-semibold tracking-wider text-accent-deep"
title="Copy player link"
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }}
>{joinCode}{copied ? ' ✓' : ''}</button>
>{joinCode}{copied ? <><Check size={11} aria-hidden /></> : ''}</button>
{seatRequests.length > 0 && (
<button data-testid="seat-requests" className="ml-0.5 grid h-5 min-w-5 place-items-center rounded-full bg-danger px-1 font-mono text-[10px] font-bold text-white"
@@ -74,16 +85,22 @@ export function SessionControl() {
}
function SeatRequestRow({ req }: { req: SeatRequest }) {
const campaign = useActiveCampaign();
const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]);
const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest);
const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character';
const grant = async (applyOffline: boolean) => {
if (applyOffline && req.offlineSnapshot) {
let current = await charactersRepo.get(req.characterId);
if (!current && req.offlineSnapshot && campaign) {
// A character the player made and pushed — add it to this campaign, then seat them.
await charactersRepo.insert({ ...req.offlineSnapshot, campaignId: campaign.id });
current = await charactersRepo.get(req.characterId);
} else if (applyOffline && req.offlineSnapshot && current) {
const { id: _id, campaignId: _c, createdAt: _cr, updatedAt: _u, ...rest } = req.offlineSnapshot;
await charactersRepo.update(req.characterId, rest);
current = await charactersRepo.get(req.characterId);
}
const current = await charactersRepo.get(req.characterId);
if (current) grantSeat(req.playerId, current);
else removeSeatRequest(req.playerId);
};
+6 -5
View File
@@ -8,6 +8,7 @@ import { sessionLogRepo } from '@/lib/db/repositories';
import type { SessionLogEntry } from '@/lib/schemas';
import type { RosterEntry } from '@/lib/sync/messages';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Dice5, Mail, MessageSquare, X } from 'lucide-react';
import { Input, Textarea } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
@@ -86,7 +87,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
</div>
)}
</div>
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink"></button>
<button onClick={onClose} aria-label="Close session panel" className="text-muted hover:text-ink"><X size={15} aria-hidden /></button>
</div>
{!inSession && (
@@ -95,21 +96,21 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
{inSession && !isGm && (
<div className="border-b border-line p-3">
<label className="text-xs font-semibold uppercase tracking-wide text-muted">Your name</label>
<label className="smallcaps">Your name</label>
<Input value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} onBlur={commitName} onKeyDown={(e) => { if (e.key === 'Enter') commitName(); }} placeholder="What should we call you?" aria-label="Your display name" className="mt-1" />
</div>
)}
{inSession && (
<div className="border-b border-line p-3">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Who's here ({roster.length})</h3>
<h3 className="mb-1 smallcaps">Who's here ({roster.length})</h3>
<ul className="flex flex-wrap gap-1">
{roster.map((p) => (
<li key={p.playerId} className={cn('flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs', p.playerId === 'gm' ? 'border-accent/50 text-accent' : 'border-line text-ink')}>
<span className="h-1.5 w-1.5 rounded-full bg-success" aria-hidden />
{p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null}
{isGm && p.playerId !== 'gm' && (
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info">📨</button>
<button onClick={() => openShare(p)} title={`Share privately with ${p.name}`} aria-label={`Share with ${p.name}`} className="ml-0.5 text-muted hover:text-info"><Mail size={12} aria-hidden /></button>
)}
</li>
))}
@@ -158,7 +159,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
<ul className="space-y-0.5 text-xs">
{(recap ?? []).map((e) => (
<li key={e.id} className="flex gap-2">
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'}>{e.kind === 'roll' ? '🎲' : '💬'}</span>
<span className={e.kind === 'roll' ? 'text-accent' : 'text-muted'} aria-hidden>{e.kind === 'roll' ? <Dice5 size={12} /> : <MessageSquare size={12} />}</span>
<span className="text-muted">{e.from}:</span>
<span className="min-w-0 flex-1 text-ink">{e.text}</span>
</li>
+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]);
}
+39
View File
@@ -0,0 +1,39 @@
import { Check, ChevronRight, ArrowUp } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import { getTurnGuide, getGenericGuide } from '@/lib/assistant/actions';
/** Collapsible "What can I do?" guide for the player in-session view. */
export function ActionGuide({ character }: { character: Character }) {
const guide = getTurnGuide(character.system, character.className?.toLowerCase() ?? '') ?? getGenericGuide(character.system);
return (
<details className="mt-3 rounded-lg border border-line bg-surface-2">
<summary className="flex cursor-pointer select-none items-center gap-1 px-3 py-2 text-xs text-muted hover:text-ink">
<ChevronRight size={13} aria-hidden /> What can I do on my turn?
</summary>
<div className="border-t border-line px-3 pb-3 pt-2">
<ul className="space-y-2">
{guide.actions.map((a) => (
<li key={a.label}>
<span className="text-xs font-semibold text-ink">{a.label}: </span>
<span className="text-xs text-muted">{a.desc}</span>
</li>
))}
</ul>
{guide.upgradeAt && character.level < guide.upgradeAt.level && (
<p className="mt-2 flex items-center gap-1 rounded-md border border-accent/30 bg-accent/5 px-2 py-1 text-xs text-accent">
<ArrowUp size={12} aria-hidden /> {guide.upgradeAt.note}
</p>
)}
{guide.upgradeAt && character.level >= guide.upgradeAt.level && (
<p className="mt-2 rounded-md border border-success/30 bg-success/5 px-2 py-1 text-xs text-success">
<Check size={12} className="mr-1 inline" aria-hidden /> {guide.upgradeAt.note}
</p>
)}
{guide.tip && (
<p className="mt-2 border-t border-line pt-2 text-xs italic text-muted">{guide.tip}</p>
)}
</div>
</details>
);
}
+46 -10
View File
@@ -1,11 +1,15 @@
import { useState } from 'react';
import { Sparkles, BrainCircuit, Minus, Plus, X } from 'lucide-react';
import type { Character, Defenses } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics';
import { ActionGuide } from './ActionGuide';
import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { useRollStore } from '@/stores/rollStore';
import { sendPlayerRoll } from '@/lib/sync/wsSync';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Meter } from '@/components/ui/Codex';
import { cn } from '@/lib/cn';
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
@@ -19,6 +23,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
const hpPct = c.hp.max > 0 ? clamp((c.hp.current / c.hp.max) * 100, 0, 100) : 0;
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
const [castMsg, setCastMsg] = useState<string | null>(null);
return (
<section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4">
@@ -27,6 +32,14 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
<span className="text-sm text-muted">Lv {c.level} {c.className} · {c.ancestry}</span>
</div>
{c.concentration && (
<div className="mb-3 flex items-center gap-2 rounded-md border border-accent/50 bg-accent/10 px-3 py-1.5 text-sm">
<BrainCircuit size={15} className="shrink-0 text-accent" aria-hidden />
<span className="text-ink">Concentrating on <span className="font-medium">{c.concentration.spellName}</span></span>
<Button size="sm" variant="ghost" className="ml-auto" onClick={() => onPatch(dropConcentration(c))}>Drop</Button>
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
{/* HP */}
<div className="rounded-lg border border-line bg-panel p-3">
@@ -34,8 +47,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
<span>Hit points</span>
<span className="tabular-nums" data-testid="my-hp">{c.hp.current}/{c.hp.max}{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</span>
</div>
<div className="mb-2 h-3 overflow-hidden rounded-full bg-surface">
<div className={cn('h-full', hpPct > 50 ? 'bg-success' : hpPct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${hpPct}%` }} />
<div className="mb-2">
<Meter value={c.hp.current} max={c.hp.max} tone={hpPct > 50 ? 'var(--app-verdigris)' : hpPct > 0 ? 'var(--app-accent)' : 'var(--app-danger)'} height={10} />
</div>
<div className="flex flex-wrap items-center gap-1">
<Button size="sm" variant="danger" onClick={() => setHp(c.hp.current - 5)}>5</Button>
@@ -44,8 +57,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
<Button size="sm" variant="primary" onClick={() => setHp(c.hp.current + 5)}>+5</Button>
<span className="mx-1 h-4 w-px bg-line" />
<span className="text-xs text-muted">Temp</span>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp - 1)}></Button>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp + 1)}>+</Button>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp - 1)}><Minus size={14} aria-hidden /></Button>
<Button size="sm" variant="ghost" onClick={() => setHp(c.hp.current, c.hp.temp + 1)}><Plus size={14} aria-hidden /></Button>
</div>
</div>
@@ -61,8 +74,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
</div>
) : (
<div className="space-y-2 text-sm">
<Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
<Pips label="Death saves " count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
<Pips label="Death saves" count={3} filled={c.defenses.deathSaves.successes} color="bg-success" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, successes: n } })} />
<Pips label="Death saves (fails)" count={3} filled={c.defenses.deathSaves.failures} color="bg-danger" onSet={(n) => setDef({ deathSaves: { ...c.defenses.deathSaves, failures: n } })} />
<div className="flex items-center justify-between">
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} />
<label className="flex items-center gap-1"><input type="checkbox" checked={c.defenses.inspiration} onChange={(e) => setDef({ inspiration: e.target.checked })} /> Inspiration</label>
@@ -93,6 +106,28 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
</div>
)}
{/* Spells — cast enforces slot consumption + concentration */}
{c.spellcasting.spells.length > 0 && (
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 text-sm font-semibold text-ink">Spells</div>
<div className="max-h-44 space-y-1 overflow-y-auto">
{[...c.spellcasting.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name)).map((s) => (
<div key={s.id} className="flex items-center gap-2 text-sm">
<span className="w-12 shrink-0 text-xs text-muted">{s.level === 0 ? 'Cant' : `Lv ${s.level}`}</span>
<span className="flex-1 truncate text-ink">
{s.name}
{s.concentration && <BrainCircuit size={11} className="ml-1 inline text-accent" aria-label="Concentration" />}
</span>
<Button size="sm" variant="ghost" onClick={() => { const r = castSpell(c, s.id); if (r.ok) { onPatch(r.patch); setCastMsg(r.log.join(' ')); } else setCastMsg(r.reason); }} aria-label={`Cast ${s.name}`}>
<Sparkles size={12} aria-hidden /> Cast
</Button>
</div>
))}
</div>
{castMsg && <p className="mt-2 text-xs text-muted" aria-live="polite">{castMsg}</p>}
</div>
)}
{/* Resources */}
{c.resources.length > 0 && (
<div className="rounded-lg border border-line bg-panel p-3">
@@ -114,6 +149,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
{/* Dice */}
<DiceBox characterId={c.id} />
</div>
<ActionGuide character={c} />
</section>
);
}
@@ -122,9 +158,9 @@ function Counter({ label, value, min, max, onChange, suffix }: { label: string;
return (
<div className="flex items-center gap-1">
{label && <span className="flex-1 text-xs text-muted">{label}</span>}
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value - 1, min, max))}></Button>
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value - 1, min, max))}><Minus size={14} aria-hidden /></Button>
<span className="w-8 text-center tabular-nums text-ink">{value}{suffix ?? ''}</span>
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value + 1, min, max))}>+</Button>
<Button size="sm" variant="ghost" onClick={() => onChange(clamp(value + 1, min, max))}><Plus size={14} aria-hidden /></Button>
</div>
);
}
@@ -158,8 +194,8 @@ function ConditionsBox({ character: c, onPatch }: { character: Character; onPatc
<div className="mb-2 flex flex-wrap gap-1">
{c.conditions.length === 0 && <span className="text-xs text-muted">None.</span>}
{c.conditions.map((cond, i) => (
<button key={i} onClick={() => remove(i)} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:bg-danger/20 hover:text-danger" title="Remove">
{cond.name}{cond.value ? ` ${cond.value}` : ''}
<button key={i} onClick={() => remove(i)} className="inline-flex items-center gap-1 rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:bg-danger/20 hover:text-danger" title="Remove">
{cond.name}{cond.value ? ` ${cond.value}` : ''} <X size={11} aria-hidden />
</button>
))}
</div>
+41 -6
View File
@@ -1,6 +1,8 @@
import { Mail, Eye, Map as MapIcon, Users, Swords } from 'lucide-react';
import { Mail, Eye, Map as MapIcon, Users, Swords, ScrollText, CheckCircle2, Circle, Play } from 'lucide-react';
import type { SystemId } from '@/lib/rules';
import type { Snapshot } from '@/lib/sync/messages';
import type { PlayerMap } from '@/lib/map';
import { useConditionGlossary } from '@/features/combat/useConditionGlossary';
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { EmptyState } from '@/components/ui/Page';
import { Meter, Badge } from '@/components/ui/Codex';
@@ -10,6 +12,8 @@ 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 condGlossary = useConditionGlossary((snapshot.system ?? '5e') as SystemId);
const handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
return (
@@ -17,7 +21,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
{privateHandout && (
<section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4">
<div className="smallcaps mb-1 flex items-center gap-1.5 text-info">
<Mail size={13} aria-hidden /> 📨 Just for you
<Mail size={13} aria-hidden /> Just for you
</div>
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2>
{privateHandout.body && <p className="mt-1 whitespace-pre-wrap text-ink">{privateHandout.body}</p>}
@@ -73,9 +77,12 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
<div className="mt-1 text-right font-mono text-xs text-muted">{c.hp.current}/{c.hp.max} HP{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</div>
{c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<span key={i} className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
))}
{c.conditions.map((cond, i) => {
const tooltip = condGlossary.get(cond.name.toLowerCase());
return (
<span key={i} title={tooltip || undefined} className="cursor-help rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
);
})}
</div>
)}
</div>
@@ -97,7 +104,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
<li key={c.id} className={cn('flex items-center gap-3 rounded-xl border px-3 py-2', c.isCurrent ? 'border-accent bg-accent-glow ring-1 ring-accent/40' : 'border-line bg-panel')}>
<span className="w-8 text-center font-mono text-lg font-semibold text-accent-deep">{c.initiative}</span>
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2">
<span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 text-xs text-accent"> turn</span>}</span>
<span className="font-display text-ink">{c.name}{c.isCurrent && <span className="ml-2 inline-flex items-center gap-0.5 text-xs text-accent"><Play size={11} aria-hidden /> turn</span>}</span>
{c.conditions.map((cond, i) => (
<span key={i} className="rounded-full bg-warning/15 px-1.5 py-0.5 text-[10px] text-warning">{cond.name}{cond.value ? ` ${cond.value}` : ''}</span>
))}
@@ -113,6 +120,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')}>
{o.done ? <CheckCircle2 size={13} className="mt-0.5 shrink-0 text-success" aria-hidden /> : <Circle size={13} className="mt-0.5 shrink-0 text-faint" aria-hidden />}
<span>{o.text}</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
</section>
)}
</>
);
}
+36 -11
View File
@@ -8,11 +8,14 @@ import { charactersRepo } from '@/lib/db/repositories';
import { useUiStore } from '@/stores/uiStore';
import { useSessionStore } from '@/stores/sessionStore';
import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { useCharacters } from '@/features/characters/hooks';
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { useCalendar, useMaps } from '@/features/world/hooks';
import { Maximize2 } from 'lucide-react';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { PlayerBoards } from './PlayerBoards';
import { SeatClaimScreen } from './SeatClaimScreen';
import { MyCharacterPanel } from './MyCharacterPanel';
@@ -47,7 +50,7 @@ function Shell({ title, subtitle, children }: { title: string; subtitle: string;
<h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
<p className="mt-1 text-sm text-muted">{subtitle}</p>
</div>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"> Fullscreen</Button>
<Button variant="secondary" onClick={enterFullscreen} className="print:hidden"><Maximize2 size={14} aria-hidden /> Fullscreen</Button>
</div>
<hr className="gilt-rule mt-3" />
</div>
@@ -87,19 +90,40 @@ function NetworkedPlayerView() {
const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
const [needPw, setNeedPw] = useState(false);
const [pwInput, setPwInput] = useState('');
// Locally-developed copies of party characters (for offline-edit handoff).
// Locally-developed copies of party characters (for offline-edit handoff) PLUS
// the player's own PCs, so they can push one the GM hasn't added yet.
const partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]);
const localChars = new Map((localList ?? []).map((c) => [c.id, c]));
const myPcs = useAllPcs();
const localChars = new Map([...(localList ?? []), ...myPcs].map((c) => [c.id, c]));
useEffect(() => {
if (error?.toLowerCase().includes('password') && !needPw) {
setNeedPw(true);
const pw = window.prompt('This session needs a password:')?.trim();
if (pw) { useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw }); setNeedPw(false); }
}
}, [error, needPw, room]);
if (error?.toLowerCase().includes('password')) setNeedPw(true);
}, [error]);
const submitPassword = () => {
const pw = pwInput.trim();
if (!pw) return;
useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw });
setPwInput('');
setNeedPw(false);
};
const passwordModal = needPw ? (
<Modal
open
onClose={() => setNeedPw(false)}
title="Session password"
className="max-w-sm"
footer={<>
<Button variant="ghost" onClick={() => setNeedPw(false)}>Cancel</Button>
<Button variant="primary" disabled={!pwInput.trim()} onClick={submitPassword}>Join</Button>
</>}
>
<p className="mb-2 text-sm text-muted">This session is password-protected. Enter the password your GM shared.</p>
<Input type="password" autoFocus value={pwInput} onChange={(e) => setPwInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submitPassword()} aria-label="Session password" placeholder="Password" />
</Modal>
) : null;
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
const handlePatch = (diff: Partial<Character>) => {
@@ -113,6 +137,7 @@ function NetworkedPlayerView() {
return (
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting</p>}
{passwordModal}
</Shell>
);
}
@@ -122,7 +147,7 @@ function NetworkedPlayerView() {
{myCharacter && seatStatus === 'granted' ? (
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} />
) : (
<SeatClaimScreen snapshot={snapshot} localChars={localChars} pending={seatStatus === 'pending'} onClaim={handleClaim} />
<SeatClaimScreen snapshot={snapshot} localChars={localChars} ownCharacters={myPcs} pending={seatStatus === 'pending'} onClaim={handleClaim} />
)}
<PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
<RollFeed />
+31 -3
View File
@@ -10,14 +10,19 @@ import { Avatar, Badge } from '@/components/ui/Codex';
* party. If a locally-developed copy exists, its offline edits ride along for
* the GM to review.
*/
export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
export function SeatClaimScreen({ snapshot, localChars, ownCharacters = [], pending, onClaim }: {
snapshot: Snapshot;
localChars: Map<string, Character>;
/** the player's own local characters — they can push one the GM hasn't added yet */
ownCharacters?: Character[];
pending: boolean;
onClaim: (characterId: string) => void;
}) {
if (snapshot.party.length === 0) {
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />;
const partyIds = new Set(snapshot.party.map((p) => p.id));
const pushable = ownCharacters.filter((c) => c.kind === 'pc' && !partyIds.has(c.id));
if (snapshot.party.length === 0 && pushable.length === 0) {
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters — make one of your own and it'll appear here to bring to the table." />;
}
return (
<section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4">
@@ -25,6 +30,8 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
<h2 className="font-display text-xl font-semibold text-ink">Which character is yours?</h2>
<p className="mt-1 text-sm text-muted">Pick your character to manage its HP, spells, and rolls live. The GM approves the request.</p>
<hr className="gilt-rule my-3" />
{snapshot.party.length > 0 && (
<div className="grid gap-2 sm:grid-cols-2">
{snapshot.party.map((c) => {
const hasOffline = localChars.has(c.id);
@@ -41,6 +48,27 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
);
})}
</div>
)}
{pushable.length > 0 && (
<div className="mt-4">
<h3 className="smallcaps mb-1.5 text-muted">Bring your own character</h3>
<div className="grid gap-2 sm:grid-cols-2">
{pushable.map((c) => (
<div key={c.id} data-testid="seat-option" className="flex items-center gap-3 rounded-xl border border-line bg-surface-2 p-3">
<Avatar name={c.name} size={40} />
<div className="min-w-0 flex-1">
<div className="truncate font-display text-lg font-semibold text-ink">{c.name}</div>
<div className="font-mono text-xs text-muted">Lv {c.level}{c.className ? ` · ${c.className}` : ''}</div>
<Badge tone="gold" className="mt-1"><UserPlus size={11} aria-hidden /> send to the GM</Badge>
</div>
<Button size="sm" variant="secondary" disabled={pending} onClick={() => onClaim(c.id)}>Use this one</Button>
</div>
))}
</div>
</div>
)}
{pending && <p className="mt-3 text-sm text-accent">Waiting for the GM to approve</p>}
</section>
);
+1 -1
View File
@@ -40,7 +40,7 @@ export function AssistantSettings() {
return (
<section className="mb-8">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Assistant (AI)</h2>
<h2 className="mb-2 smallcaps">Assistant (AI)</h2>
<p className="mb-3 max-w-2xl text-sm text-muted">
Optional. Bring your own provider and key to enable AI-grounded tips (encounter balancing,
level-up routes). The assistant always works without this it falls back to deterministic
+17 -5
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db';
import { cloudUsername, CloudError } from '@/lib/cloud/client';
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns';
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns';
import { useCampaigns } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
@@ -38,7 +38,7 @@ export function CloudCampaigns() {
if (!signedIn) {
return (
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Shared campaigns (cloud)</h2>
<h2 className="mb-2 smallcaps">Shared campaigns (cloud)</h2>
<p className="text-sm text-muted">Sign in above to publish a campaign for your players, or join one and publish your character it stays yours and syncs when you edit it.</p>
</section>
);
@@ -48,7 +48,7 @@ export function CloudCampaigns() {
return (
<section className="space-y-4 rounded-lg border border-line bg-panel p-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted">Shared campaigns (cloud)</h2>
<h2 className="smallcaps">Shared campaigns (cloud)</h2>
{list.length > 0 && (
<ul className="space-y-2">
@@ -60,12 +60,24 @@ export function CloudCampaigns() {
{c.inviteCode && (
<button className="font-mono text-xs text-accent" title="Copy invite code" onClick={() => void navigator.clipboard?.writeText(c.inviteCode!).then(() => setMsg(`Invite code ${c.inviteCode} copied.`))}>invite: {c.inviteCode}</button>
)}
{c.role === 'owner' && c.inviteCode && (
<Button size="sm" variant="ghost" title="Invalidate the old code and mint a new one" onClick={run(async () => { const r = await rotateInvite(c.id); setMsg(`New invite code: ${r.inviteCode}`); refresh(); })}>Rotate invite</Button>
)}
{c.role === 'owner' && <Button size="sm" variant="ghost" onClick={run(async () => { setViewing(c.id); setParty(await listCloudCharacters(c.id)); })}>View party</Button>}
</div>
{viewing === c.id && (
<ul className="mt-1 space-y-0.5 border-t border-line pt-1 text-xs">
{party.length === 0 ? <li className="text-muted">No characters published yet.</li> : party.map((p) => (
<li key={p.id} className="flex justify-between"><span className="text-ink">{p.name}</span><span className="text-muted">{p.mine ? 'yours' : 'a players'}</span></li>
<li key={p.id} className="flex items-center justify-between gap-2">
<span className="text-ink">{p.name}</span>
<span className="flex items-center gap-2">
<span className="text-muted">{p.mine ? 'yours' : 'a players'}</span>
{!p.mine && (
<button className="text-danger hover:underline" title="Remove this player and their characters from the campaign"
onClick={run(async () => { await removeCloudMember(c.id, p.ownerUserId); setMsg('Player removed.'); setParty(await listCloudCharacters(c.id)); })}>remove</button>
)}
</span>
</li>
))}
</ul>
)}
@@ -114,7 +126,7 @@ export function CloudCampaigns() {
{usage?.admin && (
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Admin · users &amp; storage</h3>
<h3 className="mb-1 smallcaps">Admin · users &amp; storage</h3>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
<tbody>
+24 -3
View File
@@ -187,6 +187,7 @@ function CloudSync() {
const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirmPull, setConfirmPull] = useState(false);
const [syncConflict, setSyncConflict] = useState(false);
const run = async (fn: () => Promise<void>) => {
setBusy(true); setMsg(null);
@@ -196,7 +197,18 @@ function CloudSync() {
const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p));
setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.');
});
const push = () => run(async () => { const n = await pushBackup(); setMsg(`Backed up to cloud (${Math.round(n / 1024)} KB).`); });
const push = () => run(async () => {
const r = await pushBackup();
if (r.ok) { setMsg(`Backed up to cloud (${Math.round(r.bytes / 1024)} KB).`); return; }
// Conflict: the cloud changed on another device since this one last synced —
// surface a themed choice instead of a native confirm.
setSyncConflict(true);
});
const resolveConflict = (which: 'cloud' | 'mine') => run(async () => {
setSyncConflict(false);
if (which === 'cloud') { const ok = await pullBackup(); setMsg(ok ? 'Pulled the cloud copy — reloading…' : 'No cloud backup.'); if (ok) setTimeout(() => location.reload(), 600); }
else { await pushBackup({ force: true }); setMsg('Overwrote the cloud with this device.'); }
});
const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); });
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
@@ -212,8 +224,8 @@ function CloudSync() {
{user ? (
<div className="space-y-3 pt-4">
<div className="flex flex-wrap gap-2">
<Button variant="primary" disabled={busy} onClick={push}> Back up to cloud</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}> Restore from cloud</Button>
<Button variant="primary" disabled={busy} onClick={push}><Upload size={14} aria-hidden /> Back up to cloud</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}><Download size={14} aria-hidden /> Restore from cloud</Button>
<Button variant="ghost" disabled={busy} onClick={signOut}>Sign out</Button>
</div>
</div>
@@ -231,6 +243,15 @@ function CloudSync() {
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}>
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
</Modal>
<Modal open={syncConflict} onClose={() => setSyncConflict(false)} title="Cloud changed elsewhere"
footer={<>
<Button variant="ghost" onClick={() => setSyncConflict(false)}>Cancel</Button>
<Button variant="secondary" onClick={() => resolveConflict('cloud')}>Use cloud version</Button>
<Button variant="primary" onClick={() => resolveConflict('mine')}>Keep this device</Button>
</>}>
<p className="text-sm text-muted">The cloud copy was changed on another device since this one last synced. <strong className="text-ink">Use cloud version</strong> replaces this device with the cloud copy; <strong className="text-ink">Keep this device</strong> overwrites the cloud with this device.</p>
</Modal>
</SettingsCard>
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import type { Calendar, Campaign } from '@/lib/schemas';
import { calendarRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
@@ -55,7 +56,7 @@ function CalendarView({ campaign }: { campaign: Campaign }) {
<li key={ev.id} className="flex items-center gap-3 rounded-md border border-line bg-panel px-3 py-2 text-sm">
<span className={'w-16 shrink-0 font-mono ' + (ev.day === calendar.currentDay ? 'text-accent' : 'text-muted')}>Day {ev.day}</span>
<span className="flex-1 text-ink">{ev.title}</span>
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}></button>
<button className="text-muted hover:text-danger" onClick={() => save({ ...calendar, events: calendar.events.filter((e) => e.id !== ev.id) })} aria-label={`Remove ${ev.title}`}><X size={13} aria-hidden /></button>
</li>
))}
</ul>
+21 -17
View File
@@ -1,6 +1,9 @@
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import { ChevronRight, Shield, ScrollText, CheckCheck, Skull, Handshake, VenetianMask } from 'lucide-react';
import {
Brain, Users, Swords, ScrollText, VenetianMask, Target, Map, CalendarDays, Library, Hammer, Dice5, Monitor,
ChevronRight, Shield, CheckCheck, Skull, Handshake,
} from 'lucide-react';
import type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
import { diceRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules';
@@ -15,20 +18,21 @@ export function DashboardPage() {
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
}
const LINKS = [
{ to: '/assistant', label: 'Assistant', icon: '🧠' },
{ to: '/characters', label: 'Characters', icon: '🧝' },
{ to: '/combat', label: 'Combat', icon: '⚔' },
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' },
{ to: '/npcs', label: 'NPCs', icon: '🎭' },
{ to: '/quests', label: 'Quests', icon: '🗺' },
{ to: '/maps', label: 'Maps', icon: '🗺️' },
{ to: '/calendar', label: 'Calendar', icon: '📅' },
{ to: '/compendium', label: 'Compendium', icon: '📚' },
{ to: '/homebrew', label: 'Homebrew', icon: '🛠️' },
{ to: '/dice', label: 'Dice', icon: '🎲' },
{ to: '/play', label: 'Player View', icon: '📺' },
] as const;
type NavIcon = React.ComponentType<{ size?: number; className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>;
const LINKS: { to: string; label: string; icon: NavIcon }[] = [
{ to: '/assistant', label: 'Assistant', icon: Brain },
{ to: '/characters', label: 'Characters', icon: Users },
{ to: '/combat', label: 'Combat', icon: Swords },
{ to: '/notes', label: 'Notes & Wiki', icon: ScrollText },
{ to: '/npcs', label: 'NPCs', icon: VenetianMask },
{ to: '/quests', label: 'Quests', icon: Target },
{ to: '/maps', label: 'Maps', icon: Map },
{ to: '/calendar', label: 'Calendar', icon: CalendarDays },
{ to: '/compendium', label: 'Compendium', icon: Library },
{ to: '/homebrew', label: 'Homebrew', icon: Hammer },
{ to: '/dice', label: 'Dice', icon: Dice5 },
{ to: '/play', label: 'Player View', icon: Monitor },
];
function Dashboard({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id);
@@ -84,7 +88,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
to={l.to}
className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel p-4 text-center transition-colors hover:border-accent/60 hover:bg-accent-glow"
>
<span className="text-2xl" aria-hidden>{l.icon}</span>
<l.icon size={24} className="text-muted" aria-hidden />
<span className="text-sm text-ink">{l.label}</span>
</Link>
))}
@@ -159,7 +163,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
}
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) {
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus });
const ac = sys.baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus, ...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}) });
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
return (
+4 -2
View File
@@ -1,4 +1,6 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import type { Campaign, Homebrew, HomebrewKind } from '@/lib/schemas';
import { homebrewSchema } from '@/lib/schemas';
import { homebrewRepo } from '@/lib/db/repositories';
@@ -77,7 +79,7 @@ function HomebrewView({ campaign }: { campaign: Campaign }) {
if (list.length === 0) return null;
return (
<section key={k}>
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{HOMEBREW_KIND_LABEL[k]}s</h2>
<h2 className="mb-2 smallcaps">{HOMEBREW_KIND_LABEL[k]}s</h2>
<div className="grid gap-3 md:grid-cols-2">
{list.map((hb) => <HomebrewCard key={hb.id} hb={hb} />)}
</div>
@@ -102,7 +104,7 @@ function HomebrewCard({ hb }: { hb: Homebrew }) {
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2">
<Input className="font-display font-semibold" value={h.name} onChange={(e) => update({ name: e.target.value })} aria-label="Homebrew name" />
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}><X size={14} aria-hidden /></Button>
</div>
{HOMEBREW_FIELDS[hb.kind].length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2">
+2 -2
View File
@@ -151,7 +151,7 @@ function NoteEditor({
{/* Rendered preview with clickable links */}
<div className="mt-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Preview</h3>
<h3 className="mb-1 smallcaps">Preview</h3>
<p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
{splitWikiLinks(body).map((part, i) =>
part.link ? (
@@ -172,7 +172,7 @@ function NoteEditor({
{backlinks.length > 0 && (
<div className="mt-4">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Linked from</h3>
<h3 className="mb-1 smallcaps">Linked from</h3>
<div className="flex flex-wrap gap-1">
{backlinks.map((n) => (
<button key={n.id} onClick={() => onSelect(n.id)} className="rounded-md border border-line bg-surface px-2 py-1 text-xs text-ink hover:border-accent/40">
+37 -3
View File
@@ -1,6 +1,11 @@
import { useState } from 'react';
import { Wand2, X } from 'lucide-react';
import type { Campaign, Npc } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildNpcPrompt, npcQuickSchema, fallbackNpc, type NpcQuick } from '@/lib/assistant/session';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useNpcs } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
@@ -33,7 +38,7 @@ function Npcs({ campaign }: { campaign: Campaign }) {
<>
<Input className="mb-3 max-w-sm" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search NPCs…" aria-label="Search NPCs" />
<div className="grid gap-3 md:grid-cols-2">
{filtered.map((n) => <NpcCard key={n.id} npc={n} />)}
{filtered.map((n) => <NpcCard key={n.id} npc={n} campaign={campaign} />)}
</div>
</>
)}
@@ -41,13 +46,39 @@ function Npcs({ campaign }: { campaign: Campaign }) {
);
}
function NpcCard({ npc }: { npc: Npc }) {
function NpcCard({ npc, campaign }: { npc: Npc; campaign: Campaign }) {
const [n, setN] = useState(npc);
const [genState, setGenState] = useState<'idle' | 'loading'>('idle');
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const save = useDebouncedCallback((next: Npc) => void npcsRepo.update(next.id, {
name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description,
}), 350);
const update = (patch: Partial<Npc>) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; });
const generateDetails = async () => {
setGenState('loading');
const ctx = {
systemConstraint: `Only use ${getSystem(campaign.system).label} setting, names, and context.`,
systemLabel: getSystem(campaign.system).label,
questsSummary: 'No active quests.',
notesSummary: 'No notes yet.',
};
const hint = [n.name, n.role].filter(Boolean).join(', ');
let result: NpcQuick | null = null;
if (canUseLlm) {
const { system, user } = buildNpcPrompt(ctx, hint || undefined);
const res = await complete<NpcQuick>(getLlmConfig(), { system, user, schema: npcQuickSchema });
if (res.ok && 'data' in res) result = res.data;
}
if (!result) result = fallbackNpc();
const desc = `Motivation: ${result.motivation}\n\nSecret: ${result.secret}\n\nIntro hook: ${result.hook}`;
update({ description: desc, role: result.role || n.role });
setGenState('idle');
};
return (
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2">
@@ -55,7 +86,10 @@ function NpcCard({ npc }: { npc: Npc }) {
<Select className="w-auto py-1 text-xs" value={n.status} onChange={(e) => update({ status: e.target.value as Npc['status'] })} aria-label="Status">
{STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}></Button>
<Button size="sm" variant="ghost" disabled={genState === 'loading'} onClick={() => void generateDetails()} title="Fill description with generated details" aria-label="Generate NPC details">
<Wand2 size={13} aria-hidden />
</Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => npcsRepo.remove(npc.id)} aria-label={`Delete ${n.name}`}><X size={14} aria-hidden /></Button>
</div>
<div className="mt-2 grid grid-cols-3 gap-2">
<Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />
+49 -4
View File
@@ -1,9 +1,18 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import type { Campaign, Quest, Objective } from '@/lib/schemas';
import { questsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { complete } from '@/lib/llm/client';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { buildQuestHookPrompt, questHookSchema, fallbackQuestHook, type QuestHook } from '@/lib/assistant/session';
import { buildCampaignContext } from '@/lib/assistant/context';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useQuests } from './hooks';
import { useCharacters } from '@/features/characters/hooks';
import { useNotes } from '@/features/world/hooks';
import { useEncounters } from '@/features/combat/hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
@@ -20,15 +29,51 @@ export function QuestsPage() {
function Quests({ campaign }: { campaign: Campaign }) {
const quests = useQuests(campaign.id);
const characters = useCharacters(campaign.id);
const notes = useNotes(campaign.id);
const encounters = useEncounters(campaign.id);
const order = { active: 0, 'on-hold': 1, completed: 2, failed: 3 };
const sorted = [...quests].sort((a, b) => order[a.status] - order[b.status] || a.title.localeCompare(b.title));
const [genBusy, setGenBusy] = useState(false);
const llmEnabled = useAssistantStore((s) => s.enabled);
const hasKey = useAssistantStore((s) => !!s.apiKey);
const canUseLlm = llmEnabled && hasKey;
const generateQuest = async () => {
setGenBusy(true);
try {
let hook: QuestHook | null = null;
if (canUseLlm) {
const ctx = buildCampaignContext({ campaign, characters, encounters, quests, notes });
const { system, user } = buildQuestHookPrompt(ctx);
const res = await complete<QuestHook>(getLlmConfig(), { system, user, schema: questHookSchema });
if (res.ok && 'data' in res) hook = res.data;
}
if (!hook) hook = fallbackQuestHook();
const created = await questsRepo.create(campaign.id, hook.title);
await questsRepo.update(created.id, {
description: hook.description,
reward: hook.reward,
objectives: hook.objectives.map((text) => ({ id: newId(), text, done: false })),
});
} finally {
setGenBusy(false);
}
};
return (
<Page>
<PageHeader
title="Quests"
subtitle={campaign.name}
actions={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>}
actions={
<div className="flex gap-2">
<Button variant="secondary" disabled={genBusy} onClick={() => void generateQuest()}>
{genBusy ? 'Generating…' : 'Generate quest hook'}
</Button>
<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>
</div>
}
/>
{quests.length === 0 ? (
<EmptyState title="No quests yet" hint="Track objectives, rewards, and progress." action={<Button variant="primary" onClick={() => questsRepo.create(campaign.id, 'New quest')}>+ New quest</Button>} />
@@ -65,7 +110,7 @@ function QuestCard({ quest }: { quest: Quest }) {
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
</div>
<textarea
@@ -81,7 +126,7 @@ function QuestCard({ quest }: { quest: Quest }) {
</label>
<div className="mt-3">
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">
<div className="mb-1 smallcaps">
Objectives {q.objectives.length > 0 && `(${done}/${q.objectives.length})`}
</div>
<ul className="space-y-1">
@@ -93,7 +138,7 @@ function QuestCard({ quest }: { quest: Quest }) {
value={o.text}
onChange={(e) => setObjective(o.id, { text: e.target.value })}
/>
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective"></button>
<button className="text-muted hover:text-danger" onClick={() => update({ objectives: q.objectives.filter((x) => x.id !== o.id) })} aria-label="Remove objective"><X size={13} aria-hidden /></button>
</li>
))}
</ul>
+19 -8
View File
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from 'react';
import {
Move, ScanEye, EyeOff, Ruler, Cloud, PenTool, Crosshair, Spline, CirclePlus,
ChevronRight, type LucideIcon,
ChevronRight, ChevronLeft, Play, type LucideIcon,
} from 'lucide-react';
import type { BattleMap, Campaign, MapDrawing, MapToken } from '@/lib/schemas';
import { mapsRepo, encountersRepo } from '@/lib/db/repositories';
@@ -53,6 +53,8 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
const [tool, setTool] = useState<Tool>('move');
const [textDraft, setTextDraft] = useState<MapDrawing['points'][number] | null>(null);
const [textValue, setTextValue] = useState('');
const [fogShape, setFogShape] = useState<FogShape>('brush');
const [brush, setBrush] = useState(0);
const [aoeShape, setAoeShape] = useState<AoeShape>('circle');
@@ -167,10 +169,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
}
if (tool === 'draw') {
if (drawKind === 'text') {
if (phase === 'down') {
const text = window.prompt('Label text:')?.trim();
if (text) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [p.point], color: drawColor, width: 4, text, gmOnly: gmDraw }] });
}
if (phase === 'down') setTextDraft(p.point);
return;
}
if (phase === 'down') { draftPts.current = [p.point]; }
@@ -222,6 +221,11 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
const addTextLabel = () => {
const t = textValue.trim();
if (t && textDraft) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [textDraft], color: drawColor, width: 4, text: t, gmOnly: gmDraw }] });
setTextDraft(null); setTextValue('');
};
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
const moveToken = (id: string, col: number, row: number) => setM((prev) => {
const tokens = prev.tokens.map((t) => (t.id === id ? { ...t, col, row } : t));
@@ -329,9 +333,9 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
{activeEncounter && (
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-accent-glow p-2 text-sm">
<span className="smallcaps text-accent-deep">Combat · Round {activeEncounter.round}</span>
<span className="font-display font-semibold text-ink"> {activeCombatant?.name ?? '—'}</span>
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}> Prev</Button>
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn </Button>
<span className="flex items-center gap-1 font-display font-semibold text-ink"><Play size={14} aria-hidden /> {activeCombatant?.name ?? '—'}</span>
<Button size="sm" variant="ghost" onClick={() => void encountersRepo.mutate(activeEncounter.id, previousTurn)}><ChevronLeft size={14} aria-hidden /> Prev</Button>
<Button size="sm" variant="primary" onClick={() => void encountersRepo.mutate(activeEncounter.id, nextTurn)}>Next turn <ChevronRight size={14} aria-hidden /></Button>
{activeTokenId ? <span className="text-xs text-muted"> their token glows on the map</span> : <span className="text-xs text-muted"> place their token from the palette</span>}
</div>
)}
@@ -402,6 +406,13 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
</div>
</Modal>
)}
<Modal open={!!textDraft} onClose={() => { setTextDraft(null); setTextValue(''); }} title="Text label" className="max-w-sm"
footer={<>
<Button variant="ghost" onClick={() => { setTextDraft(null); setTextValue(''); }}>Cancel</Button>
<Button variant="primary" disabled={!textValue.trim()} onClick={addTextLabel}>Add</Button>
</>}>
<Input autoFocus value={textValue} onChange={(e) => setTextValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTextLabel()} aria-label="Label text" placeholder="Label text" />
</Modal>
</div>
);
}
+2 -2
View File
@@ -38,8 +38,8 @@ export function TokenPalette({ characters, encounters, existingTokens, onPlace,
return (
<aside className="flex h-full min-h-0 flex-col gap-2 rounded-lg border border-line bg-panel p-2 text-sm">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Tokens</h3>
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette"> Hide</button>
<h3 className="smallcaps">Tokens</h3>
<button onClick={onClose} className="text-xs text-muted hover:text-ink" aria-label="Hide token palette">Hide</button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
+251
View File
@@ -0,0 +1,251 @@
/** Deterministic, per-class action guide for the player "What can I do?" panel. */
export interface ActionStep {
label: string;
desc: string;
}
export interface TurnGuide {
actions: ActionStep[];
/** Optional level gate (shown when level < minLevel). */
upgradeAt?: { level: number; note: string };
/** Reminder shown at all levels. */
tip?: string;
}
// ---- 5e guides ----
const GUIDE_5E: Record<string, TurnGuide> = {
barbarian: {
actions: [
{ label: 'Action', desc: 'Attack — if you aren\'t raging yet, use a Bonus Action to Rage first (it\'s free this turn).' },
{ label: 'Bonus Action', desc: 'Rage (once per combat, free resources). At level 2: Reckless Attack costs no action — just declare it on your first attack roll.' },
{ label: 'Movement', desc: 'Your speed is 30 ft. While raging you can\'t cast spells or concentrate, but you\'re nigh-unstoppable.' },
{ label: 'Reaction', desc: 'Opportunity Attack — if an enemy leaves your reach, you can attack as a reaction.' },
],
upgradeAt: { level: 5, note: 'At level 5 you gain Extra Attack — make two attacks on your Attack action.' },
tip: 'Rage gives resistance to Bludgeoning, Piercing, and Slashing damage. Never fight without it if you expect more than one hit.',
},
bard: {
actions: [
{ label: 'Action', desc: 'Cast a spell (Vicious Mockery to impose disadvantage costs nothing but a cantrip slot), or use Help.' },
{ label: 'Bonus Action', desc: 'Bardic Inspiration — give a d6 to an ally; they can add it to any roll within the next 10 minutes. At level 5 it becomes a d8.' },
{ label: 'Movement', desc: '30 ft. Stay at range — you have light armour and low hit points.' },
{ label: 'Reaction', desc: 'Cutting Words (once per short rest, subtract your Inspiration die from an enemy\'s attack, check, or damage roll).' },
],
tip: 'Bardic Inspiration is your most powerful tool early on. Give it out before initiative is rolled when possible.',
},
cleric: {
actions: [
{ label: 'Action', desc: 'Cast a spell — Sacred Flame (cantrip, radiant damage) costs no slots. Use higher-level slots for Guiding Bolt or Spirit Guardians.' },
{ label: 'Bonus Action', desc: 'Healing Word is a bonus-action heal — save it to pick up a downed ally. Better than Cure Wounds in most combats.' },
{ label: 'Movement', desc: '30 ft. Channel Divinity is a free action, not a bonus action — use it on the same turn as a spell.' },
{ label: 'Reaction', desc: 'Shield of Faith (concentration) boosts an ally\'s AC as your Action; your Reaction is free for Opportunity Attacks.' },
],
tip: 'Healing Word as a Bonus Action to revive downed allies, then attack or cast on your main Action. Don\'t burn all your spell slots healing mid-combat.',
},
druid: {
actions: [
{ label: 'Action', desc: 'Cast a spell (Shillelagh makes your staff a Wisdom-based melee weapon) or use Wild Shape to become a beast.' },
{ label: 'Bonus Action', desc: 'Wild Shape is a Bonus Action — transform mid-round without losing your main Action. Your gear melds into your new form.' },
{ label: 'Movement', desc: '30 ft (or the Wild Shape form\'s speed — most animals are faster). Wild Shape HP is separate from yours; it\'s a free buffer.' },
{ label: 'Reaction', desc: 'No unique reaction early on. Use Opportunity Attacks in melee or cast Absorb Elements to halve elemental damage.' },
],
upgradeAt: { level: 2, note: 'At level 2 you unlock Wild Shape. Use the CR ¼ CR ½ beast forms as frontline tanks — when they run out of HP you revert, unharmed.' },
tip: 'Wild Shape HP is a free buffer on top of your own. Get hit in beast form, then revert and cast heals if needed.',
},
fighter: {
actions: [
{ label: 'Action', desc: 'Attack — one attack until level 5. Choose a Fighting Style at level 1 (Great Weapon Master, Archery, Defence, Dueling, etc.).' },
{ label: 'Bonus Action', desc: 'Second Wind (once per short rest) — recover 1d10 + Fighter level HP. At level 3 some subclasses add bonus action attacks (Battle Master, Champion).' },
{ label: 'Movement', desc: '30 ft (35 ft in medium armour with the Mobility feat). You can split movement before and after an attack.' },
{ label: 'Reaction', desc: 'Opportunity Attack — make one attack against any creature that moves out of your reach. Strongly consider the Sentinel feat later.' },
],
upgradeAt: { level: 5, note: 'Level 5: Extra Attack — make two attacks per Attack action. Level 11: Three attacks. Level 20: Four attacks.' },
tip: 'You have the most Action Surge uses and the broadest weapon/armour access. Don\'t forget Second Wind between fights — it\'s free.',
},
monk: {
actions: [
{ label: 'Action', desc: 'Attack — use Dexterity with unarmed strikes and monk weapons (short sword, any simple weapon). Flurry of Blows can add 2 more hits per turn.' },
{ label: 'Bonus Action', desc: 'Flurry of Blows (1 ki — hit twice), Step of the Wind (1 ki — Dash or Disengage), or Patient Defense (1 ki — Dodge).' },
{ label: 'Movement', desc: '35 ft at level 1, increasing 5 ft every 4 levels. You don\'t need to engage by running in a straight line — use your speed creatively.' },
{ label: 'Reaction', desc: 'Deflect Missiles — catch or redirect incoming ranged weapon attacks. At level 3: Stunning Strike (2 ki on a hit) makes a target lose their next turn on a failed Con save.' },
],
upgradeAt: { level: 5, note: 'Level 5: Extra Attack + Stunning Strike. A Stunning Strike on a melee hit removes the target\'s next action and gives advantage to all attacks against them.' },
tip: 'Ki points replenish on a short rest. Use them every short rest — hoarding ki is a common new-player mistake.',
},
paladin: {
actions: [
{ label: 'Action', desc: 'Attack. After a hit you can expend a spell slot as a Bonus Action to add Divine Smite (2d8 radiant per slot level, doubled on crits — save slots for crits).' },
{ label: 'Bonus Action', desc: 'Divine Smite after a hit (your choice, after you see the roll). Lay on Hands is an Action. Healing using a spell slot is also an Action.' },
{ label: 'Movement', desc: '30 ft in heavy armour. Your Auras (level 6+) are passive — they don\'t cost actions, they just work.' },
{ label: 'Reaction', desc: 'Opportunity Attack. At level 6: Aura of Protection adds your Charisma modifier to all saving throws for nearby allies — no action needed.' },
],
upgradeAt: { level: 5, note: 'Level 5: Extra Attack + 2nd-level spell slots. Smiting with a 2nd-level slot adds 3d8 radiant — devastating on a crit.' },
tip: 'Divine Smite is declared after you know the attack hit and whether it\'s a crit (2× dice on a crit). Save your highest spell slots for confirmed crits.',
},
ranger: {
actions: [
{ label: 'Action', desc: 'Attack at range or melee. Mark a creature as your Favoured Enemy for bonus damage. Hunter\'s Mark (concentration, Bonus Action to recast) adds 1d6 per hit.' },
{ label: 'Bonus Action', desc: 'Cast Hunter\'s Mark (1st-level spell, concentration), or move it to a new target when the old one dies.' },
{ label: 'Movement', desc: '30 ft. Ranger subclasses often grant additional Bonus Actions at level 3 (Hunter: Horde Breaker; Beastmaster: command your companion).' },
{ label: 'Reaction', desc: 'Opportunity Attack. At level 7: Evasion — half damage (zero on a success) vs area attacks that target Dexterity saves.' },
],
upgradeAt: { level: 5, note: 'Level 5: Extra Attack. Combine with Hunter\'s Mark for consistent damage every turn.' },
tip: 'Hunter\'s Mark is concentration — don\'t cast other concentration spells while it\'s active. It moves for free when a target drops, no spell slot needed.',
},
rogue: {
actions: [
{ label: 'Action', desc: 'Attack — one attack per turn (use finesse or ranged weapons for Dexterity-based Sneak Attack). Sneak Attack triggers once per turn when you have advantage or an ally is adjacent to your target.' },
{ label: 'Bonus Action', desc: 'Cunning Action (level 2): Dash, Disengage, or Hide as a Bonus Action. Disengage means you can walk away without triggering Opportunity Attacks.' },
{ label: 'Movement', desc: '30 ft. Use Disengage (Cunning Action) to safely retreat after attacking from range or melee.' },
{ label: 'Reaction', desc: 'Uncanny Dodge (level 5): use your Reaction to halve damage from one attack per round. Evasion (level 7): half/no damage from Dexterity saves.' },
],
upgradeAt: { level: 2, note: 'Level 2: Cunning Action unlocks the Rogue\'s unique mobility. Disengage every turn after attacking to avoid Opportunity Attacks.' },
tip: 'You only need Sneak Attack once per turn but it can trigger on Opportunity Attacks too. Set up flanking (adjacent ally) to guarantee advantage.',
},
sorcerer: {
actions: [
{ label: 'Action', desc: 'Cast a spell. Fire Bolt and Ray of Frost are damage cantrips that cost no slots. Chromatic Orb (1st slot) deals 3d8 of any element — strong early choice.' },
{ label: 'Bonus Action', desc: 'Quicken Spell (2 Sorcery Points): cast a spell as a Bonus Action instead of an Action — lets you attack twice in one turn with two spells.' },
{ label: 'Movement', desc: '30 ft. Stay far from enemies — use Misty Step (2nd-level slot) to teleport 30 ft if surrounded.' },
{ label: 'Reaction', desc: 'Counterspell (3rd-level slot, level 6): cancel a spell being cast within 60 ft. No other unique reaction early on.' },
],
upgradeAt: { level: 3, note: 'Level 3: Metamagic unlocks. Twinned Spell (double target, 1 point per spell level) and Empowered Spell (1 point to reroll damage dice) are often picked first.' },
tip: 'Sorcery Points convert to extra spell slots (1 short rest). Don\'t spend all your Sorcery Points on Metamagic — keep some for emergency slots.',
},
warlock: {
actions: [
{ label: 'Action', desc: 'Cast a spell or use Eldritch Blast (cantrip, force damage, scales to 4 beams at level 17). Eldritch Blast + Agonizing Blast invocation is your go-to attack.' },
{ label: 'Bonus Action', desc: 'Hex (1st-level slot, concentration): choose an ability, target has disadvantage on checks with it, and you add 1d6 necrotic per hit. Move it when the target dies for free.' },
{ label: 'Movement', desc: '30 ft. Your spell slots recharge on a short rest — take short rests as often as the group allows to keep up output.' },
{ label: 'Reaction', desc: 'No unique early reaction. Devil\'s Sight invocation lets you see in magical darkness — combine with Darkness spell for advantage on all attacks.' },
],
upgradeAt: { level: 5, note: 'Level 5: 3rd-level spell slots (still only 2). Hypnotic Pattern and Hunger of Hadar are crowd-control standouts at this level.' },
tip: 'Your spell slots are few but powerful — always at your highest slot level. Short-rest classes (Monk, Fighter) decide short rest frequency; advocate for them if your DM forgets.',
},
wizard: {
actions: [
{ label: 'Action', desc: 'Cast a spell. Fire Bolt and Chill Touch are free cantrips. Hold your best slots (Fireball, Hypnotic Pattern) for fights that matter.' },
{ label: 'Bonus Action', desc: 'No universal bonus action. Misty Step (2nd-level) uses an Action, not a Bonus Action. Some subclass features add bonus action options.' },
{ label: 'Movement', desc: '30 ft. Stand behind your party and out of reach. Blur and Mirror Image are Concentration-free self-protections.' },
{ label: 'Reaction', desc: 'Counterspell (3rd-level slot): cancel a spell being cast. Shield (1st-level slot): add +5 AC until your next turn (excellent use of a reaction slot).' },
],
upgradeAt: { level: 5, note: 'Level 5: Fireball and Lightning Bolt become available. These 3rd-level spells are game-changers — conserve 3rd-level slots for them early on.' },
tip: 'Copy spells into your spellbook whenever you find scrolls or can access another wizard\'s book. The more spells you know, the more prepared you can be.',
},
};
// ---- PF2e guides ----
const GUIDE_PF2E: Record<string, TurnGuide> = {
barbarian: {
actions: [
{ label: '1 action', desc: 'Strike — make one weapon attack. In PF2e, you can Strike up to 3 times in one turn, but each additional attack takes a -5 / -10 penalty to hit.' },
{ label: '2 actions', desc: 'Enter Rage (free the first time per encounter). Most of your power comes from raging — always start combat in Rage.' },
{ label: '3 actions', desc: 'Double-Strike, Sudden Charge (move + attack), or take the Intimidate action to Demoralize enemies.' },
{ label: 'Reaction', desc: 'Attack of Opportunity (from the Reactive Strike feat — Fighters get it free; Barbarians can buy it). You can also Step away from threats as a free action when not engaged.' },
],
tip: 'In PF2e you have 3 actions and 1 reaction per round. Move, Strike, Strike is a common sequence (with -5 on the second). Rage first to maximise your bonus damage.',
},
champion: {
actions: [
{ label: '1 action', desc: 'Strike — use your deity\'s favoured weapon for extra class synergy.' },
{ label: '2 actions', desc: 'Raise a Shield (1 action) + Strike (1 action) = Block reaction available AND make an attack. Shield\'s AC bonus only applies when raised.' },
{ label: '3 actions', desc: 'Move (1) + Raise Shield (1) + Strike (1) is your bread-and-butter turn.' },
{ label: 'Reaction', desc: 'Champion\'s Reaction — Retributive Strike (Paladin) or Rescuing Reach (Liberator): when an ally within 15 ft is hit, you can interpose and reduce the damage. This is why you exist.' },
],
tip: 'Your Champion\'s Reaction is the most powerful defensive tool in PF2e. Always stay within 15 ft of the party\'s squishiest member. Raise your Shield every single turn.',
},
cleric: {
actions: [
{ label: 'Heal / Harm', desc: 'Heal at 1 action targets only you; 2 actions targets a touched creature; 3 actions is an area burst that heals all allies and damages undead. Pick based on the situation.' },
{ label: 'Cast a Spell', desc: 'Most spells take 2 actions. Divine Spellcasters have access to Harm / Heal, Bless, and weapon spells through their deity.' },
{ label: 'Channel Smite', desc: '2 actions: Strike AND add a Harm/Heal charge to the hit (1 + spell rank damage). Extremely efficient if you want to be in melee.' },
{ label: 'Reaction', desc: 'No class reaction at level 1. Take a shield if you want a Block reaction — it matters against single big hits.' },
],
tip: 'The 3-action Heal burst is PF2e\'s most efficient healing: one 3-action spell heals every ally in range. Decide at the start of combat whether you\'re healing or offending that round.',
},
fighter: {
actions: [
{ label: '1 action', desc: 'Strike — Fighters have the highest weapon proficiency in the game (Legendary by level 17).' },
{ label: '2 actions', desc: 'Power Attack (level 1 feat) — Strike for extra damage with a -2 MAP. Double Slice (two weapon) or Knockdown (felling blow). Use one of these every turn early on.' },
{ label: '3 actions', desc: 'Sudden Charge (2 actions — move 2× speed + Strike), or Intimidating Strike (Strike + Demoralize in one action at level 10).' },
{ label: 'Reaction', desc: 'Reactive Strike — hit any creature that uses a manipulate action, Stands Up, or moves through your reach. Extremely valuable; use it every time it triggers.' },
],
tip: 'Your Reactive Strike is a free extra attack every single round. Build around triggering it: hold position, let enemies come to you, and punish movement.',
},
ranger: {
actions: [
{ label: 'Hunt Prey', desc: '1 action at the start of each combat. Gives +2 to Perception checks and +2 to damage against your Prey for the whole fight. Never skip this.' },
{ label: 'Strike', desc: '1 action. Ranger\'s Flurry (level 1 optional rule) reduces MAP on second Strike by 2 — great for two-weapon and bow builds.' },
{ label: 'Spells', desc: 'You get a small list of ranger spells from level 1 if you take the Spellcasting archetype (or the Druidic Ranger subclass). Many have 1-minute durations — prep before combat.' },
{ label: 'Reaction', desc: 'No class reaction at level 1. Consider the Attack of Opportunity feat chain for melee rangers. Evasive Arrow (ranged) can redirect enemy fire at level 12.' },
],
tip: 'Hunt Prey first, always. Your target has essentially -2 to -4 to their effective AC relative to you. If your Prey drops, use the free Hunt Prey reaction to pick a new one.',
},
rogue: {
actions: [
{ label: 'Strike', desc: 'Sneak Attack (+1d6 per 2 rogue levels) triggers on any flat-footed target. Flanking (you and an ally both adjacent to the target) makes the target flat-footed.' },
{ label: 'Feint', desc: '1 action — Deception check vs target\'s Perception DC. On success: flat-footed to you until end of your next turn. Enables Sneak Attack solo.' },
{ label: 'Skill Actions', desc: 'Tumble Through (Acrobatics), Steal, Disable Device, Recall Knowledge — Rogues have more skill actions than anyone. Use them every turn.' },
{ label: 'Reaction', desc: 'Nimble Dodge (level 1) — add +2 to AC once per round as a Reaction when attacked. Better than many class reactions.' },
],
tip: 'Sneak Attack requires flat-footed — arrange flanking at the start of every fight. Nimble Dodge protects you when flanking fails.',
},
sorcerer: {
actions: [
{ label: 'Cast a Spell (2 actions)', desc: 'Most spells cost 2 actions. Haste is 2 actions on an ally — one of the best buffs in PF2e. Burning Hands, Fear, and Magic Missile are strong early options.' },
{ label: 'Cast a Spell (1 action)', desc: 'Cantrips and some focus spells can be cast for 1 action (with limitations). A cantrip each turn fills your 3rd action after moving and casting.' },
{ label: 'Blood Magic', desc: 'Your bloodline grants a Focus Pool and a Focus Spell. These replenish on 10-minute refocuses — use them freely.' },
{ label: 'Reaction', desc: 'No class reaction. Contingency (level 14 wizard spell) is the gold standard; until then, just move away from danger.' },
],
tip: 'PF2e Sorcerers cast Spontaneously — pick fewer spells but cast them at any level. Choose flexible options like Fear (scales well) over highly situational picks.',
},
wizard: {
actions: [
{ label: 'Cast a Spell (2 actions)', desc: 'Most spells cost 2 actions. At level 1, Grease (difficult terrain in an area), Magic Missile (no roll), and Fear are excellent. Save your best slots for fights that demand them.' },
{ label: 'Recall Knowledge', desc: '1 action — identify a creature\'s weaknesses, immunities, and key abilities. In PF2e this is vital; knowing a troll regenerates fire damage prevents a wasted round.' },
{ label: 'Cantrip', desc: 'Electric Arc hits two adjacent targets for 1d4+INT — the best damage cantrip in PF2e. Use it when you don\'t have a better target for a spell slot.' },
{ label: 'Reaction', desc: 'Counterspell — expend a prepared copy of a spell to cancel the same spell cast against you. Prepare Counterspell-worthy copies of any spell the enemy might cast.' },
],
tip: 'Prepare Recall Knowledge on at least one party member (via the Identifying feature or ask your DM). In PF2e, knowing resistances and weaknesses is almost as valuable as a damage spell.',
},
monk: {
actions: [
{ label: 'Flurry of Blows', desc: '1 action — Strike twice in 1 action (first at -0, second at -4 MAP instead of the usual -5). Your signature move — use it every turn.' },
{ label: 'Ki Strike', desc: '1 action (focus spell) — Strike + add d6 force damage. Replenish with 10-minute refocus.' },
{ label: 'Stunning Fist', desc: '1 action (focus, 1 focus point) — Strike + Constitution save. On failure: Stunned 1 (lost 1 action next turn). An action-economy nightmare for enemies.' },
{ label: 'Reaction', desc: 'Deflect Arrow (level 2 feat) — reduce ranged weapon damage by 1d10+DEX with a Reaction. Consider the Crane Stance for +1 AC Reaction-free.' },
],
tip: 'Monks are an action-economy class — Flurry of Blows gives 2 Strikes for 1 action. Build your kit around actions that generate value: Flurry, a focus spell, and a positioning move.',
},
};
export function getTurnGuide(system: string, classSlug: string): TurnGuide | undefined {
const guides = system === 'pf2e' ? GUIDE_PF2E : GUIDE_5E;
return guides[classSlug.toLowerCase()];
}
/** Generic guide when no class-specific data is available. */
export function getGenericGuide(system: string): TurnGuide {
if (system === 'pf2e') {
return {
actions: [
{ label: '1 action', desc: 'Strike — make one weapon or unarmed attack.' },
{ label: '2 actions', desc: 'Most spells, Raise Shield, or a 2-action activity from your class.' },
{ label: '3 actions', desc: 'Move and still act: 1 Stride (1 action) + 2-action activity, or use a 3-action spell.' },
{ label: 'Reaction', desc: 'Attack of Opportunity (if you have it), Block (with a raised shield), or your class-specific reaction.' },
],
tip: 'PF2e gives you 3 actions and 1 reaction per round. Actions carry a Multiple Attack Penalty (MAP): -5 on the 2nd Strike, -10 on the 3rd.',
};
}
return {
actions: [
{ label: 'Action', desc: 'Attack, Cast a Spell, Dash, Dodge, Help, or Use an Object.' },
{ label: 'Bonus Action', desc: 'Class or spell abilities that cost a Bonus Action (e.g. second-wind, certain spells). You only have 1 per turn.' },
{ label: 'Movement', desc: 'Move up to your speed at any point during your turn. You can split it before and after your Action.' },
{ label: 'Reaction', desc: 'Opportunity Attack when an enemy leaves your reach, or a spell like Shield / Counterspell if you have one prepared.' },
],
tip: 'You get one Action, one possible Bonus Action, movement, and a Reaction each round. Reactions are used on other people\'s turns.',
};
}
+34
View File
@@ -94,4 +94,38 @@ describe('suggestReinforcements', () => {
it('returns null for an empty encounter (caller builds fresh instead)', () => {
expect(suggestReinforcements('5e', [3, 3], [], pool, 'Hard')).toBeNull();
});
it('suggests removing monsters when the fight is already over the target', () => {
// 3 Ogres (cr 2) vs two level-3 PCs is far above Deadly; target Deadly should
// ease the fight DOWN rather than add more.
const existing = [
{ name: 'Ogre', cr: 2 }, { name: 'Ogre 2', cr: 2 }, { name: 'Ogre 3', cr: 2 },
];
const plan = suggestReinforcements('5e', [3, 3], existing, pool, 'Deadly');
expect(plan).not.toBeNull();
expect(plan!.add).toEqual([]);
expect(plan!.remove).toBeDefined();
expect(plan!.remove![0]!.name).toBe('Ogre');
expect(plan!.remove![0]!.count).toBeGreaterThanOrEqual(1);
expect(plan!.reasoning).toMatch(/remove/i);
expect(plan!.reasoning).not.toMatch(/add/i);
});
it('suggests a swap when a single creature alone exceeds the target', () => {
// One Ogre vs two level-3 PCs already beats Medium; nothing left to remove.
const plan = suggestReinforcements('5e', [3, 3], [{ name: 'Ogre', cr: 2 }], pool, 'Medium');
expect(plan).not.toBeNull();
expect(plan!.add).toEqual([]);
expect(plan!.remove).toBeUndefined();
expect(plan!.reasoning).toMatch(/swap/i);
expect(plan!.reasoning).toMatch(/ogre/i);
});
it('still adds when the fight is below the target', () => {
const existing = [{ name: 'Goblin', cr: 0.25 }, { name: 'Goblin 2', cr: 0.25 }, { name: 'Goblin 3', cr: 0.25 }];
const plan = suggestReinforcements('5e', [3, 3], existing, pool, 'Hard');
expect(plan!.add.length).toBeGreaterThan(0);
expect(plan!.remove).toBeUndefined();
expect(plan!.reasoning).toMatch(/add/i);
});
});
+2
View File
@@ -6,6 +6,8 @@ export type Severity = 'info' | 'warn' | 'danger';
export type SuggestAction =
| { type: 'goto'; to: string; params?: Record<string, string>; label: string }
| { type: 'longRest'; characterId: string; label: string }
| { type: 'shortRest'; characterId: string; label: string }
| { type: 'completeQuest'; questId: string; label: string }
| { type: 'createNote'; title: string; label: string };
export interface Suggestion {
+286
View File
@@ -0,0 +1,286 @@
/** Static per-class guidance shown during character creation. No AI, no async. */
export interface ClassTip {
role: string;
/** e.g. "Strength (attacks) · Charisma (spells, auras)" */
primaryAbilities: string;
beginner: boolean;
skillSuggestions: string;
abilityTip?: string;
}
const TIPS_5E: Record<string, ClassTip> = {
barbarian: {
role: 'A fearless, rage-fuelled warrior who charges into melee and soaks up punishment. Very simple to play — hit things, don\'t die.',
primaryAbilities: 'Strength (attacks, grapple) · Constitution (HP and Rage)',
beginner: true,
skillSuggestions: 'Athletics, Intimidation, Perception, Survival',
abilityTip: 'Put your highest score in Strength, second in Constitution. Barbarians don\'t wear heavy armour — your AC = 10 + DEX + CON, so Constitution doubly matters.',
},
bard: {
role: 'A jack-of-all-trades who inspires allies, casts spells, and out-talks everyone. Works well in any role.',
primaryAbilities: 'Charisma (spells, Bardic Inspiration) · Dexterity (AC, initiative)',
beginner: false,
skillSuggestions: 'Persuasion, Deception, Performance, Insight',
abilityTip: 'Charisma is everything for a Bard. Second priority: Dexterity for AC, then Constitution for concentration spells.',
},
cleric: {
role: 'A divine spellcaster who heals allies and smites enemies. Flexible — some subclasses (War, Forge) fight in the front line.',
primaryAbilities: 'Wisdom (all spells, Channel Divinity) · Strength or Dexterity (depending on build)',
beginner: true,
skillSuggestions: 'Medicine, Religion, Insight, Persuasion',
abilityTip: 'Wisdom first — it powers every Cleric spell. Front-line Clerics want Strength second; support Clerics want Constitution for concentration.',
},
druid: {
role: 'A nature magic caster who can transform into animals (Wild Shape). Powerful but benefits from reading the options.',
primaryAbilities: 'Wisdom (spells, Wild Shape save DC) · Constitution (concentration spells)',
beginner: false,
skillSuggestions: 'Nature, Perception, Survival, Insight',
abilityTip: 'Wisdom is your core stat. Constitution keeps concentration spells alive. Low Strength is fine — use Wild Shape for physical challenges.',
},
fighter: {
role: 'The most reliable front-line warrior. Gets more attacks than anyone, can wear any armour, and is very forgiving to play.',
primaryAbilities: 'Strength (melee attacks) or Dexterity (ranged / finesse builds) · Constitution (HP, Second Wind)',
beginner: true,
skillSuggestions: 'Athletics, Perception, Intimidation, History',
abilityTip: 'Pick a focus: heavy-armour melee (Strength + Constitution) or light-armour finesse (Dexterity + Constitution). Don\'t try to split them.',
},
monk: {
role: 'A fast unarmed martial artist who darts around the battlefield punching multiple enemies per turn.',
primaryAbilities: 'Dexterity (AC, attacks, finesse weapons) · Wisdom (Unarmored Defense, ki abilities)',
beginner: false,
skillSuggestions: 'Athletics, Acrobatics, Stealth, Insight',
abilityTip: 'Dexterity and Wisdom both feed your AC (= DEX + WIS) — invest in both. Ki points fuel everything; Constitution helps you take a hit while concentrating.',
},
paladin: {
role: 'A holy warrior who hits hard, heals allies, and radiates powerful protective auras. One of the strongest front-liners.',
primaryAbilities: 'Strength (melee attacks, armour) · Charisma (spells, auras, Lay on Hands, Divine Smite bonus)',
beginner: false,
skillSuggestions: 'Persuasion, Athletics, Religion, Insight',
abilityTip: 'You need two high stats: Strength for hitting things and Charisma for everything magical. Constitution can be your dump stat — heavy armour and d10 HP cover it.',
},
ranger: {
role: 'A skilled hunter and tracker who excels at a favoured terrain or enemy type. Best at skirmishing from range.',
primaryAbilities: 'Dexterity (ranged and finesse attacks, AC) or Strength (melee) · Wisdom (spells)',
beginner: false,
skillSuggestions: 'Perception, Stealth, Survival, Athletics',
abilityTip: 'Dexterity for bows and light armour is the most common build. Wisdom powers your spells — prioritise it over Charisma. Constitution for survivability.',
},
rogue: {
role: 'A sneaky striker who deals huge Sneak Attack damage on one hit per turn, plus the best skill coverage in the game.',
primaryAbilities: 'Dexterity (attacks, Sneak Attack with finesse/ranged, AC, Stealth) · Intelligence or Charisma (subclass)',
beginner: true,
skillSuggestions: 'Stealth, Perception, Sleight of Hand, Deception, Athletics',
abilityTip: 'Dexterity is your core stat. You only need one hit per turn for big damage — prioritise staying alive (and unseen) over maximising damage output.',
},
sorcerer: {
role: 'A blaster mage with innate magical power. Fewer spells than a Wizard but Metamagic bends the rules dramatically.',
primaryAbilities: 'Charisma (spell attacks and save DCs) · Constitution (concentration, survivability)',
beginner: true,
skillSuggestions: 'Persuasion, Arcana, Intimidation, Insight',
abilityTip: 'Charisma is everything. Constitution second — you are in a robe with 6 HP per level and will be targeted. Pick up the Mage Armour spell early.',
},
warlock: {
role: 'A pact-powered caster who recharges spell slots on a short rest — unique among casters. Eldritch Blast is your bread and butter.',
primaryAbilities: 'Charisma (spells, Eldritch Blast invocations) · Constitution (concentration)',
beginner: false,
skillSuggestions: 'Arcana, Intimidation, Deception, History',
abilityTip: 'Charisma first. Until high level you only have 12 spell slots — lean on Eldritch Blast with Invocations to stay useful every round.',
},
wizard: {
role: 'The most versatile spellcaster — prepare different spells each day for any situation. Deeply rewarding, needs spell knowledge.',
primaryAbilities: 'Intelligence (all spells, Arcane Recovery) · Constitution (concentration spells)',
beginner: false,
skillSuggestions: 'Arcana, History, Investigation, Medicine',
abilityTip: 'Intelligence powers everything. Constitution second — Concentration spells are your strongest tools and you need to maintain them after taking a hit.',
},
};
const TIPS_PF2E: Record<string, ClassTip> = {
alchemist: {
role: 'A tinkerer who brews bombs, mutagens, and elixirs. Highly customisable but needs careful resource management between encounters.',
primaryAbilities: 'Intelligence (formula DC, items per day) · Dexterity (AC in light armour)',
beginner: false,
skillSuggestions: 'Crafting, Arcana, Medicine, Thievery',
},
barbarian: {
role: 'A rage-fuelled melee brawler with huge hit points. Your Instinct (animal, giant, spirit, etc.) defines your flavour and bonus.',
primaryAbilities: 'Strength (attacks) · Constitution (HP — Rage adds CON to max HP)',
beginner: true,
skillSuggestions: 'Athletics, Intimidation, Survival',
abilityTip: 'Strength first. Constitution second — Rage adds your Constitution modifier to HP on top of the base die. Dexterity for unarmoured builds.',
},
bard: {
role: 'A charismatic performer with occult spellcasting and composition cantrips that buff allies every round. Excellent support.',
primaryAbilities: 'Charisma (spells, composition cantrips) · Dexterity (AC, Reflex)',
beginner: false,
skillSuggestions: 'Performance, Deception, Diplomacy, Occultism',
abilityTip: 'Charisma is your foundation. Dexterity second for AC in light armour. Constitution for keeping concentration spells up.',
},
champion: {
role: 'A heavily armoured divine warrior whose class Reaction reduces damage taken by allies — uniquely powerful in a party.',
primaryAbilities: 'Strength (attacks) · Charisma (divine spells, Lay on Hands, Champion reaction) · Constitution (HP)',
beginner: true,
skillSuggestions: 'Religion, Athletics, Intimidation, Medicine',
abilityTip: 'Strength and Charisma both matter. Heavy armour covers weak Dexterity. Your Champion Reaction scales with Charisma.',
},
cleric: {
role: 'A divine spellcaster whose god determines your offensive or support role. The Divine Font gives extra heal/harm spells per day.',
primaryAbilities: 'Wisdom (spells, divine font) · Strength or Dexterity (deity weapon / unarmoured)',
beginner: true,
skillSuggestions: 'Religion, Medicine, Diplomacy, Insight',
abilityTip: 'Wisdom is your core stat. Warpriest Clerics add Strength; Cloistered Clerics need Constitution for sustained spellcasting.',
},
druid: {
role: 'A nature magic caster who can transform into animals or call on elemental forces. Very versatile with powerful spells.',
primaryAbilities: 'Wisdom (spells, Wild Shape save DC) · Constitution (HP in Wild Shape forms)',
beginner: false,
skillSuggestions: 'Nature, Survival, Medicine, Athletics',
abilityTip: 'Wisdom is your primary stat. Constitution for HP when in Wild Shape. Dexterity for AC when not transformed.',
},
fighter: {
role: 'The best weapon combatant in the game — higher proficiency than any other class. Multiple attacks and powerful stances.',
primaryAbilities: 'Strength (melee / thrown attacks) or Dexterity (ranged / finesse attacks)',
beginner: true,
skillSuggestions: 'Athletics, Intimidation, Acrobatics, Perception',
abilityTip: 'Pick Strength for melee or Dexterity for ranged/finesse. Constitution for hit points. Fighters don\'t need a third stat to shine.',
},
monk: {
role: 'An unarmed martial artist with extreme mobility. Ki abilities let you do things no weapon fighter can — flying kicks, stunning strikes.',
primaryAbilities: 'Strength or Dexterity (attacks and movement) · Wisdom (Unarmored Defense, ki)',
beginner: false,
skillSuggestions: 'Athletics, Acrobatics, Perception, Stealth',
abilityTip: 'Your AC = 10 + DEX + WIS, so both stats matter. Decide early: Strength-based martial arts or Dexterity-based finesse.',
},
oracle: {
role: 'A divine spontaneous caster with a Curse that grows as you cast higher-rank spells. Dramatic power with real tradeoffs.',
primaryAbilities: 'Charisma (all spells) · Constitution (HP, curse management)',
beginner: false,
skillSuggestions: 'Religion, Occultism, Diplomacy, Medicine',
abilityTip: 'Charisma first. Constitution second — your curse adds drawbacks that are harder to manage at low HP. Monitor your curse rank carefully.',
},
ranger: {
role: 'A skilled hunter and outlander. Hunt Prey gives a persistent bonus against your current target every combat — keep track of it.',
primaryAbilities: 'Dexterity (ranged attacks, AC) or Strength (melee) · Wisdom (spells)',
beginner: false,
skillSuggestions: 'Nature, Survival, Stealth, Athletics',
abilityTip: 'Choose a focus: ranged (Dexterity + bow) or melee (Strength + weapon). Wisdom for your ranger spells. Perception is trained free.',
},
rogue: {
role: 'A skill master and precision striker. Sneak Attack triggers when the target is Flat-Footed — positioning and flanking matter.',
primaryAbilities: 'Dexterity (attacks, AC, Stealth) · Intelligence or Charisma (Racket choice)',
beginner: true,
skillSuggestions: 'Stealth, Thievery, Deception, Perception, Acrobatics',
abilityTip: 'Dexterity is your core stat. Your second stat depends on your Racket: Scoundrel needs Charisma, Mastermind needs Intelligence, Thief needs Dexterity first.',
},
sorcerer: {
role: 'A spontaneous arcane or occult caster with innate power from a magical bloodline. More flexible casting than prepared classes.',
primaryAbilities: 'Charisma (all spells) · Constitution (concentration and survivability)',
beginner: true,
skillSuggestions: 'Arcana, Intimidation, Deception, Occultism',
abilityTip: 'Charisma is everything. Constitution second — you have light armour and will be targeted. Your bloodline gives you bonus spells automatically.',
},
swashbuckler: {
role: 'A dashing duelist who builds Panache through daring deeds, then spends it on devastating Finishers. Style and substance.',
primaryAbilities: 'Dexterity (attacks, AC) · Charisma (Panache, some class feats)',
beginner: false,
skillSuggestions: 'Acrobatics, Athletics, Deception, Performance',
abilityTip: 'Dexterity for everything combat-related. Charisma for Panache and social moments. Invest in whichever skill triggers your Style.',
},
witch: {
role: 'A patron-bonded spellcaster with powerful hexes and a familiar that delivers your touch spells. Protect your familiar.',
primaryAbilities: 'Intelligence, Wisdom, or Charisma (depends on patron) · Constitution for spellcasting',
beginner: false,
skillSuggestions: 'Arcana, Occultism, Nature, Diplomacy',
abilityTip: 'Your patron determines your casting stat — check your patron carefully. Constitution second to keep casting without disruption.',
},
wizard: {
role: 'A prepared arcane caster with more spells over a career than any other class. Thorough preparation wins battles.',
primaryAbilities: 'Intelligence (all spells, Arcane Thesis) · Constitution (concentration spells)',
beginner: false,
skillSuggestions: 'Arcana, Occultism, Society, History',
abilityTip: 'Intelligence first, always. Constitution second for concentration spells. Dexterity for AC matters early when you have no armour.',
},
animist: {
role: 'A divine/spirit caster who channels apparitions for shifting spell options. Versatile but bookkeeping-heavy.',
primaryAbilities: 'Wisdom (spells, focus) · Constitution (durability)',
beginner: false,
skillSuggestions: 'Religion, Nature, Occultism, Medicine',
},
exemplar: {
role: 'A mythic melee striker who channels divine sparks (Ikons) into legendary feats of arms. Big, cinematic hits.',
primaryAbilities: 'Strength (attacks) · Constitution (durability)',
beginner: false,
skillSuggestions: 'Athletics, Religion, Intimidation, Diplomacy',
},
gunslinger: {
role: 'A firearms and crossbow specialist with devastating first-shot damage and unique reload tactics.',
primaryAbilities: 'Dexterity (attacks, AC) · Perception (initiative, deadly aim)',
beginner: false,
skillSuggestions: 'Crafting, Acrobatics, Stealth, Intimidation',
},
inventor: {
role: 'A gadgeteer who builds an innovation (armour, weapon, or construct) and Overdrives it for risky power spikes.',
primaryAbilities: 'Intelligence (key checks) · Constitution or Dexterity (defence)',
beginner: false,
skillSuggestions: 'Crafting, Arcana, Society, Athletics',
},
kineticist: {
role: 'An elemental blaster who channels kinetic gates for at-will impulses — no spell slots to track. Easy to play, hard to run dry.',
primaryAbilities: 'Constitution (impulse DC, durability)',
beginner: true,
skillSuggestions: 'Nature, Athletics, Acrobatics, Crafting',
},
magus: {
role: 'A spell-and-sword duelist who fuses a spell into a strike with Spellstrike. Bursty; manage your spell slots carefully.',
primaryAbilities: 'Strength or Dexterity (attacks) · Intelligence (spells, DC)',
beginner: false,
skillSuggestions: 'Arcana, Athletics, Acrobatics, Society',
},
psychic: {
role: 'An occult spontaneous caster with amped cantrips and a psyche you unleash for big turns. Few spells, huge cantrips.',
primaryAbilities: 'Charisma or Intelligence (spells, DC) · Constitution (durability)',
beginner: false,
skillSuggestions: 'Occultism, Arcana, Society, Intimidation',
},
summoner: {
role: 'You and your eidolon act as one — a powerful summoned ally you command. Two bodies, one shared action economy.',
primaryAbilities: 'Charisma (spells, eidolon) · Constitution (shared HP matters)',
beginner: false,
skillSuggestions: 'Arcana, Nature, Athletics, Diplomacy',
},
thaumaturge: {
role: 'An occult investigator who exploits monster weaknesses with esoteric implements and a tome of lore. Skill-rich and adaptable.',
primaryAbilities: 'Charisma (key checks, Exploit Vulnerability) · Constitution or Dexterity (defence)',
beginner: false,
skillSuggestions: 'Occultism, Religion, Arcana, Diplomacy',
},
};
export function getClassTip(system: string, classSlug: string): ClassTip | undefined {
const tips = system === 'pf2e' ? TIPS_PF2E : TIPS_5E;
return tips[classSlug.toLowerCase()];
}
/**
* Returns a synergy note if the race's meta string contains ability boosts that
* match the class's key abilities. Both systems' meta formats are handled:
* 5e: "+2 DEX, +1 WIS · 30 ft"
* PF2e: "+Con/+Wis · 10 HP"
*/
export function raceSynergyNote(raceMeta: string, keyAbilities: string[]): string | null {
if (!raceMeta || !keyAbilities.length) return null;
const boosted = new Set<string>();
// Match "+2 DEX" or "+Wis" or "+Con" patterns
for (const m of raceMeta.matchAll(/\+\d*([A-Za-z]{3})/g)) {
const ab = m[1];
if (ab) boosted.add(ab.toLowerCase());
}
const matches = keyAbilities.filter((k) => boosted.has(k.toLowerCase()));
if (!matches.length) return null;
const names: Record<string, string> = {
str: 'Strength', dex: 'Dexterity', con: 'Constitution',
int: 'Intelligence', wis: 'Wisdom', cha: 'Charisma',
};
const label = matches.map((k) => names[k] ?? k).join(' and ');
return `This ancestry boosts ${label} — a natural fit for this class's primary abilities.`;
}
+70
View File
@@ -62,6 +62,8 @@ export interface ReinforceMonster {
export interface ReinforcePlan {
reasoning: string;
add: { name: string; count: number }[];
/** Monsters to take out when the fight is already at/above the target (over-tuned). */
remove?: { name: string; count: number }[];
targetDifficulty: string;
}
@@ -98,6 +100,18 @@ export function suggestReinforcements(
const target = thresholds.find((t) => t.label.toLowerCase() === targetDifficulty.toLowerCase())?.value ?? 0;
if (target <= 0) return null;
// If the fight is already AT or ABOVE the target, it's tuned correctly or
// over-tuned — adding more would only make it harder. Suggest softening it
// instead of reinforcing it.
const currentXp = computeBudget(
system,
partyLevels,
existing.map((m) => ({ cr: m.cr, level: m.level })),
).ratingXp;
if (currentXp >= target) {
return easeReinforcements(system, partyLevels, existing, target, targetDifficulty);
}
const minCountToReach = (rating: number): number | null => {
for (let n = 1; n <= maxAdds; n++) {
const extra = Array.from({ length: n }, () => (system === '5e' ? { cr: rating } : { level: rating }));
@@ -157,3 +171,59 @@ export function suggestReinforcements(
: `Add ${best.count}${more} ${best.name} to push this fight toward ${targetDifficulty}.`;
return { reasoning, add: [{ name: best.name, count: best.count }], targetDifficulty };
}
/**
* The fight already meets or exceeds the target difficulty. Suggest taking out the
* fewest bodies that drops it back UNDER the target so it's no harder than intended.
* Removing creatures also shrinks the 5e encounter multiplier, so the budget is
* recomputed after each removal rather than subtracted linearly. We never empty the
* encounter: if even leaving a single creature stays at/above target, we say so and
* point at swapping for something weaker.
*/
function easeReinforcements(
system: SystemId,
partyLevels: number[],
existing: ReinforceMonster[],
target: number,
targetDifficulty: string,
): ReinforcePlan {
// Strongest first: dropping the heaviest hitters eases the fight with the fewest
// bodies removed. Stable on equal ratings so ties resolve deterministically.
const order = existing
.map((m, i) => ({ m, i, rating: ratingOf(system, m) ?? -Infinity }))
.sort((a, b) => b.rating - a.rating || a.i - b.i);
const removedCounts = new Map<string, number>();
const keep = [...order];
// Take from the front (strongest) but always leave at least one creature behind.
while (keep.length > 1) {
const dropped = keep.shift()!;
const bn = baseName(dropped.m.name);
removedCounts.set(bn, (removedCounts.get(bn) ?? 0) + 1);
const remaining = keep.map((e) => ({ cr: e.m.cr, level: e.m.level }));
if (computeBudget(system, partyLevels, remaining).ratingXp < target) break;
}
const remove = [...removedCounts].map(([name, count]) => ({ name, count }));
const totalRemoved = remove.reduce((s, r) => s + r.count, 0);
if (totalRemoved === 0) {
// Couldn't trim anything (a single over-budget creature). Suggest a swap instead.
const solo = baseName(existing[0]!.name);
return {
reasoning:
`This fight is already at or above ${targetDifficulty}. ` +
`Swap the ${solo} for a weaker creature to soften it.`,
add: [],
targetDifficulty,
};
}
const phrase = remove
.map((r) => `${r.count} ${r.name}`)
.join(' and ');
const reasoning =
`This fight is already at or above ${targetDifficulty}. ` +
`Remove ${phrase} to ease it back toward ${targetDifficulty}.`;
return { reasoning, add: [], remove, targetDifficulty };
}
+30
View File
@@ -44,4 +44,34 @@ describe('balanceSuggestionSchema', () => {
expect(balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: 0 }], targetDifficulty: 'Medium' }).success).toBe(false);
expect(balanceSuggestionSchema.safeParse({ add: [], targetDifficulty: 'Medium' }).success).toBe(false);
});
it('coerces string counts (a common model quirk) instead of rejecting', () => {
const r = balanceSuggestionSchema.safeParse({ reasoning: 'x', add: [{ name: 'Ogre', count: '2' }], targetDifficulty: 'Medium' });
expect(r.success).toBe(true);
if (r.success) expect(r.data.add[0]!.count).toBe(2);
});
it('finds the add array whatever the model names it', () => {
// Exact shapes DeepSeek returned in the wild: "creatures", then "addCreatures".
for (const key of ['creatures', 'addCreatures', 'monsters', 'someInventedKey'] as const) {
const r = balanceSuggestionSchema.safeParse({
[key]: [{ name: 'Goblin', count: 2 }],
reasoning: 'Two goblins push this to Deadly for a lone level 1 paladin.',
targetDifficulty: 'Deadly',
});
expect(r.success, `key="${key}"`).toBe(true);
if (r.success) {
expect(r.data.add).toHaveLength(1);
expect(r.data.add[0]!.name).toBe('Goblin');
expect(r.data.add[0]!.count).toBe(2);
}
}
});
it('does not mistake the remove list for additions', () => {
const r = balanceSuggestionSchema.safeParse({
remove: [{ name: 'Ogre', count: 1 }],
reasoning: 'Drop the ogre to ease this fight.',
targetDifficulty: 'Medium',
});
// No additions array at all → add is required and absent → reject.
expect(r.success).toBe(false);
});
});
+50 -6
View File
@@ -2,15 +2,54 @@ import { z } from 'zod';
import type { Character } from '@/lib/schemas';
import type { CampaignContext, CreatureCandidate } from './context';
export const balanceSuggestionSchema = z.object({
// Counts are coerced (models routinely return numbers as JSON strings, e.g. "2")
// and allow a wider upper bound than the prompt asks for, so a slightly
// over-eager suggestion is accepted rather than rejecting the whole response.
const monsterCount = z.coerce.number().int().min(1).max(12);
/** An array of at least one `{ name, … }` object — the shape of an additions list. */
function looksLikeCreatureList(v: unknown): v is Record<string, unknown>[] {
return Array.isArray(v) && v.length > 0 &&
v.every((x) => !!x && typeof x === 'object' && typeof (x as { name?: unknown }).name === 'string');
}
/**
* Models keep inventing their own name for the additions array DeepSeek has
* returned "creatures", "addCreatures", instead of the expected "add". Rather
* than chase a fixed alias list, find the additions array structurally: a known
* synonym first, else any other top-level array of `{ name, … }` objects that
* isn't the "remove" list. Keeps a correct suggestion from being discarded over a
* key-name mismatch.
*/
function normalizeBalanceShape(raw: unknown): unknown {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw;
const o = { ...(raw as Record<string, unknown>) };
if (Array.isArray(o.add)) return o;
for (const alias of ['creatures', 'monsters', 'additions', 'addCreatures', 'add_creatures', 'toAdd']) {
if (looksLikeCreatureList(o[alias])) { o.add = o[alias]; return o; }
}
for (const [key, value] of Object.entries(o)) {
if (key === 'add' || key === 'remove') continue;
if (looksLikeCreatureList(value)) { o.add = value; return o; }
}
return o;
}
export const balanceSuggestionSchema = z.preprocess(normalizeBalanceShape, z.object({
reasoning: z.string(),
add: z.array(z.object({
name: z.string(),
count: z.number().int().min(1).max(8),
count: monsterCount,
ref: z.string().optional(),
})),
/** Monsters to take out when the fight is over-tuned (already at/above target). */
remove: z.array(z.object({
name: z.string(),
count: monsterCount,
})).optional(),
targetDifficulty: z.string(),
});
}));
export type BalanceSuggestion = z.infer<typeof balanceSuggestionSchema>;
function partyLine(ctx: CampaignContext): string {
@@ -28,9 +67,14 @@ export function buildBalancePrompt(
const system =
`${ctx.systemConstraint}\n\n` +
`You are an encounter-balancing assistant for a ${ctx.systemLabel} game master. ` +
`Recommend which creatures to ADD to the current encounter to bring it to roughly "${current.targetDifficulty}" difficulty. ` +
`Recommend which creatures to add to the current encounter to bring it to roughly "${current.targetDifficulty}" difficulty. ` +
`You MUST choose only from the provided candidate creatures (by exact name). Do not invent creatures or use any from another system. ` +
`Prefer 13 distinct creatures with sensible counts. Keep the reasoning to one or two sentences.`;
`Prefer 13 distinct creatures with sensible counts.\n\n` +
`Respond with a JSON object with EXACTLY these keys:\n` +
` "add": array of objects, each { "name": <exact candidate name>, "count": <integer ≥ 1> }\n` +
` "reasoning": a one or two sentence string\n` +
` "targetDifficulty": the target difficulty as a string\n` +
`Name the array "add" — not "creatures" or "monsters". Counts are plain integers (2, not "2").`;
const candidateList = current.candidates
.map((c) => `- ${c.name} (rating ${c.rating}${c.hp ? `, hp ${c.hp}` : ''}${c.ac ? `, ac ${c.ac}` : ''})`)
@@ -41,7 +85,7 @@ export function buildBalancePrompt(
`Current encounter difficulty: ${current.difficulty}.\n` +
`Target difficulty: ${current.targetDifficulty}.\n\n` +
`Candidate creatures (choose only from these):\n${candidateList}\n\n` +
`Respond with the creatures to add (name + count), a short reasoning, and the targetDifficulty.`;
`Return the JSON object described above (keys: "add", "reasoning", "targetDifficulty").`;
return { system, user };
}
+193
View File
@@ -0,0 +1,193 @@
import { z } from 'zod';
import type { CampaignContext } from './context';
// ---- Session hooks ----
export const sessionHookSchema = z.object({
hooks: z.array(z.object({
title: z.string(),
opening: z.string(),
tension: z.string(),
})).min(3).max(3),
});
export type SessionHook = z.infer<typeof sessionHookSchema>['hooks'][number];
export function buildSessionHookPrompt(ctx: CampaignContext): { system: string; user: string } {
const system =
`${ctx.systemConstraint}\n\n` +
`You are a session-prep assistant for a ${ctx.systemLabel} game master. ` +
`Generate exactly 3 distinct session opening hooks. Each hook needs a short title (3-6 words), ` +
`an opening paragraph (2-3 sentences, suitable to read aloud or adapt at the table), and a one-sentence tension note ` +
`(what makes this session interesting from a dramatic standpoint). ` +
`Ground your hooks in the party's current situation — active quests, recent encounters, NPC threads. ` +
`Vary the tone: one action-forward, one character/social, one mystery or twist.`;
const partyLine = ctx.party.map((p) => `${p.name} (level ${p.level} ${p.className})`).join(', ') || 'the party';
const user =
`Campaign context:\n` +
`Party: ${partyLine}\n` +
`Active quests: ${ctx.questsSummary}\n` +
`Notes/lore: ${ctx.notesSummary}\n` +
(ctx.recentEncounters.length ? `Recent encounters: ${ctx.recentEncounters.slice(0, 3).map((e) => `${e.name} (${e.difficulty})`).join(', ')}\n` : '') +
`\nGenerate 3 opening hooks for the next session.`;
return { system, user };
}
const HOOK_TEMPLATES = [
{
title: 'Into the Unknown',
opening: (q: string) =>
`The road ahead leads deeper into uncertain territory. ${q ? `The party's current task — ${q} — draws them onward, ` : 'There\'s work to be done, '}but rumours along the way hint that things may be more complicated than expected.`,
tension: 'An unexpected complication threatens to derail the party\'s immediate plans.',
},
{
title: 'Old Debts',
opening: () =>
`A face from the past — an ally, a rival, or someone who simply remembers — reappears with news that changes the situation. They\'re not hostile, but they want something only the party can provide.`,
tension: 'What they need may directly conflict with the party\'s current goals.',
},
{
title: 'Crossed Paths',
opening: () =>
`Two separate threads tangle without warning: an urgent job lands at the worst possible moment, or unexpected help arrives from a quarter no one anticipated. The party has to decide quickly which thread to pull.`,
tension: 'The party must prioritise between two competing urgent situations.',
},
];
export function fallbackSessionHooks(ctx: CampaignContext): SessionHook[] {
const quests = ctx.questsSummary !== 'No active quests.'
? ctx.questsSummary.replace(/^\d+ active: /, '').split('; ')
: [];
const firstQuest = quests[0] ?? '';
return HOOK_TEMPLATES.map((t) => ({
title: t.title,
opening: t.opening(firstQuest),
tension: t.tension,
}));
}
// ---- NPC quick-gen ----
export const npcQuickSchema = z.object({
name: z.string(),
role: z.string(),
motivation: z.string(),
secret: z.string(),
hook: z.string(),
});
export type NpcQuick = z.infer<typeof npcQuickSchema>;
export function buildNpcPrompt(
ctx: Pick<CampaignContext, 'systemConstraint' | 'systemLabel' | 'questsSummary' | 'notesSummary'>,
hint?: string,
): { system: string; user: string } {
const system =
`${ctx.systemConstraint}\n\n` +
`You are an NPC generator for a ${ctx.systemLabel} game master. ` +
`Generate one vivid, playable NPC with a name, a role (short phrase: "innkeeper", "mercenary captain", "hedge mage", etc.), ` +
`a motivation (what they want right now — concrete, specific), a secret (one thing they would never willingly reveal), ` +
`and a hook (one sentence: how you introduce or use them at the table). Keep all fields concise — the GM fills in the rest.`;
const user =
`Campaign notes: ${ctx.notesSummary}. Active quests: ${ctx.questsSummary}.` +
(hint ? ` Requested type / context: ${hint}.` : '') +
` Generate one NPC.`;
return { system, user };
}
const NPC_TEMPLATES: NpcQuick[] = [
{
name: 'Aldric Stonehaven',
role: 'Inn owner',
motivation: 'Keep his inn profitable and his guests\' troubles from spilling into his common room',
secret: 'He owes a significant debt to a local thieves\' guild and occasionally passes along information about guests',
hook: 'He nervously wipes the same glass over and over, eyes darting to the door — and quietly asks the party if they\'re looking for work',
},
{
name: 'Sister Vera',
role: 'Temple healer',
motivation: 'Acquire a rare alchemical reagent to complete a cure for a sickness spreading in the lower city',
secret: 'She was once a skilled thief; the temple doesn\'t know, and she prefers it stays that way',
hook: 'She approaches the party after tending to their wounds, asking if they know anything about certain missing reagents',
},
{
name: 'Captain Brenn Ashvale',
role: 'Mercenary captain',
motivation: 'Collect on a long-overdue contract before her company falls apart from unpaid wages',
secret: 'The contract she\'s chasing was supposed to be straightforward — she\'s terrified of what she found in the ruins instead',
hook: 'She\'s drinking alone in the corner, fully armoured and clearly not planning to sleep tonight',
},
{
name: 'Mira of the Crossroads',
role: 'Travelling merchant',
motivation: 'Reach the next city before a rival merchant corners the market on her rare goods',
secret: 'The "rare goods" she\'s carrying were lifted from a noble\'s estate under ambiguous circumstances',
hook: 'She flagged down the party on the road, offering a discount on supplies in exchange for travelling together — she doesn\'t want to be alone',
},
];
export function fallbackNpc(): NpcQuick {
return NPC_TEMPLATES[Math.floor(Math.random() * NPC_TEMPLATES.length)]!;
}
// ---- Quest hook gen ----
export const questHookSchema = z.object({
title: z.string(),
description: z.string(),
reward: z.string(),
objectives: z.array(z.string()).min(1).max(4),
});
export type QuestHook = z.infer<typeof questHookSchema>;
export function buildQuestHookPrompt(ctx: CampaignContext): { system: string; user: string } {
const system =
`${ctx.systemConstraint}\n\n` +
`You are a quest designer for a ${ctx.systemLabel} game master. ` +
`Generate one compelling quest: a title (5 words or fewer), a 23 sentence description for the GM (not a read-aloud — a situation summary), ` +
`a reward line (gold amount, item, or favour — be specific), and 24 concrete objectives as short imperative phrases.` +
`The quest should be completable in one session. Connect it to the existing campaign notes or active quests if possible.`;
const partyAvgLevel = ctx.partyLevels.length
? Math.round(ctx.partyLevels.reduce((a, b) => a + b, 0) / ctx.partyLevels.length)
: 1;
const user =
`Party average level: ${partyAvgLevel}. Active quests: ${ctx.questsSummary}. Notes: ${ctx.notesSummary}.\n` +
`Generate one new side quest appropriate for this party.`;
return { system, user };
}
const QUEST_TEMPLATES: QuestHook[] = [
{
title: 'The Missing Merchant',
description: 'A local merchant departed three days ago for a nearby town and never arrived. His family has received no word and fears the worst. The road is not normally dangerous, which makes the disappearance all the stranger.',
reward: '150 gp and the merchant\'s gratitude (discount on future trade goods)',
objectives: ['Investigate the merchant\'s last known stop', 'Search the road between settlements', 'Return the merchant safely — or discover what happened'],
},
{
title: 'Trouble at the Mill',
description: 'The grain mill that feeds the town has fallen silent. The miller and his apprentices vanished two nights ago. Locals report strange lights and sounds from the millpond after dark — something is in the water.',
reward: '100 gp from the town council and free lodging for a month',
objectives: ['Investigate the mill and surrounding area', 'Discover what is lurking in the millpond', 'Ensure the mill is safe to operate again'],
},
{
title: 'A Stolen Heirloom',
description: 'A noble family\'s ancestral signet ring was taken by a skilled thief during a dinner party. Every guest is a suspect. The family will pay handsomely to recover it quietly — and even more to know who took it.',
reward: '200 gp and a favour from a noble house',
objectives: ['Question the guests and staff', 'Identify the thief through investigation', 'Recover the signet ring'],
},
{
title: 'Old Ruins, New Danger',
description: 'Explorers opened a sealed chamber in nearby ruins and haven\'t returned. Their employer wants to know what happened and whether the contents of the chamber are still accessible. The ruins were sealed for a reason.',
reward: 'A share of whatever the explorers found, plus 100 gp per party member',
objectives: ['Enter the ruins through the explorers\' route', 'Locate the sealed chamber', 'Deal with whatever the explorers disturbed', 'Report back to the employer'],
},
];
export function fallbackQuestHook(): QuestHook {
return QUEST_TEMPLATES[Math.floor(Math.random() * QUEST_TEMPLATES.length)]!;
}
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { characterDefaults, type Character, type Quest } from '@/lib/schemas';
import { questSignals, casterSignals, buildSignals } from './signals';
function pc(over: Partial<Character> = {}): Character {
return {
id: 'pc', campaignId: 'c', system: '5e', kind: 'pc', name: 'Mage', ancestry: '', className: 'Wizard', level: 5,
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
hp: { current: 30, max: 30, temp: 0 }, speed: 30, armorBonus: 0,
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
...over,
};
}
function quest(over: Partial<Quest> = {}): Quest {
return { id: 'q', campaignId: 'c', title: 'Find the relic', status: 'active', description: '', reward: '', objectives: [], createdAt: '', updatedAt: '', ...over };
}
describe('questSignals', () => {
it('offers to complete a quest whose objectives are all done', () => {
const q = quest({ objectives: [{ id: 'o1', text: 'a', done: true }, { id: 'o2', text: 'b', done: true }] });
const [s] = questSignals([q]);
expect(s?.action).toEqual({ type: 'completeQuest', questId: 'q', label: 'Mark complete' });
});
it('stays quiet when an objective is unfinished or there are none', () => {
expect(questSignals([quest({ objectives: [{ id: 'o1', text: 'a', done: false }] })])).toEqual([]);
expect(questSignals([quest({ objectives: [] })])).toEqual([]);
expect(questSignals([quest({ status: 'completed', objectives: [{ id: 'o1', text: 'a', done: true }] })])).toEqual([]);
});
});
describe('casterSignals', () => {
it('flags a caster with all slots expended and offers a short rest', () => {
const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 0 }, { level: 2, max: 2, current: 0 }], spells: [] } });
const sig = casterSignals([c]).find((s) => s.id === 'no-slots-pc');
expect(sig?.action).toEqual({ type: 'shortRest', characterId: 'pc', label: 'Short rest' });
});
it('does not flag when some slots remain', () => {
const c = pc({ spellcasting: { slots: [{ level: 1, max: 3, current: 1 }], spells: [] } });
expect(casterSignals([c]).some((s) => s.id === 'no-slots-pc')).toBe(false);
});
it('warns when concentrating while bloodied', () => {
const c = pc({ hp: { current: 10, max: 30, temp: 0 }, concentration: { spellId: 's', spellName: 'Bless' } });
expect(casterSignals([c]).some((s) => s.id === 'conc-risk-pc')).toBe(true);
});
});
describe('buildSignals', () => {
it('merges detectors, de-dupes, and sorts danger → warn → info', () => {
const downed = pc({ id: 'a', name: 'A', hp: { current: 0, max: 30, temp: 0 } });
const noSlots = pc({ id: 'b', name: 'B', spellcasting: { slots: [{ level: 1, max: 2, current: 0 }], spells: [] } });
const out = buildSignals({ characters: [downed, noSlots], notes: [], quests: [], activeEncounter: undefined });
expect(out.length).toBeGreaterThan(0);
// severity is non-decreasing (danger=0 first)
const ranks = out.map((s) => ({ danger: 0, warn: 1, info: 2 })[s.severity]);
expect(ranks).toEqual([...ranks].sort((x, y) => x - y));
// unique ids
expect(new Set(out.map((s) => s.id)).size).toBe(out.length);
});
});
+92
View File
@@ -0,0 +1,92 @@
import type { Character, Encounter, Note, Quest } from '@/lib/schemas';
import {
resourceSuggestions,
planningSuggestions,
combatSuggestions,
type Suggestion,
} from './advisors';
/**
* The Signals engine: pure detectors over campaign + combat + character state
* that emit actionable, one-click proposals. This is the "buttons, not chatbot"
* layer it recognizes a situation and offers the fix; the user never has to
* formulate a request. It builds on the existing advisor detectors and adds
* kernel-aware ones now that combat/spellcasting state is enforced.
*/
/** A quest whose objectives are all done but is still marked active → offer to complete it. */
export function questSignals(quests: Quest[]): Suggestion[] {
const out: Suggestion[] = [];
for (const q of quests) {
if (q.status !== 'active' || q.objectives.length === 0) continue;
if (q.objectives.every((o) => o.done)) {
out.push({
id: `quest-done-${q.id}`,
category: 'planning',
severity: 'info',
title: `${q.title}” is finished`,
detail: 'Every objective is checked off — mark the quest complete?',
action: { type: 'completeQuest', questId: q.id, label: 'Mark complete' },
});
}
}
return out;
}
/** Spellcasters who have burned through their slots, and concentration-at-risk nudges. */
export function casterSignals(characters: Character[]): Suggestion[] {
const out: Suggestion[] = [];
for (const c of characters) {
if (c.kind !== 'pc' || c.hp.current <= 0) continue;
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
if (slots.length > 0 && slots.every((s) => s.current === 0)) {
out.push({
id: `no-slots-${c.id}`,
category: 'resources',
severity: 'warn',
title: `${c.name} is out of spell slots`,
detail: 'No slots remaining — a rest would restore them.',
action: { type: 'shortRest', characterId: c.id, label: 'Short rest' },
});
}
// Concentration while bloodied is fragile — surface it so the GM keeps it in mind.
if (c.concentration && c.hp.max > 0 && c.hp.current < c.hp.max * 0.5) {
out.push({
id: `conc-risk-${c.id}`,
category: 'combat',
severity: 'info',
title: `${c.name} is concentrating on ${c.concentration.spellName} while bloodied`,
detail: 'Damage will force a Constitution save — watch for it.',
});
}
}
return out;
}
const SEVERITY_RANK: Record<Suggestion['severity'], number> = { danger: 0, warn: 1, info: 2 };
export interface BuildSignalsInput {
characters: Character[];
notes: Note[];
quests: Quest[];
activeEncounter?: Encounter | undefined;
}
/**
* Aggregate every detector into one severity-sorted, de-duplicated list the
* single source the ambient signals UI and the Assistant page both render.
*/
export function buildSignals(input: BuildSignalsInput): Suggestion[] {
const all = [
...combatSuggestions(input.activeEncounter),
...casterSignals(input.characters),
...resourceSuggestions(input.characters),
...questSignals(input.quests),
...planningSuggestions(input.notes, input.quests),
];
const seen = new Set<string>();
const deduped = all.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true)));
return deduped.sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
}
+8
View File
@@ -40,6 +40,14 @@ export function publishCharacter(campaignId: string, character: { id: string; na
export function listCloudCharacters(campaignId: string): Promise<CloudCharInfo[]> {
return req<CloudCharInfo[]>(`/campaigns/${campaignId}/characters`);
}
/** Owner-only: invalidate the old invite code and get a fresh one. */
export function rotateInvite(campaignId: string): Promise<{ inviteCode: string }> {
return req<{ inviteCode: string }>(`/campaigns/${campaignId}/invite`, { method: 'POST' });
}
/** Owner-only: remove a player and their published characters from the campaign. */
export function removeCloudMember(campaignId: string, userId: string): Promise<{ ok: boolean }> {
return req<{ ok: boolean }>(`/campaigns/${campaignId}/members/${userId}`, { method: 'DELETE' });
}
export async function cloudUsageBytes(): Promise<number> {
return (await req<{ bytes: number }>('/usage')).bytes;
}
+23 -5
View File
@@ -8,12 +8,17 @@ import { buildBackup, restoreBackup } from '@/lib/io/backup';
const TOKEN_KEY = 'ttrpg-cloud-token';
const USER_KEY = 'ttrpg-cloud-user';
const SYNCED_KEY = 'ttrpg-cloud-synced-at';
const base = () => `${location.origin}/api`;
export function cloudUsername(): string | null { return localStorage.getItem(USER_KEY); }
function token(): string | null { return localStorage.getItem(TOKEN_KEY); }
function setSession(t: string, username: string): void { localStorage.setItem(TOKEN_KEY, t); localStorage.setItem(USER_KEY, username); }
function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); }
function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); localStorage.removeItem(SYNCED_KEY); }
/** Version token of the cloud copy this device last synced with (for conflict detection). */
export function cloudLastSyncedAt(): number | null { const v = localStorage.getItem(SYNCED_KEY); return v ? Number(v) : null; }
function setLastSyncedAt(n: number): void { localStorage.setItem(SYNCED_KEY, String(n)); }
export class CloudError extends Error {}
@@ -39,15 +44,26 @@ export async function logout(): Promise<void> {
clearSession();
}
/** Upload this device's full backup to the cloud. */
export async function pushBackup(): Promise<number> {
export type PushResult = { ok: true; bytes: number } | { ok: false; conflict: true; savedAt: number };
/**
* Upload this device's full backup. Uses optimistic concurrency: unless `force`,
* it tells the server which version we last synced; if the cloud has moved on
* (another device pushed), the server returns a conflict and we DON'T overwrite
* the caller resolves it (pull the cloud, or force-push to win).
*/
export async function pushBackup(opts?: { force?: boolean }): Promise<PushResult> {
const t = token();
if (!t) throw new CloudError('Not signed in.');
const blob = JSON.stringify(await buildBackup());
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob }) });
const baseSavedAt = opts?.force ? undefined : cloudLastSyncedAt();
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob, baseSavedAt }) });
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
if (res.status === 409) { const d = (await res.json().catch(() => ({}))) as { savedAt?: number }; return { ok: false, conflict: true, savedAt: Number(d.savedAt) || 0 }; }
if (!res.ok) throw new CloudError('Upload failed.');
return blob.length;
const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
return { ok: true, bytes: blob.length };
}
/** Replace this device's data with the cloud copy. Returns false if there's no cloud save yet. */
@@ -58,6 +74,8 @@ export async function pullBackup(): Promise<boolean> {
if (res.status === 404) return false;
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
if (!res.ok) throw new CloudError('Download failed.');
const savedAt = Number(res.headers.get('x-saved-at'));
await restoreBackup(await res.text());
if (Number.isFinite(savedAt) && savedAt > 0) setLastSyncedAt(savedAt);
return true;
}
+21
View File
@@ -161,6 +161,27 @@ describe('HP transitions', () => {
expect(applyDamage(c, Number.NaN).hp.current).toBe(10);
expect(applyHealing(c, -5).hp.current).toBe(10);
});
it('applies typed-damage resistances when defenses are present', () => {
const base = { ...mk('a', 0, 20), damageDefenses: { resist: ['fire'], immune: ['poison'], vulnerable: ['cold'], conditionImmune: [], notes: [], resistFlat: [], weakness: [] } };
expect(applyDamage(base, 10, 'fire').hp.current).toBe(15); // resisted → 5
expect(applyDamage(base, 10, 'poison').hp.current).toBe(20); // immune → 0
expect(applyDamage(base, 10, 'cold').hp.current).toBe(0); // vulnerable → 20
expect(applyDamage(base, 10, 'acid').hp.current).toBe(10); // untracked type → full
expect(applyDamage(base, 10).hp.current).toBe(10); // no type → full (back-compat)
});
it('typeless damage is unchanged when no defenses are set', () => {
expect(applyDamage(mk('a', 0, 10), 4, 'fire').hp.current).toBe(6);
});
it('applies pf2e flat weakness (adds) and resistance (subtracts)', () => {
const wk = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: [], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [], weakness: [{ type: 'fire', amount: 5 }] } };
expect(applyDamage(wk, 10, 'fire').hp.current).toBe(15); // 10 + 5 weakness
const rs = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: [], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [{ type: 'all', amount: 5 }], weakness: [] } };
expect(applyDamage(rs, 8, 'cold').hp.current).toBe(27); // 30 - (8 - 5 'all') = 27
expect(applyDamage(rs, 4, 'cold').hp.current).toBe(30); // 4 - 5 → 0, no change
});
});
describe('endEncounter', () => {
+25 -4
View File
@@ -207,11 +207,32 @@ export function moveCombatant(enc: Encounter, id: string, direction: -1 | 1): En
// ---------------- HP transitions ----------------
/** Apply damage: temporary HP absorbs first, then current (may go negative). */
export function applyDamage(c: Combatant, amount: number): Combatant {
/**
* Apply damage of an optional type. Resistances are consulted first (immune 0,
* resistant halved and rounded down, vulnerable doubled), then temporary HP
* absorbs, then current HP (which may go negative). Passing no `type` reproduces
* the original typeless behaviour exactly.
*/
export function applyDamage(c: Combatant, amount: number, type?: string): Combatant {
if (!Number.isFinite(amount) || amount <= 0) return c;
const absorbed = Math.min(c.hp.temp, amount);
const remaining = amount - absorbed;
let dmg = amount;
const def = c.damageDefenses;
if (type && def) {
if (def.immune.includes(type)) return c; // immune — no change
// 5e: halve / double
if (def.vulnerable.includes(type)) dmg = amount * 2;
else if (def.resist.includes(type)) dmg = Math.floor(amount / 2);
// pf2e: flat weakness adds, then flat resistance subtracts ('all' matches any type)
const wk = def.weakness?.find((w) => w.type === type || w.type === 'all');
if (wk) dmg += wk.amount;
const rs = def.resistFlat?.find((r) => r.type === type || r.type === 'all');
if (rs) dmg = Math.max(0, dmg - rs.amount);
}
if (dmg <= 0) return c; // fully resisted / immune — no change
const absorbed = Math.min(c.hp.temp, dmg);
const remaining = dmg - absorbed;
return {
...c,
hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - remaining },
+2 -2
View File
@@ -95,12 +95,12 @@ export async function loadClasses(system: SystemId): Promise<RulesetClass[]> {
return cache.get(key) as RulesetClass[];
}
export async function loadRaces5e(): Promise<{ slug: string; name: string; desc: string; asi: string; speed: string; traits: string }[]> {
export async function loadRaces5e(): Promise<{ slug: string; name: string; desc: string; asi: string; speed: string; vision: string; traits: string }[]> {
if (!cache.has('races5e')) {
const mod = await import('@/data/srd/races.json');
cache.set('races5e', mod.default as unknown[]);
}
return cache.get('races5e') as { slug: string; name: string; desc: string; asi: string; speed: string; traits: string }[];
return cache.get('races5e') as { slug: string; name: string; desc: string; asi: string; speed: string; vision: string; traits: string }[];
}
export async function loadBackgrounds5e(): Promise<{ slug: string; name: string; desc: string; skills: string; feature: string }[]> {
+2
View File
@@ -57,7 +57,9 @@ export interface Spell {
range?: string;
duration?: string;
concentration?: boolean | string;
requires_concentration?: boolean;
ritual?: boolean | string;
can_be_cast_as_ritual?: boolean;
components?: string;
material?: string;
dnd_class?: string;
+24
View File
@@ -127,6 +127,30 @@ export class TtrpgDatabase extends Dexie {
// v13 — P14b: persisted live-session recap (rolls + chat) per campaign.
this.version(13).stores({ sessionLog: 'id, campaignId, ts' });
// v14 — V2 rules kernel: characters gain a top-level `concentration` slot and
// each spell entry gains snapshotted mechanics (concentration/save/damage).
// All defaulted, so this backfill is belt-and-suspenders (repos re-parse on save).
this.version(14).stores({}).upgrade(async (tx) => {
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
if (c.concentration === undefined) c.concentration = null;
if (c.equippedArmor === undefined) c.equippedArmor = null;
const sc = c.spellcasting as { spells?: Record<string, unknown>[] } | undefined;
for (const s of sc?.spells ?? []) {
if (s.concentration === undefined) s.concentration = false;
if (s.save === undefined) s.save = null;
if (s.damage === undefined) s.damage = [];
}
});
});
// v15 — V2 Phase 7: characters gain a structured `feats` array (was free text
// in notes). Defaulted, so backfill is belt-and-suspenders.
this.version(15).stores({}).upgrade(async (tx) => {
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
if (c.feats === undefined) c.feats = [];
});
});
}
}
+5
View File
@@ -88,6 +88,11 @@ export const charactersRepo = {
return db.characters.where('campaignId').equals(campaignId).toArray();
},
/** All player characters across every campaign (PCs are campaign-agnostic). */
listAllPcs(): Promise<Character[]> {
return db.characters.where('kind').equals('pc').toArray();
},
get(id: string): Promise<Character | undefined> {
return db.characters.get(id);
},
+24
View File
@@ -30,4 +30,28 @@ describe('backup round-trip', () => {
await expect(restoreBackup('{"foo":1}')).rejects.toThrow();
await expect(restoreBackup('not json')).rejects.toThrow();
});
it('backfills new fields on a character from an older backup (no missing-field crash)', async () => {
const c = await campaignsRepo.create({ name: 'Old Save', system: '5e', description: '' });
// Simulate a pre-v15 character row: no feats/concentration/equippedArmor.
const legacy = {
format: 'ttrpg-manager:backup', version: 1,
data: {
campaigns: [{ id: c.id, name: 'Old Save', system: '5e', description: '', createdAt: 't', updatedAt: 't' }],
characters: [{
id: 'old1', campaignId: c.id, system: '5e', kind: 'pc', name: 'Legacy', ancestry: '', className: 'Fighter', level: 3,
abilities: { str: 14, dex: 12, con: 13, int: 10, wis: 11, cha: 9 }, hp: { current: 20, max: 20, temp: 0 },
speed: 30, armorBonus: 0, skillRanks: {}, saveRanks: {}, perceptionRank: 'trained', notes: '', createdAt: 't', updatedAt: 't',
}],
},
};
await clearAllData();
await restoreBackup(JSON.stringify(legacy));
const [restored] = await charactersRepo.listByCampaign(c.id);
expect(restored!.name).toBe('Legacy');
// The fields the sheet reads must exist (default-backfilled), not be undefined.
expect(restored!.feats).toEqual([]);
expect(restored!.concentration).toBeNull();
expect(restored!.spellcasting.spells).toEqual([]);
});
});
+22 -1
View File
@@ -1,4 +1,9 @@
import type { ZodTypeAny } from 'zod';
import { db } from '@/lib/db/db';
import {
campaignSchema, characterSchema, encounterSchema, diceRollSchema,
noteSchema, npcSchema, questSchema, calendarSchema, battleMapSchema, homebrewSchema,
} from '@/lib/schemas';
import { downloadJson } from './file';
const TABLES = [
@@ -6,6 +11,14 @@ const TABLES = [
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew',
] as const;
/** Schema per table, so restored rows are validated + backfilled with defaults
* (a backup from an older app version is missing newer fields, e.g. `feats`). */
const SCHEMA: Record<(typeof TABLES)[number], ZodTypeAny> = {
campaigns: campaignSchema, characters: characterSchema, encounters: encounterSchema,
diceRolls: diceRollSchema, notes: noteSchema, npcs: npcSchema, quests: questSchema,
calendars: calendarSchema, maps: battleMapSchema, homebrew: homebrewSchema,
};
const FORMAT = 'ttrpg-manager:backup';
export class BackupError extends Error {}
@@ -40,7 +53,15 @@ export async function restoreBackup(text: string): Promise<void> {
for (const t of TABLES) {
await db.table(t).clear();
const rows = data[t];
if (Array.isArray(rows) && rows.length) await db.table(t).bulkAdd(rows);
if (!Array.isArray(rows) || !rows.length) continue;
// Validate + backfill defaults so a backup from an older app version (or an
// edited/foreign file) can't land rows missing newer fields and crash the UI.
const valid: unknown[] = [];
for (const row of rows) {
const r = SCHEMA[t].safeParse(row);
if (r.success) valid.push(r.data);
}
if (valid.length) await db.table(t).bulkAdd(valid);
}
});
}
+22
View File
@@ -76,6 +76,28 @@ describe('complete — structured output', () => {
const r = await complete(base, { system: 's', user: 'u', schema });
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
});
it('parses fenced JSON surrounded by prose (DeepSeek-style)', async () => {
const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat' };
mockFetch(() => openaiReply('Here is the encounter:\n```json\n{"name":"Goblin"}\n```\nHope that helps!'));
const r = await complete(cfg, { system: 's', user: 'u', schema });
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
});
it('extracts the first JSON object from surrounding prose without fences', async () => {
mockFetch(() => anthropicReply('Sure! {"name":"Goblin"} — let me know if you need more.'));
const r = await complete(base, { system: 's', user: 'u', schema });
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
});
it('handles braces appearing in trailing prose', async () => {
mockFetch(() => anthropicReply('{"name":"Goblin"}\nUse {placeholders} as needed.'));
const r = await complete(base, { system: 's', user: 'u', schema });
expect(r).toEqual({ ok: true, data: { name: 'Goblin' } });
});
it('tolerates braces inside JSON string values', async () => {
const nested = z.object({ reasoning: z.string(), name: z.string() });
mockFetch(() => anthropicReply('```\n{"reasoning":"add {2} goblins","name":"Goblin"}\n```'));
const r = await complete(base, { system: 's', user: 'u', schema: nested });
expect(r).toEqual({ ok: true, data: { reasoning: 'add {2} goblins', name: 'Goblin' } });
});
it('returns parse error on malformed JSON', async () => {
mockFetch(() => anthropicReply('not json at all'));
const r = await complete(base, { system: 's', user: 'u', schema });
+48 -6
View File
@@ -1,7 +1,10 @@
import type { CompleteOptions, LlmConfig, LlmErrorKind, LlmResult } from './types';
const DEFAULT_TIMEOUT = 30_000;
const DEFAULT_MAX_TOKENS = 1024;
// Generous so reasoning models (e.g. DeepSeek, whose chain-of-thought counts
// against the completion budget) have room to finish thinking AND emit the JSON.
// A small cap truncates them mid-reasoning, leaving an empty `content`.
const DEFAULT_MAX_TOKENS = 4096;
function err<T>(error: LlmErrorKind, message: string): LlmResult<T> {
return { ok: false, error, message };
@@ -15,12 +18,48 @@ function jsonInstruction(): string {
return '\n\nRespond with ONLY a single valid JSON object — no prose, no markdown code fences — matching the requested shape exactly.';
}
/** Best-effort extraction of a JSON object from a model response. */
/** Strip Markdown code fences (```json … ```) from anywhere in the text. */
function stripCodeFences(text: string): string {
// Remove an opening fence (with optional language tag) and its matching closing
// fence, even when surrounded by prose. Falls back to dropping stray fence markers.
const fenced = text.match(/```[ \t]*(?:json|jsonc|json5)?[ \t]*\r?\n([\s\S]*?)```/i);
if (fenced?.[1] !== undefined) return fenced[1];
return text.replace(/```[ \t]*[a-z0-9]*[ \t]*/gi, '');
}
/** Scan for the first complete, brace-balanced JSON object, ignoring braces in strings. */
function extractFirstJsonObject(text: string): string | undefined {
const start = text.indexOf('{');
if (start < 0) return undefined;
let depth = 0;
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return text.slice(start, i + 1);
}
}
return undefined;
}
/**
* Best-effort extraction of a JSON object from a model response. Tolerates Markdown
* code fences and prose around the JSON (as DeepSeek and other models often emit),
* extracting the first brace-balanced object before parsing.
*/
function tryParseJson(text: string): unknown {
const trimmed = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
const candidate = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed;
const unfenced = stripCodeFences(text).trim();
const candidate = extractFirstJsonObject(unfenced) ?? unfenced;
try {
return JSON.parse(candidate);
} catch {
@@ -140,6 +179,9 @@ export async function complete<T>(cfg: LlmConfig, opts: CompleteOptions<T>): Pro
const text = extractText(cfg.provider, json);
if (!wantJson) return { ok: true, text };
if (text.trim() === '') {
return err('parse', 'The model returned an empty response — it likely ran out of output tokens before answering (raise max tokens, or the model spent its whole budget reasoning).');
}
const parsed = tryParseJson(text);
if (parsed === undefined) return err('parse', 'The model did not return valid JSON.');
const result = opts.schema!.safeParse(parsed);
+5 -3
View File
@@ -1,4 +1,4 @@
import type { ZodType } from 'zod';
import type { ZodType, ZodTypeDef } from 'zod';
export type LlmProvider = 'anthropic' | 'openai-compatible';
@@ -34,8 +34,10 @@ export interface CompleteOptions<T = unknown> {
system: string;
/** User message. */
user: string;
/** When provided, structured JSON output is requested and validated against it. */
schema?: ZodType<T>;
/** When provided, structured JSON output is requested and validated against it.
* Input is left open (`any`) so schemas using coerce/preprocess/transform whose
* parsed input type differs from T are accepted; T is pinned to the output. */
schema?: ZodType<T, ZodTypeDef, any>;
/** A short name describing the JSON shape (used by providers that require it). */
schemaName?: string;
signal?: AbortSignal;
+66
View File
@@ -0,0 +1,66 @@
import type { Character, Spellcasting } from '@/lib/schemas/character';
import type { EnforceResult } from './result';
/** Consume one spell slot at `level` (regular slots first, then matching pact slot). */
function consumeSlot(sc: Spellcasting, level: number): { spellcasting: Spellcasting; usedLevel: number } | null {
const slot = sc.slots.find((s) => s.level === level);
if (slot && slot.current > 0) {
return {
spellcasting: { ...sc, slots: sc.slots.map((s) => (s.level === level ? { ...s, current: s.current - 1 } : s)) },
usedLevel: level,
};
}
// Warlock pact magic — a single pooled slot at its own level.
if (sc.pact && sc.pact.level === level && sc.pact.current > 0) {
return {
spellcasting: { ...sc, pact: { ...sc.pact, current: sc.pact.current - 1 } },
usedLevel: level,
};
}
return null;
}
/**
* Cast a spell the character knows, at a chosen slot level ( the spell's own
* level, to allow upcasting). Pure: decrements a slot, sets/replaces
* concentration, and returns the patch + log. Cantrips cost no slot.
*
* Fails (no mutation) when no slot of the requested level is available the UI
* surfaces `reason` and lets the player pick a different level.
*/
export function castSpell(c: Character, spellId: string, atLevel?: number): EnforceResult {
const spell = c.spellcasting.spells.find((s) => s.id === spellId);
if (!spell) return { ok: false, reason: 'That spell is not on this character.' };
const log: string[] = [];
const patch: Partial<Character> = {};
if (spell.level > 0) {
const useLevel = Math.max(atLevel ?? spell.level, spell.level);
const consumed = consumeSlot(c.spellcasting, useLevel);
if (!consumed) return { ok: false, reason: `No level-${useLevel} slot available for ${spell.name}.` };
patch.spellcasting = consumed.spellcasting;
log.push(`Cast ${spell.name} using a level-${consumed.usedLevel} slot.`);
} else {
log.push(`Cast ${spell.name} (cantrip).`);
}
if (spell.concentration) {
if (c.concentration && c.concentration.spellId !== spell.id) {
log.push(`Concentration on ${c.concentration.spellName} ends.`);
}
patch.concentration = { spellId: spell.id, spellName: spell.name };
}
return { ok: true, patch, log };
}
/** Clear active concentration (e.g. the player drops it, or it breaks). */
export function dropConcentration(c: Character): Partial<Character> {
return c.concentration ? { concentration: null } : {};
}
/** 5e Constitution save DC to maintain concentration after taking `damage`. */
export function concentrationDC(damage: number): number {
return Math.max(10, Math.floor(damage / 2));
}
+73
View File
@@ -0,0 +1,73 @@
import type { SystemId } from '@/lib/rules';
import type { AbilityKey, AdvState } from './types';
/**
* The mechanical effect a condition imposes a declarative table, never
* hardcoded `if`s in combat code. `deriveState` (creatureState.ts) folds these
* into a single query the tracker and dice roller ask.
*/
export interface ConditionEffect {
/** sets effective speed to 0 */
speedZero?: boolean;
/** this creature's attack rolls */
attackRolls?: AdvState;
/** attack rolls made AGAINST this creature */
attacksAgainst?: AdvState;
/** disadvantage on ability checks (5e) */
abilityChecks?: boolean;
/** per-ability save penalty (auto-fail dominates disadvantage) */
saves?: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
/** cannot take actions or reactions */
incapacitated?: boolean;
/** pf2e: a numeric status penalty scaling with the condition's value */
statusPenalty?: boolean;
}
const ALL_PHYS: Partial<Record<AbilityKey, 'auto-fail'>> = { str: 'auto-fail', dex: 'auto-fail' };
/** D&D 5e condition mechanics (keys lowercased to match stored names). */
export const CONDITION_EFFECTS_5E: Record<string, ConditionEffect> = {
blinded: { attackRolls: 'disadvantage', attacksAgainst: 'advantage' },
frightened: { attackRolls: 'disadvantage', abilityChecks: true },
grappled: { speedZero: true },
incapacitated: { incapacitated: true },
invisible: { attackRolls: 'advantage', attacksAgainst: 'disadvantage' },
paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
petrified: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
poisoned: { attackRolls: 'disadvantage', abilityChecks: true },
prone: { attackRolls: 'disadvantage', attacksAgainst: 'advantage' },
restrained: { speedZero: true, attackRolls: 'disadvantage', attacksAgainst: 'advantage', saves: { dex: 'disadvantage' } },
stunned: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage', saves: ALL_PHYS },
};
/**
* Pathfinder 2e condition mechanics. PF2e uses flat numeric penalties rather
* than advantage/disadvantage; valued conditions (frightened N, clumsy N, )
* contribute a status penalty aggregated by `deriveState`.
*/
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
blinded: { attacksAgainst: 'advantage' },
clumsy: { statusPenalty: true },
drained: { statusPenalty: true },
dazzled: { attacksAgainst: 'advantage' },
enfeebled: { statusPenalty: true },
fatigued: { statusPenalty: true },
frightened: { statusPenalty: true },
grabbed: { speedZero: true, attacksAgainst: 'advantage' },
immobilized: { speedZero: true },
paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
petrified: { incapacitated: true, speedZero: true },
prone: { attacksAgainst: 'advantage' },
restrained: { speedZero: true, incapacitated: false, attacksAgainst: 'advantage' },
sickened: { statusPenalty: true },
slowed: { statusPenalty: true },
stunned: { incapacitated: true },
stupefied: { statusPenalty: true },
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
'off-guard': { attacksAgainst: 'advantage' },
};
export function conditionEffects(system: SystemId): Record<string, ConditionEffect> {
return system === 'pf2e' ? CONDITION_EFFECTS_PF2E : CONDITION_EFFECTS_5E;
}
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { deriveState } from './creatureState';
import { concentrationDC } from './cast';
import type { Condition } from '@/lib/schemas/common';
const cond = (name: string, value?: number): Condition => (value !== undefined ? { name, value } : { name });
describe('deriveState (5e)', () => {
it('grappled zeroes speed', () => {
const s = deriveState('5e', 30, [cond('Grappled')]);
expect(s.speed).toBe(0);
expect(s.badges).toContain('Speed 0');
});
it('poisoned imposes disadvantage on attacks and ability checks', () => {
const s = deriveState('5e', 30, [cond('Poisoned')]);
expect(s.attackModifier).toBe('disadvantage');
expect(s.abilityCheckDisadvantage).toBe(true);
});
it('restrained: speed 0, disadv attacks, attacked with advantage, dex save disadvantage', () => {
const s = deriveState('5e', 30, [cond('Restrained')]);
expect(s.speed).toBe(0);
expect(s.attackModifier).toBe('disadvantage');
expect(s.attacksAgainstModifier).toBe('advantage');
expect(s.saveModifiers.dex).toBe('disadvantage');
});
it('paralyzed auto-fails str/dex saves and incapacitates', () => {
const s = deriveState('5e', 30, [cond('Paralyzed')]);
expect(s.incapacitated).toBe(true);
expect(s.saveModifiers.str).toBe('auto-fail');
expect(s.saveModifiers.dex).toBe('auto-fail');
});
it('opposing advantage and disadvantage cancel to normal', () => {
// invisible grants advantage to attack; poisoned imposes disadvantage → normal
const s = deriveState('5e', 30, [cond('Invisible'), cond('Poisoned')]);
expect(s.attackModifier).toBe('normal');
});
it('unknown/homebrew condition contributes nothing', () => {
const s = deriveState('5e', 30, [cond('Inspired by bards')]);
expect(s).toMatchObject({ speed: 30, attackModifier: 'normal', incapacitated: false });
expect(s.badges).toEqual([]);
});
});
describe('deriveState (pf2e)', () => {
it('frightened contributes a numeric status penalty equal to its value', () => {
const s = deriveState('pf2e', 25, [cond('Frightened', 2)]);
expect(s.statusPenalty).toBe(2);
expect(s.badges).toContain('2 status');
});
it('status penalties do not stack — worst value wins', () => {
const s = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]);
expect(s.statusPenalty).toBe(3);
});
it('grabbed zeroes speed and grants advantage to attackers', () => {
const s = deriveState('pf2e', 25, [cond('Grabbed')]);
expect(s.speed).toBe(0);
expect(s.attacksAgainstModifier).toBe('advantage');
});
});
describe('concentrationDC', () => {
it('is half the damage, floored, minimum 10', () => {
expect(concentrationDC(8)).toBe(10); // floor(4)=4 → min 10
expect(concentrationDC(22)).toBe(11);
expect(concentrationDC(40)).toBe(20);
});
});
+88
View File
@@ -0,0 +1,88 @@
import type { SystemId } from '@/lib/rules';
import type { Condition } from '@/lib/schemas/common';
import { conditionEffects } from './conditionEffects';
import type { AbilityKey, AdvState } from './types';
export interface MechanicalState {
/** effective speed after conditions (0 if any condition zeroes it) */
speed: number;
/** advantage state on THIS creature's attack rolls (5e) */
attackModifier: AdvState;
/** advantage state on attacks made AGAINST this creature (5e) */
attacksAgainstModifier: AdvState;
/** cannot take actions/reactions */
incapacitated: boolean;
/** per-ability save penalties (auto-fail dominates disadvantage) */
saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
/** disadvantage on ability checks (5e) */
abilityCheckDisadvantage: boolean;
/** pf2e: aggregated numeric status penalty (worst single value, penalties don't stack) */
statusPenalty: number;
/** short human-readable badges for the UI, e.g. "Speed 0", "Disadv. attacks" */
badges: string[];
}
/** Combine two advantage states by the 5e rule: opposing sources cancel to normal. */
function combineAdv(a: AdvState, b: AdvState): AdvState {
if (a === b) return a;
if (a === 'normal') return b;
if (b === 'normal') return a;
return 'normal'; // one advantage + one disadvantage cancel
}
/**
* Fold a creature's conditions into a single mechanical read-model. Pure and
* synchronous combat, the dice roller, and the player view all call this
* (the player view ships the same table and derives effects locally from the
* broadcast condition names, so no derived state crosses the wire).
*
* Unknown / homebrew condition names contribute nothing (they remain visible
* labels), exactly as before.
*/
export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[]): MechanicalState {
const table = conditionEffects(system);
let speedZero = false;
let attackModifier: AdvState = 'normal';
let attacksAgainstModifier: AdvState = 'normal';
let incapacitated = false;
let abilityCheckDisadvantage = false;
let statusPenalty = 0;
const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {};
for (const cond of conditions) {
const eff = table[cond.name.trim().toLowerCase()];
if (!eff) continue;
if (eff.speedZero) speedZero = true;
if (eff.incapacitated) incapacitated = true;
if (eff.abilityChecks) abilityCheckDisadvantage = true;
if (eff.attackRolls) attackModifier = combineAdv(attackModifier, eff.attackRolls);
if (eff.attacksAgainst) attacksAgainstModifier = combineAdv(attacksAgainstModifier, eff.attacksAgainst);
if (eff.saves) {
for (const [ability, level] of Object.entries(eff.saves) as [AbilityKey, 'disadvantage' | 'auto-fail'][]) {
// auto-fail dominates disadvantage
if (level === 'auto-fail' || saveModifiers[ability] === undefined) saveModifiers[ability] = level;
}
}
// pf2e: status penalties don't stack — take the worst single value.
if (eff.statusPenalty && cond.value !== undefined) statusPenalty = Math.max(statusPenalty, cond.value);
}
const badges: string[] = [];
if (speedZero) badges.push('Speed 0');
if (incapacitated) badges.push('Incapacitated');
if (attackModifier === 'disadvantage') badges.push('Disadv. attacks');
if (attackModifier === 'advantage') badges.push('Adv. attacks');
if (attacksAgainstModifier === 'advantage') badges.push('Attacked w/ adv.');
if (statusPenalty > 0) badges.push(`${statusPenalty} status`);
return {
speed: speedZero ? 0 : baseSpeed,
attackModifier,
attacksAgainstModifier,
incapacitated,
saveModifiers,
abilityCheckDisadvantage,
statusPenalty,
badges,
};
}
+48
View File
@@ -0,0 +1,48 @@
import type { Character } from '@/lib/schemas/character';
export interface DeathSaveOutcome {
patch: Partial<Character>;
/** 'pending' = still rolling; 'stable' = 3 successes; 'dead' = 3 failures; 'revived' = nat 20 */
outcome: 'pending' | 'stable' | 'dead' | 'revived';
log: string;
}
/**
* Apply one 5e death saving throw given a raw d20 face. Pure:
* - nat 20 regain 1 HP, clear the death-save clock (back in the fight)
* - nat 1 two failures
* - 10 one success (3 stable)
* - <10 one failure (3 dead)
*/
export function rollDeathSave(c: Character, d20: number): DeathSaveOutcome {
const ds = c.defenses.deathSaves;
if (d20 === 20) {
return {
patch: {
hp: { ...c.hp, current: 1 },
defenses: { ...c.defenses, deathSaves: { successes: 0, failures: 0 } },
},
outcome: 'revived',
log: `${c.name} rolls a natural 20 and regains 1 HP!`,
};
}
let successes = ds.successes;
let failures = ds.failures;
if (d20 === 1) failures = Math.min(3, failures + 2);
else if (d20 >= 10) successes = Math.min(3, successes + 1);
else failures = Math.min(3, failures + 1);
const patch: Partial<Character> = { defenses: { ...c.defenses, deathSaves: { successes, failures } } };
if (failures >= 3) return { patch, outcome: 'dead', log: `${c.name} fails a third death save and dies.` };
if (successes >= 3) return { patch, outcome: 'stable', log: `${c.name} stabilizes.` };
return {
patch,
outcome: 'pending',
log: d20 >= 10
? `${c.name} succeeds a death save (${successes}/3).`
: `${c.name} fails a death save (${failures}/3).`,
};
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { characterDefaults, newSpellEntry, type Character } from '@/lib/schemas';
import { castSpell, dropConcentration } from './cast';
import { spendResource, regainResource } from './resources';
import { rollDeathSave } from './deathSaves';
function mk(over: Partial<Character> = {}): Character {
return {
id: 'pc', campaignId: 'c', system: '5e', kind: 'pc', name: 'Mage', ancestry: '', className: 'Wizard', level: 5,
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
hp: { current: 0, max: 24, temp: 0 }, speed: 30, armorBonus: 0,
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
...characterDefaults(), notes: '', createdAt: '', updatedAt: '',
...over,
};
}
describe('castSpell', () => {
const fireball = newSpellEntry({ id: 's1', name: 'Fireball', level: 3, save: { ability: 'dex', basis: 'half' } });
const haste = newSpellEntry({ id: 's2', name: 'Haste', level: 3, concentration: true });
const bless = newSpellEntry({ id: 's3', name: 'Bless', level: 1, concentration: true });
const firebolt = newSpellEntry({ id: 's4', name: 'Fire Bolt', level: 0 });
const base = mk({
spellcasting: { slots: [{ level: 1, max: 4, current: 4 }, { level: 3, max: 3, current: 2 }], spells: [fireball, haste, bless, firebolt] },
});
it('decrements the slot of the spell level', () => {
const r = castSpell(base, 's1');
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.patch.spellcasting!.slots.find((s) => s.level === 3)!.current).toBe(1);
expect(r.log[0]).toMatch(/level-3 slot/);
});
it('upcasts: casting a level-3 spell with a higher slot consumes that slot', () => {
const withHigh = mk({ spellcasting: { slots: [{ level: 3, max: 1, current: 0 }, { level: 5, max: 1, current: 1 }], spells: [fireball] } });
const r = castSpell(withHigh, 's1', 5);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.patch.spellcasting!.slots.find((s) => s.level === 5)!.current).toBe(0);
});
it('fails when no slot of the requested level remains', () => {
const empty = mk({ spellcasting: { slots: [{ level: 3, max: 2, current: 0 }], spells: [fireball] } });
const r = castSpell(empty, 's1');
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.reason).toMatch(/No level-3 slot/);
});
it('cantrips cost no slot', () => {
const r = castSpell(base, 's4');
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.patch.spellcasting).toBeUndefined();
expect(r.log[0]).toMatch(/cantrip/);
});
it('sets concentration and replaces a prior concentration spell', () => {
const concentrating = mk({
concentration: { spellId: 's3', spellName: 'Bless' },
spellcasting: { slots: [{ level: 3, max: 3, current: 2 }], spells: [haste, bless] },
});
const r = castSpell(concentrating, 's2');
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.patch.concentration).toEqual({ spellId: 's2', spellName: 'Haste' });
expect(r.log.some((l) => /Concentration on Bless ends/.test(l))).toBe(true);
});
it('consumes a warlock pact slot when no regular slot matches', () => {
const warlock = mk({ spellcasting: { slots: [], pact: { level: 3, max: 2, current: 2 }, spells: [haste] } });
const r = castSpell(warlock, 's2', 3);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.patch.spellcasting!.pact!.current).toBe(1);
});
it('dropConcentration clears the slot', () => {
const concentrating = mk({ concentration: { spellId: 's2', spellName: 'Haste' } });
expect(dropConcentration(concentrating)).toEqual({ concentration: null });
expect(dropConcentration(mk())).toEqual({});
});
});
describe('spendResource / regainResource', () => {
const base = mk({ resources: [{ id: 'ki', name: 'Ki', current: 2, max: 5, recovery: 'short' }] });
it('spends and fails when too low', () => {
const ok = spendResource(base, 'ki', 2);
expect(ok.ok).toBe(true);
if (ok.ok) expect(ok.patch.resources![0]!.current).toBe(0);
const bad = spendResource(base, 'ki', 3);
expect(bad.ok).toBe(false);
});
it('regains up to max', () => {
const r = regainResource(base, 'ki', 10);
expect(r.ok).toBe(true);
if (r.ok) expect(r.patch.resources![0]!.current).toBe(5);
});
});
describe('rollDeathSave', () => {
it('nat 20 revives at 1 HP and clears the clock', () => {
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 1, failures: 2 } } }), 20);
expect(r.outcome).toBe('revived');
expect(r.patch.hp!.current).toBe(1);
expect(r.patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
});
it('nat 1 adds two failures → dead at three', () => {
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 0, failures: 1 } } }), 1);
expect(r.outcome).toBe('dead');
});
it('≥10 succeeds; third success stabilizes', () => {
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 2, failures: 0 } } }), 12);
expect(r.outcome).toBe('stable');
});
it('<10 is a single failure, still pending', () => {
const r = rollDeathSave(mk({ defenses: { ...characterDefaults().defenses, deathSaves: { successes: 0, failures: 0 } } }), 7);
expect(r.outcome).toBe('pending');
expect(r.patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 1 });
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* The mechanics kernel: structured, enforceable game rules derived on-demand
* from the already-cached compendium JSON. No new assets are loaded these
* wrap the existing lazy loaders in src/lib/compendium, so the PWA cache config
* is unchanged. Derived mechanics are memoized per process.
*/
import { loadSpells, loadArmor5e, loadMonsters } from '@/lib/compendium';
import { normalizeSpell5e } from './normalize/spell';
import { normalizeArmor5e } from './normalize/armor';
import { normalizeMonsterDefenses } from './normalize/monsterDefenses';
import type { ArmorMechanics, DamageDefenses, SpellMechanics } from './types';
export * from './types';
export type { EnforceResult } from './result';
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
export { normalizeArmor5e } from './normalize/armor';
export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefenses } from './normalize/monsterDefenses';
export { castSpell, dropConcentration, concentrationDC } from './cast';
export { spendResource, regainResource } from './resources';
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect } from './conditionEffects';
export { deriveState, type MechanicalState } from './creatureState';
let spellMechCache: Map<string, SpellMechanics> | null = null;
let armorMechCache: Map<string, ArmorMechanics> | null = null;
/** Mechanics for a single 5e spell by slug (undefined if not in the SRD set). */
export async function spellMechanics5e(slug: string): Promise<SpellMechanics | undefined> {
if (!spellMechCache) {
const raw = await loadSpells();
spellMechCache = new Map();
for (const s of raw) {
const m = normalizeSpell5e(s);
if (m) spellMechCache.set(m.slug, m);
}
}
return spellMechCache.get(slug);
}
/** Mechanics for a single 5e armor by name (case-insensitive). */
export async function armorMechanics5e(name: string): Promise<ArmorMechanics | undefined> {
if (!armorMechCache) {
const raw = await loadArmor5e();
armorMechCache = new Map();
for (const a of raw) {
const m = normalizeArmor5e(a);
if (m) armorMechCache.set(m.name.toLowerCase(), m);
}
}
return armorMechCache.get(name.toLowerCase());
}
/** All 5e armor mechanics (for the "equip" picker). */
export async function allArmorMechanics5e(): Promise<ArmorMechanics[]> {
await armorMechanics5e('');
return [...(armorMechCache?.values() ?? [])];
}
/** Damage/condition defenses for a 5e monster by slug. */
export async function monsterDefenses5e(slug: string): Promise<DamageDefenses | undefined> {
const raw = await loadMonsters();
const m = raw.find((x) => x.slug === slug);
return m ? normalizeMonsterDefenses(m) : undefined;
}
+36
View File
@@ -0,0 +1,36 @@
import type { Armor5e } from '@/lib/compendium/types';
import type { ArmorMechanics } from '../types';
/**
* 5e equipment armor unified mechanics.
*
* The source `ac` is a string ("11 + Dex", "14 + Dex (max 2)", "18", "+2" for a
* shield) and `dexCap` is null (light/unlimited), a number (medium cap), or 0
* (heavy). We trust `dexCap` and parse the leading number out of `ac`.
*/
export function normalizeArmor5e(raw: Armor5e): ArmorMechanics | null {
if (!raw.name) return null;
const type = (raw.type ?? '').toLowerCase();
const isShield = type === 'shield';
const acStr = raw.ac ?? '';
// Shields read "+2"; armor reads "11 + Dex" or "18".
const baseAc = Number(/-?\d+/.exec(acStr)?.[0] ?? (isShield ? 2 : 10));
const category: ArmorMechanics['category'] = isShield
? 'shield'
: type === 'light' ? 'light'
: type === 'medium' ? 'medium'
: type === 'heavy' ? 'heavy'
: 'unarmored';
// Shields don't cap Dex (they're additive); honor the source dexCap otherwise.
const dexCap = isShield ? null : (raw.dexCap ?? null);
return {
name: raw.name,
category,
baseAc,
dexCap,
stealthDisadvantage: !!raw.stealthPenalty,
};
}
@@ -0,0 +1,98 @@
import type { Monster } from '@/lib/compendium/types';
import { asDamageType, type DamageDefenses, type DamageType } from '../types';
/**
* Parse a free-text defense string like "cold, fire" or
* "bludgeoning, piercing, and slashing from nonmagical attacks".
*
* We deliberately only enforce *clean* lists: a clause that carries a qualifier
* ("from nonmagical attacks", "that is silvered", etc.) is dropped into `notes`
* for the GM to read, never auto-applied matching the "don't over-promise
* automation" rule. Returns the clean damage types plus any leftover notes.
*/
function parseDamageList(raw: string | undefined): { types: DamageType[]; notes: string[] } {
const types: DamageType[] = [];
const notes: string[] = [];
const text = (raw ?? '').trim();
if (!text) return { types, notes };
// Split on ';' first (distinct clauses), then commas/"and" within a clause.
for (const clause of text.split(/;/)) {
const c = clause.trim();
if (!c) continue;
// A qualifier ("from", "that aren't", "except") means it's conditional — note it.
if (/\b(from|that|except|unless|nonmagical|magical|silvered|adamantine)\b/i.test(c)) {
notes.push(c);
continue;
}
for (const word of c.split(/,|\band\b/)) {
const t = asDamageType(word);
if (t && !types.includes(t)) types.push(t);
}
}
return { types, notes };
}
function parseConditionList(raw: string | undefined): string[] {
return (raw ?? '')
.split(/,|\band\b|;/)
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
/** Normalize a pf2e damage/condition word ("non-magical attacks", "cold_iron"). */
function pf2eType(s: string): DamageType | undefined {
return asDamageType(s.replace(/_/g, ' ').trim());
}
/** pf2e creature flat-defense shape, ready to attach to a combatant. */
export interface Pf2eDefenses {
immune: DamageType[];
conditionImmune: string[];
resistFlat: { type: string; amount: number }[];
weakness: { type: string; amount: number }[];
notes: string[];
}
/**
* pf2e creature flat resistances/weaknesses + immunities. pf2e uses flat
* amounts (resistance 5, weakness 5) rather than 5e's halve/double, and 'all'
* applies to every type. Immunities mix damage types and conditions.
*/
export function normalizeMonsterDefensesPf2e(raw: {
immunity?: unknown; resistance?: unknown; weakness?: unknown;
}): Pf2eDefenses {
const immune: DamageType[] = [];
const conditionImmune: string[] = [];
for (const i of Array.isArray(raw.immunity) ? (raw.immunity as string[]) : []) {
const t = pf2eType(String(i));
if (t) immune.push(t);
else conditionImmune.push(String(i).trim().toLowerCase());
}
const flat = (obj: unknown): { type: string; amount: number }[] => {
if (!obj || typeof obj !== 'object') return [];
const out: { type: string; amount: number }[] = [];
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
const amount = Number(v);
if (!Number.isFinite(amount) || amount <= 0) continue;
const key = k.toLowerCase() === 'all' ? 'all' : pf2eType(k);
if (key) out.push({ type: key, amount });
}
return out;
};
return { immune, conditionImmune, resistFlat: flat(raw.resistance), weakness: flat(raw.weakness), notes: [] };
}
/** Open5e monster → structured damage/condition defenses. */
export function normalizeMonsterDefenses(raw: Monster): DamageDefenses {
const resist = parseDamageList(raw.damage_resistances);
const immune = parseDamageList(raw.damage_immunities);
const vulnerable = parseDamageList(raw.damage_vulnerabilities);
return {
resist: resist.types,
immune: immune.types,
vulnerable: vulnerable.types,
conditionImmune: parseConditionList(raw.condition_immunities),
notes: [...resist.notes, ...immune.notes, ...vulnerable.notes],
};
}

Some files were not shown because too many files have changed in this diff Show More