60 Commits

Author SHA1 Message Date
NilsBriggen 1ca8bb4482 MPMB-style ability score table: one row per ability, one column per source
Replaces the ability score card grid in both the character sheet and
creation wizard with a proper breakdown table matching MPMB's layout:

- Sheet: "Breakdown" button opens a modal with every source labeled as a
  column (Ancestry, Race, ASI, Level boost, etc.), a Manual override ±
  cell per ability, and Total/Mod columns. Clicking any ability card also
  opens it. Legacy characters without abilityBuild show a single Base column.

- 5e wizard: method buttons (standard/point-buy/roll/manual) remain, but
  the ability grid is replaced by a table — Base column adapts to method
  (select for array/roll, number field for buy/manual), Race and ASI columns
  appear only when relevant, point-buy remaining shown in footer.

- PF2e wizard: replaces dropdown grid with a click-to-assign boost table —
  fixed ancestry boosts shown as +2 chips, free-boost slot columns let you
  click any cell to assign/unassign that slot to that ability (with source
  uniqueness enforced inline).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:31:08 +02:00
NilsBriggen ae676aa86c Fix empty-body JSON error on body-less admin requests
The shared cloud req() helper set content-type: application/json on every
request, so body-less DELETE/revoke/rotate calls tripped Fastify's
"Body cannot be empty" 400. Only send the header when there's a body, and
(defensively) make the server parse an empty application/json body as
undefined instead of erroring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:06:38 +02:00
NilsBriggen ab35b378af Merge: perfection pass + backend security hardening + admin panel 2026-06-10 20:55:58 +02:00
NilsBriggen 5119fc6d1c Proper admin panel at /admin
Replaces the table tucked into Settings with a real instance dashboard,
gated server-side to ADMIN_USERS (frontend nav entry appears for admins only).

Backend (/api/admin/*, admin-token required):
- GET overview: uptime, RSS/heap, data-dir bytes, account count vs MAX_USERS,
  default quota, campaign/character totals, live-room load
- GET users: created date, usage vs effective quota, session count, admin flag
- POST users/:name/revoke — log a user out everywhere
- DELETE users/:name — delete account + backup + owned campaigns + published
  characters (admin accounts are protected from deletion)
- GET/DELETE campaigns — oversight + removal incl. published characters
- GET rooms — players/seats/images/idle per room; join codes never exposed

Frontend: new /admin page (health cards, account management with quota editor +
two-click destructive confirms, campaign table, live-room table), ShieldCheck nav
entry via useIsAdmin(), Settings now links to the panel instead of embedding it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:49:59 +02:00
NilsBriggen 0892b8f19c Backend security hardening for a small public server
Audit-driven; goal: one user can't exhaust the box or starve the shared Traefik stack.

Resource containment:
- compose: mem_limit 512m, memswap_limit, pids_limit 200, cpus 1.0 (blast radius = container)
- Enforce per-user cloud quota (default 30MB, admin override) on /api/save + /api/characters → 413
- Cap published-character size (2MB); cap rooms (200), images/room (40), image bytes/room (40MB)
- Cap WS image dataUrl length in the wire schema; per-IP WS connection cap (20)
- MAX_USERS registration ceiling → 503 when full

Availability + correctness:
- Async crypto.scrypt (was scryptSync) so password hashing can't freeze the event loop;
  constant-time on unknown users to avoid username probing
- trustProxy so req.ip is the real client behind Traefik — rate limits are per-IP, not global
- Tighter auth-endpoint bucket (10/min) on /register + /login; prune stale rate buckets
- O(1) token->user index; log persist() failures instead of swallowing them
- Trim /healthz info; surface quota in /api/usage + the admin dashboard storage bar
- Client surfaces 413 (quota) / 429 (rate) clearly

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:37:14 +02:00
NilsBriggen 8fd530df73 Perfection pass: complete level-up/build flows + dying polish
Second adversarial audit of the freshly-shipped code; fixed bugs + completeness gaps.

- abilityBuild stays in sync with totals on level-up (appendLevelIncreases)
- 5e feat picker in level-up; PF2e feat-name autocomplete; expertise sets ranks
- Higher-level creation collects PF2e skill increases + 5e expertise
- PF2e heritage: schema field, wizard picker, sheet, Pathbuilder import
- 5e Hit Dice as a tracked resource (half-level long-rest recovery via recoverStep)
- Dying flow: knockout sets HP 0, stays unconscious at 0 HP, healing wakes +Wounded,
  Heroic Recovery (spend hero points); damage-while-dying + recovery reminders (pf2e)
- Healing caps at effective max (drained/exhaustion-4) on sheet, tracker, and rest;
  massive-damage death uses effective max; persistent-damage badge
- UX: in-place valued-condition steppers, point-buy budget caps, prepared-vs-known
  spell guidance, granted skills in review, system-gated score generator

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:37:01 +02:00
NilsBriggen 4ee004e25f Merge: bug hunt + 5e/PF2e parity + character-build UX overhaul 2026-06-10 14:14:09 +02:00
NilsBriggen b3914b1dda Bug hunt + 5e/PF2e parity + character-build UX overhaul
Audited every calculation (26-agent adversarial workflow + hand-verification).
Core math was sound; fixed real bugs, closed parity gaps, and overhauled the
character-build UX. 396 -> 424 tests; lint clean; build green.

Correctness fixes:
- Heavy armor no longer applies a negative DEX penalty to AC (3 paths + default)
- 5e Exhaustion 4 (HP-max halved) implemented via deriveEffectiveMaxHp + badge
- PF2e dazzled no longer makes a creature easier to hit
- PF2e immunity 'all' zeroes all damage (was misfiled as a condition immunity)
- Director schema bounds; PF2e spell-save basis from the `basic` flag; 5e
  disadvantage-only save guard; remove bogus Concentrating, add Broken/Quickened
- Fix 3 lint errors (useCloud hooks naming, useless escape, explicit any)

PF2e survival subsystem (parity with 5e death saves):
- mechanics/dying.ts (maxDying/isDead/knockOut/applyRecovery/pf2eRestDecay)
- DefensesSection knock-out + recovery-check + death UI; rest-decay in applyRest;
  drained HP reduction

Character-build UX:
- Persisted abilityBuild (base + per-source contributions); sheet & wizard show
  every ability source; higher-level PF2e gains L5/10/15/20 boosts
- Creation surfaces granted skills (incl. new PF2e background skill parsing)
- LevelUpModal enumerates features gained + prompts every owed choice (subclass,
  feats, fighting style, ...) via collectChoices/collectFeatures/subclassPrompt

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:14:01 +02:00
NilsBriggen cac5979820 D6: AI Director polish + parity hardening
- pf2e parity: a unit test confirms the director carries the system through
  (systemConstraint + prompt say Pathfinder 2e, no cross-system leak) and still
  produces a grounded deterministic turn. (Degree-of-success/conditions already
  branch per system via the shared kernel.)
- UX: the transcript pane is now a bounded, scrollable region (max-h-[60vh]) so
  long sessions don't blow the page layout; it still auto-scrolls to the newest line.
- e2e: e2e/director.spec.ts exercises the full flow against a real browser — DM
  narrates a grounded scene with no API key (deterministic badge, empty-state
  clears, Continue appears), and the player persona seats a party character and
  takes a turn.

396 unit tests + 2 director e2e green; lint clean; build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 03:25:41 +02:00
NilsBriggen ab8956f0cb D5: broadcast the AI Director to players at the table
For live co-GM play, the AI's narration now reaches the players' screens, and its
roll buttons already broadcast their results to the table (via rollAndShow →
broadcastGmRoll).

- When hosting a session (role gm + connected) and "Share narration with players"
  is on, each director narration is pushed over the table chat channel (sendChat)
  so players see the AI DM speak in their session feed. A no-op when not hosting.
- SceneControls gains the share toggle (default on); directorStore persists it.

Reuses the existing, e2e-tested realtime chat primitive — strictly additive, no
protocol/server change. GM stays authoritative. 395 tests green; lint clean;
build OK; page + controls verified rendering on a clean load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 03:21:52 +02:00
NilsBriggen 35e0dc4fc1 D4: token budget — rolling transcript summary keeps long sessions bounded
Director sessions can run for hours; the context would grow without bound. Now a
fixed live window of recent turns is sent each turn, and older turns are folded
into a rolling per-session summary.

- planContext (pure): summary covers [0, summarizedThrough); the live window is
  the rest. Once the window exceeds windowSize + SUMMARIZE_BATCH, the oldest turns
  fold down to the last windowSize — never leaving a gap between summary and window.
- maybeSummarize (after each turn): folds the batch into AiSession.summary via a
  separate complete() call when a key is set, else a deterministic compaction
  (one compact line per turn, capped) — bounded with or without an LLM.
- The summary already rides in the system prompt (it never displaces the grounded
  roster), so the closed-set anti-hallucination anchor is preserved as history scrolls.

Pure windowing + deterministic-summary covered by unit tests. 395 tests green;
lint clean; build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 03:17:45 +02:00
NilsBriggen 2f0700f8d3 D3: AI Player — pilot a real PC, with caster actions
The director can now play a party character, not just DM. A Mode switch
(Dungeon Master / Player) plus an AI-seat picker choose which PC the AI pilots.

- Scene injection: when seated, the controlled PC's real sheet detail is fed to
  the director — attacks with derived to-hit (sys.weaponAttack) + damage
  expressions, spells (level/concentration), resources, and spell slots — so the
  AI player acts from true numbers, in first person.
- Persona-aware prompt + cue already route DM→runs monsters / player→"it is your
  turn"; the seat sets controlledName.
- Caster actions (castSpell/spendResource) flow through the D2 approve-each apply
  bridge unchanged — slot/resource spend is enforced by the kernel, concentration
  mirrors to the combatant.
- UI: SceneControls (mode + seat), seat-gating ("pick a character"), and
  player-flavored buttons (Take turn). directorStore gains controlledCharacterId.

Verified in-app: switching to Player mode, seating "Lia the Brave · Fighter 3",
and taking a turn produced first-person narration. 388 unit tests green (incl. new
player-persona scene/ prompt tests); lint clean; build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 03:13:47 +02:00
NilsBriggen ce9bbc8220 D2: AI Director apply bridge — approve-each, routed through the rules kernel
The director's proposed state changes are now one-click "Apply" actions. Nothing
mutates a Character or Encounter until the human clicks Apply — the AI never
writes game state on its own.

- useDirectorAction (the 3rd anti-hallucination gate): re-resolves every action's
  target against the live roster/encounter and rejects unknowns, then routes
  through the pure kernel + combat engine — applyDamage/applyHealing/setTempHp/
  updateCombatant (damage/heal/tempHp/condition), nextTurn (advanceTurn),
  logEvent (log), and castSpell/spendResource for caster actions. Persists via the
  transactional encountersRepo.mutate / charactersRepo.update.
- No-auto-roll preserved: damaging a concentrating creature SURFACES a
  concentration save (concentrationDC) as a roll button — it is never auto-rolled.
  Massive-damage death is flagged. castSpell mirrors new concentration onto the
  combatant so later saves surface.
- ActionCard gains a working Apply button (idempotent: disables after applying,
  shows the result/skip reason); applied actions append a system transcript entry
  so the next turn sees the new ground truth.

8 integration tests against a real Dexie cover each action→kernel patch, the
concentration-save surfacing, and rejection of off-roster targets. 386 unit tests
green; lint clean; build OK; verified the page renders + runs a turn on a clean load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 03:07:16 +02:00
NilsBriggen 961fe8655a D1: AI Director (MVP) — grounded AI DM narration + no-auto-roll dice buttons
The first user-visible slice of the AI DM / AI Player "director": a new
/director page where the AI narrates the scene, voices NPCs/monsters, and runs
enemies — grounded entirely in the campaign's real party and active encounter,
and degrading to a deterministic narrator when no AI key is set.

Engine (pure, framework-free) in src/lib/assistant/director/:
- schema.ts: DeepSeek-tolerant directorTurnSchema (z.preprocess structural
  repair + z.coerce + .catch() enums + per-element safeParse-drop) → a single
  validated turn { narration, rollRequests[], actions[], suggestions[] }.
- context.ts: buildDirectorScene assembles a CLOSED roster from the active
  encounter (or the party) with deriveState badges — the only entities the
  director may name.
- prompt.ts: persona-aware system prompt (DM/player) leading with the
  systemConstraint; multi-turn message mapping from the transcript.
- engine.ts: runDirectorTurn + sanitizeTurn — the anti-hallucination gate that
  drops any action referencing an off-roster entity (and ungrounded
  addCombatant), mirroring the encounter advisor's candidate filter.
- fallback.ts: deterministic director that still surfaces roll buttons.

Hard rules, enforced:
- NEVER auto-roll: a roll fires only from RollRequestCard's onClick (rollAndShow).
  A unit test asserts the engine layer never imports the roll seam, making
  auto-roll structurally impossible.
- Approve-each: D1 is read-only (actions render as previews; the Apply bridge
  lands in D2). The director never writes game state.
- Always-on fallback: works with no API key.

Also: Dexie v18 `aiSessions` table + aiSessionsRepo (+ cascade delete), a small
directorStore, a Settings card, and the /director route + nav entry.

Verified in-app on the sample 5e campaign: grounded narration named the real
current combatant; on an enemy's turn a "Aller Rosk attack vs Pip Underbough"
roll card appeared with diceRolls UNCHANGED until the button was clicked, which
then fired exactly one roll and recorded the result back into the transcript.
381 unit tests green; lint clean (3 pre-existing); build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 02:55:42 +02:00
NilsBriggen 375e22ad6f D0: multi-turn complete() — optional messages[] for the director engine
Add an optional `messages` array to CompleteOptions so the upcoming AI DM /
AI Player "director" can carry a multi-turn transcript. A single `turns()`
helper feeds both the Anthropic and OpenAI request builders:

- No `messages` → byte-identical to today's single `[{role:'user',...}]` body,
  so all existing single-shot callers are untouched.
- With `messages` → enforces the shared provider invariants (first turn must be
  `user`; a trailing `assistant` prefill gets a "Continue." user turn appended,
  which Opus 4.6+ otherwise 400s on).

Regression tests cover both providers, the byte-identical no-messages path, and
the alternation guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 02:37:04 +02:00
NilsBriggen 4ce7a90534 Deepen feature engine: full 5e subclass depth + PF2e parity
No-shortcuts pass on the class feature/choice engine, and PF2e brought level with 5e.

5e subclass depth (subclass.5e.ts):
- SUBCLASS_PROGRESSION_5E: per-level features for ~22 subclasses (SRD cores + popular),
  and subclass-specific CHOICES — Battle Master maneuvers (3/5/7/9 by level), Arcane Archer
  shots, Rune Knight runes, Way of Four Elements disciplines, Hunter's selectable features
  (Hunter's Prey/Defensive Tactics/Multiattack/Superior Defense), Totem Spirit, Dragon
  Ancestor. collectFeatures5e now expands the chosen subclass into its real features.

PF2e feature/choice engine (data.pf2e.ts) — full parity:
- CLASS_PROGRESSION_PF2E: signature features by level for all 24 classes.
- Universal choices generated for every class: Class feats (per-class levels incl. the
  1st-level martial feat), Skill feats (even levels), General feats (3/7/11/15/19),
  Ancestry feats (1/5/9/13/17), plus the 1st-level subclass (Instinct/Doctrine/Bloodline/
  Racket/…) from PF2E_SUBCLASSES with options.

Engine generalized: collectChoices(system)/collectFeatures(system) dispatch 5e vs pf2e;
ClassFeaturesSection now renders for BOTH systems (pf2e subclass resolves via choices[]).

Verified in-app: PF2e Barbarian 5 → Instinct (6 options) + Class(3)/Skill(2)/General(1)/
Ancestry(2) feats = '9 to choose' + Rage/Deny Advantage; selecting Dragon Instinct resolves
and shows as a feature. 5e Fighter+Battle Master → Maneuvers (choose 3) + Combat Superiority.
352 tests green (+ pf2e & subclass suites), build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 02:11:26 +02:00
NilsBriggen 82a2aef42f Phase 7: class feature & choice engine — every unlockable option is surfaced
A character's class choices were never prompted (the user's example: a Warlock must
choose a Pact Boon + Eldritch Invocations). Now:
- CLASS_PROGRESSION_5E authors all 12 classes' features by level AND every decision point:
  Fighting Style, Pact Boon, Eldritch Invocations (count grows by level), Metamagic,
  Expertise, Favored Enemy/Terrain, with full option lists.
- collectChoices5e / collectFeatures5e compute, across a (multiclass) character's classes,
  exactly which choices are unlocked (and how many to pick at the current level) and which
  features are gained. Subclass selection (classes editor) and ASI/feat (builder/level-up)
  are handled elsewhere and excluded. Unit-tested incl. the Warlock pact example.
- New 'Class Features & Choices' sheet section lists features by level and presents a picker
  per unlocked choice, with a 'N to choose' badge so nothing is missed. Resolved choices
  persist (character.choices, v17 migration).

Verified in-app: Fighter 3 / Warlock 5 surfaces Fighting Style (1), Pact Boon (1), Eldritch
Invocations (3) + Pact Magic feature; selecting one drops the pending count. 346 tests green
(7 new), build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:51:23 +02:00
NilsBriggen ce6e2f430f Phase 4 (part 2): higher-level ASIs in the builder
Building a character above level 1 now grants its Ability Score Improvements. The wizard
computes the ASI count for the input level + class (4/8/12/16/19, plus Fighter's 6/14 and
Rogue's 10) and shows an allocation panel (+1 each, capped at 20, can't over-spend, gated
on Next). Folds into the final scores alongside race bonuses; the ability card shows the
'base · +race · +ASI' breakdown.

Verified: a level-8 Fighter correctly offers 3 ASIs (levels 4, 6, 8 = 6 points). Build OK,
339 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:36:39 +02:00
NilsBriggen f8ad8cb681 Phase 4 (part 1): backed feat & weapon pickers
- FeatPickerModal: browse the 104-feat compendium (PHB/XGtE/TCE) and add a real feat with
  its source + description, instead of typing a name. Wired into FeatsSection (5e).
- WeaponPickerModal: pick a 5e weapon and get a ready-to-roll attack with the correct
  damage dice/type and a sensible ability default (ranged/finesse → DEX, else STR).
  Wired into AttacksSection (5e).

Directly addresses 'feats are missing' and 'selectable weapons are missing'. Verified
in-app (Rapier→finesse attack; Great Weapon Master + XGtE/TCE feats listed). 339 tests
green, build OK. (Subclass/background mechanical effects + higher-level auto-build are the
remaining Phase 4 depth.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:31:27 +02:00
NilsBriggen 4534865e74 Phase 3: full multiclass model + automatic spell slots (5e)
- Character gains a classes[] array (per-class levels + subclass); className/level become
  derived mirrors (primaryClass/totalLevel/normalizeClassMirror helpers). Dexie v16
  migration backfills classes[] from className/level and pulls Subclass:/Background: out
  of the notes free-text (whole-line, keeping the rest).
- Correct 5e multiclass derivation (unit-tested): dnd5eMulticlassHp (first character level
  = max die, rest avg+CON), dnd5eMulticlassSpellLevel, dnd5eClassesSlots (one caster uses
  its own table; two+ use the PHB multiclass table; warlock pact stays separate).
- Sheet ClassesEditor (5e): add/remove classes, set subclass/level; editing auto-recomputes
  the level/class mirrors and reconciles spell-slot maxes (preserving spent slots) — so
  spell slots are now calculated automatically. A 'Recalc HP' button (never clobbers
  hand-rolled HP silently).
- LevelUpModal is class-aware: pick which class to advance or multiclass into a new one;
  applies to classes[], re-derives mirrors + combined slots. pf2e path unchanged.
- Wizard writes classes[] on creation; subclass no longer stored in notes.

Verified in-app: existing char migrated to classes[]; Fighter 3 -> +Wizard 5 = total 8,
spell slots auto-appear at 4/3/2, reconcile preserves spent, removal restores. 339 tests
green (10 multiclass), build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:24:02 +02:00
NilsBriggen 7775851bbe Phase 2: wire MPMB spell data in (XGtE/TCE + more sources)
The app loaded SRD-only spells; the parsed MPMB spell file was shallow (names only) and
unused. Now:
- parse_mpmb.py extracts the rich spell fields (level, school, casting time, range,
  components, duration, save, ritual, classes) and writes to src/data/srd.
- New compendium/mpmb.ts normalizes MPMB spells to the Open5e Spell shape (school/time
  expansion, source-code → label) — pure, unit-tested.
- loadSpells merges 204 non-SRD MPMB spells (95 XGtE, 21 TCE, + Eberron/Theros/Strixhaven/
  Fizban's/etc.) into the 321 SRD spells (525 total), deduped by name.
- Spell type gains a  field; the compendium spell list shows it ('Cantrip · XGtE')
  and adds a Source filter.

Verified in-app (Toll the Dead → 'Cantrip · XGtE'). 333 tests green, build OK.
Only mpmb-spells.json regenerated; feats/others untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 01:10:30 +02:00
NilsBriggen 11a9e87100 Phase 1: builder/UX quick wins (5e wording, no-campaign, details, ability UI)
- 5e skill/save proficiency shows 'Not proficient/Proficient/Expertise' instead of the
  PF2e rank names (Untrained/Trained/Expert); PF2e keeps its ranks. (shared rankLabel)
- Characters can be created without a campaign: campaignId is optional ('' = unassigned),
  CharactersPage no longer requires an active campaign, the wizard's campaign prop is
  optional, and the sheet has a Campaign selector to attach/move later.
- New character details: appearance, personality/behaviour, alignment, and a real
  background field (promoted out of notes); a Details wizard step + a Details sheet card.
- Ability step shows a clearer total + 'base · +race' breakdown.
- Point-buy verified correct (8:0..15:9 / 27, clamped 8-15, Next gated on budget).

All schema additions default-safe (characterDefaults updated). 329 tests green, build OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 00:56:26 +02:00
NilsBriggen f1e15d4011 5e builder: add the full set of official PHB/XGE/TCE subclasses
Fills DND5E_SUBCLASSES with the official archetypes missing from classes.json across all
12 classes (Battle Master, Hexblade, Assassin, the 8 wizard schools, all cleric domains,
etc.) — ~90 entries, merged + deduped by name ahead of the on-disk SRD+homebrew list.
Selectable options only; feature mechanics remain a separate task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:38:41 +02:00
NilsBriggen 86cc3191ca 5e builder: add curated official subclasses (start with Rogue Mastermind)
classes.json ships the SRD subclass + third-party homebrew but is missing most official
PHB/XGE archetypes. New DND5E_SUBCLASSES supplement is merged (deduped by name) ahead of
the on-disk list in the builder, surviving any classes.json regeneration. First entry:
Rogue → Mastermind. (Subclass feature mechanics remain a separate task; this adds the
selectable options.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:34:49 +02:00
NilsBriggen ba26a9d41b chore: stop tracking the transient .claude/scheduled_tasks.lock 2026-06-09 22:02:34 +02:00
NilsBriggen e7a588904d docs: mark reconnect/concentration/level fixes resolved; all CRITICAL+HIGH cleared 2026-06-09 19:47:31 +02:00
NilsBriggen db829ff1a6 Fix CRITICAL player seat loss on WebSocket reconnect (stable playerId + seat grace)
A player who dropped briefly got a fresh server playerId on auto-reconnect and silently
lost their seat: the UI still showed 'granted' but every HP/condition/spell patch and dice
roll was rejected server-side. Now:

- The client persists a stable playerId (localStorage) and sends it on join.
- The server reuses that id when it isn't already live on another connection (so a second
  tab/peer can't hijack an active seat), and on reconnect re-binds the seat to the new
  socket and re-sends seatGranted — control is restored seamlessly.
- Seats survive a disconnect for a 5-minute grace window (marked, not deleted); sweep()
  evicts seats whose holder never returns.

Adds 3 server tests (reconnect reclaim, grace eviction, anti-hijack). 4 files: join
message gains optional playerId; server dispatch, RoomHub, and wsSync client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:46:58 +02:00
NilsBriggen 80c5d2a828 Sheet: clarify that editing Level inline doesn't recompute HP/slots (use Level Up)
The inline Level field silently changed only proficiency-derived stats, leaving HP and
spell slots at the old level with no signal. Adds a hint pointing to the Level Up flow,
which applies HP/slots correctly. (Auto-recompute is intentionally avoided since HP/slots
are often hand-tuned.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:41:38 +02:00
NilsBriggen b0309c245b Combat: GM concentration flag on combatants so the save prompt actually fires
The post-damage concentration save was keyed only off Character.concentration, which is
set just when a seated player casts via their own panel — so a GM running a monster/NPC
(or any unseated PC) never got the reminder, defeating the feature. Combatants now carry
an optional concentrating flag with a one-click toggle in the row; noteConcentrationCheck
keys off it (falling back to the linked Character). Verified in-app: toggle + 10 damage →
'roll a DC 10 Constitution save or lose concentration'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:40:16 +02:00
NilsBriggen b491d57949 docs: mark PF2e boost-based ability generation as resolved 2026-06-09 19:08:39 +02:00
NilsBriggen 9c53f74124 PF2e builder: boost-based ability generation (replaces incorrect 5e arrays)
PF2e characters were generated with the 5e method (standard array / 27-pt point-buy /
4d6) and never applied ancestry/background/class attribute boosts, so every derived stat
was wrong. The PF2e ability step is now boost-based:

- Every score starts at 10; ancestry fixed boosts + flaw, ancestry free boost(s),
  background (choose-one + free), class key ability, and 4 free boosts are applied with
  the +2-below-18 / +1 rule (pf2eApplyBoosts).
- Boost data parsed from the bundled ancestry/background fields (parseAncestryBoosts,
  parseBackgroundBoosts, parseFlaw). Defaults are a legal, class-favoring assignment;
  same-source duplicate boosts are disabled in the pickers (PF2e doesn't allow them).
- Live ability cards show the computed scores. 5e keeps array/point-buy/manual.

Verified in-app (Dwarf Alchemist Acolyte → STR 14/DEX 12/CON 14/INT 18/WIS 12/CHA 8,
flaw + fixed boosts correct). Pure core has 8 unit tests. 5e path unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:08:02 +02:00
NilsBriggen f7e1c43612 Clear lint warnings from the audit branch (deps + extract NotFound)
- CreationWizard: add sys.skills to the loader effect's deps (stable singleton).
- Extract NotFound to src/app/NotFound.tsx so router.tsx no longer trips
  react-refresh/only-export-components.

Branch is now warning-clean (the 3 remaining eslint errors are pre-existing on master,
in files this branch does not touch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:50:27 +02:00
NilsBriggen 4681119424 docs: record audit resolution status on the fix branch
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:46:42 +02:00
NilsBriggen bd78c87fb3 NumberField draft buffer; surface massive-damage / death-at-0 rules in combat
- NumberField keeps a focus-time string draft so min-bounded fields (point-buy, HP,
  quantities) no longer snap to the minimum on every keystroke while retyping; the
  committed value is still always a clamped integer.
- Combat tracker now logs the 5e death rules when a downed PC takes damage: instant
  death from massive damage (leftover >= max HP), otherwise a reminder to mark a death
  save failure (two on a crit). Surfaced, not auto-applied — the player owns their
  character's death-save count (consistent with the no-auto-roll design). New tested
  isMassiveDamageDeath() engine helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:41:07 +02:00
NilsBriggen f2e4259c84 UX safety: confirm destructive deletes, scope dice clear, back up sessionLog, add 404
- New themed useConfirm() hook (no native dialogs). World deletes (notes, quests,
  homebrew, maps) now confirm before removing.
- Dice 'Clear history' is disabled with no active campaign (it used to wipe EVERY
  campaign's rolls) and now confirms; only ever clears the active campaign.
- sessionLog is included in backup/restore and the campaign cascade-delete — it was
  silently dropped on backup and orphaned on campaign delete.
- Unknown URLs render a themed 'Page not found' with a link home instead of a blank shell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:37:52 +02:00
NilsBriggen 3e39e44a8a Add spell slot-level picker: warlocks can cast, any caster can upcast
Both cast UIs (SpellcastingSection + MyCharacterPanel) called castSpell without a level,
so it always tried the spell's base level — a warlock (only a pact slot at the pact
level) could never cast, and upcasting was impossible. New availableSlotLevels() lists
the castable levels (regular slots ≥ spell level with a charge, plus the pact level); the
UI shows a level selector and passes the chosen level. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:30:01 +02:00
NilsBriggen ea60b16385 Fix monster statblock display + PF2e dedup + Drow ASI data
- MonsterDetail now renders all movement modes (fly/swim/climb/burrow were dropped for
  174 monsters), plus Saving Throws and Skills lines (present in data, never shown).
- Passive Perception is computed from the creature's own WIS/Perception so it can't
  disagree with its stats (fixes the 7 SRD monsters with a wrong passive, e.g. Blink
  Dog 10->13, Bone/Chain Devil). Also surfaces damage vulnerabilities.
- loadPf2e dedupes entries by name (Remaster + legacy duplicates), so the compendium
  shows one 'Longsword'/'Goblin Warrior' instead of two or three.
- races.json: Drow ASI corrected to +2 DEX / +1 CHA (was a non-SRD +2 INT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:27:18 +02:00
NilsBriggen 4d354571a5 Builder now applies race/background mechanics; fix skill data + pf2e template softlock
The wizard collected race/background/skill choices but applied almost none of them.
Now (5e):
- Racial ASI is parsed and folded into the final ability scores (Human +1 each, etc.);
  every downstream stat (HP, AC, attacks, saves, DCs) reflects it. Shown on the
  Abilities step and Review.
- Background fixed skills and racial 'proficiency in the X skill' traits are granted
  as trained, on top of class picks.
- Skill step prefers the curated class table: canonical 5e skill lists (fixes the
  'and Survival'/'Animal Handling' tokenization that dropped skills) and the correct
  pf2e free-choice skill count (was inflated for ~13 classes).

UX:
- 'Ready-made hero' templates no longer softlock the Abilities step: scores above the
  point-buy cap switch to a new Manual entry mode (1-30) instead of '-Infinity / 27'.
- Standard-array/roll assignment swaps values on collision instead of disabling them.

Parsers extracted to builder/origin.ts with unit tests; buildCharacter gains
grantedSkills. Deferred: pf2e ancestry/background/class attribute boosts (needs the
boost-based generator), subclass/class-feature/feat mechanical effects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:24:41 +02:00
NilsBriggen 35728fab3d Fix spell data normalization: Wish backlash, Magic Missile dice, multi-save spells
- parseDamage skips self-inflicted backlash ('you take …'), so Wish no longer
  snapshots a phantom 1d10 necrotic onto the spell.
- parseDamage tolerates a flat modifier on the dice, so Magic Missile captures
  '1d4+1 force' instead of nothing.
- parseSave scores candidate saves and prefers the operative one (introduced by
  'must succeed on a' / 'makes a') over incidental 'disadvantage on X saving throws'
  mentions — Irresistible Dance now resolves to Wis, not Dex.

Adds 3 regression tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:17:47 +02:00
NilsBriggen f602c832e6 Fix condition mechanics: per-statistic PF2e penalties, 5e exhaustion, slowed, frightened decay
- PF2e clumsy/enfeebled/drained/stupefied now penalise their correct, independent
  statistic family (Dex/Str/Con/mental) instead of collapsing into one max'd '−N status'.
  Frightened/sickened are 'all checks'. Distinct badges per family.
- Slowed is action loss only (no roll penalty); fatigued shows flat −1 AC/saves —
  neither is value-scaled anymore.
- 5e Exhaustion now derives its cumulative level effects (ability-check disadvantage L1,
  speed halved L2, attack + all-save disadvantage L3, speed 0 L5, dead L6).
- 5e Prone badge is range-qualified (adv. melee / disadv. ranged).
- New tickConditionsEndOfTurn: PF2e Frightened decays by 1 at end of turn (wired into
  nextTurn via the combat tracker), removed at 0.

MechanicalState.statusPenalty (single number) → statusPenalties (per-family map).
Tests updated + added. Deferred: Drained HP/max-HP reduction (needs creature level
threaded into the damage flow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:16:33 +02:00
NilsBriggen da3dbbedba Fix PF2e rules math: AC proficiency, weapon damage, MAP, striking, slots, Refocus
Confirmed accuracy defects from the audit:
- AC now scales with level + armor proficiency (10 + Dex + (level+rank) + item),
  defaulting to trained; was 10 + Dex only, wrong by +3..+28.
- Weapon potency (itemBonus) applies to the attack roll ONLY, not flat damage.
- weaponAttack now reports the Multiple Attack Penalty (-5/-10, agile -4/-8) and
  expands striking runes into extra weapon dice; AttacksSection surfaces MAP.
- Full-caster spell slots: 2 at the rank's unlock level, 3 thereafter (was a flat 3,
  giving a level-1 caster 3 first-rank slots instead of 2).
- PF2e Refocus restores one Focus Point (recoverStep), not the whole pool.

Adds 6 regression tests. types.ts gains WeaponInput.agile/striking, WeaponResult.map,
CharacterRulesInput.acRank, RestOption.recoverStep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:11:00 +02:00
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
194 changed files with 16129 additions and 885 deletions
+3
View File
@@ -11,3 +11,6 @@ test-results
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
# Transient harness lock (not part of the project)
.claude/scheduled_tasks.lock
+10
View File
@@ -17,10 +17,20 @@ services:
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev - ALLOWED_ORIGINS=https://ttrpg.briggen.dev
# comma-separated usernames that can see the admin panel (set to your account) # comma-separated usernames that can see the admin panel (set to your account)
- ADMIN_USERS=nilsb - ADMIN_USERS=nilsb
# default per-user cloud storage cap (bytes); admins override per user in the dashboard
- DEFAULT_QUOTA_BYTES=31457280 # 30 MB
# hard ceiling on accounts so open registration can't fill the disk
- MAX_USERS=500
volumes: volumes:
- ttrpg-data:/data - ttrpg-data:/data
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
# Hard caps so a runaway session can't starve the host or the shared Traefik
# stack — the blast radius stays this container, then `restart: unless-stopped`.
mem_limit: 512m
memswap_limit: 512m
pids_limit: 200
cpus: 1.0
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)" - "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)"
+849
View File
@@ -0,0 +1,849 @@
# TTRPG Manager — Full Code Audit
_Generated 2026-06-09 from an automated multi-agent audit (20 specialist analyzers; every calculation/data-accuracy claim independently re-derived from canonical 5e/PF2e rules by an adversarial verifier). This file is a work product — safe to delete or move._
**Tally:** 99 findings — 38 accuracy/data defects confirmed, 2 refuted (excluded below), 59 usability/feature findings (not independently verified, lower-confidence by nature).
Severity shown as `severity` or `analyst→verifier-corrected` where the independent check adjusted it.
---
## Resolution status (branch `fix/audit-2026-06-09`)
Most confirmed accuracy/data defects and the high-impact UX traps are **fixed** on this branch (8 commits, +1716/125, full test suite green, production build passes). Highlights of what landed:
- **PF2e math:** AC level+proficiency scaling, potency-not-on-damage, Multiple Attack Penalty, striking runes, level-1 slot counts, Refocus = 1 point.
- **Conditions:** per-statistic PF2e penalties, 5e Exhaustion levels, Slowed/Fatigued correctness, Frightened end-of-turn decay.
- **Spell data:** Wish backlash excluded, Magic Missile dice, multi-save spells.
- **Builder:** racial ASI + background/trait skill proficiencies applied; canonical skill lists/counts; **PF2e template softlock fixed** (verified in-app); manual ability mode; array-swap. **PF2e boost-based ability generation** (ancestry/background/class/free boosts + flaw, replacing the wrong 5e arrays) — verified in-app.
- **Monsters:** all speeds + saves/skills rendered; passive Perception computed (fixes the 7 wrong ones).
- **Spellcasting:** slot-level picker (warlocks can cast; upcasting works). PF2e compendium de-duplicated. Drow ASI corrected.
- **Safety:** confirm-before-delete across world content; dice clear scoped to active campaign; sessionLog in backup + cascade; 404 route; NumberField no longer snaps to min while typing; massive-damage / death-at-0 reminders in combat.
- **Live play:** **player WebSocket-reconnect** now keeps the seat (stable client playerId + 5-min server grace + anti-hijack) so a brief drop no longer silently swallows HP edits/rolls; **GM concentration flag** on combatants so the post-damage save prompt actually fires for monsters/NPCs (verified in-app); inline Level edits now warn they don't recompute HP/slots.
All CRITICAL and HIGH confirmed defects are now addressed. **Remaining deferred** (medium severity or net-new features): subclass/class-feature/feat mechanical effects; PF2e Drained HP reduction; delay/ready combat action; net-new features (D&D Beyond import, loot builder, XP tracking, real multiclass model). The `--app-faint` contrast tweak was left to the designer.
## 🎲 Calculation accuracy (23)
#### [MEDIUM→HIGH] PF2e AC omits the level-scaling armor proficiency bonus
*system: pf2e · dimension: calc-pf2e-core · _verified: confirmed_*
- **Where:** `src/lib/rules/pf2e/index.ts:78-87`
- **Issue:** baseArmorClass for PF2e computes AC as baseAc + Dex(capped) + misc (with armor) or 10 + Dex + misc (unarmored). It never adds the character's armor/defense proficiency, which in PF2e contributes (level + rank bonus) to AC and scales every level. The comment on line 85 admits proficiency is 'folded into armorBonus for the MVP sheet', i.e. the user must manually enter it; nothing auto-computes or auto-fills it (CreationWizard.tsx:498 even passes armorBonus: 0). So a defended character's AC is wrong by the entire proficiency amount by default.
- **Expected:** PF2e AC = 10 + Dex(capped by armor) + proficiency(level + rank bonus) + item bonus. Worked example: a level-5 fighter trained (rank +2) in their armor, Dex +1 (cap permitting), unarmored: AC = 10 + 1 + (5 + 2) + 0 = 18. Current code returns 10 + 1 + 0 = 11 unless the GM manually types +7 into armorBonus. Unlike to-hit, saves, and skills (which all auto-scale), AC silently fails to scale with level.
- **Fix:** Thread an armor/AC proficiency rank into CharacterRulesInput (e.g. acRank) and add pf2eProficiency(level, acRank) to baseArmorClass, mirroring how saves and skills already work. Keep armorBonus for shield/item bonuses only. At minimum, surface in the UI that AC excludes proficiency so GMs know to compensate.
#### [HIGH] PF2e weapon attack wrongly adds the item (potency) bonus to damage
*system: pf2e · dimension: calc-pf2e-weapons-misc · _verified: confirmed_*
- **Where:** `src/lib/rules/pf2e/index.ts:105`
- **Issue:** weaponAttack computes dmgMod = (abilityMod) + item, applying the flat itemBonus to the damage expression in addition to the attack roll. In PF2e the item bonus to an attack comes from a weapon potency rune (+1/+2/+3) that modifies ONLY the attack roll; it never adds a flat bonus to damage. Damage from magic weapons comes from striking runes (extra weapon dice), not a flat item bonus. The 5e implementation does the same (dnd5e/index.ts:108-110), which is correct for 5e but was copied into PF2e where it is wrong. The types.ts:33 JSDoc 'flat magic/enhancement bonus to hit and damage' and the UI label '+item' (AttacksSection.tsx:71) cement the incorrect dual application.
- **Expected:** In PF2e the potency/item bonus applies only to the attack roll. Worked example: level 3 fighter, STR 18 (+4), trained (3+2=+5), +1 potency weapon, 1d8 -> to-hit = +4 +5 +1 = +10 (correct in code); damage = 1d8+4 (STR only). Code returns damage '1d8+5' (4 STR + 1 item), which is +1 too high. For a +3 striking weapon the code would be +3 too high on the flat damage and still miss the extra striking dice entirely. Fix: drop `+ item` from dmgMod in the PF2e weaponAttack (keep it only in toHit), and model striking runes as additional weapon dice rather than a flat damage bonus.
- **Fix:** Remove the item bonus from the PF2e damage modifier (apply potency only to toHit). Optionally add a separate 'striking' field that multiplies the weapon's damage dice (striking=2, greater=3, major=4) to model magic-weapon damage correctly.
#### [HIGH] PF2e clumsy/enfeebled/drained/stupefied all collapse into one undifferentiated status penalty
*system: pf2e · dimension: calc-conditions-damage · _verified: confirmed_*
- **Where:** `src/lib/mechanics/conditionEffects.ts:51-66; src/lib/mechanics/creatureState.ts:67`
- **Issue:** All PF2e valued conditions are mapped to a generic `statusPenalty: true` and deriveState aggregates them with `statusPenalty = Math.max(statusPenalty, cond.value)` into a single number. But each condition penalizes a DIFFERENT statistic: clumsy -> Dexterity-based (AC, Reflex, ranged attacks); enfeebled -> Strength-based (melee attack/damage, Athletics); drained -> Constitution-based (Fortitude); stupefied -> Int/Wis/Cha (spell attacks, spell DCs, Will). Because they target different stats they should never be compared against each other, yet the code takes the single worst value and presents one undifferentiated 'N status' badge. A creature with Enfeebled 2 + Clumsy 1 should take 2 to Strength rolls and 1 to Dexterity rolls; the code yields a single 2 with no indication which stat it applies to.
- **Expected:** Each valued condition should carry which statistic family it penalizes so the GM/tracker can apply it to the correct roll. Worked example: a creature Enfeebled 2 and Clumsy 1 attacking with a Strength melee weapon takes 2 (enfeebled), while its AC/Reflex take 1 (clumsy); the current model reports a single '2 status' with no stat, implying 2 to everything including AC/Reflex, which is wrong.
- **Fix:** Tag each PF2e valued condition with its target statistic (str/dex/con/mental/all) and aggregate per-statistic (worst value within the same statistic), instead of one global max. Render distinct badges (e.g. '2 Str rolls', '1 Dex/AC').
#### [HIGH] PF2e Slowed treated as a status penalty to checks/DCs instead of an action loss
*system: pf2e · dimension: calc-conditions-damage · _verified: confirmed_*
- **Where:** `src/lib/mechanics/conditionEffects.ts:64; src/lib/rules/conditions.ts:57`
- **Issue:** Slowed is mapped to `{ statusPenalty: true }` and is declared `valued: true`. deriveState therefore turns a 'Slowed 2' into a 2 status penalty to checks/DCs and shows a '2 status' badge. Slowed in PF2e is purely an action-economy condition: 'When you regain your actions at the start of your turn, reduce the number of actions you regain by your slowed value.' It imposes NO penalty to any check, DC, AC, or save.
- **Expected:** Slowed should contribute no statusPenalty. Worked example: a Slowed 2 fighter rolling a Strike should roll at its normal modifier (slowed only costs it actions that turn); the code instead applies 2 to the roll, a fabricated penalty.
- **Fix:** Remove `statusPenalty` from slowed (and represent it, if at all, as an actions-lost note/badge). Fatigued likewise is a flat 1 to AC/saves regardless of value and should not use value-scaled statusPenalty.
#### [CRITICAL→HIGH] Racial ability score increases are never applied to the character
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*
- **Where:** `src/features/characters/builder/CreationWizard.tsx:97-99,196-200,243-265; src/lib/rules/progression.ts:111`
- **Issue:** The wizard parses a race's ASI text only into a display string (`asiSummary`/`meta`) and never adds it to the ability scores. `buildCharacter` returns `abilities: choices.abilities` unchanged, and `finish()` spreads `...built` into the saved character. So a Mountain Dwarf Fighter with STR 15 is saved as STR 15, not 17; a Human is never given +1 to all six scores.
- **Expected:** A race's ASI must be added to the final ability scores. Worked example: SRD Hill Dwarf adds +2 CON (+1 WIS); a character built with CON 14 should be saved with CON 16 (mod +3), giving +1 HP/level and better CON saves. Standard-array Human (+1 to all) with the array 15/14/13/12/10/8 should become 16/15/14/13/11/9. Currently every score is stored exactly as picked, so HP, AC (Dex), attack/save/skill mods, and spell save DCs are all systematically too low for every race except (coincidentally) one with no ASI.
- **Fix:** Parse each race's ASI into a structured {ability: bonus} map (the regex at lines 97-99 already extracts ability+amount) and add it into `abilities` before calling buildCharacter / saving, including the 'one/two of your choice' cases via a small picker. Handle Human '+1 each' explicitly.
#### [HIGH] PF2e character builder uses 5e ability arrays and never applies ancestry/background/class attribute boosts
*system: pf2e · dimension: data-pf2e · _verified: confirmed_*
- **Where:** `src/features/characters/builder/CreationWizard.tsx:142-156, 196-200; src/lib/rules/abilityGen.ts:6-20`
- **Issue:** Ability scores for PF2e characters are generated with the 5e system: STANDARD_ARRAY = [15,14,13,12,10,8], 27-point point-buy (POINT_BUY_BUDGET 27, min 8 / max 15), or 4d6-drop-lowest. PF2e's actual method (every score starts at 10, then ancestry +2/+2/free, background +2/+2, class key +2, and four free +2 boosts, with the +1-instead-of-+2 rule above 18) is not implemented. The ancestry's `attribute` boosts are read only to build a display label (line 110-111) and are never added to abilities; background and class boosts are ignored entirely. buildCharacter (line 196) receives the raw 5e-array abilities unchanged.
- **Expected:** A PF2e level-1 character starts every ability at 10 and applies boosts: e.g. a Human Fighter (key STR) with Warrior background (STR/CON) choosing free boosts to DEX/CON/CON/WIS would reach STR 16, DEX 14, CON 14, WIS 12 — not the 5e 15/14/13/12/10/8 spread. Because every AC, save, attack, spell DC and HP derives from abilities, generated PF2e characters have systematically wrong scores.
- **Fix:** Add a PF2e boost-based ability step (start at 10, apply ancestry attribute[] + background + class key + 4 free boosts with the >=18 +1 rule already present in applyIncreases at progression.ts:185). The data needed (ancestry attribute[], class keyAbilities) is already bundled.
#### [HIGH] PF2e AC omits the level + armor-proficiency term
*system: pf2e · dimension: features-gap*
- **Where:** `src/lib/rules/pf2e/index.ts:78-87`
- **Issue:** baseArmorClass for pf2e returns baseAc + min(dex, dexCap) + armorBonus and the comment concedes 'item/proficiency folded into armorBonus for the MVP sheet'. PF2e AC = 10 + Dex(capped) + proficiency(level + rank bonus) + item bonus. The entire level+proficiency contribution is missing unless the GM manually types it into armorBonus, which also doubles as the shield/misc field.
- **Expected:** AC should add pf2eProficiency(level, armorRank). Worked example: a level-5 PF2e fighter (Dex +2) trained in their armor should get +5 (level) +2 (trained) = +7 from proficiency. With studded leather (base 12) that's 12 + 2 + 7 = AC 21; the current code returns 12 + 2 + armorBonus, so without a manual +7 fudge the sheet shows AC 14 — 7 too low.
- **Fix:** Add an armor proficiency rank to equippedArmor (or character) for pf2e and fold pf2eProficiency(level, rank) into baseArmorClass, the same way saves/skills already add it. Keep armorBonus for true item/circumstance bonuses only.
#### [MEDIUM] Pf2e Refocus restores the entire Focus Point pool instead of 1 point
*system: pf2e · dimension: calc-mechanics-resources · _verified: confirmed_*
- **Where:** `src/lib/rules/rest.ts:22-24, src/lib/rules/pf2e/index.ts:15`
- **Issue:** The Refocus option is defined with recovers: ['short'] and a comment 'Refocus restores 1 Focus Point' (pf2e/index.ts:14-15). But applyRest refreshes every short-recovery resource to its max: `opt.recovers.includes(r.recovery) ? { ...r, current: r.max } : r` (rest.ts:22-24). So a Focus Point pool modeled as recovery:'short' is fully restored by a single Refocus.
- **Expected:** Per the PF2e Core Rulebook, the Refocus activity recovers exactly 1 Focus Point (baseline). A caster with a 3-point focus pool who has spent all 3 should be at 1 after one Refocus, not 3. Worked example: focus {current:0, max:3, recovery:'short'} after Refocus → expected current 1; code yields current 3 (a 3x over-restore).
- **Fix:** Distinguish 'recover all of this tag' (5e short-rest: pact, channel divinity) from 'recover 1 of this tag' (pf2e Refocus). Either give RestOption a per-tag recoverAmount, or model focus points with a dedicated recovery tag whose rest restores +1 (clamped to max) rather than = max. Long-rest 'Rest for the Night' should still restore the full pool.
#### [MEDIUM] 5e Prone grants advantage to all attackers, ignoring ranged disadvantage
*system: 5e · dimension: calc-conditions-damage · _verified: confirmed_*
- **Where:** `src/lib/mechanics/conditionEffects.ts:38`
- **Issue:** Prone is mapped to `attacksAgainst: 'advantage'` unconditionally. The 5e rule is range-dependent: an attack against a prone creature has advantage only if the attacker is within 5 feet (melee); otherwise (ranged/beyond 5 ft) it has DISADVANTAGE. The data file states this correctly (conditions.json:90: 'Otherwise, the attack roll has disadvantage'), but the enforced table always yields advantage, overstating the benefit for ranged attackers.
- **Expected:** A ranged attacker shooting a prone target should have disadvantage, not advantage. Worked example: an archer 30 ft from a prone goblin should roll with disadvantage; the derived badge/state tells the GM the goblin is 'Attacked w/ adv.', which is wrong at range.
- **Fix:** Since the engine lacks attacker range, surface prone's against-attack modifier as range-conditional in the badge text (e.g. 'Adv. if melee, disadv. if ranged') rather than a flat advantage, or thread attacker distance into deriveState. Note: paralyzed/unconscious also omit the within-5ft auto-crit rule, which is acceptable since crits aren't modeled, but prone's ranged case actively misstates the modifier.
#### [MEDIUM] Sight rays leak through wall corners and endpoints (touching treated as non-blocking)
*system: both · dimension: calc-map-geometry · _verified: confirmed_*
- **Where:** `src/lib/map/vision.ts:28-37 (segmentsIntersect), used by computeVisibleCells:72`
- **Issue:** segmentsIntersect only returns true for a strict proper crossing (all four orientation signs strictly opposite). It explicitly returns false for collinear/touching cases, including when a sight ray passes exactly through a wall's endpoint or through the shared vertex where two wall segments meet (a corner). So a viewer can see 'around'/through a wall corner and through wall joints that should fully block sight. Reproduced: two walls forming an L-corner at (100,100) — wall A (0,100)-(100,100), wall B (100,100)-(100,200) — with eye at (50,50) and target (150,150); pathBlocked returns false (the diagonal ray slips through the shared corner vertex). Also a cell center landing exactly on a wall, or a ray grazing a single wall's endpoint, both return non-blocked. The 0.01 eye nudge does not fix corner cases because cell centers are not nudged and rays can still pass exactly through a vertex.
- **Expected:** A sight ray that touches/crosses a wall endpoint or a shared corner vertex of two contiguous blocking segments should be treated as blocked (rooms must not leak vision/fog at their corners). Canonical: line of sight is blocked if the segment from eye to cell intersects any wall, including endpoint contact. Worked example: viewer just inside an L-shaped room corner should NOT reveal the cell diagonally outside the corner; current code reveals it.
- **Fix:** Treat endpoint/collinear contact as blocking in segmentsIntersect (return true when any d is 0 and the touch point lies on both segments), OR cast the ray to a point slightly inset toward the eye and test multiple sample points per cell, OR build walls as closed polygons and block on any boundary touch. Add tests for: ray through a wall endpoint, ray through a shared corner vertex of a polyline wall, and cell center exactly on a wall.
#### [HIGH→MEDIUM] normalizeSpell5e snapshots phantom 1d10 necrotic damage onto Wish
*system: 5e · dimension: data-spells · _verified: confirmed_*
- **Where:** `src/lib/mechanics/normalize/spell.ts:28-43 (parseDamage); src/data/srd/spells-srd.json (wish)`
- **Issue:** parseDamage runs a global regex /(\d+d\d+)\s+(\w+)\s+damage/ over the whole description. Wish's description contains the backlash clause 'you take 1d10 necrotic damage per level of that spell'. That text is the caster's self-inflicted penalty for casting Wish, not damage the spell deals to a target. The normalized output is damage:[{dice:'1d10',type:'necrotic'}], which is snapshotted onto the SpellEntry (CompendiumPage.tsx:352) and persisted on the character sheet.
- **Expected:** Wish deals no inherent spell damage; its normalized `damage` array should be empty ([]). Concrete: a Wish SpellEntry should show no damage dice. Instead the snapshot stores 1d10 necrotic, implying the spell deals 1d10 necrotic to a target, which is false.
- **Fix:** Do not derive a spell's offensive damage from free-text regex. At minimum, exclude clauses containing 'you take'/'per level of that spell'/self-damage backlash. Better: source structured damage from a per-spell data field rather than prose, or gate parseDamage to only the sentence(s) describing the save/attack effect.
#### [MEDIUM] parseSave picks the FIRST save ability mentioned, snapshotting the wrong save for multi-save spells
*system: 5e · dimension: data-spells · _verified: confirmed_*
- **Where:** `src/lib/mechanics/normalize/spell.ts:18-25 (parseSave)`
- **Issue:** parseSave uses a single (non-global) regex match and returns the first '<ability> saving throw' found. For spells that mention more than one ability, the first mention is not always the operative save. Irresistible Dance: desc first says the target 'has disadvantage on dexterity saving throws', then 'makes a wisdom saving throw to regain control'. parseSave returns ability:'dex' though the actual save is Wisdom. 11 SRD spells mention multiple save abilities (contagion, earthquake, enlargereduce, irresistible-dance, prismatic-spray, prismatic-wall, sleet-storm, slow, storm-of-vengeance, symbol, wall-of-ice).
- **Expected:** Irresistible Dance's save is a Wisdom save (made as an action to end the effect); the snapshot should be {ability:'wis',basis:'negates'}. Code returns {ability:'dex',basis:'negates'}. (Most others happen to put the operative save first, so impact is concentrated in irresistible-dance, and potentially contagion/symbol where the first-listed ability differs from the casting save.)
- **Fix:** Prefer the save attached to the spell's primary effect (e.g. the one paired with 'must succeed on'/'or be <condition>'/'or take') rather than the first textual match; or source the save from a structured per-spell field. Add a regression test for irresistible-dance and contagion.
#### [HIGH→MEDIUM] PF2e builder grants too many trained skills at level 1 for ~13 of 25 classes
*system: pf2e · dimension: data-pf2e · _verified: confirmed_*
- **Where:** `public/data/pf2e/classes.json (skillCount) consumed at src/features/characters/builder/CreationWizard.tsx:163-164`
- **Issue:** The builder computes trained-skill count as selectedClass.skillCount + max(0, INT mod), reading skillCount from classes.json. That data-file value is derived in src/lib/ruleset/normalize.ts:125 as `trainedSkills.additional + trainedSkills.value.length`, i.e. it ADDS the class's auto-trained (fixed) skills to the number of FREELY chosen skills. The builder then lets the player freely choose that inflated total. Result: Champion shows 3 (canon 2), Bard 6 (canon 4), Thaumaturge 7 (canon 3), Rogue 8 (canon 7), Investigator/Ranger/Swashbuckler 5 (canon 4), Alchemist/Barbarian/Inventor/Kineticist/Oracle/Psychic 4 (canon 3), Cleric/Druid/Wizard 3 (canon 2). 13 classes over-count.
- **Expected:** PF2e trained skills at level 1 = a class-specific number of FREELY chosen skills + INT mod; the class's fixed/auto-trained skills are granted separately, not chosen. Worked example: a Champion with INT +1 trains 2 freely-chosen skills + 1 (INT) = 3 chosen skills, and is separately auto-trained in Religion. The builder instead offers 3 + 1 = 4 freely-chosen skills (counting Religion as a free pick). Correct per-class free counts: Alchemist 3, Barbarian 3, Bard 4, Champion 2, Cleric 2, Druid 2, Fighter 3, Inventor 3, Investigator 4, Kineticist 3, Magus 2, Monk 4, Oracle 3, Psychic 3, Ranger 4, Rogue 7, Sorcerer 2, Summoner 3, Swashbuckler 4, Thaumaturge 3, Witch 3, Wizard 2.
- **Fix:** In normalizeFoundryClass set skillCount to trainedSkills.additional only (the freely-chosen count), and seed the fixed trainedSkills.value as pre-applied trained skills. Then the builder's `+ INT mod` math matches PF2e. Re-run scripts/fetch_foundry_pf2e.ts to regenerate classes.json.
#### [MEDIUM] PF2e full casters are granted 3 spell slots per rank at level 1 (canon is 2)
*system: pf2e · dimension: data-pf2e · _verified: confirmed_*
- **Where:** `src/lib/rules/pf2e/progression.ts:60-70 (pf2eSlots)`
- **Issue:** pf2eSlots gives every available spell rank exactly 3 slots (1 for rank 10) regardless of level. At character level 1 a full caster therefore gets 3 first-rank slots, but PF2e grants only 2 spell slots of 1st rank at level 1 (the 3rd slot arrives at level 2). The function comment acknowledges it is 'approximate and editable'.
- **Expected:** PF2e spell slots per rank scale by level: a new caster has 2 slots of 1st rank at level 1, 3 at level 2; each rank generally reaches 3 slots only a level or two after it unlocks, and the highest rank often has fewer. Worked example: level-1 Wizard = 2 first-rank slots (+ cantrips), not 3; level-3 Wizard = 3 first-rank + 2 second-rank, not 3/3.
- **Fix:** Encode the standard PF2e per-level slot table (or at minimum: 2 slots at the level a rank unlocks, 3 thereafter, highest rank reduced). Lower priority since slots are editable on the sheet.
#### [LOW] Proficiency floors level to a minimum of 1 (level-0 edge)
*system: pf2e · dimension: calc-pf2e-core*
- **Where:** `src/lib/rules/pf2e/index.ts:34`
- **Issue:** pf2eProficiency clamps level with Math.max(1, Math.floor(level) || 1) before adding the rank bonus. For a level-0 entity that is trained, this returns 1 + 2 = 3 instead of the rules-accurate 0 + 2 = 2. This does not affect PC math (PF2e PCs are level 1-20) but would mis-score any level-0 creature/object fed through this path.
- **Expected:** Proficiency = (level if rank > untrained else 0) + rank bonus, with level used as-is. At level 0, trained should give 0 + 2 = 2. The clamp to a minimum of 1 inflates this by 1. Negligible for standard PCs (level >= 1) but technically incorrect at the boundary.
- **Fix:** If level-0 inputs are never possible, leave as-is (impact is nil). If they can occur, clamp the floor at 0 instead of 1: Math.max(0, Math.floor(level) || 0).
#### [LOW] PF2e night's rest full-heals instead of Con-mod x level
*system: pf2e · dimension: calc-pf2e-weapons-misc*
- **Where:** `src/lib/rules/rest.ts:14-16`
- **Issue:** applyRest sets hp.current = hp.max whenever opt.restoresHp is true. The PF2e 'Rest for the Night' restores HP equal to your Constitution modifier (minimum 1) multiplied by your level, not full HP. This is a shared rest path with 5e (where a long rest does full-heal, correctly), so PF2e overheals on a night's rest.
- **Expected:** PF2e: a full night's rest recovers Con mod x level HP (min 1 x level). Example: level 5, Con +2 -> 10 HP back, not to full. (5e long rest correctly restores all HP, so the rule must branch by system.) Refocus correctly restores no HP, so only the night's-rest HP amount is affected.
- **Fix:** Branch HP recovery by system: keep full-heal for 5e long rest; for PF2e night's rest restore max(1, conMod) x level HP capped at hp.max.
#### [LOW] 5e nat-20 and nat-1 force a crit on all DC checks
*system: 5e · dimension: calc-dice-rng*
- **Where:** `src/lib/dice/check.ts:40-43`
- **Issue:** Natural 20 returns critical-success and natural 1 returns critical-failure before total is compared to DC, so every DC check auto-succeeds on a 20 and auto-fails on a 1. In 5e RAW this applies only to attack rolls and death saves.
- **Expected:** For 5e ability checks and saves the degree should be success when total is at least DC and failure otherwise, with no natural-die override. Example: a plus-0 Stealth check rolling a natural 20 for total 20 against DC 25 is a RAW failure but the code returns critical-success.
- **Fix:** Apply the nat-20 and nat-1 auto-results only for attack rolls and death saves in 5e.
#### [LOW] 5e encounter multiplier ignores party-size adjustment (no column shift for <3 or >=6 PCs)
*system: 5e · dimension: calc-encounter-budget*
- **Where:** `src/lib/combat/budget.ts:58-65,67-70`
- **Issue:** encounterMultiplier(count) is a function of monster count only. The DMG p.83 simplified encounter rules say that when the party has fewer than 3 PCs you should treat the encounter as if it had one MORE monster (move one column right / use the next-higher multiplier), and when the party has 6+ PCs treat it as one FEWER monster (next-lower multiplier). budget5e never passes party size into encounterMultiplier, so a 3-monster fight always uses x2 regardless of whether 2 PCs or 7 PCs face it.
- **Expected:** Per DMG p.83: with a small party (<3 PCs) bump the multiplier up one step on the monster-count table; with a large party (>=6 PCs) drop it one step. Worked example: 3 monsters vs a 2-PC party should use the x2.5 column (one step up from x2). Code returns x2 for any party size. Note this is partially mitigated here because the difficulty thresholds are summed per-character (line 72-76), so larger/smaller parties already get scaled budgets; the missing piece is specifically the multiplier-column shift, whose absence slightly under-rates large parties' fights as harder-than-shown and small parties' fights as easier-than-shown relative to strict DMG output.
- **Fix:** Pass partyLevels.length into encounterMultiplier and shift the effective monster count by +1 when size<3 and -1 when size>=6 before selecting the multiplier, matching DMG p.83. Optionally gate behind a setting since many tables ignore this rule.
#### [LOW] Visibility nudge applied to viewer eye only, not to target cell centers (potential asymmetry)
*system: both · dimension: calc-map-geometry*
- **Where:** `src/lib/map/vision.ts:64,69`
- **Issue:** computeVisibleCells nudges only the viewer: `const eye = { x: v.x + 0.01, y: v.y + 0.01 }` while target cell centers stay on exact grid lines `(c + 0.5) * gridSize`. Because the ray's two endpoints are treated asymmetrically (one nudged, one exact), whether a grazing wall blocks can in principle differ depending on which token is the viewer, so 'A sees B' is not guaranteed identical to 'B sees A' at exact-grazing geometry. In practice the dominant failure is the touching=non-blocking rule (which leaks symmetrically), so a clean A-yes/B-no split is hard to trigger, but the construction is not provably symmetric.
- **Expected:** Mutual visibility should be symmetric: if viewer at P can see cell at Q's center, a viewer at Q should see cell at P's center under identical wall geometry. Apply the same epsilon handling to both endpoints, or remove the directional nudge in favor of robust endpoint-inclusive intersection.
- **Fix:** Nudge consistently (or not at all) and resolve grazing via the inclusive-intersection fix from vision-corner-fog-leak; add a symmetry test asserting sees(P,Q) === sees(Q,P) across a wall grid.
#### [LOW] Square AoE over-covers when cursor sits on a cell center instead of a grid corner
*system: 5e · dimension: calc-map-geometry*
- **Where:** `src/lib/map/shapes.ts:67-74 (squareCells); caller MapEditor.tsx:116,163`
- **Issue:** squareCells includes every cell whose center is within ±(side/2) px of the cursor point, with no grid snapping. A 20-ft square (half = 100px on a 50px/5ft grid) anchored on a grid corner (200,200) correctly yields 16 cells (4x4), but anchored on a cell center (125,125) yields 25 cells (5x5) because cell centers at the ±100 boundary are inclusively counted on both sides. The template thus changes size with sub-cell cursor placement.
- **Expected:** A 5e NxN-ft square/cube on a grid should always cover (N/5)x(N/5) cells (e.g. 20 ft -> 4x4 = 16 cells) regardless of cursor sub-cell position; templates are normally snapped to grid intersections. Worked example: 20 ft square should be 16 cells, not 25.
- **Fix:** Snap the square's anchor to the nearest grid intersection before rasterizing (or use a strict '<' half-open bound on the high side), so coverage is grid-count exact. Note: this is an advisory overlay (no auto-applied effect), hence low severity.
#### [LOW] Cone AoE clips far corners using radial distance rather than axial length
*system: 5e · dimension: calc-map-geometry*
- **Where:** `src/lib/map/shapes.ts:76-90 (coneCells); caller MapEditor.tsx:164`
- **Issue:** coneCells includes a cell only if its radial distance from the origin is <= lenPx AND its angle from the cone direction is within halfAngle. A 5e cone's length is measured along its axis; cells near the cone's outer edge but within the triangular extent can have radial distance slightly greater than the axial length and get excluded, so the far corners of the cone are clipped to an arc. The apex angle 53 deg (MapEditor.tsx:164) is the correct ~53.13 deg = 2*atan(0.5) 5e cone approximation, so only the length metric is at issue.
- **Expected:** 5e cone of length L covers cells out to L feet measured straight from the origin along the cone; the template is a triangle/wedge, not a pie slice clipped by a circle. Effect is a slight under-coverage of the two far corners of the cone wedge.
- **Fix:** Cap by axial projection length (distance along the cone direction) instead of radial distance, or accept the arc approximation. Advisory overlay only, so low severity.
#### [LOW] Spell save/damage normalization is prose-regex and misses ~8% of saves
*system: 5e · dimension: data-loaders*
- **Where:** `src/lib/mechanics/normalize/spell.ts:18-43`
- **Issue:** normalizeSpell5e derives save ability and damage by regex over desc. On the live SRD set, of 128 spells whose desc contains "saving throw", the /(ability) saving throw/ regex matches 118 — 10 are missed (phrasings like "a saving throw against your spell save DC" where the ability word is not adjacent). Damage parsing catches 73 of 133 desc mentions of "damage" (the rest are flat/scaling/half-damage prose with no NdN dice). Missed saves yield save:null, so the assistant would not surface a DC prompt for those spells.
- **Expected:** Ideally pull save/damage from structured fields, but Open5e SRD spells don't expose them. Worked example: a spell described as "…must make a saving throw against your spell save DC…" parses to save:null and the EncounterTracker would not surface its save. Given the project's "never auto-roll, only surface the DC" rule, the impact is a missing prompt, not a wrong number — hence low severity. Degrades gracefully.
- **Fix:** Add fallback patterns ("saving throw against your spell save DC", "Dexterity save", etc.) and consider a small curated override table for the ~10 misses. Keep it best-effort; never block on it.
#### [MEDIUM→LOW] Magic Missile normalized damage is empty (regex can't match '1d4 + 1 force damage')
*system: 5e · dimension: data-spells · _verified: confirmed_*
- **Where:** `src/lib/mechanics/normalize/spell.ts:31 (parseDamage regex); src/data/srd/spells-srd.json (magic-missile)`
- **Issue:** parseDamage's regex /(\d+d\d+)\s+(\w+)\s+damage/ requires the dice to be immediately followed by '<type> damage'. Magic Missile's desc reads 'A dart deals 1d4 + 1 force damage', so the token after '1d4' is '+', not a damage type, and the regex fails. Magic Missile normalizes to damage:[].
- **Expected:** Magic Missile deals 1d4+1 force damage per dart (3 darts at 1st level). Canonical: 3 x (1d4+1) force. The snapshot should at least capture the force-damage dice; instead it captures nothing, so the sheet/combat snapshot for Magic Missile shows no damage.
- **Fix:** Broaden the damage regex to tolerate flat modifiers, e.g. /(\d+d\d+(?:\s*[+-]\s*\d+)?)\s+(\w+)\s+damage/, and preserve the modifier in the dice string. Same gap affects any spell phrased '<dice> + N <type> damage'.
## 📚 Data accuracy (15)
#### [HIGH] Fighter skillChoices splits "Animal Handling" into two bogus entries, dropping that skill
*system: 5e · dimension: data-loaders · _verified: confirmed_*
- **Where:** `src/data/srd/classes.json (fighter entry) / src/lib/ruleset/normalize.ts:66`
- **Issue:** classes.json stores fighter skillChoices as ["Acrobatics","Animal","Handling","Athletics","History","Insight","Intimidation","Perception","and Survival"]. "Animal Handling" was split into separate "Animal" and "Handling" tokens (a comma in the source between the two words, or a normalization artifact). Neither "animal" nor "handling" matches the skill key `animal-handling`/label "Animal Handling" in CreationWizard.tsx:169-171, so a Fighter cannot select Animal Handling as a proficiency despite it being a valid Fighter skill.
- **Expected:** Fighter skillChoices should contain a single "Animal Handling" entry (and "Survival", not "and Survival"). A level-1 Fighter choosing 2 skills should be offered Animal Handling; the builder currently omits it.
- **Fix:** Regenerate classes.json from a clean source list, or post-process to rejoin "Animal"/"Handling" and strip the "and " prefix. Validate every class's skillChoices against the known 5e skill label set and fail the scraper on unmatched tokens.
#### [MEDIUM→HIGH] Blink Dog passive Perception listed as 10, should be 13
*system: 5e · dimension: data-monsters · _verified: confirmed_*
- **Where:** `src/data/srd/monsters-srd.json (Blink Dog, senses field)`
- **Issue:** Blink Dog's senses string is 'passive Perception 10'. The same record lists skills.perception = 3, and WIS 13 (+1). Passive Perception = 10 + Perception bonus = 10 + 3 = 13. The stated 10 is internally inconsistent with its own perception skill and wrong vs the SRD.
- **Expected:** Canonical SRD Blink Dog: Skills Perception +3, passive Perception 13. Worked example: 10 + (Perception +3) = 13. senses should read 'passive Perception 13'.
- **Fix:** Correct the senses string to 'passive Perception 13'. Better: compute passive Perception (10 + perception-or-WIS mod) at render time instead of trusting the source string, so it can never disagree with the skills/ability data.
#### [MEDIUM→HIGH] Bone Devil passive Perception listed as 9, should be 12
*system: 5e · dimension: data-monsters · _verified: confirmed_*
- **Where:** `src/data/srd/monsters-srd.json (Bone Devil, senses field)`
- **Issue:** Bone Devil's senses string is 'darkvision 120 ft., passive Perception 9'. WIS 14 (+2), no Perception proficiency, so passive Perception = 10 + 2 = 12. Stated 9 is too low by 3.
- **Expected:** Canonical SRD Bone Devil: passive Perception 12. Worked example: WIS 14 -> +2; 10 + 2 = 12.
- **Fix:** Fix the senses string to 'passive Perception 12', or compute passives at render time from WIS + perception skill.
#### [MEDIUM→HIGH] Chain Devil passive Perception listed as 8, should be 11
*system: 5e · dimension: data-monsters · _verified: confirmed_*
- **Where:** `src/data/srd/monsters-srd.json (Chain Devil, senses field)`
- **Issue:** Chain Devil's senses string is 'darkvision 120 ft., passive Perception 8'. WIS 12 (+1), no Perception proficiency, so passive Perception = 10 + 1 = 11. Stated 8 is too low by 3.
- **Expected:** Canonical SRD Chain Devil: passive Perception 11. Worked example: WIS 12 -> +1; 10 + 1 = 11.
- **Fix:** Fix the senses string to 'passive Perception 11', or compute passives at render time.
#### [HIGH→MEDIUM] Class skillChoices retain leading "and ", silently dropping a valid skill from the 5e builder
*system: 5e · dimension: data-loaders · _verified: confirmed_*
- **Where:** `src/lib/ruleset/normalize.ts:65-66 (data: src/data/srd/classes.json)`
- **Issue:** normalizeOpen5eClass splits the skill list only on commas and never strips the trailing Oxford "and ". The last skill of nearly every class is stored as "and Survival", "and Religion", "and Stealth", etc. In CreationWizard the choices are lowercased and matched against skill keys/labels (CreationWizard.tsx:169-171); "and survival" matches neither the key `survival` nor the label "Survival", so that skill is dropped from the class's allowed proficiency list. Confirmed in data: barbarian/cleric/paladin/sorcerer/warlock/wizard end in "and Religion"/"and Survival", druid/ranger "and Survival", monk "and Stealth", fighter "and Survival".
- **Expected:** The final list element should be the bare skill name. Worked example: Barbarian skillChoices should be ["Animal Handling","Athletics","Intimidation","Nature","Perception","Survival"]; code stores [...,"and Survival"] so Survival is unselectable for a Barbarian in the builder. Fix: in normalize.ts strip a leading /^and\s+/i (and split on "and" as well as commas) before pushing each choice, and/or regenerate classes.json.
- **Fix:** In normalizeOpen5eClass, map each split token through .replace(/^and\s+/i,'').trim(), or split on /,|\band\b/. Regenerate src/data/srd/classes.json. Add a unit test asserting Barbarian.skillChoices includes "Survival" and excludes "and Survival".
#### [MEDIUM] Drow ASI in races.json is wrong (+2 INT instead of Elf +2 DEX / Drow +1 CHA)
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*
- **Where:** `src/data/srd/races.json (Drow entry)`
- **Issue:** The Drow entry lists 'Your Intelligence score increases by 2' and only the generic Fey Ancestry trait. This is the Kobold-Press standalone-Drow writeup, not the SRD elf/drow. SRD Drow is a subrace of Elf: +2 DEX (from Elf) and +1 CHA (from Drow), with Superior Darkvision, Sunlight Sensitivity, Drow Magic, and Keen Senses.
- **Expected:** A Drow should receive +2 DEX and +1 CHA. As written, even if ASI were applied (it isn't currently), a Drow would get +2 INT — wrong ability entirely, mis-stating attack/AC (Dex) and Charisma-based casting/saves. Example: an intended Drow rogue would get no Dex bonus from race here.
- **Fix:** Replace race data with SRD-correct values (Drow as Elf subrace: +2 DEX, +1 CHA, Superior Darkvision 120 ft, Sunlight Sensitivity, Drow Magic, Keen Senses, Trance).
#### [MEDIUM] All PF2e datasets contain large numbers of duplicate (remaster/legacy) entries shown unfiltered
*system: pf2e · dimension: data-pf2e · _verified: confirmed_*
- **Where:** `scripts/fetch_pf2e.ts:117-120 (postProcess only dedupes 'action'); consumed raw by src/features/compendium/registry.tsx via loadPf2e()`
- **Issue:** The fetch script de-duplicates only the 'action' category; every other category is written verbatim from Archives of Nethys, which lists each reprinted item once per source (e.g. Remastered + legacy). Measured duplicate counts (total - unique-by-name): creatures 866 (4702 vs 3836), feats 2143 (8402 vs 6259), equipment 2434 (8605 vs 6171), spells 659 (2455 vs 1796), weapons 280 (614 vs 334), deities 230 (716 vs 486), backgrounds 116, heritages 93, archetypes 89, ancestries 19. The compendium (registry.tsx pf2eCategory -> loadPf2e) renders these raw, so e.g. 'Goblin Warrior', 'Longsword', and '8-Round Magazine' each appear two or three times in the browser.
- **Expected:** Each game element should appear once in the compendium (preferring the remastered entry). A user browsing the PF2e Bestiary should see one 'Goblin Warrior', not two identical rows; the Weapons list should show one 'Longsword'.
- **Fix:** Apply dedupeByName (already defined in fetch_pf2e.ts) to all categories, not just actions — preferring the Remastered source — or dedupe by slug/name at load time in loadPf2e(). The builder already does this locally for ancestries/backgrounds via dedupeByName in overview.ts, confirming the duplication is a known problem only partially patched.
#### [MEDIUM] Undo writes a captured snapshot with save() (not the transactional mutate), clobbering any concurrent live-session player edits
*system: both · dimension: ux-combat-play*
- **Where:** `src/features/combat/EncounterTracker.tsx:57-67; src/lib/db/repositories.ts:181-197`
- **Issue:** mutate() pushes the closure's `encounter` prop onto undoStack before the async write resolves, and undo() restores it via encountersRepo.save(prev) — a full put() (repositories.ts:181-184), NOT the transactional read-modify-write mutate(). The save bypasses the freshest-state read that mutate uses to avoid clobbering. In a hosted session, a player's HP patch lands on the GM's Character docs (not the encounter), so this specific clobber is limited to encounter state; but two combat surfaces (e.g., the GM's own tracker and the embedded RollFeed/CombatPage) or a rapid mutate-then-undo can still resurrect a stale encounter document over newer combat changes. The undo snapshot is the React prop value, which may already be one render behind the committed DB state.
- **Expected:** Undo should reapply prior state transactionally (or store and replay the inverse) rather than blindly put()-ing a possibly-stale full document, so it cannot overwrite changes committed after the snapshot was captured.
- **Fix:** Have undo() go through encountersRepo.mutate(id, () => prev) or capture the post-commit state; consider snapshotting inside the mutate callback rather than from the render prop.
#### [LOW] Spell picker uses full-caster level curve for half-casters and Warlock
*system: 5e · dimension: calc-5e-weapons-hp*
- **Where:** `CreationWizard.tsx:189`
- **Issue:** Picker maxLevel uses ceil of level over 2 for all casters; a L5 Paladin sees 3rd-level spells though its top slot is 2nd.
- **Expected:** Half-casters trail about 2 levels; use built.spellcasting.slots for the cap. Minor, trimmable later.
- **Fix:** Cap picker by highest level in built.spellcasting.slots.
#### [LOW] Spider (tiny) passive Perception listed as 12, should be 10
*system: 5e · dimension: data-monsters*
- **Where:** `src/data/srd/monsters-srd.json (Spider, senses field)`
- **Issue:** The tiny Spider's senses string is 'darkvision 30 ft., passive Perception 12'. WIS 10 (+0), no Perception proficiency (only Stealth +4), so passive Perception = 10 + 0 = 10. Stated 12 is too high by 2.
- **Expected:** Canonical SRD Spider: passive Perception 10. Worked example: WIS 10 -> +0; 10 + 0 = 10.
- **Fix:** Fix the senses string to 'passive Perception 10', or compute passives at render time.
#### [LOW] Swarm of Ravens passive Perception listed as 15, should be 13
*system: 5e · dimension: data-monsters*
- **Where:** `src/data/srd/monsters-srd.json (Swarm of Ravens, senses field)`
- **Issue:** Swarm of Ravens' senses string is 'passive Perception 15'. WIS 12 (+1) with Perception +3 (proficient) in the SRD, so passive Perception = 10 + 3 = 13. Stated 15 is too high by 2 (and the skills object is empty, so the record cannot self-justify 15).
- **Expected:** Canonical SRD Swarm of Ravens: Skills Perception +3, passive Perception 13. Worked example: 10 + 3 = 13.
- **Fix:** Fix the senses string to 'passive Perception 13' and add skills.perception = 3, or compute passives at render time.
#### [LOW] Acid Arrow school is Evocation in source data; canonical is Conjuration
*system: 5e · dimension: data-spells*
- **Where:** `src/data/srd/spells-srd.json:24 (acid-arrow .school)`
- **Issue:** The acid-arrow entry has "school": "Evocation". In the 5e SRD/PHB, Melf's Acid Arrow (Acid Arrow) is a 2nd-level Conjuration spell. This is an Open5e source-data error that flows through normalizeSpell5e unchanged (spell.ts:54 copies raw.school verbatim) and is displayed in the compendium.
- **Expected:** Acid Arrow is 2nd-level Conjuration. The school field should read 'Conjuration'. Worked example: filtering/searching the compendium by school 'Conjuration' will omit Acid Arrow, and by 'Evocation' will wrongly include it.
- **Fix:** Correct the school to 'Conjuration' in spells-srd.json (and spells-full.json if mirrored). Low gameplay impact (school rarely affects mechanics) but it is a factual data error.
#### [LOW] Magic item subtitle duplicates 'requires attunement' phrase
*system: 5e · dimension: data-equipment*
- **Where:** `src/features/compendium/details.tsx:121`
- **Issue:** The magic-item detail subtitle concatenates the literal string 'requires attunement ' with the raw requires_attunement field. In magicitems-srd.json that field is itself the full phrase (e.g. 'requires attunement' for Cloak of Protection, or 'requires attunement by a paladin' for Holy Avenger). So the rendered subtitle becomes '(requires attunement requires attunement)' for the common case and '(requires attunement requires attunement by a paladin)' for restricted items.
- **Expected:** Render the field value alone since it already contains the verb. E.g. for Cloak of Protection show '(requires attunement)'; for Holy Avenger show '(requires attunement by a paladin)'. Concrete fix: change the template to ` (${item.requires_attunement})` (the field already starts with 'requires attunement').
- **Fix:** Drop the hard-coded 'requires attunement ' prefix and interpolate only the field: ` (${item.requires_attunement})`. Verify across the ~125 attuned items in magicitems-srd.json which all begin with 'requires attunement'.
#### [MEDIUM→LOW] feats.json contains non-SRD, benefit-stripped feats and is never loaded; SRD feats live in mpmb-feats.json
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*
- **Where:** `src/data/srd/feats.json; src/lib/compendium/index.ts:66-73`
- **Issue:** src/data/srd/feats.json is an open5e/Kobold-Press set (Ace Driver, Brutal Attack, Boundless Reserves, …) whose descriptions are truncated to lead-ins like 'You gain the following benefits:' with the actual benefits removed. It is not imported anywhere. The compendium's loadFeats5e() instead reads mpmb-feats.json, which DOES contain the 104 SRD feats with usable effect text (e.g. Great Weapon Master full rules).
- **Expected:** The standard PHB/SRD feats (Great Weapon Master, Sharpshooter, Lucky, Alert, War Caster, Sentinel, Resilient, Tough, Mobile, Polearm Master, Crossbow Expert, etc.) should be the available feat list with their mechanical text. They are entirely absent from feats.json (only 'Grappler' overlaps) but present and complete in mpmb-feats.json.
- **Fix:** Delete or replace the unused open5e feats.json to avoid confusion, and standardize on mpmb-feats.json (with its structured descriptions) as the single feat source used by both compendium and a future feat picker.
#### [LOW] Two divergent PF2e class data sources (data file vs hardcoded table) can disagree
*system: pf2e · dimension: data-pf2e*
- **Where:** `src/lib/rules/pf2e/progression.ts:9-35 (hardcoded PF2E_CLASSES) vs public/data/pf2e/classes.json (Foundry-scraped) merged in CreationWizard.tsx:76-83`
- **Issue:** Class info comes from two places that are independently authored and already drift. The builder's class LIST, HP-per-level display and skillCount come from classes.json; but buildCharacter() (progression.ts:85) pulls HP, save ranks, perception and caster from the hardcoded PF2E_CLASSES table via getClassDef. Where they disagree the results are inconsistent: e.g. skillCount is 3 in the hardcoded table but 3-8 in the data file (the data file value wins for the picker), and Champion keyAbility is ['str'] hardcoded vs ['dex','str'] in the data file. HP values happen to match canon in both, but the split invites future drift and makes the skillcount bug harder to spot.
- **Expected:** A single source of truth for each class field. HP/saves/caster should be read from the same record the picker displays, or the hardcoded table should be the authoritative enrichment of the data file (it already supplies caster/subclasses).
- **Fix:** Either drive buildCharacter from the loaded RulesetClass (passing saveRanks/caster through, as finish() already does for saves at line 250-253), or fold the data-file fields into PF2E_CLASSES. Reconcile Champion keyAbility to ['str','dex'].
## 🧩 Data completeness (15)
#### [MEDIUM→HIGH] Statblock view shows only walk speed; fly/swim/climb/burrow dropped for 174 monsters
*system: 5e · dimension: data-monsters · _verified: confirmed_*
- **Where:** `src/features/compendium/MonsterDetail.tsx:43`
- **Issue:** The Speed line renders only m.speed.walk ('{m.speed.walk} ft.'). The JSON stores full speed maps (e.g. Adult Red Dragon {walk:40, climb:40, fly:80}; Aboleth {walk:10, swim:40}; Giant Spider {walk:30, climb:30}), but every non-walk movement mode is silently dropped. 174 of 322 monsters have at least one non-walk speed, so a GM reading the in-app statblock cannot see that the red dragon flies 80 ft. or the aboleth swims 40 ft.
- **Expected:** Render all movement modes, e.g. 'Speed 40 ft., climb 40 ft., fly 80 ft.' by iterating m.speed entries rather than reading only .walk.
- **Fix:** Iterate over all keys of m.speed (walk, fly, swim, climb, burrow, hover) and join into the Speed line, matching the SRD statblock format.
#### [HIGH] Background skill/tool/language proficiencies are dropped (saved only as a note)
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*
- **Where:** `src/features/characters/builder/CreationWizard.tsx:103,255`
- **Issue:** The chosen background is stored only as free text in `notes` ('Background: Acolyte'). The background's granted skill proficiencies, tool proficiencies and languages (present in backgrounds.json as `skills`/`tools`/`languages`) are never written into `skillRanks` or anywhere mechanical.
- **Expected:** A 5e background grants 2 fixed skill proficiencies plus tools/languages. Worked example: Acolyte grants Insight and Religion as trained skills. After building a Fighter with the Acolyte background, the sheet should show Insight and Religion as proficient (skillRanks set to 'trained'), on top of the class skill picks. Currently those skills remain untrained, understating those skill checks by the proficiency bonus (e.g. -2 at levels 1-4).
- **Fix:** Parse background.skills into skill keys and merge them as 'trained' into the built character's skillRanks at finish(); surface tools/languages on the sheet.
#### [HIGH] Subclasses are names-only blurbs; no subclass feature is ever applied
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*
- **Where:** `src/data/srd/classes.json; src/features/characters/builder/CreationWizard.tsx:255; src/lib/ruleset/normalize.ts:78`
- **Issue:** Subclasses in classes.json carry only `{name, desc}` (a flavor paragraph) — no features, no level table, no spells. The chosen subclass is saved only as a note ('Subclass: Champion'). Nothing mechanical (e.g. Champion's Improved Critical, Life Domain's bonus spells/heavy-armor proficiency, Draconic Bloodline's bonus HP) is granted.
- **Expected:** Choosing a subclass at the appropriate level must grant its features. Worked example: a level-3 Cleric (Life Domain) should gain Heavy Armor proficiency and the Life Domain bonus spells (e.g. bless, cure wounds) plus Disciple of Life; a Sorcerer (Draconic Bloodline) should gain +1 HP per level and 13+Dex unarmored AC. None of this happens — the subclass is purely descriptive text.
- **Fix:** Either clearly scope subclasses as descriptive-only (and tell the user to add features manually) or add structured subclass feature/spell/proficiency data and apply it at build/level-up.
#### [MEDIUM→HIGH] Racial traits (proficiencies, vision, resistances) are never applied — only shown as text
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*
- **Where:** `src/features/characters/builder/CreationWizard.tsx:94-101`
- **Issue:** Racial traits like Elf 'Keen Senses' (Perception proficiency), Half-Orc 'Menacing' (Intimidation proficiency), Dwarf darkvision/poison resistance, and racial speed beyond a parsed number are concatenated into a display `desc` string but never applied to skillRanks, proficiencies, defenses, or senses.
- **Expected:** Race traits with mechanical weight should be applied. Worked example: an Elf should be proficient in Perception (skillRanks.perception='trained'); a Half-Orc should be proficient in Intimidation. After building an Elf, Perception remains untrained, understating Perception by the proficiency bonus.
- **Fix:** Map known trait grants (skill proficiencies from Keen Senses/Menacing, damage resistances, darkvision) into the built character's mechanical fields; at minimum auto-add trait-granted skill proficiencies to skillRanks.
#### [HIGH] Dice Clear history wipes ALL campaigns rolls when no campaign active, no confirm
*system: both · dimension: ux-global*
- **Where:** `src/features/dice/DicePage.tsx:84`
- **Issue:** campaignId=activeCampaignId??undefined (36); Clear history->diceRepo.clear(campaignId) (84); repositories.ts:220-223 else-branch db.diceRolls.clear() wipes table for ALL campaigns when none active. recent() shows all rolls globally (206-209). No confirm.
- **Expected:** Disable when undefined or scope to visible rows, confirm via Modal; else a GM with no active campaign loses every campaigns rolls.
- **Fix:** Disable when no campaign active; confirm via Modal.
#### [MEDIUM] Saving throws, skills, and perception present in data but never shown in statblock
*system: 5e · dimension: data-monsters · _verified: confirmed_*
- **Where:** `src/features/compendium/MonsterDetail.tsx:60-71 (Saves/Skills lines absent)`
- **Issue:** The JSON carries explicit saving-throw bonuses for 91 monsters (strength_save, dexterity_save, etc.) and a skills object, but MonsterDetail renders neither. Example: Adult Red Dragon has saves DEX +6, CON +13, WIS +7, CHA +11 and skills Perception +13 / Stealth +6, none of which appear in the rendered statblock. A GM cannot see the dragon's saving-throw bonuses, which are needed to resolve spells and effects against it.
- **Expected:** Add 'Saving Throws' and 'Skills' lines (and surface perception) using the *_save fields and skills object, e.g. 'Saving Throws DEX +6, CON +13, WIS +7, CHA +11'.
- **Fix:** Add Saving Throws and Skills StatLines built from the *_save fields and the skills map so the displayed statblock is complete.
#### [MEDIUM] races.json is non-SRD-heavy with no subraces; real SRD subraces in mpmb-race.json are name-only and unused
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*
- **Where:** `src/data/srd/races.json; src/data/srd/mpmb-race.json; src/lib/compendium/index.ts:98-104`
- **Issue:** The wizard's race list (races.json, 20 entries) mixes the 9 SRD base races with 11 non-SRD Kobold-Press races (Alseid, Catfolk, Darakhul, Derro, Erina, Gearforged, Minotaur, Mushroomfolk, Satarre, Shade, plus base-Drow) and contains NO subraces (Hill/Mountain Dwarf, High/Wood Elf, Lightfoot/Stout Halfling, Forest/Rock Gnome). mpmb-race.json has the proper SRD subraces (mountain dwarf, wood elf, forest gnome, stout halfling, …, 126 entries) but only stores names, so it can't be used for mechanics and isn't loaded by the wizard.
- **Expected:** 5e SRD characters choose a subrace whose ASI/traits matter (e.g. Hill Dwarf +1 WIS and +1 HP/level vs Mountain Dwarf +2 STR and armor training; Wood Elf 35 ft speed + Mask of the Wild vs High Elf cantrip + extra language). With only base races and no subrace, players cannot build a correct SRD character and lose the subrace ASI entirely.
- **Fix:** Source structured SRD race+subrace data (ASI, speed, traits) and feed the wizard from it; either drop non-SRD races or clearly label them as homebrew.
#### [MEDIUM] sessionLog excluded from cascade-delete and backup/restore
*system: both · dimension: ux-global*
- **Where:** `src/lib/io/backup.ts:9`
- **Issue:** sessionLog (db.ts:29) missing from backup.ts:9-12 and campaignsRepo.remove cascade (66-79); sessionLogRepo.clear() (236-239) never called -> dropped on backup, orphaned on delete.
- **Expected:** Add sessionLog to backup TABLES+SCHEMA and the cascade.
- **Fix:** Add sessionLog to backup.TABLES and remove() cascade.
#### [LOW] Bard skillChoices is empty — skill-list parse failed (works only by accident)
*system: 5e · dimension: data-loaders*
- **Where:** `src/data/srd/classes.json (bard entry) / src/lib/ruleset/normalize.ts:65-66`
- **Issue:** Bard's skillChoices is []. The Open5e bard text is "Choose any three skills" which has no "from <list>" clause, so the /from (.+)$/ regex yields nothing. CreationWizard.tsx:167 treats an empty skillChoices as "any skill" and falls back to allSkillKeys. For Bard the correct 5e rule is indeed "any three skills," so the outcome is coincidentally correct, but the data is a silent parse failure rather than an intentional value and would mislead the ClassDetail "Skills — from …" display (details.tsx:62 shows just the count with no list).
- **Expected:** Either store the full skill list (all 18) for "any" classes, or carry an explicit "any" flag, so the empty array is not ambiguous between "parse failed" and "choose from all".
- **Fix:** In normalizeOpen5eClass, when the text says "any"/no list is found, populate skillChoices with the full skill set or set a boolean like anySkill:true, so downstream code and the compendium detail render correctly and the failure mode is not silent.
#### [LOW] ~15.8 MB of dead, unimported JSON sits in src/data/srd (incl. the once-empty feats.json)
*system: both · dimension: data-loaders*
- **Where:** `src/data/srd/ (21 files)`
- **Issue:** 21 of 31 files under src/data/srd are not imported anywhere (verified by grepping every filename across src and confirming no import.meta.glob/require over the data dir). Dead files total ~15.78 MB, dominated by monsters-full.json (11.1 MB), spells-full.json (2.3 MB), magicitems-full.json (1.8 MB). Notably feats.json (13.2 KB) — the file the audit brief flagged as "0 bytes critical" — is now populated but is NOT the file the feats loader uses: loadFeats5e imports mpmb-feats.json (index.ts:68), so feats.json is dead. weapons-srd.json is dead because loadWeapons5e imports weapons-full.json (index.ts:52). Because Vite only bundles files reached via import(), these do NOT inflate the JS bundle, but they bloat the repo/source tree and any copy-all build steps.
- **Expected:** src/data/srd should contain only files reachable from a loader. The 10 live files are: monsters-srd, spells-srd, magicitems-srd, weapons-full, equipment, mpmb-feats, conditions, classes, races, backgrounds. Worked example: deleting monsters-full/spells-full/magicitems-full/the mpmb-* (except mpmb-feats)/the *-sample/feats.json/weapons-srd/actions.json/rules.json reclaims ~15.8 MB with zero code changes needed.
- **Fix:** Move the *-full and *-sample variants out of the bundled source tree (or delete), and remove the unused mpmb-* / feats.json / weapons-srd.json / actions.json / rules.json. Either delete feats.json or repoint loadFeats5e to it if it is the intended SRD feats source.
#### [LOW] races/feats/classes/backgrounds.json are NOT 0 bytes (audit premise is stale)
*system: 5e · dimension: data-loaders*
- **Where:** `src/data/srd/races.json, feats.json, classes.json, backgrounds.json`
- **Issue:** The brief states these four files are 0 bytes and the UI would break loading them. Current on-disk sizes: races.json 12,694 B (20 entries), backgrounds.json 21,442 B (42), classes.json 46,266 B (12), feats.json 13,486 B (104) — all modified Jun 8 17:52. loadRaces5e/loadBackgrounds5e/loadClasses parse them successfully and their loader TS types match the JSON shapes (races: slug/name/desc/asi/speed/vision/traits; backgrounds: slug/name/desc/skills/tools/languages/feature). No runtime breakage from empty files exists today. (feats.json is populated but unused — see feats-loader-source-mismatch / dead-bundled-srd-json.)
- **Expected:** No action for emptiness; the files have valid content and load. This finding records that the headline "0 bytes -> runtime breakage" risk is resolved, so reviewers don't chase a non-issue.
- **Fix:** Treat the 0-byte concern as resolved. Focus remediation on the skillChoices corruption and the dead/duplicate-source files instead.
#### [LOW] spell_list stores raw Open5e API URLs instead of spell names/slugs
*system: 5e · dimension: data-monsters*
- **Where:** `src/data/srd/monsters-srd.json (spellcaster spell_list field, e.g. Lich)`
- **Issue:** Spellcaster spell_list arrays contain raw API URLs like 'https://api.open5e.com/v2/spells/mage-hand/?format=json' rather than spell names or slugs, making the field unusable for cross-linking. Impact is limited because the human-readable prepared-spell list is duplicated in the Spellcasting special_ability desc (which is rendered), and that text is accurate (e.g. Mage: DC 14, +6 to hit, correct for INT 17 / 9th-level).
- **Expected:** spell_list should hold spell slugs/names (e.g. 'mage-hand') to enable linking to the spells compendium; or rely on the special_ability text and drop the URL field.
- **Fix:** Normalize spell_list to slugs during data prep, or ignore the field since the rendered Spellcasting text already contains the prepared list.
#### [LOW] parseDamage flattens all dice clauses without marking the base hit, conflating riders/upcast/alt-damage
*system: 5e · dimension: data-spells*
- **Where:** `src/lib/mechanics/normalize/spell.ts:28-43 (parseDamage)`
- **Issue:** parseDamage collects every distinct '<dice> <type> damage' pair into a flat array with no notion of which is the base on-hit damage vs. secondary/rider/alternative damage. Examples: prismatic-spray yields 5 entries (10d6 fire/acid/lightning/poison/cold — these are mutually-exclusive random rays, not summed), wall-of-ice yields two cold entries (10d6 and 5d6 are different triggers), spirit-guardians yields 3d8 radiant AND 3d8 necrotic (the caster picks ONE). A consumer summing or displaying the array would overstate damage.
- **Expected:** The snapshot should distinguish base damage from conditional/alternative damage, or at least not present mutually-exclusive options as a single additive set. e.g. Prismatic Spray rolls a d8 to pick one ray; it does not deal 50d6.
- **Fix:** If the snapshot is only an informational hint, document that; if it feeds any auto-computed total, restrict to the first/base damage clause or tag entries (base vs alt vs rider). Add the at-higher-levels scaling as structured data so upcast damage can be computed.
#### [LOW] Morningstar properties is null and Net has damage '0'/empty type
*system: 5e · dimension: data-equipment*
- **Where:** `src/data/srd/weapons-srd.json:385, 396-397`
- **Issue:** Morningstar has `properties: null` rather than `[]` (SRD Morningstar correctly has no properties, so the value is semantically right but the null vs empty-array inconsistency can break consumers expecting an array). Net has `damage_dice: "0"` and `damage_type: ""`, and Blowgun has `damage_dice: "1"`. These are accurate to the SRD (Net deals no damage; Blowgun deals 1 piercing) but the non-dice string format ('0','1') and null properties are data-hygiene risks for any code that parses damage_dice as NdM or maps over properties.
- **Expected:** Morningstar should be `properties: []` for consistency with every other no-property weapon (e.g. Flail, Mace, War pick all use []). Net damage_type should be 'piercing' is not required (Net deals no damage in SRD, so empty is acceptable); Blowgun '1' piercing is correct per SRD.
- **Fix:** Normalize Morningstar `properties` to [] to match sibling entries; optionally guard consumers against non-NdM damage_dice strings ('0','1'). No stat is wrong — purely a consistency/robustness improvement.
#### [LOW] index.json reports 3950 actions but the file holds 646 (stale build artifact)
*system: pf2e · dimension: data-pf2e*
- **Where:** `public/data/pf2e/index.json (action count 3950) vs public/data/pf2e/actions.json (646 entries)`
- **Issue:** index.json lists the action category count as 3950, but actions.json actually contains 646 entries after the dedup/junk-filter added in commit 1c87b0e. index.json was not regenerated. This is harmless at runtime because no source file reads index.json (grep for 'index.json' in src returns nothing), but the metadata is misleading.
- **Expected:** index.json count should equal the file's entry count (646). The fetch script writes index from entries.length, so a re-run would self-correct.
- **Fix:** Regenerate index.json (re-run scripts/fetch_pf2e.ts) or drop the unused index.json. Cosmetic only.
## 🖱 Usability (20)
#### [CRITICAL] PF2e beginner templates softlock the Abilities step ("-Infinity / 27", Next disabled)
*system: pf2e · dimension: ux-character*
- **Where:** `src/features/characters/builder/CreationWizard.tsx:226-232 (applyTemplate) + 46-52 (TEMPLATES.pf2e) + 404/430 (point-buy UI)`
- **Issue:** applyTemplate() unconditionally sets method='pointbuy' and copies the template's ability array into pb. The pf2e templates use real PF2e starting scores above 15 (Stalwart Fighter str:18, Clever Wizard int:18, Sneaky Rogue dex:18, Healing Cleric wis:18). pointBuyRemaining() returns -Infinity whenever any score is outside 8..15 (pointCost returns null -> pointBuySpent returns Infinity), so pbValid is false and stepValid.Abilities is false, disabling 'Next'. The point-buy NumberFields are also hard-clamped to max={POINT_BUY_MAX}=15 (line 430), so the user cannot even see/keep the 18 once they touch a field. The header shows literally 'Points remaining: -Infinity / 27'. A beginner who clicks the headline 'Start from a ready-made hero' shortcut is trapped on the Abilities step.
- **Expected:** Applying a template must not leave the wizard in an invalid, un-advanceable state. PF2e characters legitimately start with an 18 in their key attribute, so they should not be forced into the 5e 27-point buy at all. Concrete fix: in applyTemplate, set method='manual'/'standard-as-assigned' style or introduce a 'manual' ability mode that accepts the template scores verbatim (clamp 1..30, not 8..15) and treats the step as valid; OR for pf2e skip point-buy and seed the pool/assignment so the 18 is representable. At minimum, do not setMethod('pointbuy') when any template score is outside 8..15. Worked example: choosing 'Stalwart Fighter' (pf2e) should leave STR 18 / DEX 14 / CON 14 / INT 10 / WIS 12 / CHA 10 intact and allow 'Next'.
- **Fix:** Add a 'manual' ability method (free-entry, clamp 1..30) and have applyTemplate select it; keep point-buy only for user-driven point-buy. Alternatively gate: `setMethod(t.ability values all in 8..15 ? 'pointbuy' : 'manual')`. Ensure stepValid.Abilities is true for manual mode.
#### [CRITICAL] Player silently loses their seat on any WebSocket reconnect; HP edits and rolls stop reaching the GM with no UI cue
*system: both · dimension: ux-combat-play*
- **Where:** `server/src/rooms.ts:111,153-162,205-222,264-275; src/lib/sync/wsSync.ts:53-58,72-75,115-121`
- **Issue:** The server assigns a fresh playerId via crypto.randomUUID() on every connection (rooms.ts:111) and deletes the player's seat on disconnect (rooms.ts:271). The client auto-reconnects (wsSync.ts:115-121) and re-sends `join`, getting a NEW playerId, but it never resets myCharacter/seatStatus — those only reset on joinSession/leaveRoom/stopSession (wsSync.ts:134,221-222). So after a transient drop the player UI still renders MyCharacterPanel as 'granted', but sendPlayerPatch/sendPlayerRoll are rejected server-side because the new playerId holds no seat (rooms.ts:210-211 `if (!seat || seat.characterId !== characterId) return;` and rooms.ts:221-222 `if (!seat) return;`). The player's HP/condition/spell changes and dice rolls silently vanish; the only feedback is the small 'Connecting…'/'Live' header chip.
- **Expected:** A reconnecting player should keep (or transparently re-acquire) their seat. Concrete fix: give the player a stable client-generated playerId persisted in joinIntent and send it on join, OR keep the seat alive across brief disconnects with a grace window keyed by a resume token (mirroring the GM's gmSecret resume at rooms.ts:71-80), OR on reconnect have the client detect seatStatus==='granted' and automatically re-send claimSeat for myCharacter.id and have the GM/server auto-regrant. At minimum, the client should drop seatStatus to 'pending' on socket close so the player sees they must re-claim instead of editing into the void.
- **Fix:** Persist a stable playerId on the client and resend it on join; on the server, rekey seats by that stable id and add a short disconnect grace period before deleting the seat. Until then, reset seatStatus to 'pending' in socket.onclose so the UI stops implying live control.
#### [HIGH] Editing Level on the sheet changes nothing else (HP/spell slots/proficiency don't update, no prompt)
*system: both · dimension: ux-character*
- **Where:** `src/features/characters/CharacterSheet.tsx:180-182`
- **Issue:** The header 'Level' NumberField does `onChange={(level) => update({ level })}` — a bare field write. Proficiency display and derived modifiers update (they read c.level), but HP max, spell slots, pact slots, and any per-level resources are NOT recomputed and there is no nudge to use Level Up. A GM who bumps a character from 1 to 5 here to fix data ends up with a level-5 character still showing level-1 HP and zero/level-1 spell slots, with no warning. Conversely, the dedicated 'Level up' button only ever does +1. There's no way to set an arbitrary level and have the sheet reconcile.
- **Expected:** Either (a) make this field read-only / informational and direct the user to 'Level up', or (b) when level is edited directly, surface a banner offering to recompute HP and spell slots (e.g. via buildCharacter/planLevelUp), or (c) hide direct level editing entirely. A user changing level should never be left with stale HP/slots silently. Worked example: level 1 Wizard (HP ~6-8, no slots) set to level 5 should not keep 8 HP and 0 first-level slots with no indication anything is wrong.
- **Fix:** Make the inline Level field non-editable (or add a confirm + recompute path). At minimum show a warning when c.level differs from what the HP/slots imply.
#### [HIGH] World-content deletes on one click, no confirm/undo
*system: both · dimension: ux-global*
- **Where:** `src/features/world/NotesPage.tsx:130`
- **Issue:** repo.remove() called directly, no confirm: NotesPage:130, QuestsPage:113, HomebrewPage:107, MapsPage:95. Campaign delete uses confirm Modal (CampaignsPage:181-207); no undo.
- **Expected:** Confirm deletes of authored content like campaign delete; one misclick destroys a whole quest, no recovery.
- **Fix:** Reuse confirm Modal or add undo.
#### [MEDIUM] NumberField has no empty/intermediate state — clears to min/0 every keystroke, making min-bounded fields painful to retype
*system: both · dimension: ux-character*
- **Where:** `src/components/ui/NumberField.tsx:21-33`
- **Issue:** The input is fully controlled with `value={Number.isFinite(value) ? value : 0}` and onChange immediately coerces+clamps: empty string -> Number('')===0 -> Math.max(min, 0). There is no local string buffer, so you can never transiently empty the field. With a min (point-buy min 8, ability min 1, qty min 0/1), clearing to retype snaps the field to min on the first keystroke and prevents natural editing. Example: a point-buy STR field (min 8) — to change 8 to 12 you cannot select-all and type '12' cleanly: deleting yields 8, then typing '1' before the 8 etc. is awkward; on mobile the value jumps around. Same for HP 'Cur' (no min but coerces blank to 0) and Max HP (min 0).
- **Expected:** Keep a local string state so the field can be transiently empty while typing, and only commit/clamp on blur (or when a valid number is parsed). A user should be able to clear the field and type a new number without it snapping to min mid-edit. Worked example: clearing a min=8 field should show empty until blur, then default to min if left blank.
- **Fix:** Add internal `useState` string buffer; render the raw string while focused, parse+clamp+emit onBlur (and on Enter). This is a one-component fix that improves every numeric input in creation and the sheet.
#### [MEDIUM] Creation wizard loses all entered data on stray backdrop click / Escape with no confirmation
*system: both · dimension: ux-character*
- **Where:** `src/features/characters/builder/CreationWizard.tsx:271-272 (Modal open onClose) + src/components/ui/Modal.tsx:43-46,72`
- **Issue:** The wizard renders inside Modal, which closes on Escape (Modal.tsx:43-46) and on backdrop click (Modal.tsx:72 `onClick={onClose}`). The wizard passes onClose straight through with no guard. A multi-step character (name, class, abilities, skills, spells) is discarded instantly on a misclick outside the panel or a stray Escape — there is no 'Discard character?' confirmation and nothing is persisted until 'Create character' on the final step.
- **Expected:** A multi-step creation flow should confirm before discarding non-trivial progress (e.g. if a class is selected or any field is filled). At minimum, disable backdrop-to-dismiss for the wizard, or intercept onClose to confirm. Worked example: user on the Spells step who clicks just outside the dialog should be asked to confirm, not silently lose 5 steps of work.
- **Fix:** Wrap onClose with a confirm when state is dirty (selectedClass set or name non-empty), or pass a Modal prop to disable backdrop/Escape dismissal for the wizard.
#### [MEDIUM] Standard-array / roll assignment offers no swap — reassigning a value forces manual juggling of every dropdown
*system: both · dimension: ux-character*
- **Where:** `src/features/characters/builder/CreationWizard.tsx:425-428 and src/features/characters/sheet/AbilityGenModal.tsx:95-106`
- **Issue:** In pool modes, each ability is a <Select> of pool indices where already-assigned indices are `disabled`. Because there is no swap-on-collision logic, the user cannot simply move the '15' from STR to DEX — DEX's 15 option is disabled (it's used by STR). To re-order, the user must first set STR to some free value, then set DEX to 15. The validity guard requires all six indices distinct (`new Set(assignment).size === 6`), so the natural 'pick 15 on two abilities then fix it' path is blocked at every step. This is classic array-assignment friction the UI doesn't solve.
- **Expected:** Selecting a value already held by another ability should swap the two assignments (the other ability takes the value this one gave up), so any reassignment is one action. Worked example: STR=15, DEX=10; choosing 15 for DEX should set DEX=15 and STR=10 automatically.
- **Fix:** On change, if the chosen pool index is held by ability j, swap assignment[i] and assignment[j] instead of disabling. Apply to both CreationWizard and AbilityGenModal (same pattern).
#### [MEDIUM] On disconnect, players keep showing the last snapshot with no on-board staleness indicator
*system: both · dimension: ux-combat-play*
- **Where:** `src/lib/sync/wsSync.ts:115-121; src/stores/playerSessionStore.ts:51 (reset only on join/stop); src/features/player/PlayerBoards.tsx; src/features/play/PlayerConnection.tsx:12`
- **Issue:** When the player's socket closes, the snapshot is intentionally NOT cleared (reset only runs on joinSession/stopSession), so the board (HP bars, initiative, map) keeps rendering the last-received state. That is reasonable, but the only indication that the data is frozen is the tiny header chip flipping to 'Connecting…' (PlayerConnection.tsx:12). During combat a player can stare at an out-of-date initiative/HP board believing it is live. There is no banner over the boards and no 'last updated' marker.
- **Expected:** While status !== 'connected' and a snapshot is shown, overlay a clear 'Reconnecting — showing last known state' banner on the player boards (and dim them), so players don't act on stale HP/turn info.
- **Fix:** Pass connection status into PlayerBoards (or render a sticky banner in NetworkedPlayerView) when status !== 'connected' and a snapshot exists.
#### [MEDIUM] text-faint fails WCAG AA contrast on normal body text
*system: both · dimension: ux-global*
- **Where:** `src/styles/globals.css:25`
- **Issue:** --app-faint ~3.37:1 light/~3.10:1 dark on panel, under 4.5:1 AA; on text-xs info CompendiumPage:221,248; DicePage:104; DashboardPage:179-215; SettingsPage:58.
- **Expected:** Raise --app-faint to >=4.5:1 or use text-muted.
- **Fix:** Raise --app-faint to >=4.5:1 or use text-muted.
#### [MEDIUM] No 404/not-found route; unknown URLs render blank shell
*system: n/a · dimension: ux-global*
- **Where:** `src/router.tsx:65`
- **Issue:** createRouter has no defaultNotFoundComponent, root no notFoundComponent, no catch-all; URLs outside 17 routes -> empty main, no way back.
- **Expected:** Add defaultNotFoundComponent rendering an EmptyState with a Go to Campaigns link.
- **Fix:** Add a 404 EmptyState not-found component.
#### [MEDIUM] Import button never advertises Pathbuilder support
*system: both · dimension: features-gap*
- **Where:** `src/features/characters/CharactersPage.tsx:47-49`
- **Issue:** The roster's 'Import' button has no label, tooltip, or helper text saying it accepts Pathbuilder JSON; pickTextFile() opens a generic file dialog. A user with a Pathbuilder export has no signal the feature exists, so a built importer goes unused.
- **Expected:** Surface accepted formats near the control, e.g. button title or subtext 'Import a saved character or Pathbuilder 2e export (.json)'. Empty-state copy could also mention it.
- **Fix:** Add a tooltip/subtitle listing supported import sources and constrain the file picker to .json. Cheap fix that unlocks an already-implemented feature.
#### [MEDIUM] AI assistant features silently inert without a user-supplied API key
*system: both · dimension: features-gap*
- **Where:** `src/lib/llm/client.ts:138-140; src/stores/assistantStore.ts:11-15`
- **Issue:** The assistant is disabled by default (enabled:false) and complete() returns 'disabled'/'no-key' unless the GM pastes their own API key and base URL into Settings. So LLM-backed features (NPC generator free-text flavor, session-prep hooks, level-up/encounter advisory prose) do nothing meaningful out of the box. Deterministic features (encounter builder, signals) still work, but the 'Assistant' surface looks broken to a GM who hasn't configured a provider.
- **Expected:** Either ship a working default (proxy/free tier) or make the no-key state self-explanatory at every assistant entry point, clearly separating the deterministic features (which always work) from the LLM ones (which need a key). Concrete: NpcGenCard should state 'Add an API key in Settings to generate flavor' rather than failing on click.
- **Fix:** Gate LLM-only cards behind a visible 'connect a provider' affordance and confirm each has a deterministic fallback. Document the BYO-key requirement prominently.
#### [LOW] Default attack ability hardcoded to STR; no finesse or ranged auto-pick
*system: 5e · dimension: calc-5e-weapons-hp*
- **Where:** `AttacksSection.tsx:28`
- **Issue:** New attacks default ability to str; weaponAttack has no finesse or ranged logic, so a Rapier or a ranged weapon uses STR not DEX.
- **Expected:** Ranged uses DEX, finesse uses the higher of STR and DEX. A Rogue with STR 10 and DEX 16 and prof bonus 2 using a Rapier should be plus 5 to hit and 1d8 plus 3, but the default shows plus 2 and 1d8.
- **Fix:** Default the ability to DEX for ranged or the higher of STR and DEX for finesse.
#### [LOW] applyRollMode applies advantage to the first single die of any size, not the d20
*system: both · dimension: calc-dice-rng*
- **Where:** `src/lib/dice/notation.ts:68-78`
- **Issue:** applyRollMode converts the first single-die term regardless of die size. For 1d4+1d20 with advantage it produces 2d4kh1+1d20, applying advantage to the d4. Verified by running the regex.
- **Expected:** Advantage is a d20 mechanic, so for 1d4+1d20 the intended output is 1d4+2d20kh1. The primary caller rollCheck always emits a leading 1d20 so checks work in practice.
- **Fix:** Prefer the first d20 term, falling back to the first single die only when no d20 is present.
#### [LOW] Prepared-spell checkbox has no limit and no count — no enforcement or guidance for prepared casters
*system: both · dimension: ux-character*
- **Where:** `src/features/characters/sheet/SpellcastingSection.tsx:176-181`
- **Issue:** Each leveled spell has a free 'Prepared' checkbox with no cap and no displayed count of how many a character may prepare (5e prepared casters = spellcasting ability mod + level/class level). There's no 'X / Y prepared' readout or warning, so the sheet gives zero help distinguishing the 'known/repertoire' list from the legal prepared set. The wizard copy explicitly says 'You prepare or cast them from slots on the character sheet', but the sheet offers no prepared-count support.
- **Expected:** Show a prepared count (e.g. 'Prepared 4/6') for prepared casters and optionally warn past the limit. Even a non-enforcing 'N prepared' tally would orient the user. Worked example: a level-5 Cleric (WIS +3) prepares 8 spells; the sheet should at least display how many are currently checked.
- **Fix:** Render a prepared tally next to 'Spell slots' (count of s.prepared && s.level>0); optionally compute the suggested max from class+ability and show it as guidance.
#### [LOW] Skills step requires an exact count but message/limit can mismatch when a class restricts choices
*system: both · dimension: ux-character*
- **Where:** `src/features/characters/builder/CreationWizard.tsx:163-175, 238, 442`
- **Issue:** skillCount is the class skill count (+ INT mod on pf2e), but the selectable list is skillOptions which, for classes with a restricted skillChoices list, can be SMALLER than skillCount. toggleSkill caps selections at skillCount (not at skillOptions.length), while stepValid.Skills requires `skills.length === Math.min(skillCount, skillOptions.length)`. The header text uses Math.min so it reads correctly, but the toggle cap uses the raw skillCount, so if skillOptions.length < skillCount a user could in principle be told to pick min(...) yet the checkbox-disable threshold is the larger skillCount — an inconsistent ceiling. For pf2e where intMod inflates skillCount, the restricted-list case can be confusing about exactly how many are required vs selectable.
- **Expected:** Use a single source of truth (the effective required count = Math.min(skillCount, skillOptions.length)) for the disable cap, the header, and validation, so the UI never lets/asks the user toggle beyond what the step requires. Worked example: a class whose skillChoices list has only 3 entries but skillCount=4 should require and allow exactly 3, consistently.
- **Fix:** Define `const required = Math.min(skillCount, skillOptions.length)` once and use it in toggleSkill, the header, and stepValid.
#### [LOW] Ambient signals are noisy: a per-turn 'turn' info signal plus always-on planning/resource items churn the list every action
*system: both · dimension: ux-combat-play*
- **Where:** `src/lib/assistant/advisors.ts:97-114,78-92,41-49; src/features/assistant/SignalsBell.tsx:44-49`
- **Issue:** buildSignals runs combatSuggestions which emits an info 'Round X: <name>'s turn' signal on every turn (advisors.ts:100-102), plus 'N quests in progress', 'has expended spell slots', and 'party running low' that persist across the whole session. SignalsBell rebuilds on every characters/encounter change (SignalsBell.tsx:44-47). Because the 'turn' signal id is the constant 'turn' but its title changes each turn, dismissing it once hides the turn indicator for the rest of the session, while not dismissing means the popover content shifts under the user on every Next-turn. The attention badge only counts non-info items (SignalsBell.tsx:49), which mitigates badge spam, but the open popover is still busy/low-signal.
- **Expected:** Drop the per-turn 'turn' info signal from the ambient bell (it duplicates the tracker header and changes every action), and consider collapsing always-on informational items behind a 'show info' toggle so the bell foregrounds danger/warn items.
- **Fix:** Remove the 'turn' signal from combatSuggestions (or mark it tracker-only), and de-emphasize standing info-level planning/resource signals.
#### [LOW] LLM encounter/level-up advisor surfaces only a terse one-line error and silently falls back, with no retry/raw-output affordance
*system: both · dimension: ux-combat-play*
- **Where:** `src/features/assistant/useEncounterAdvisor.ts:133-147; src/lib/llm/client.ts:182-189; src/features/assistant/useLevelUpAdvisor.ts:46`
- **Issue:** When the model returns malformed JSON or the wrong shape, complete() collapses every cause into generic strings: 'The model did not return valid JSON.' or 'The model output did not match the expected shape.' (client.ts:186-188). The encounter advisor shows 'AI unavailable: <message> Showing a deterministic pick.' (useEncounterAdvisor.ts:145) and the level-up advisor shows 'AI unavailable: … Showing general routes.' (useLevelUpAdvisor.ts:46) then silently uses deterministic output. There is no way to see the raw model text, no explicit 'retry AI' (Regenerate re-enters the same path and may silently go deterministic again because targetIsHarder/validation gates), and the empty-response case (token exhaustion) is only explained in one branch (client.ts:182-183).
- **Expected:** Distinguish failure modes (empty/timeout/HTTP/shape) in user-facing copy, indicate clearly when output is deterministic vs AI, and on a shape mismatch keep enough context to offer a real retry. When the LLM returns zero usable candidates (useEncounterAdvisor.ts:137-138 valid.length===0), tell the user the AI picked unavailable creatures rather than silently swapping to deterministic.
- **Fix:** Add a message on the valid.length===0 branch; pass through the zod issue path in the parse error; label the suggestion source prominently and offer an explicit 'Try AI again'.
#### [LOW] Down-PC death-save reminder and concentration note compute from a stale closure, so rapid Next-turn clicks can attribute the prompt to the wrong combatant
*system: both · dimension: ux-combat-play*
- **Where:** `src/features/combat/EncounterTracker.tsx:91-97`
- **Issue:** advanceTurn persists the turn via mutate(nextTurn) (transactional, correct), but then computes the 'upcoming' combatant by re-running nextTurn(encounter) on the closure's `encounter` prop, which is one render behind. If the GM presses Next (or the n/→ key) twice before React re-renders, the second invocation's closure still holds the pre-advance encounter, so the 'is down — make a death saving throw' / concentration reminder can be logged for the wrong combatant or round. The actual turn pointer stays correct (mutate reads fresh state); only the advisory log line can misfire.
- **Expected:** Derive the upcoming combatant inside the same transactional mutate (so it reads committed state), or log the reminder from within the mutate callback that advanced the turn, ensuring the named combatant matches the one whose turn actually began.
- **Fix:** Move the down/concentration reminder into the nextTurn mutate callback (compute from the post-advance encounter), eliminating the stale-closure path.
#### [LOW] Minor a11y: no skip link, palette ARIA/scroll, reduced-motion iteration-count
*system: n/a · dimension: ux-global*
- **Where:** `src/app/RootLayout.tsx:228; src/components/CommandPalette.tsx:70; src/styles/globals.css:143`
- **Issue:** RootLayout:228 no skip link/main id. CommandPalette:70 arrow keys set idx but no scrollIntoView + no listbox/option/aria-activedescendant. globals.css:143 reduced-motion omits animation-iteration-count:1 so infinite animations loop.
- **Expected:** Add skip link+main id; scrollIntoView+listbox ARIA in palette; animation-iteration-count:1 in reduced-motion block.
- **Fix:** Add skip link; palette scroll+ARIA; iteration-count:1.
## Missing features (24)
#### [MEDIUM→HIGH] Massive-damage instant death is not implemented
*system: 5e · dimension: calc-combat-engine · _verified: confirmed_*
- **Where:** `src/lib/combat/engine.ts:216-240; src/lib/mechanics/deathSaves.ts`
- **Issue:** Nothing checks instant death from massive damage. applyDamage lets current go negative (engine.ts:238, no lower clamp) which is the right substrate, but nothing compares the overflow to max HP and no dead outcome arises from damage. Grep for massive/instant finds only the deathSaves d20 path.
- **Expected:** 5e (Instant Death): if leftover damage after dropping a creature to 0 HP is >= its max HP, it dies instantly. Example: a 12-max PC at 4 HP takes 17 -> 0 with 13 overflow; 13>=12 so dead, no death saves. Engine should detect overflow>=max and mark dead.
- **Fix:** When a hit reduces current<=0, compute overflow and if overflow>=hp.max mark the combatant dead and stop the death-save clock; add boundary tests (==max -> dead, ==max-1 -> dying).
#### [CRITICAL→HIGH] UI never passes atLevel: warlocks cannot cast pact spells; no upcasting/heightening possible
*system: both · dimension: calc-mechanics-resources · _verified: confirmed_*
- **Where:** `src/features/characters/sheet/SpellcastingSection.tsx:61, src/features/player/MyCharacterPanel.tsx:121 (kernel: src/lib/mechanics/cast.ts:31-46)`
- **Issue:** Both call sites invoke castSpell(c, id) with no atLevel, so castSpell uses useLevel = Math.max(spell.level, spell.level) = the spell's own base level (cast.ts:39). consumeSlot(sc, useLevel) only matches a regular slot at that exact level OR a pact slot whose pact.level === useLevel (cast.ts:5-19). A warlock has no regular slots and a pact slot at the pact level (PACT[5]=[2,3] in dnd5e/progression.ts:50 → pact.level 3), while a typical warlock spell like Hex is level 1. Casting Hex calls consumeSlot(sc, 1): no regular level-1 slot and sc.pact.level (3) !== 1, so it returns null and castSpell fails with 'No level-1 slot available'. The warlock can therefore never spend a pact slot from the UI; the kernel's pact path (verified only by the test passing atLevel: 3 at enforce.test.ts:74) is unreachable in the app. The same omission means no 5e upcast and no pf2e heightening is ever selectable.
- **Expected:** The cast UI must let the player choose the slot level (a level picker / right-click menu) and pass it as atLevel. For a warlock, casting any leveled spell should consume a pact slot at the pact level: castSpell(c, hexId, c.spellcasting.pact.level) → consumeSlot finds sc.pact.level === 3, decrements pact.current. Worked example: warlock 5, pact {level:3, current:2}, casts Hex (level 1) → should consume 1 pact slot leaving current 1; currently the cast is rejected outright.
- **Fix:** Add a slot-level selector to the cast control in both SpellcastingSection and MyCharacterPanel; default it to spell.level but offer every level that has an available regular slot plus the pact level when sc.pact exists, and pass the chosen level as castSpell(c, id, level). The kernel already supports this correctly.
#### [HIGH] 5e Exhaustion's six-level mechanical effects are never derived
*system: 5e · dimension: calc-conditions-damage · _verified: confirmed_*
- **Where:** `src/lib/mechanics/conditionEffects.ts:29-42; src/lib/rules/conditions.ts:15`
- **Issue:** Exhaustion is the only valued 5e condition (conditions.ts:15) and its level effects are documented in conditions.json (disadvantage on ability checks at L1, speed halved at L2, disadvantage on attacks and saving throws at L3, HP max halved at L4, speed 0 at L5, death at L6). But CONDITION_EFFECTS_5E has no `exhaustion` key, so deriveState produces nothing for it: no abilityCheckDisadvantage, no speed reduction, no attack/save disadvantage, no speed 0 at level 5. The condition is a visible label only with zero mechanical enforcement.
- **Expected:** Exhaustion 3 should set abilityCheckDisadvantage=true AND attackModifier=disadvantage AND apply disadvantage to all saving throws (cumulative L1+L2+L3); Exhaustion 5 should additionally set speed 0. Worked example: a level-3-exhausted creature attacking should roll with disadvantage and have disadvantage on every save; the code returns normal on all of these.
- **Fix:** Add a value-aware exhaustion handler in deriveState that applies cumulative effects by level (L1 ability-check disadv, L2 speed halved, L3 attack+all-save disadv, L5 speed 0). Note speed-halved (L2) currently has no representation in MechanicalState (only speedZero), so a fractional-speed field is also needed.
#### [HIGH] No per-level 5e class-feature progression exists (Extra Attack, Sneak Attack, Rage, etc.)
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*
- **Where:** `src/lib/rules/dnd5e/progression.ts:8-33; src/lib/rules/progression.ts; src/data/srd/classes.json`
- **Issue:** The 5e class data structure (ClassDef / RulesetClass) has no `features` field at all. classes.json `description` is just a one-word tag of the level-1 feature ('Rage','Expertise','Fighting Style'). buildCharacter and planLevelUp grant only HP, saves, skills, spell slots, ASI levels. Core combat-defining features are never recorded on the character.
- **Expected:** A level-5 Fighter should have Extra Attack (two attacks per Attack action); a level-5 Rogue should have 3d6 Sneak Attack; a level-2 Barbarian should track Rage uses; a level-2 Fighter should have Action Surge. None of these are added at build or level-up, so a built mid-level martial character is mechanically incomplete and the user must hand-author every feature in the free-text Feats section.
- **Fix:** Add a per-class, per-level feature table (at least names + effect text, ideally structured for Extra Attack / Sneak Attack dice) and write granted features into character.feats (or a features list) at build and level-up.
#### [HIGH] Feats are manual free-text with no link to feat data and no mechanical effect
*system: 5e · dimension: data-classes-races-feats · _verified: confirmed_*
- **Where:** `src/features/characters/sheet/FeatsSection.tsx:13-21; src/features/characters/sheet/LevelUpModal.tsx:128-130`
- **Issue:** The only way to add a feat is to type a name (and optionally a description) by hand in FeatsSection. There is no selector backed by the feat compendium, and a feat's effect is never applied to abilities/AC/saves/proficiencies. Even the ASI-vs-feat choice at level-up just says 'Add your chosen feat on the sheet after leveling.'
- **Expected:** Picking a feat should apply its mechanics. Worked examples: Tough should add +2 HP per level (a level-5 Tough character gains +10 max HP); Resilient (Con) should add Constitution save proficiency; Lightly/Heavily Armored should grant armor proficiency; a half-ASI feat like Athlete should add +1 to an ability. None of these are applied — the feat is a label with optional notes.
- **Fix:** Provide a feat picker backed by mpmb-feats.json (which has structured benefit text) and apply at least the deterministic mechanical feats (Tough HP, Resilient save proficiency, armor-training feats, +1 half-feats) to the character.
#### [HIGH] GM-applied damage almost never triggers a concentration prompt because the tracker reads Character.concentration, which the GM only knows if a seated player cast via their own panel
*system: 5e · dimension: ux-combat-play*
- **Where:** `src/features/combat/EncounterTracker.tsx:81-88,237-244; src/lib/sync/wsSync.ts:87-97; src/lib/schemas/encounter.ts (combatant has no concentration field)`
- **Issue:** noteConcentrationCheck looks up pcCharacter(combatant).concentration (a field on the Character doc), not on the Combatant. A Combatant has no concentration field (encounter.ts combatantSchema). The Character.concentration is set on the GM side only when a seated player casts a concentration spell through MyCharacterPanel and the diff is persisted via the playerPatched handler (wsSync.ts:87-97 includes `concentration`). So when the GM runs a PC/NPC themselves, or the player is not seated, or the spell was cast verbally at the table, the tracker never knows the creature is concentrating and never surfaces the DC after damage — defeating the documented 'detect and surface the DC' design (the very feature the comment at EncounterTracker.tsx:75-80 promises).
- **Expected:** After applying damage to any combatant the GM has flagged as concentrating, the tracker should surface 'roll DC max(10, floor(dmg/2)) Con save'. Provide a per-combatant 'concentrating on …' toggle in the row (independent of the linked Character) so the GM can mark NPC casters and PCs they run, and have noteConcentrationCheck read that combatant flag. Example: a marked caster taking 22 damage should log 'DC 11 Constitution save', per concentrationDC(22)=max(10,floor(11))=11.
- **Fix:** Add a concentration flag (and spell name) to the Combatant schema with a quick toggle in CombatantRow; key the post-damage save prompt off the combatant flag, falling back to the linked Character.
#### [HIGH] No D&D Beyond character import
*system: 5e · dimension: features-gap*
- **Where:** `src/lib/io/ (only character.ts + pathbuilder.ts exist)`
- **Issue:** The only external importer is Pathbuilder 2e (src/lib/io/pathbuilder.ts). A repo-wide grep for dndbeyond/ddb/'D&D Beyond' finds no importer. 5e players, the app's primary audience, have no way to bring an existing D&D Beyond character in — they must hand-rebuild it in the wizard.
- **Expected:** A 5e import path: accept a D&D Beyond JSON export / character-service payload (or at minimum a documented .json shape) and map abilities, class/level, race, proficiencies, AC, HP, spells, and inventory into the Character schema, mirroring parseCharacterImport's Pathbuilder branch. Concrete: importing a level-5 D&D Beyond Wizard should populate abilities, 6 levels of slots, prepared spells, and inventory with one file pick.
- **Fix:** Add a ddb importer module paralleling pathbuilder.ts and wire it into parseCharacterImport's auto-detect. Even a best-effort mapping of the common fields (abilities/HP/AC/class/level/skills/spells) would remove the single biggest 5e onboarding wall.
#### [HIGH] Pathbuilder import drops spells, feats, inventory, and attacks
*system: pf2e · dimension: features-gap*
- **Where:** `src/lib/io/pathbuilder.ts:21-52`
- **Issue:** pathbuilderToCharacterFields maps only name/class/level/ancestry, abilities, HP, save ranks, skill ranks, and perception. Pathbuilder build JSON also carries spells, feats, formula/equipment, weapons, and focus points — none are read. The imported pf2e character arrives with an empty spell list, no feats, no inventory, no attacks, and background/heritage stuffed into a notes string.
- **Expected:** Map Pathbuilder's spellCasters[]/focus, feats[], equipment[]/weapons[] into spellcasting.spells (with slots), feats[], inventory[], and attacks[]. Concrete: importing a Pathbuilder Wizard should yield its prepared spell list and slot counts, not an empty Spellcasting section the player must re-enter by hand.
- **Fix:** Extend the importer to consume spellCasters, feats, weapons, and equipment arrays from the build. Surface in the UI which sections were/weren't imported so the player knows what to finish.
#### [HIGH] No loot / treasure builder (roadmap item absent)
*system: both · dimension: features-gap*
- **Where:** `whole src tree (no loot/treasure module)`
- **Issue:** README's roadmap explicitly lists 'encounter/loot builders'. The encounter builder exists (src/lib/assistant/encounter.ts), but a grep for loot/treasure across src finds nothing — there is no treasure generator, hoard table, or per-encounter reward tracking. A GM cannot roll or assign loot, and there is no party currency/treasure ledger beyond a single character's currency field.
- **Expected:** A loot builder: 5e DMG treasure-hoard/individual tables by CR, or PF2e GMG treasure-by-level. Concrete: 'generate treasure for a level-5 PF2e party' should propose gp + a couple of level-appropriate items the GM can hand out (and optionally write to a character's inventory/currency).
- **Fix:** Add a loot module mirroring budget.ts/encounter.ts: data-driven treasure tables per system, a 'Generate loot' action on an encounter, and a one-click 'award to party' that writes currency/items to selected characters.
#### [HIGH] No XP tracking or award flow; leveling is fully manual
*system: both · dimension: features-gap*
- **Where:** `src/lib/schemas/character.ts:135-191; src/features/combat/EncounterTracker.tsx:168`
- **Issue:** characterSchema has no xp/experience field. The combat tracker computes and displays totalXp and awardPerCharacter (EncounterTracker.tsx:168) but there is no button to grant it, and nowhere to store it. Leveling (LevelUpModal) is a manual 'Apply level N' the GM clicks; it never checks an XP threshold. A campaign run on XP advancement has to track XP entirely outside the app.
- **Expected:** Add an xp field to Character, an 'Award XP' action that distributes an encounter's awardPerCharacter to party members, and a level-up prompt when XP crosses the threshold (5e XP-by-level table; PF2e 1000-XP-per-level). Milestone groups can simply ignore XP. Concrete: defeating a 1100-XP encounter for 4 PCs should offer to add 275 XP each and flag anyone who can now level.
- **Fix:** Persist XP on the character, wire the already-computed encounter award into a one-click grant, and gate/suggest level-up on threshold. Milestone mode stays the current manual button.
#### [HIGH→MEDIUM] Multiple Attack Penalty (MAP) not implemented or surfaced
*system: pf2e · dimension: calc-pf2e-weapons-misc · _verified: confirmed_*
- **Where:** `src/lib/rules/pf2e/index.ts:101 (and src/features/characters/sheet/AttacksSection.tsx:57-88)`
- **Issue:** weaponAttack returns only the first-Strike to-hit. There is no Multiple Attack Penalty: PF2e applies -5 on the second attack action in a turn and -10 on the third+ (reduced to -4/-8 for agile weapons). The WeaponInput type has no 'agile' trait and the attack UI shows a single to-hit number with no MAP variants, so a GM/player cannot see the second/third Strike bonuses that the PF2e action economy is built around.
- **Expected:** PF2e CRB/Player Core: the second attack action on your turn takes a -5 penalty and the third and later take -10; agile weapons reduce these to -4 and -8. Example: a +10 first Strike with a non-agile weapon should show +10 / +5 / +0; with an agile weapon +10 / +6 / +2. The tool should expose these (e.g. return mapSecond/mapThird, or accept an 'agile' flag on WeaponInput).
- **Fix:** Add MAP to the PF2e weaponAttack output (second/third to-hit) and an 'agile' flag on WeaponInput so the -4/-8 case is handled; surface all three values in AttacksSection.
#### [MEDIUM] Striking runes (extra weapon damage dice) not modeled
*system: pf2e · dimension: calc-pf2e-weapons-misc · _verified: confirmed_*
- **Where:** `src/lib/rules/types.ts:25-36, src/lib/rules/pf2e/index.ts:105-106`
- **Issue:** The weapon model has only damageDice (free-text string) and a flat itemBonus. There is no representation of striking runes, which in PF2e increase the NUMBER of weapon damage dice (striking = 2x, greater striking = 3x, major striking = 4x base dice). As a result a striking weapon cannot be represented correctly, and (per the related defect) magic-weapon damage is instead mis-modeled as a flat item bonus.
- **Expected:** A 1d8 longsword with a striking rune deals 2d8 (greater striking 3d8, major 4d8) plus the ability modifier — extra dice, not a flat bonus. Add a striking tier that multiplies the base damage dice count.
- **Fix:** Introduce an optional striking/diceCount field on WeaponInput and have the PF2e weaponAttack expand damageDice accordingly (e.g. '1d8' + striking -> '2d8').
#### [HIGH→MEDIUM] Taking damage while at 0 HP does not cause a death-save failure (crit not 2)
*system: 5e · dimension: calc-combat-engine · _verified: confirmed_*
- **Where:** `src/lib/combat/engine.ts:216-240; src/features/combat/EncounterTracker.tsx:237-244`
- **Issue:** applyDamage only adjusts hp.temp/hp.current; it never records a death-save failure when a PC at 0 HP is hit. EncounterTracker onDamage likewise writes only hp and never increments defenses.deathSaves.failures. rollDeathSave only processes a rolled d20 with no entry point for damage-induced failures. No path adds a failure for damage at 0 HP, and none adds two for a crit.
- **Expected:** 5e (Damage at 0 Hit Points): any damage taken while at 0 HP causes one death-save failure; a critical hit causes two. Example: downed PC at successes 1 / failures 1 takes a non-crit hit -> failures 2; if that hit were a crit -> failures 3 (dead). The engine should bump the linked character failures by 1 (or 2 on a crit) when damage lands on current<=0.
- **Fix:** Add a failure transition when applyDamage/onDamage hits a PC already at hp.current<=0 (1 normally, 2 on crit), threading a crit flag through the damage UI into defenses.deathSaves; add tests.
#### [MEDIUM] PF2e Frightened does not decrease by 1 at end of turn
*system: pf2e · dimension: calc-conditions-damage · _verified: confirmed_*
- **Where:** `src/lib/mechanics/conditionEffects.ts:56; src/features/combat/EncounterTracker.tsx`
- **Issue:** PF2e Frightened reduces by 1 at the end of each of the affected creature's turns ('at the end of each of your turns, the value of your frightened condition decreases by 1'). The codebase models frightened only as a static `statusPenalty` and there is no turn-advance hook that decrements valued conditions. Grep for the condition tick/decrement logic across combat finds none.
- **Expected:** After a frightened creature ends its turn, Frightened 2 should become Frightened 1 automatically. Worked example: a creature hit by a Frightened 3 effect should be at 3 on turn 1, 2 after its first turn, etc.; the app keeps it at 3 indefinitely until manually edited.
- **Fix:** On end-of-turn advancement for a PF2e combatant, decrement its frightened value by 1 (floor at 0, removing at 0), matching the auto-expiry already implied by the `duration` field.
#### [MEDIUM] PF2e Drained does not reduce HP or maximum HP
*system: pf2e · dimension: calc-conditions-damage · _verified: confirmed_*
- **Where:** `src/lib/mechanics/conditionEffects.ts:52`
- **Issue:** Drained is modeled only as a `statusPenalty` (Constitution/Fortitude). The rule also reduces current and maximum HP: 'you lose a number of Hit Points equal to your level (minimum 1) times the drained value, and your maximum Hit Points are reduced by the same amount.' This HP/max-HP reduction is not applied anywhere.
- **Expected:** A level 5 creature gaining Drained 2 should immediately lose 10 HP and have max HP reduced by 10 until the condition is removed. The app applies neither, only a 2 Con/Fortitude (and even that is merged into the generic statusPenalty).
- **Fix:** When Drained is added/changed for a PF2e creature, reduce max HP by level×value and clamp current HP. This needs creature level available to the combatant model.
#### [MEDIUM] No delay/ready-action and no drag reorder — only single-step up/down nudges to fix initiative order
*system: both · dimension: ux-combat-play*
- **Where:** `src/features/combat/EncounterTracker.tsx:553-560; src/lib/combat/engine.ts:195-206`
- **Issue:** Running initiative live has no 'delay' or 'ready action' concept (a core 5e/PF2e table action where a creature moves later in the order). Reordering is only moveCombatant(±1) via two arrow buttons, so moving a combatant several slots requires repeated clicks, and there is no way to insert a delayed combatant at the current position or hold their turn. grep for delay/ready/hold across src/features/combat and src/lib/combat returns nothing.
- **Expected:** GMs routinely need to delay a creature to a later initiative or drop them into the current slot. Provide a 'Delay' action that re-inserts the combatant just after the current turn (or lets the GM type a new initiative that re-sorts — which updateCombatant already supports), and ideally drag-to-reorder. Even a numeric initiative edit re-sorts live (engine.ts:187-191), but there is no one-click 'act now/act later'.
- **Fix:** Add a Delay button per combatant that, during an active fight, moves them to just-after the current turn index (or sets their initiative to current-1) and re-anchors the pointer; optionally add drag reordering.
#### [MEDIUM] PF2e focus points, spontaneous & prepared casting unmodeled
*system: pf2e · dimension: features-gap*
- **Where:** `src/features/characters/sheet/SpellcastingSection.tsx; src/lib/schemas/character.ts:84-106`
- **Issue:** Spellcasting uses one generic slot grid for both systems. PF2e's focus pool (1-3 Focus Points refreshed by a 10-min Refocus) has no representation — the rest engine defines a 'refocus' option (pf2e/index.ts:15) but there is no focus-point resource it can refresh. Spontaneous casters (signature spells, casting from a shared rank pool) and prepared-per-slot casting are not distinguished; everything is a manual count grid.
- **Expected:** Model focus points as a first-class short-recovery resource that Refocus restores, and let pf2e casters mark spell type (prepared vs spontaneous) and focus spells. Concrete: a Sorcerer should track a focus pool that Refocus refills, and cast spontaneously from rank slots rather than per-prepared-spell.
- **Fix:** Add a focusPool (current/max) to the character, hook Refocus to refill it, and add a casting-tradition/type flag so the UI can present spontaneous vs prepared correctly. This is the main remaining 5e-vs-pf2e casting parity gap.
#### [MEDIUM] Multiclass is a slot calculator, not a real character model
*system: 5e · dimension: features-gap*
- **Where:** `src/lib/schemas/character.ts:147-148; src/features/characters/sheet/SpellcastingSection.tsx:196-212`
- **Issue:** Character has a single free-text className and one level; there is no per-class level breakdown. The only multiclass support is MulticlassSlots, a manual calculator where the player types full/half/third caster levels to fill the slot grid. Multiclass proficiencies, per-class HP, and class features are not derived; saves/skills assume one class.
- **Expected:** A classes[] array with {name, level, subclass}, from which proficiency bonus, HP, and combined caster level are computed. Concrete: a Fighter 3 / Wizard 2 should auto-derive PB +3, combined caster level 2, and the union of class proficiencies — not require the player to hand-fill slots and remember which saves they have.
- **Fix:** Promote className/level to a classes[] model and derive the dependent stats. This is documented as out of scope in the roadmap; flagging it as the depth ceiling a multiclassing player will hit.
#### [MEDIUM] Class/subclass features and feat effects are not surfaced or automated
*system: both · dimension: features-gap*
- **Where:** `src/features/characters/sheet/FeatsSection.tsx:24-46; src/features/characters/sheet/LevelUpModal.tsx:128-131`
- **Issue:** Feats are free-text records the player types a name+description into (FeatsSection); subclasses are only picker labels in the wizard. The sheet never tells a player what features they gain at a level — LevelUpModal says 'Add your chosen feat on the sheet after leveling' and lists plan.notes, but does not enumerate class/subclass features. A leveling player must consult an external book to know what they got.
- **Expected:** At level-up, surface the class/subclass features gained at that level (data exists in src/data/srd/classes.json and public/data/pf2e/classes.json), and ideally pre-fill a feat/feature record. Concrete: leveling a Champion to 3 should show 'Divine Ally', not a blank feats box.
- **Fix:** Join the ruleset class data into the level-up flow to list features-by-level and auto-create feat/feature stubs. The data is already bundled; it just isn't read at level-up.
#### [MEDIUM] Live sync hardwired to same-origin /ws — no relay for self-hosters
*system: both · dimension: features-gap*
- **Where:** `src/lib/sync/wsSync.ts:32-35`
- **Issue:** wsUrl() builds the WebSocket URL from location.host + '/ws', and cloud client uses the same origin. A user running the PWA from a static host, file://, or a different origin than the bundled Node server has no live multiplayer or cloud sync and no setting to point at a relay. The 'local-first, no server' framing collides with the fact that the headline shared-table features require co-located server infrastructure.
- **Expected:** A configurable sync/relay endpoint (env or Settings) so self-hosters can run sessions, plus a clear offline-vs-online capability split in the UI. Concrete: a GM self-hosting the static site should be able to set a relay URL and host a session.
- **Fix:** Add a configurable WS/cloud base URL (Settings + VITE_ env), defaulting to same-origin. Document that shared play needs the bundled server.
#### [LOW] No Class DC computation exposed
*system: pf2e · dimension: calc-pf2e-core*
- **Where:** `src/lib/rules/pf2e/index.ts:38-116`
- **Issue:** The PF2e rules object exposes spellSaveDc (used as the spell DC at SpellcastingSection.tsx:33) and spellAttackBonus, but there is no Class DC. PF2e characters have a Class DC (10 + key-ability mod + class proficiency + item) that gates abilities like Athletics maneuvers' counterparts, alchemist DCs, kineticist, monk stances, etc. A grep for classDc/classDC/class_dc across src/ returns nothing.
- **Expected:** Class DC = 10 + key ability modifier + proficiency(level + rank bonus) + item. Worked example: level-5 fighter, Str 18 (+4), trained class DC (+2): 10 + 4 + (5 + 2) = 21. The codebase provides no function to compute this; it can be approximated via spellSaveDc only if the class happens to be a caster, which most martials are not.
- **Fix:** Add a classDc(input, ability, rank) method to the PF2e RulesSystem returning 10 + abilityModifier(ability) + pf2eProficiency(level, rank), analogous to the existing spellSaveDc. Plumb the class's key ability and class DC rank (from PF2E_CLASSES) into it.
#### [LOW] rng.ts has no dedicated tests; inclusive range unverified
*system: n/a · dimension: calc-dice-rng*
- **Where:** `src/lib/rng.ts`
- **Issue:** No rng.test.ts exists. The int() inclusive range and seeding are tested only indirectly. Empirically int(1,20) is inclusive and unbiased over 5M samples, so correct, but an off-by-one regression never rolling the max would go uncaught.
- **Expected:** A rng.test.ts asserting int(1,6) yields only 1 through 6, createRng of a seed is deterministic and reproducible, and a coarse uniformity check.
- **Fix:** Add src/lib/rng.test.ts for inclusive bounds, determinism, and distribution.
#### [LOW] SpellMechanics has no at-higher-levels / cantrip-scaling field, so snapshot can't represent upcast damage
*system: 5e · dimension: data-spells*
- **Where:** `src/lib/mechanics/types.ts:36-48 (SpellMechanics); src/lib/mechanics/normalize/spell.ts:46-59`
- **Issue:** The normalized SpellMechanics model only stores level, school, concentration, ritual, save, and a flat damage array. The rich source field higher_level (present and accurate for scaling spells like Fireball '+1d6 per slot above 3rd', Acid Splash '5th/11th/17th', Magic Missile '+1 dart') is dropped entirely during normalization, so the sheet snapshot cannot represent cantrip scaling or upcast damage.
- **Expected:** For a level-5 caster, Acid Splash should be 2d6 and Eldritch Blast should be two beams; for Fireball upcast to a 5th-level slot, 10d6. The snapshot only ever stores the base (1d6 / 1d10 / 8d6) with no scaling rule, so any damage display is base-only.
- **Fix:** Add an optional scaling descriptor (per-slot dice delta and/or cantrip break levels) to SpellMechanics and parse higher_level into it, so combat can compute upcast/cantrip damage rather than always showing base dice.
#### [LOW] Heritage (mandatory PF2e level-1 choice) is absent from the character builder
*system: pf2e · dimension: data-pf2e*
- **Where:** `src/features/characters/builder/CreationWizard.tsx:377-394 (Origin step picks only ancestry + background)`
- **Issue:** The builder's Origin step offers Ancestry and Background pickers but no Heritage picker, even though 432 heritages are bundled (public/data/pf2e/heritages.json) and exposed in the compendium. In PF2e every character must choose a heritage at level 1 (e.g. Dwarf -> Ancient-Blooded / Rock Dwarf / ...), which grants concrete mechanical benefits. grep for 'heritage' across src/features/characters returns nothing.
- **Expected:** After choosing an ancestry, the PF2e builder should let the player choose a heritage (the data is present and could be filtered to the chosen ancestry). Currently heritage must be added manually to notes post-creation.
- **Fix:** Add a Heritage sub-picker to the Origin step for pf2e (loadPf2e('heritages')), ideally filtered by the selected ancestry.
## Per-dimension summaries
- **calc-5e-core** — The 5e core derived-stat engine in src/lib/rules is correct and well-tested. Ability modifiers, the proficiency-by-level table, skill/save modifiers (including expertise doubling), AC (unarmored and all three armor categories with correct Dex caps), initiative, passive perception, spell save DC / attack, and the PHB fixed-average HP formula all match the SRD; I verified the non-trivial cases (proficiency table at every breakpoint, expertise, dex-cap handling, HP) numerically and against the existing rules.test.ts. No defects found in this dimension.
- **calc-5e-weapons-hp** — ok
- **calc-5e-slots-multiclass** — The 5e spellcasting progression in src/lib/rules/dnd5e/progression.ts is fully correct against the PHB. I verified all 20 rows of the full-caster slot table (including the subtle high-level rows 17-20: L18 gains a 5th-level slot for [4,3,3,3,3,...], L19 a 6th, L20 a second 7th, ending [4,3,3,3,3,2,2,1,1]), all rows of the half-caster table (Paladin/Ranger, spells from L2, L1 correctly absent so a lone half-caster gets zero slots), and all 20 rows of the warlock Pact Magic table ([count, slotLevel]). The multiclass combined-caster-level formula (full×1 + floor(half/2) + floor(third/3)) is correct, reads the full-caster table at the combined level, caps at 20, returns no slots at combined level 0, and structurally excludes warlock pact magic. All 16 unit tests pass and my independent cell-by-cell recomputation found no discrepancies. No defects to report.
- **calc-pf2e-core** — The PF2e proficiency engine is correct and well-tested. pf2eProficiency (src/lib/rules/pf2e/index.ts:32-36) correctly returns 0 for untrained (no level added — the classic "adds level while untrained" bug is absent) and level + rank bonus (+2/+4/+6/+8 for trained/expert/master/legendary) otherwise. A level-5 expert yields 5+4=9, matching the unit test at rules.test.ts:79. Ability modifier (floor((score-10)/2)), Fort/Ref/Will saves (con/dex/wis only), skills, Perception-driven initiative, spell DC, and spell attack all compute ability + (level + rank bonus) correctly. The two real gaps are AC, which entirely omits the level-scaling armor proficiency from its default formula, and the absence of any Class DC computation.
- **calc-pf2e-weapons-misc** — The PF2e proficiency math (level + rank bonus, untrained = 0), single-Strike to-hit, Bulk thresholds (encumbered at 5+Str mod, max at 10+Str mod), and the 5e carry comparison (Str×5 / Str×15) are all correct, and the encumbrance UI uses the right strict-greater-than comparisons. The most significant defect is that the PF2e weaponAttack adds the flat item bonus to damage, which is wrong for PF2e (a potency rune adds only to the attack roll; damage bonuses come from striking runes as extra dice). Multiple Attack Penalty and striking runes are not implemented or surfaced at all, so the action economy that defines PF2e combat is absent from the attack tool.
- **calc-combat-engine** — Combat tracker engine audit.
- **calc-dice-rng** — three findings
- **calc-mechanics-resources** — The mechanics kernel itself (cast.ts, resources.ts, rest.ts) is largely correct: cantrips never consume slots, leveled casting clamps to at least the spell's own level, upcasting consumes the chosen higher slot, pact slots are tracked separately with no double-spend, failures are non-mutating, and spend/regain floor/cap correctly with no negative or NaN balances reachable in practice. Two real defects exist. (1) Critical UI integration gap: both castSpell callers invoke it without an atLevel argument, so no upcast level can ever be chosen — and because a warlock's pact slot sits at a fixed pact level (e.g. level 3 at character level 5) while a known spell entry carries its own base level (e.g. Hex level 1), the kernel's pact-slot path is unreachable and warlocks cannot cast their leveled spells at all from the app. (2) Pf2e Refocus over-restores Focus Points: it restores the entire pool instead of 1, contradicting both the PF2e rule and the code's own comment. Slot/resource refill on rest is otherwise tagged to the correct rest type.
- **calc-conditions-damage** — The 5e damage-type math is correct: immune zeroes, resistance is Math.floor(amount/2), vulnerability doubles, and PF2e flat weakness-then-resistance ordering is right (engine.ts:216-240). The 5e advantage/disadvantage condition table is largely accurate, but two real defects exist there (prone ranged attacks overstated; exhaustion's six-level effects are never enforced at all). The biggest problems are in PF2e: every valued condition (clumsy/enfeebled/drained/stupefied/frightened/sickened/slowed) is collapsed into a single undifferentiated "worst value wins" status penalty, so penalties tied to different statistics are wrongly compared and merged, slowed (an action-economy condition) is wrongly turned into a check penalty, frightened never auto-decrements, and drained's HP/max-HP loss is missing.
- **calc-encounter-budget** — The encounter-budget math in src/lib/combat/budget.ts is overwhelmingly correct. The 5e CR-to-XP table (all 34 entries), the 5e XP thresholds per character level (all 80 cells across 20 levels, DMG p.82), and the 5e encounter multiplier (1/1.5/2/2.5/3/4 with correct count boundaries) all match the canonical DMG values exactly. The PF2e creature-XP-by-level-difference table, the per-party-level XP budget (40/60/80/120/160), and the party-size adjustment (10/15/20/30/40 per extra/fewer member) are all correct. XP award division (5e split per PC, PF2e awarded per character) is correct, and the UI correctly shows raw award XP while rating difficulty off the multiplier-adjusted XP. The only deviation found is that 5e omits the optional DMG party-size multiplier-column shift, which is a low-impact, partially-mitigated simplification rather than a wrong value.
- **calc-map-geometry** — The distance metrics are correct: 5e default is Chebyshev (every diagonal = 5 ft) and the optional alternating 5-10-5 diagonal rule is implemented under the "pf2e" mode key, both matching their canonical rules (worked examples verified). Coordinate/projection transforms (worldToCell↔cellCenter, zoomToPoint↔worldFromScreen, fitViewport) round-trip exactly. The main real defect is in line-of-sight: collinear/endpoint "touching" is deliberately treated as non-blocking, which leaks vision through wall corners and wall endpoints — the classic room-corner fog leak — and the existing tests don't cover that case. AoE templates are advisory-only overlays and are largely correct, with minor center-anchored over/under-coverage that is a UX/snapping nuance rather than a rules bug.
- **data-loaders** — The four files flagged as "0 bytes critical" (races/feats/classes/backgrounds.json) are now populated (12-46 KB, modified Jun 8) and load without runtime breakage; the stale-0-bytes claim no longer holds. Loader TS types match the real JSON shapes for monsters, spells, items, weapons, armor, races, backgrounds and conditions, and all 14 PF2e public/data files exist. The real defects are in class skill-choice normalization (corrupted entries that silently drop valid skills from the 5e character builder) and a large amount of dead, unimported JSON bundled in src/data/srd. Monster defenses, armor dex-cap, and spell save/damage parsing are best-effort but correct enough to be low-risk; there is no monster "+9 to hit"/"DC 14" text-parsing path (the SRD data already carries structured attack_bonus/damage_dice fields and the prose is shown raw).
- **data-monsters** — The active monster dataset is src/data/srd/monsters-srd.json (322 Open5e-format monsters), loaded by src/lib/compendium/index.ts:25. Core combat numbers are highly accurate: I spot-checked ~20 iconic SRD monsters (Goblin, Orc, Ogre, Wolf, Skeleton, Zombie, Bandit, Commoner, Giant Spider, Troll, Owlbear, Hill Giant, Vampire, Gelatinous Cube, Adult Red Dragon, Aboleth, Lich, Young Red Dragon, etc.) and every AC, HP, hit dice, CR, ability score, to-hit bonus, and save DC matched the canonical 5e SRD; all 708 'Hit: X (NdM+B)' damage averages are internally consistent (0 mismatches), and high-CR blocks retain full legendary actions, immunities, and spellcasting. The one genuine data-accuracy defect is a cluster of 5 monsters whose passive Perception value (embedded in the senses string and rendered verbatim to the GM) is wrong. Separately there are surfacing gaps: MonsterDetail.tsx renders only walk speed and never shows saving throws, skills, or perception (data is present in JSON but hidden), and 137/322 monsters carry an empty skills object.
- **data-spells** — The loaded source file src/data/srd/spells-srd.json is highly accurate and reasonably complete: 321 SRD spells (319 wotc-srd + 2 o5e), matching the published 5e SRD count. Across 17 spot-checked well-known spells, level, range, components, casting time, concentration flag and ritual flag are all correct in the source; the only source-data error found is Acid Arrow's school (Evocation; should be Conjuration). The real defects live in the normalize layer (src/lib/mechanics/normalize/spell.ts), whose crude regex parsing of `save` and `damage` is snapshotted onto character sheets (CompendiumPage.tsx AddToCharacter) and used for combat enforcement: it invents damage for Wish, drops Magic Missile's damage, and picks the wrong save ability for spells that mention multiple save abilities (e.g. Irresistible Dance). Completeness is otherwise good (Witch Bolt/Tsunami are correctly absent — they are not in the open SRD), and concentration flags are reliable because normalize reads the authoritative requires_concentration field rather than the duration prose.
- **data-equipment** — Weapon, armor, and magic-item DATA is highly accurate against the 5e SRD. All 36 core weapons in weapons-srd.json and weapons-full.json have correct damage dice, damage types, and properties (Longsword 1d8 slashing versatile 1d10, Greataxe 1d12, Shortbow 1d6 piercing 80/320, Rapier 1d8 finesse, Dagger 1d4 finesse thrown — all verified). All 13 armor entries in equipment.json have correct base AC, dex caps, stealth flags, and STR requirements (Leather 11+Dex, Chain Mail 16/str13, Plate 18, Shield +2), and normalize/armor.ts plus the AC formula correctly yield light=no cap, medium=+2, heavy=0. Magic-item rarity and attunement values spot-checked against canonical SRD values are correct. The only real defect is a presentation bug that duplicates the attunement phrase in the item subtitle; remaining items are minor data-hygiene notes, not wrong stats.
- **data-classes-races-feats** — A user can pick a class, race, background, subclass, skills, and spells in the wizard and get a character with correct HP, save proficiencies, trained skills, and spell slots — but almost none of the chosen build's mechanical content is actually applied. Racial ability score increases are never added (abilities pass through raw), racial traits are never applied, background skill/tool/language proficiencies are dropped (saved as a plain note), subclasses are names-only blurbs with zero features, and feats are manual free-text with no link to the feat data and no mechanical effect. There is no per-level class-feature progression data for 5e at all (no Extra Attack, Sneak Attack, Rage, Channel Divinity, etc.). Compounding this, the data files driving the wizard (races.json, classes.json, feats.json) are open5e / Kobold-Press content with non-SRD entries, missing subraces, and at least one wrong ASI (Drow), while the correct SRD data sitting in mpmb-*.json is either name-only or used only by the read-only compendium browser.
- **data-pf2e** — Pathfinder 2e content genuinely ships and is populated at runtime: 14 large JSON datasets live in public/data/pf2e/ (creatures 4702, feats 8402, spells 2455, equipment 8605, ancestries 94, heritages 432, backgrounds 612, archetypes 335, deities 716, weapons 614, armor 75, actions 646, conditions 98, plus 25 classes), fetched lazily by src/lib/compendium/index.ts and wired into 14 compendium categories in src/features/compendium/registry.tsx. In raw breadth the pf2e compendium actually exceeds the 5e side (5e SRD ships only 322 monsters). The character builder's pf2e branch is data-backed for ancestries, backgrounds, classes and spells (ancestry HP/speed are correct and feed HP math). However there are real accuracy/quality defects: every pf2e dataset contains large numbers of duplicate (remaster vs legacy) entries that surface unfiltered in the compendium; the per-class trained-skill count used by the builder over-counts free skill choices for ~13 of 25 classes; the builder uses the 5e ability-array/point-buy system instead of PF2e's native ancestry/background/class boost system, so no attribute boosts are ever applied; full-caster spell slots are over-granted at level 1; and heritage (a mandatory PF2e level-1 choice) is missing from the builder.
- **ux-character** — Character creation and the sheet are broadly functional and thoughtfully built (NaN-guarded NumberField, focus-trapped modals, autosave with flush-on-unmount, good empty-state copy, class/ability/skill hints). But there are real flow defects. The single most damaging: the pf2e "ready-made hero" beginner templates load ability scores (e.g. STR 18) into a 27-point point-buy model whose inputs are hard-capped at 15, producing "-Infinity / 27" and a disabled Next — the exact users the templates target (beginners) get softlocked. Other friction: editing Level on the sheet silently does nothing to HP/slots; NumberField clears to min on every keystroke so min-bounded fields are painful to retype; pool-mode ability assignment has no swap; the creation wizard discards all progress on a stray backdrop/Escape with no confirm; and there is no prepared-spell limit or guidance.
- **ux-combat-play** — The combat engine itself is solid: identity-tracked turn pointer, transactional read-modify-write mutate, correct HP/temp/resistance math, and an undo stack make running initiative live (add/remove/reorder/re-roll) genuinely robust, and the player projection correctly masks enemy HP. The highest-impact problem is in the live session layer: a player who suffers any transient WebSocket drop is silently de-seated server-side (new playerId per connection, seat deleted on disconnect) while their client still shows the seated panel, so their HP edits and dice rolls quietly stop reaching the GM. Secondary friction: there is no delay/ready-action or drag reorder (only one-step up/down nudges), the GM's damage tool never prompts for or tracks concentration unless a seated player happens to have cast through their own panel, and the ambient "turn" signal plus broad signal list are noisy. The LLM advisor degrades gracefully to deterministic picks and the JSON-coercion layer is well hardened, but its failure messaging is thin and the disconnect/staleness cues for players are minimal.
- **ux-global** — probe
- **features-gap** — The app is feature-broad and most roadmap items genuinely ship (notes/wiki, fog-of-war maps + player view, encounter difficulty math, conditions read-model, cloud sync, live sessions, pf2e compendium). The biggest product-level gaps are character onboarding and progression: no D&D Beyond import at all and a thin Pathbuilder importer (abilities/HP/skill ranks only — no spells, feats, inventory, or attacks); no loot/treasure builder (an explicit roadmap promise); and no XP economy (encounter XP is display-only, characters have no XP field, leveling is a manual button). PF2e has real parity holes vs 5e in the kernel: AC omits the level+armor-proficiency term (forced into a manual fudge field), and focus points / spontaneous / prepared casting are unmodeled. Multiclass and class/subclass features are tracking-only by design. The AI assistant is BYO-key and off by default, so its proactive features silently do nothing for most GMs.
## Appendix — excluded (refuted by verification)
### ~~Healing a downed combatant in the tracker does not reset its death saves~~ (calc-combat-engine)
- **Original claim:** applyHealing only raises hp.current toward max and onHeal patches only hp. It never clears defenses.deathSaves, so healing a PC from 0 HP back to positive in the tracker leaves stale successes/failures. rest.ts:52 resets death saves on HP-restoring rest, but in-combat healing does not.
- **Why excluded:** The finding is a category error. It claims applyHealing (engine.ts:243-246) and the onHeal handler (EncounterTracker.tsx:246) fail to clear `defenses.deathSaves`, leaving stale failures when a downed combatant is healed in the tracker. But `defenses.deathSaves` does not exist on the Combatant model at all.
Verified facts:
1. The combat tracker operates on `Combatant` objects. The Combatant schema (src/lib/schemas/encounter.ts:22-44) has fields id, name, kind, characterId, monsterRef, initiative, initBonus, ac, hp, conditions, notes, damageDefenses, cr, level. There is NO `deathSaves` and NO `defenses` field. So applyHealing has nothing of the sort to reset.
2. Death saves live exclusively o
### ~~Feats compendium is sourced from MPMB homebrew dump, not the SRD feats.json~~ (data-loaders)
- **Original claim:** loadFeats5e imports mpmb-feats.json (a MakePlayerMyBeerd character-sheet feat dump, 104 entries keyed by id with terse mechanical blurbs and source codes like {"source":"P","page":165}) instead of the curated src/data/srd/feats.json that was added Jun 8. The MPMB descriptions are abbreviated sheet-text (e.g. Actor: "Advantage on Charisma (Deception) and (Performance) if trying to pass as another…"), not full rules text, and include non-SRD homebrew sources. The Feat type's source field (types.ts:106) matches the MPMB shape, so it loads, but the displayed content is the lower-quality homebrew set rather than the intended SRD data.
- **Why excluded:** The analyst's premise is backwards. I opened both files. CURRENTLY LOADED src/data/srd/mpmb-feats.json (index.ts:68) is a dict of 104 entries whose names are the REAL published D&D 5e feats: Actor, Alert, Great Weapon Master, Lucky, Mage Slayer, Polearm Master, Sentinel, Sharpshooter, Shield Master, Tough, War Caster, etc., source "P" = Player's Handbook, with accurate (if terse) mechanical text (e.g. Sharpshooter, Great Weapon Master verified correct). The file the analyst calls "curated SRD" (src/data/srd/feats.json, 74 entries) actually contains NON-SRD/third-party-homebrew names: Ace Driver (vehicle feat), Boundless Reserves, Brutal Attack, Crossbow Expertise, Deadeye, Floriographer (flo
+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.getByLabel('Settings').click();
await gm.getByRole('button', { name: 'Load sample campaign' }).click(); await gm.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); 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, '') ?? ''; const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6); 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.getByRole('button', { name: /Share with/ }).click();
await gm.getByLabel('Share title').fill('Secret passage'); await gm.getByLabel('Share title').fill('Secret passage');
await gm.getByRole('button', { name: /Send to/ }).click(); 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 expect(player.getByText('Secret passage')).toBeVisible();
await gmCtx.close(); 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.getByLabel('Settings').click();
await gm.getByRole('button', { name: 'Load sample campaign' }).click(); await gm.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); 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, '') ?? ''; const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
expect(code).toHaveLength(6); 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 }) => { test('assistant page renders the campaign insights section', async ({ page }) => {
await page.getByLabel('Settings').click(); await page.getByLabel('Settings').click();
await page.getByRole('button', { name: 'Load sample campaign' }).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(); await expect(page.getByRole('heading', { name: 'Campaign insights' })).toBeVisible();
// Sample has a single encounter → not enough for a trend yet. // Sample has a single encounter → not enough for a trend yet.
await expect(page.getByText(/No trends detected yet/)).toBeVisible(); 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 // Assistant shows a resource suggestion
await page.getByLabel('Primary').getByRole('link', { name: 'Dashboard' }).click(); 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.getByRole('heading', { name: 'Assistant' })).toBeVisible();
await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible(); await expect(page.getByText(/Lia the Brave is bloodied/)).toBeVisible();
+44
View File
@@ -0,0 +1,44 @@
import { test, expect } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.evaluate(async () => {
indexedDB.deleteDatabase('ttrpg-manager');
localStorage.clear();
});
await page.reload();
// Load the sample campaign (gives us a party + an active encounter).
await page.getByLabel('Settings').click();
await page.getByRole('button', { name: 'Load sample campaign' }).click();
await expect(page.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
});
test('AI Director narrates a grounded scene with no API key (deterministic)', async ({ page }) => {
await page.getByLabel('Primary').getByRole('link', { name: 'AI Director' }).click();
await expect(page.getByRole('heading', { name: 'AI Director' })).toBeVisible();
await expect(page.getByText(/deterministic mode/)).toBeVisible();
// Empty until we start.
await expect(page.getByText('The story begins when you start the scene.')).toBeVisible();
await page.getByRole('button', { name: 'Begin the scene' }).click();
// A narration entry appears; the empty-state is gone; source badge shows deterministic.
await expect(page.getByText('The story begins when you start the scene.')).toBeHidden();
await expect(page.getByText('deterministic', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible();
});
test('the director can play a chosen party character (player persona)', async ({ page }) => {
await page.getByLabel('Primary').getByRole('link', { name: 'AI Director' }).click();
await page.getByRole('button', { name: 'Player', exact: true }).click();
// Subtitle reflects the mode.
await expect(page.getByText(/· Player/)).toBeVisible();
// Seat the AI on the first party character.
await page.getByLabel('Character the AI plays').selectOption({ index: 1 });
await page.getByRole('button', { name: /Take the first action|Take turn/ }).click();
await expect(page.getByText('deterministic', { exact: true })).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.locator('input[data-autofocus]').fill('Brew');
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Dashboard' }).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 // Create a homebrew monster and name it
await page.getByRole('button', { name: /New monster/ }).click(); 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 // NPC
await page.getByRole('link', { name: 'Dashboard' }).click(); 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 page.getByRole('button', { name: '+ New NPC' }).first().click();
await expect(page.getByLabel('NPC name')).toBeVisible(); await expect(page.getByLabel('NPC name')).toBeVisible();
// Quest with an objective // Quest with an objective
await page.getByRole('link', { name: 'Dashboard' }).click(); 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.getByRole('button', { name: '+ New quest' }).first().click();
await page.getByPlaceholder('Add objective…').fill('Find the Tome'); await page.getByPlaceholder('Add objective…').fill('Find the Tome');
await page.getByRole('button', { name: 'Add', exact: true }).click(); 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 // Calendar advances days
await page.getByRole('link', { name: 'Dashboard' }).click(); 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 page.getByRole('button', { name: '+1 week' }).click();
await expect(page.getByText('7', { exact: true })).toBeVisible(); await expect(page.getByText('7', { exact: true })).toBeVisible();
}); });
+20 -2
View File
@@ -2,8 +2,9 @@
"""Parse MPMB all_WotC_published.js into structured JSON files""" """Parse MPMB all_WotC_published.js into structured JSON files"""
import re, json, os import re, json, os
INPUT = "/home/nilsb/Downloads/all_WotC_published.js" INPUT = os.environ.get("MPMB_INPUT", "/home/nilsb/Downloads/all_WotC_published.js")
OUT_DIR = "/home/nilsb/Documents/Projects/TTRPG_manager/src/assets/srd" # Output path (the app reads from src/data/srd). Override with MPMB_OUT for a dry run.
OUT_DIR = os.environ.get("MPMB_OUT", "/home/nilsb/Documents/Projects/TTRPG_manager/src/data/srd")
def parse_mpmb(filepath): def parse_mpmb(filepath):
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
@@ -87,6 +88,16 @@ def safe_extract_fields(obj_str):
'description': r'description\s*:\s*["\']([^"]+)["\']', 'description': r'description\s*:\s*["\']([^"]+)["\']',
'weight': r'weight\s*:\s*(-?\d+)', 'weight': r'weight\s*:\s*(-?\d+)',
'prerequisite': r'prerequisite\s*:\s*["\']([^"\']+)["\']', 'prerequisite': r'prerequisite\s*:\s*["\']([^"\']+)["\']',
# --- spell fields (additive; only present on SpellsList entries) ---
'level': r'\blevel\s*:\s*(\d+)',
'school': r'\bschool\s*:\s*["\']([^"\']+)["\']',
'time': r'\btime\s*:\s*["\']([^"\']+)["\']',
'range': r'\brange\s*:\s*["\']([^"\']+)["\']',
'components': r'\bcomponents\s*:\s*["\']([^"\']+)["\']',
'duration': r'\bduration\s*:\s*["\']([^"\']+)["\']',
'save': r'\bsave\s*:\s*["\']([^"\']+)["\']',
'ritual': r'\britual\s*:\s*(true|false)',
'classes': r'\bclasses\s*:\s*(\[[^\]]*\])',
} }
for field, pattern in patterns.items(): for field, pattern in patterns.items():
m = re.search(pattern, obj_str) m = re.search(pattern, obj_str)
@@ -94,6 +105,13 @@ def safe_extract_fields(obj_str):
val = m.group(1) val = m.group(1)
if field == 'weight': if field == 'weight':
val = int(val) val = int(val)
elif field == 'level':
val = int(val)
elif field == 'ritual':
val = (val == 'true')
elif field == 'classes':
# ["cleric","warlock"] -> list of class names
val = re.findall(r'["\']([^"\']+)["\']', val)
elif field == 'source': elif field == 'source':
# Parse [[SourceName, page], [SourceName2, page2]] # Parse [[SourceName, page], [SourceName2, page2]]
sources = re.findall(r'\["([^"]+)",?\s*(\d+)?\]', val) sources = re.findall(r'\["([^"]+)",?\s*(\d+)?\]', val)
+57 -1
View File
@@ -23,8 +23,12 @@ describe('AccountStore', () => {
if (!r.ok) throw new Error('register failed'); if (!r.ok) throw new Error('register failed');
const u = await store.userByToken(r.token); const u = await store.userByToken(r.token);
expect(u).toBeTruthy(); 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}'); 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(); expect(await store.userByToken('garbage-token')).toBeNull();
}); });
@@ -50,4 +54,56 @@ describe('AccountStore', () => {
expect(await adminStore.setQuota('ghost', 1)).toBe(false); expect(await adminStore.setQuota('ghost', 1)).toBe(false);
expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000); expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000);
}); });
it('quotaFor uses the override when set, else the default', async () => {
const r = await store.register('quotaUser', 'password123');
if (!r.ok) throw new Error('register failed');
const u = (await store.userByToken(r.token))!;
const def = store.quotaFor(u); // instance default
expect(def).toBeGreaterThan(0);
await store.setQuota('quotaUser', 99);
expect(store.quotaFor((await store.userByToken(r.token))!)).toBe(99);
});
it('refuses registration past the max-users cap', async () => {
const capped = new AccountStore(dir, undefined, [], 1);
expect((await capped.register('first', 'password123')).ok).toBe(true);
const second = await capped.register('second', 'password123');
expect(second.ok).toBe(false);
expect((second as { code: string }).code).toBe('closed');
});
it('revokes all tokens (log out everywhere)', async () => {
const r = await store.register('revokee', 'password123');
if (!r.ok) throw new Error('register failed');
expect(await store.userByToken(r.token)).toBeTruthy();
expect(await store.revokeTokens('revokee')).toBe(true);
expect(await store.userByToken(r.token)).toBeNull();
expect(await store.revokeTokens('ghost')).toBe(false);
expect((await store.login('revokee', 'password123')).ok).toBe(true); // password still works
});
it('deletes a user + their blob, but never an admin', async () => {
const s = new AccountStore(dir, undefined, ['boss2']);
await s.register('boss2', 'password123');
const r = await s.register('victim', 'password123');
if (!r.ok) throw new Error('register failed');
const u = (await s.userByToken(r.token))!;
await s.saveBlob(u.id, '{"x":1}');
expect(await s.deleteUser('boss2')).toBeNull(); // admins are protected
const removedId = await s.deleteUser('victim');
expect(removedId).toBe(u.id);
expect(await s.userByToken(r.token)).toBeNull(); // tokens dead
expect(await s.loadBlob(u.id)).toBeNull(); // blob gone
expect((await s.login('victim', 'password123')).ok).toBe(false); // account gone
});
it('lists detailed rows for the admin panel', async () => {
const r = await store.register('detailed', 'password123');
if (!r.ok) throw new Error('register failed');
const row = (await store.listUsersDetailed()).find((x) => x.username === 'detailed')!;
expect(row.tokenCount).toBe(1);
expect(row.admin).toBe(false);
expect(row.createdAt).toBeGreaterThan(0);
});
}); });
+94 -15
View File
@@ -1,7 +1,12 @@
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { promisify } from 'node:util';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
// Async scrypt so password hashing runs on the threadpool, NOT the single event
// loop — a burst of logins can't freeze the whole server (scryptSync did).
const scryptAsync = promisify(crypto.scrypt) as (pw: crypto.BinaryLike, salt: crypto.BinaryLike, keylen: number) => Promise<Buffer>;
/** /**
* Lean file-backed accounts + cloud-backup store. No external DB — a single * Lean file-backed accounts + cloud-backup store. No external DB — a single
* users.json plus one blob file per user under DATA_DIR. Passwords are scrypt- * users.json plus one blob file per user under DATA_DIR. Passwords are scrypt-
@@ -23,9 +28,11 @@ interface User {
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/; const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
const MAX_TOKENS = 10; const MAX_TOKENS = 10;
/** Default per-user cloud storage cap when no admin override is set. */
export const DEFAULT_QUOTA_BYTES = Number(process.env.DEFAULT_QUOTA_BYTES) || 30 * 1024 * 1024;
function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); } function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); }
function hashPassword(password: string, salt: string): string { return crypto.scryptSync(password, salt, 64).toString('hex'); } async function hashPassword(password: string, salt: string): Promise<string> { return (await scryptAsync(password, salt, 64)).toString('hex'); }
function timingEqual(a: string, b: string): boolean { function timingEqual(a: string, b: string): boolean {
const ab = Buffer.from(a), bb = Buffer.from(b); const ab = Buffer.from(a), bb = Buffer.from(b);
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb); return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
@@ -37,20 +44,65 @@ export interface AuthError { ok: false; code: string; message: string }
export class AccountStore { export class AccountStore {
private users = new Map<string, User>(); // key: lowercased username private users = new Map<string, User>(); // key: lowercased username
private byId = new Map<string, User>(); private byId = new Map<string, User>();
private byToken = new Map<string, User>(); // sha256(token) → user, for O(1) auth lookups
private queue: Promise<unknown> = Promise.resolve(); private queue: Promise<unknown> = Promise.resolve();
private loaded = false; private loaded = false;
constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = []) {} constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = [], private maxUsers = Infinity) {}
private usersFile() { return path.join(this.dir, 'users.json'); } private usersFile() { return path.join(this.dir, 'users.json'); }
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); } private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); } isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); }
/** This user's storage limit: their admin override, else the instance default. */
quotaFor(u: User): number { return u.quotaBytes && u.quotaBytes > 0 ? u.quotaBytes : DEFAULT_QUOTA_BYTES; }
/** Whether a new account can be created (registration is open under the cap). */
atCapacity(): boolean { return this.users.size >= this.maxUsers; }
async listUsers(): Promise<Array<{ id: string; username: string; quotaBytes?: number }>> { async listUsers(): Promise<Array<{ id: string; username: string; quotaBytes?: number }>> {
await this.load(); await this.load();
return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) })); return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) }));
} }
/** Admin detail rows — everything the panel shows except usage (caller joins that). */
async listUsersDetailed(): Promise<Array<{ id: string; username: string; createdAt: number; updatedAt: number; quotaBytes: number; tokenCount: number; admin: boolean }>> {
await this.load();
return [...this.users.values()].map((u) => ({
id: u.id, username: u.username, createdAt: u.createdAt, updatedAt: u.updatedAt,
quotaBytes: u.quotaBytes ?? 0, tokenCount: u.tokens.length, admin: this.isAdmin(u.username),
}));
}
/** Invalidate every session token for a user ("log out everywhere"). */
async revokeTokens(username: string): Promise<boolean> {
await this.load();
const u = this.users.get(username.toLowerCase());
if (!u) return false;
for (const h of u.tokens) this.byToken.delete(h);
u.tokens = [];
u.updatedAt = this.now();
await this.persist();
return true;
}
/**
* Delete an account and its backup blob. Admin accounts can't be deleted from the
* panel (protects against lockout + a compromised admin nuking another admin).
* Returns the removed user's id so the caller can purge their cloud data too.
*/
async deleteUser(username: string): Promise<string | null> {
await this.load();
const u = this.users.get(username.toLowerCase());
if (!u || this.isAdmin(u.username)) return null;
for (const h of u.tokens) this.byToken.delete(h);
this.users.delete(u.username.toLowerCase());
this.byId.delete(u.id);
try { await fs.unlink(this.blobFile(u.id)); } catch { /* no blob */ }
await this.persist();
return u.id;
}
async setQuota(username: string, bytes: number): Promise<boolean> { async setQuota(username: string, bytes: number): Promise<boolean> {
await this.load(); await this.load();
const u = this.users.get(username.toLowerCase()); const u = this.users.get(username.toLowerCase());
@@ -72,8 +124,15 @@ export class AccountStore {
try { try {
const raw = await fs.readFile(this.usersFile(), 'utf8'); const raw = await fs.readFile(this.usersFile(), 'utf8');
const arr = JSON.parse(raw) as User[]; const arr = JSON.parse(raw) as User[];
for (const u of arr) { this.users.set(u.username.toLowerCase(), u); this.byId.set(u.id, u); } for (const u of arr) {
} catch { /* no file yet */ } this.users.set(u.username.toLowerCase(), u);
this.byId.set(u.id, u);
for (const h of u.tokens) this.byToken.set(h, u);
}
} catch (e) {
// ENOENT on first boot is expected; anything else is a real problem worth seeing.
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[accounts] load failed', e);
}
} }
private persist(): Promise<void> { private persist(): Promise<void> {
@@ -82,14 +141,19 @@ export class AccountStore {
const tmp = `${this.usersFile()}.tmp`; const tmp = `${this.usersFile()}.tmp`;
await fs.writeFile(tmp, JSON.stringify([...this.users.values()])); await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
await fs.rename(tmp, this.usersFile()); await fs.rename(tmp, this.usersFile());
}).catch(() => {}); }).catch((e) => { console.error('[accounts] persist failed — data may be lost on restart', e); });
return this.queue as Promise<void>; return this.queue as Promise<void>;
} }
private issueToken(u: User): string { private issueToken(u: User): string {
const token = crypto.randomBytes(32).toString('base64url'); const token = crypto.randomBytes(32).toString('base64url');
u.tokens.push(sha256(token)); const h = sha256(token);
if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS); u.tokens.push(h);
this.byToken.set(h, u);
if (u.tokens.length > MAX_TOKENS) {
for (const stale of u.tokens.slice(0, u.tokens.length - MAX_TOKENS)) this.byToken.delete(stale);
u.tokens = u.tokens.slice(-MAX_TOKENS);
}
u.updatedAt = this.now(); u.updatedAt = this.now();
return token; return token;
} }
@@ -99,8 +163,9 @@ export class AccountStore {
if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 332 letters, digits, . _ or -.' }; if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 332 letters, digits, . _ or -.' };
if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' }; if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' };
if (this.users.has(username.toLowerCase())) return { ok: false, code: 'taken', message: 'That username is taken.' }; if (this.users.has(username.toLowerCase())) return { ok: false, code: 'taken', message: 'That username is taken.' };
if (this.atCapacity()) return { ok: false, code: 'closed', message: 'Registration is temporarily closed (instance at capacity).' };
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
const u: User = { id: crypto.randomUUID(), username, salt, hash: hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() }; const u: User = { id: crypto.randomUUID(), username, salt, hash: await hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() };
const token = this.issueToken(u); const token = this.issueToken(u);
this.users.set(username.toLowerCase(), u); this.users.set(username.toLowerCase(), u);
this.byId.set(u.id, u); this.byId.set(u.id, u);
@@ -111,7 +176,11 @@ export class AccountStore {
async login(username: string, password: string): Promise<AuthResult | AuthError> { async login(username: string, password: string): Promise<AuthResult | AuthError> {
await this.load(); await this.load();
const u = this.users.get((username ?? '').toLowerCase()); const u = this.users.get((username ?? '').toLowerCase());
if (!u || !timingEqual(u.hash, hashPassword(password ?? '', u.salt))) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' }; // Hash even when the user is unknown (against a throwaway salt) so the response
// time doesn't reveal whether a username exists.
const salt = u?.salt ?? 'absent';
const candidate = await hashPassword(password ?? '', salt);
if (!u || !timingEqual(u.hash, candidate)) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' };
const token = this.issueToken(u); const token = this.issueToken(u);
await this.persist(); await this.persist();
return { ok: true, token, username: u.username }; return { ok: true, token, username: u.username };
@@ -120,28 +189,38 @@ export class AccountStore {
async userByToken(token: string | undefined): Promise<User | null> { async userByToken(token: string | undefined): Promise<User | null> {
if (!token) return null; if (!token) return null;
await this.load(); await this.load();
const h = sha256(token); return this.byToken.get(sha256(token)) ?? null;
for (const u of this.users.values()) if (u.tokens.includes(h)) return u;
return null;
} }
async logout(token: string): Promise<void> { async logout(token: string): Promise<void> {
const u = await this.userByToken(token); await this.load();
const h = sha256(token);
const u = this.byToken.get(h);
if (!u) return; if (!u) return;
u.tokens = u.tokens.filter((t) => t !== sha256(token)); u.tokens = u.tokens.filter((t) => t !== h);
this.byToken.delete(h);
await this.persist(); 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 }); await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true });
const tmp = `${this.blobFile(id)}.tmp`; const tmp = `${this.blobFile(id)}.tmp`;
await fs.writeFile(tmp, blob); await fs.writeFile(tmp, blob);
await fs.rename(tmp, this.blobFile(id)); await fs.rename(tmp, this.blobFile(id));
return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs);
} }
async loadBlob(id: string): Promise<string | null> { async loadBlob(id: string): Promise<string | null> {
try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return 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; } userCount(): number { return this.users.size; }
} }
+59 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { CloudStore } from './campaigns'; import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
describe('CloudStore', () => { describe('CloudStore', () => {
let dir: string; let dir: string;
@@ -42,6 +42,43 @@ describe('CloudStore', () => {
expect(await store.usageBytes('p1')).toBeGreaterThan(0); expect(await store.usageBytes('p1')).toBeGreaterThan(0);
}); });
it('admin: lists, deletes campaigns, and purges a user wholesale', async () => {
const mine = await store.createCampaign('gm1', 'Mine', '5e');
const theirs = await store.createCampaign('gm2', 'Theirs', 'pf2e');
await store.joinByInvite('gm1', theirs.inviteCode); // gm1 is also a member elsewhere
await store.putCharacter('gm1', mine.id, { id: 'c1', name: 'A', data: '{"a":1}' });
await store.putCharacter('gm1', theirs.id, { id: 'c2', name: 'B', data: '{"b":2}' });
const rows = await store.adminList();
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.id === mine.id)).toMatchObject({ characters: 1, members: 0 });
// Purging gm1 removes the campaign they OWN (+ its chars), their membership,
// and their characters published in other campaigns.
const purged = await store.purgeUser('gm1');
expect(purged).toEqual({ campaigns: 1, characters: 2 });
expect(store.isMember(theirs.id, 'gm1')).toBe(false);
expect(await store.joinByInvite('p9', mine.inviteCode)).toBeNull(); // invite dead
expect((await store.totals()).campaigns).toBe(1);
expect(await store.deleteCampaign(theirs.id)).toBe(true);
expect(await store.deleteCampaign(theirs.id)).toBe(false); // already gone
expect((await store.totals()).campaigns).toBe(0);
});
it('rejects an oversized character and reports usage deltas', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
const huge = 'x'.repeat(MAX_CHARACTER_BYTES + 1);
expect(await store.putCharacter('p1', c.id, { id: 'big', name: 'Big', data: huge })).toBeNull();
expect(await store.characterBytes('big')).toBe(0); // nothing stored
await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":1}' });
expect(store.ownsCharacter('p1', 'ch1')).toBe(true);
expect(store.ownsCharacter('p2', 'ch1')).toBe(false);
expect(await store.characterBytes('ch1')).toBe(Buffer.byteLength('{"hp":1}'));
});
it('lets the char owner or campaign owner delete; persists across instances', async () => { it('lets the char owner or campaign owner delete; persists across instances', async () => {
const c = await store.createCampaign('gm1', 'C', '5e'); const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode); await store.joinByInvite('p1', c.inviteCode);
@@ -55,4 +92,25 @@ describe('CloudStore', () => {
expect((await store2.listForUser('gm1'))[0]?.id).toBe(c.id); expect((await store2.listForUser('gm1'))[0]?.id).toBe(c.id);
expect(await store2.listCharacters(c.id)).toHaveLength(0); 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
});
}); });
+98 -2
View File
@@ -32,6 +32,9 @@ export interface CloudCharacter {
} }
const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
/** A single published character can't exceed this (a sheet with an embedded portrait
* is well under 2 MB); a hard stop before per-user quota even comes into play. */
export const MAX_CHARACTER_BYTES = 2 * 1024 * 1024;
export class CloudStore { export class CloudStore {
private campaigns = new Map<string, CloudCampaign>(); private campaigns = new Map<string, CloudCampaign>();
@@ -50,7 +53,9 @@ export class CloudStore {
const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] }; const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] };
for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); } for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); }
for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch); for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch);
} catch { /* no file yet */ } } catch (e) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[cloud] load failed', e);
}
} }
private persist(): Promise<void> { private persist(): Promise<void> {
@@ -59,7 +64,7 @@ export class CloudStore {
const tmp = `${this.file()}.tmp`; const tmp = `${this.file()}.tmp`;
await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] })); await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] }));
await fs.rename(tmp, this.file()); await fs.rename(tmp, this.file());
}).catch(() => {}); }).catch((e) => { console.error('[cloud] persist failed — data may be lost on restart', e); });
return this.queue as Promise<void>; return this.queue as Promise<void>;
} }
@@ -83,6 +88,32 @@ export class CloudStore {
return c; 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 { isMember(campaignId: string, userId: string): boolean {
const c = this.campaigns.get(campaignId); const c = this.campaigns.get(campaignId);
return !!c && (c.ownerUserId === userId || c.members.includes(userId)); return !!c && (c.ownerUserId === userId || c.members.includes(userId));
@@ -114,6 +145,7 @@ export class CloudStore {
async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise<CloudCharacter | null> { async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise<CloudCharacter | null> {
await this.load(); await this.load();
if (!this.isMember(campaignId, userId)) return null; if (!this.isMember(campaignId, userId)) return null;
if (Buffer.byteLength(char.data) > MAX_CHARACTER_BYTES) return null; // oversized blob — reject
const existing = this.characters.get(char.id); const existing = this.characters.get(char.id);
if (existing && existing.ownerUserId !== userId) return null; // only the owner may update if (existing && existing.ownerUserId !== userId) return null; // only the owner may update
const record: CloudCharacter = { id: char.id, campaignId, ownerUserId: existing?.ownerUserId ?? userId, name: char.name.slice(0, 120), data: char.data, updatedAt: this.now() }; const record: CloudCharacter = { id: char.id, campaignId, ownerUserId: existing?.ownerUserId ?? userId, name: char.name.slice(0, 120), data: char.data, updatedAt: this.now() };
@@ -137,6 +169,70 @@ export class CloudStore {
return true; return true;
} }
/** Byte size of one stored character's data (0 if it doesn't exist) — for quota deltas. */
async characterBytes(characterId: string): Promise<number> {
await this.load();
const ch = this.characters.get(characterId);
return ch ? Buffer.byteLength(ch.data) : 0;
}
/** Whether the user already owns a published character with this id. */
ownsCharacter(userId: string, characterId: string): boolean {
return this.characters.get(characterId)?.ownerUserId === userId;
}
/** Admin overview rows: every campaign with member/character counts + stored bytes. */
async adminList(): Promise<Array<{ id: string; name: string; system: string; ownerUserId: string; members: number; characters: number; bytes: number; createdAt: number; updatedAt: number }>> {
await this.load();
return [...this.campaigns.values()].map((c) => {
let characters = 0, bytes = 0;
for (const ch of this.characters.values()) if (ch.campaignId === c.id) { characters++; bytes += Buffer.byteLength(ch.data); }
return { id: c.id, name: c.name, system: c.system, ownerUserId: c.ownerUserId, members: c.members.length, characters, bytes, createdAt: c.createdAt, updatedAt: c.updatedAt };
});
}
/** Admin: delete a campaign and every character published into it. */
async deleteCampaign(campaignId: string): Promise<boolean> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return false;
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(campaignId);
for (const [id, ch] of this.characters) if (ch.campaignId === campaignId) this.characters.delete(id);
await this.persist();
return true;
}
/**
* Admin: purge everything a deleted user left behind — campaigns they own (with
* those campaigns' characters), their memberships, and their published characters.
*/
async purgeUser(userId: string): Promise<{ campaigns: number; characters: number }> {
await this.load();
let campaigns = 0, characters = 0;
for (const [id, c] of [...this.campaigns]) {
if (c.ownerUserId === userId) {
campaigns++;
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(id);
for (const [chId, ch] of [...this.characters]) if (ch.campaignId === id) { this.characters.delete(chId); characters++; }
} else if (c.members.includes(userId)) {
c.members = c.members.filter((m) => m !== userId);
}
}
for (const [chId, ch] of [...this.characters]) if (ch.ownerUserId === userId) { this.characters.delete(chId); characters++; }
await this.persist();
return { campaigns, characters };
}
/** Instance-wide totals for the admin overview. */
async totals(): Promise<{ campaigns: number; characters: number; bytes: number }> {
await this.load();
let bytes = 0;
for (const ch of this.characters.values()) bytes += Buffer.byteLength(ch.data);
return { campaigns: this.campaigns.size, characters: this.characters.size, bytes };
}
/** Total bytes a user is responsible for (their characters' data) — for usage tracking. */ /** Total bytes a user is responsible for (their characters' data) — for usage tracking. */
async usageBytes(userId: string): Promise<number> { async usageBytes(userId: string): Promise<number> {
await this.load(); await this.load();
+191 -29
View File
@@ -4,17 +4,22 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import type { FastifyRequest } from 'fastify'; import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { promises as fs } from 'node:fs';
import { RoomHub, type Sender } from './rooms'; import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts'; import { AccountStore, DEFAULT_QUOTA_BYTES } from './accounts';
import { CloudStore } from './campaigns'; import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
const PORT = Number(process.env.PORT ?? 8787); const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data')); const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data'));
const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS) const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS)
const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs) const BODY_LIMIT = 32 * 1024 * 1024; // 32 MB (cloud backup blobs; > the per-user quota)
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean); const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); const ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const MAX_USERS = Number(process.env.MAX_USERS) || Infinity;
// Concurrent WebSocket connections allowed from one client IP — generous for a
// household sharing a NAT, but a hard stop on socket-spam room/image floods.
const MAX_WS_PER_IP = Number(process.env.MAX_WS_PER_IP) || 20;
const bearer = (req: FastifyRequest): string | undefined => { const bearer = (req: FastifyRequest): string | undefined => {
const h = req.headers.authorization; const h = req.headers.authorization;
@@ -23,32 +28,65 @@ const bearer = (req: FastifyRequest): string | undefined => {
// per-socket token bucket: 80 messages / 10s // per-socket token bucket: 80 messages / 10s
const RATE = { capacity: 80, refillMs: 10_000 }; 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() { export function buildServer() {
const app = Fastify({ bodyLimit: BODY_LIMIT }); // trustProxy: behind Traefik the socket peer is the proxy, so without this every
// visitor shares one rate-limit bucket. Trust the proxy's X-Forwarded-For so
// req.ip is the real client. Only Traefik can reach the container, so this is safe.
const app = Fastify({ bodyLimit: BODY_LIMIT, trustProxy: true });
// Tolerate a body-less request that still sets content-type: application/json
// (browsers/fetch wrappers often do this for DELETE/POST-with-no-body). The
// default parser 400s on an empty JSON body; every handler treats a missing
// body as {}, so parse empty → undefined instead.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const s = (body as string).trim();
if (!s) return done(null, undefined);
try { done(null, JSON.parse(s)); }
catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); }
});
const hub = new RoomHub(); const hub = new RoomHub();
const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS); const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS, MAX_USERS);
void accounts.load(); void accounts.load();
const cloud = new CloudStore(DATA_DIR); const cloud = new CloudStore(DATA_DIR);
void cloud.load(); void cloud.load();
const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000); const sweeper = setInterval(() => { hub.sweep(); pruneRateBuckets(); }, 5 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(sweeper)); app.addHook('onClose', async () => clearInterval(sweeper));
// Simple per-IP throttle for the account/cloud API. // Per-IP throttle for the account/cloud API. Auth endpoints get a tighter bucket
// since each call does password hashing and is the target of credential-stuffing.
const ipHits = new Map<string, { n: number; reset: number }>(); const ipHits = new Map<string, { n: number; reset: number }>();
const authHits = new Map<string, { n: number; reset: number }>();
const AUTH_PATHS = new Set(['/api/register', '/api/login']);
const bump = (map: Map<string, { n: number; reset: number }>, ip: string, limit: number, windowMs: number): boolean => {
const now = Date.now();
const e = map.get(ip);
if (!e || now > e.reset) { map.set(ip, { n: 1, reset: now + windowMs }); return true; }
return ++e.n <= limit;
};
const pruneRateBuckets = () => {
const now = Date.now();
for (const [ip, e] of ipHits) if (now > e.reset) ipHits.delete(ip);
for (const [ip, e] of authHits) if (now > e.reset) authHits.delete(ip);
};
app.addHook('onRequest', async (req, reply) => { app.addHook('onRequest', async (req, reply) => {
if (!req.url.startsWith('/api/')) return; if (!req.url.startsWith('/api/')) return;
const now = Date.now(); if (!bump(ipHits, req.ip, 60, 60_000)) return reply.code(429).send({ error: 'rate' });
const e = ipHits.get(req.ip); // Strip the querystring before matching the auth path.
if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 }); const path0 = req.url.split('?')[0]!;
else if (++e.n > 60) await reply.code(429).send({ error: 'rate' }); if (AUTH_PATHS.has(path0) && !bump(authHits, req.ip, 10, 60_000)) {
return reply.code(429).send({ error: 'rate', message: 'Too many attempts — wait a minute and try again.' });
}
}); });
// ---- accounts + cloud backup sync ---- // ---- accounts + cloud backup sync ----
app.post('/api/register', async (req, reply) => { app.post('/api/register', async (req, reply) => {
const { username, password } = (req.body ?? {}) as { username?: string; password?: string }; const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
const r = await accounts.register(String(username ?? ''), String(password ?? '')); const r = await accounts.register(String(username ?? ''), String(password ?? ''));
if (!r.ok) return reply.code(400).send({ error: r.code, message: r.message }); if (!r.ok) return reply.code(r.code === 'closed' ? 503 : 400).send({ error: r.code, message: r.message });
return { token: r.token, username: r.username }; return { token: r.token, username: r.username };
}); });
app.post('/api/login', async (req, reply) => { app.post('/api/login', async (req, reply) => {
@@ -61,17 +99,30 @@ export function buildServer() {
app.put('/api/save', async (req, reply) => { app.put('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req)); const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' }); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const blob = (req.body as { blob?: string } | undefined)?.blob; const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
if (typeof blob !== 'string') return reply.code(400).send({ error: 'bad-blob' }); if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
await accounts.saveBlob(u.id, blob); // Quota: the backup blob plus the user's published characters must fit their limit.
return { ok: true, size: blob.length }; const projected = Buffer.byteLength(body.blob) + (await cloud.usageBytes(u.id));
if (projected > accounts.quotaFor(u)) {
return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached. Trim old data or ask the admin to raise your quota.', limit: accounts.quotaFor(u) });
}
// 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) => { app.get('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req)); const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' }); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const blob = await accounts.loadBlob(u.id); const blob = await accounts.loadBlob(u.id);
if (blob === null) return reply.code(404).send({ error: 'no-save' }); 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 ---- // ---- shared cloud campaigns + member-owned characters ----
@@ -94,12 +145,30 @@ export function buildServer() {
if (!c) return reply.code(404).send({ error: 'no-campaign', message: 'No campaign with that invite code.' }); 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' }; 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) => { app.put('/api/characters', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); 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 } }; const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } };
if (!campaignId || !character?.id || typeof character.data !== 'string') return reply.code(400).send({ error: 'bad-request' }); if (!campaignId || !character?.id || typeof character.data !== 'string') return reply.code(400).send({ error: 'bad-request' });
if (Buffer.byteLength(character.data) > MAX_CHARACTER_BYTES) return reply.code(413).send({ error: 'too-large', message: 'That character is too large to publish.' });
// Quota: total usage after this upsert (replace the old copy's bytes with the new).
const delta = Buffer.byteLength(character.data) - (cloud.ownsCharacter(u.id, character.id) ? await cloud.characterBytes(character.id) : 0);
const projected = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)) + delta;
if (projected > accounts.quotaFor(u)) {
return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached.', limit: accounts.quotaFor(u) });
}
const r = await cloud.putCharacter(u.id, campaignId, { id: character.id, name: String(character.name ?? ''), data: character.data }); const r = await cloud.putCharacter(u.id, campaignId, { id: character.id, name: String(character.name ?? ''), data: character.data });
if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, or you do not own that character.' }); if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, you do not own that character, or it is too large.' });
return { ok: true, updatedAt: r.updatedAt }; return { ok: true, updatedAt: r.updatedAt };
}); });
app.get('/api/campaigns/:id/characters', async (req, reply) => { app.get('/api/campaigns/:id/characters', async (req, reply) => {
@@ -118,31 +187,110 @@ export function buildServer() {
app.get('/api/usage', async (req, reply) => { app.get('/api/usage', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)); const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id));
return { bytes, admin: accounts.isAdmin(u.username) }; return { bytes, quota: accounts.quotaFor(u), admin: accounts.isAdmin(u.username) };
});
// ---- admin panel API (accounts in ADMIN_USERS only) ----
const startedAt = Date.now();
const adminOf = async (req: FastifyRequest) => {
const u = await userOf(req);
return u && accounts.isAdmin(u.username) ? u : null;
};
/** Recursive byte total of the data dir (users.json + cloud.json + blobs). */
const dirBytes = async (dir: string): Promise<number> => {
let total = 0;
try {
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) total += await dirBytes(p);
else total += (await fs.stat(p)).size.valueOf();
}
} catch { /* missing dir = 0 */ }
return total;
};
app.get('/api/admin/overview', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const mem = process.memoryUsage();
const roomStats = hub.stats();
const totals = await cloud.totals();
return {
uptimeMs: Date.now() - startedAt,
memory: { rss: mem.rss, heapUsed: mem.heapUsed },
dataDirBytes: await dirBytes(DATA_DIR),
users: { count: accounts.userCount(), max: Number.isFinite(MAX_USERS) ? MAX_USERS : null },
defaultQuotaBytes: DEFAULT_QUOTA_BYTES,
campaigns: totals.campaigns,
characters: totals.characters,
characterBytes: totals.bytes,
rooms: {
count: roomStats.length,
players: roomStats.reduce((n, r) => n + r.players, 0),
imageBytes: roomStats.reduce((n, r) => n + r.imageBytes, 0),
},
};
}); });
// ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ----
app.get('/api/admin/users', async (req, reply) => { app.get('/api/admin/users', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' }); const users = await accounts.listUsersDetailed();
const users = await accounts.listUsers();
const rows = await Promise.all(users.map(async (x) => ({ const rows = await Promise.all(users.map(async (x) => ({
username: x.username, username: x.username,
admin: x.admin,
createdAt: x.createdAt,
usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)), usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)),
quotaBytes: x.quotaBytes ?? 0, quotaBytes: x.quotaBytes,
effectiveQuota: x.quotaBytes > 0 ? x.quotaBytes : DEFAULT_QUOTA_BYTES,
tokenCount: x.tokenCount,
}))); })));
return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) }; return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) };
}); });
app.post('/api/admin/quota', async (req, reply) => { app.post('/api/admin/quota', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number }; const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number };
const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0); const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' }); return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
}); });
app.post('/api/admin/users/:username/revoke', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const ok = await accounts.revokeTokens((req.params as { username: string }).username);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
});
app.delete('/api/admin/users/:username', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const name = (req.params as { username: string }).username;
const id = await accounts.deleteUser(name);
if (!id) return reply.code(400).send({ error: 'cannot-delete', message: 'No such user, or the account is an admin.' });
const purged = await cloud.purgeUser(id);
return { ok: true, purged };
});
app.get('/api/admin/campaigns', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const byId = new Map((await accounts.listUsers()).map((x) => [x.id, x.username]));
const rows = (await cloud.adminList()).map((c) => ({ ...c, owner: byId.get(c.ownerUserId) ?? '(deleted)' }));
return { campaigns: rows.sort((a, b) => b.updatedAt - a.updatedAt) };
});
app.delete('/api/admin/campaigns/:id', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const ok = await cloud.deleteCampaign((req.params as { id: string }).id);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-campaign' });
});
app.get('/api/admin/rooms', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
return { rooms: hub.stats() };
});
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } }); void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
// Live WebSocket connections per client IP — a hard cap on socket-spam.
const wsPerIp = new Map<string, number>();
void app.register(async (instance) => { void app.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket, req) => { instance.get('/ws', { websocket: true }, (socket, req) => {
// Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured. // Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
@@ -151,10 +299,24 @@ export function buildServer() {
socket.close(1008, 'origin'); socket.close(1008, 'origin');
return; return;
} }
const ip = req.ip;
const live = wsPerIp.get(ip) ?? 0;
if (live >= MAX_WS_PER_IP) { socket.close(1013, 'too-many'); return; }
wsPerIp.set(ip, live + 1);
const releaseIp = () => { const n = (wsPerIp.get(ip) ?? 1) - 1; if (n <= 0) wsPerIp.delete(ip); else wsPerIp.set(ip, n); };
const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } }; const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } };
let tokens = RATE.capacity; let tokens = RATE.capacity;
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs); 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) => { socket.on('message', (raw: Buffer) => {
if (tokens-- <= 0) { socket.close(1008, 'rate'); return; } if (tokens-- <= 0) { socket.close(1008, 'rate'); return; }
let parsed; let parsed;
@@ -163,7 +325,7 @@ export function buildServer() {
const m = parsed.data; const m = parsed.data;
switch (m.t) { switch (m.t) {
case 'host': hub.host(sender, m.password, m.resume); break; case 'host': hub.host(sender, m.password, m.resume); break;
case 'join': hub.join(sender, m.joinCode, m.password); break; case 'join': hub.join(sender, m.joinCode, m.password, m.playerId); break;
case 'state': hub.state(sender, m.gmSecret, m.snapshot); break; case 'state': hub.state(sender, m.gmSecret, m.snapshot); break;
case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break; case 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break;
case 'requestImage': hub.requestImage(sender, m.id); break; case 'requestImage': hub.requestImage(sender, m.id); break;
@@ -177,11 +339,11 @@ export function buildServer() {
case 'setName': hub.setName(sender, m.name); break; case 'setName': hub.setName(sender, m.name); break;
} }
}); });
socket.on('close', () => { clearInterval(refill); hub.disconnect(sender); }); socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); releaseIp(); hub.disconnect(sender); });
}); });
}); });
app.get('/healthz', async () => ({ ok: true, rooms: hub.roomCount() })); app.get('/healthz', async () => ({ ok: true }));
// Serve the built SPA with history fallback (so /play?room=... deep-links work). // Serve the built SPA with history fallback (so /play?room=... deep-links work).
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false }); void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
+1 -1
View File
@@ -29,7 +29,7 @@ function next(ws: WebSocket, t: ServerMessage['t']): Promise<ServerMessage> {
ws.on('message', onMsg); 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)', () => { describe('realtime server (integration)', () => {
it('host → join → snapshot flow; players cannot push state', async () => { it('host → join → snapshot flow; players cannot push state', async () => {
+79 -1
View File
@@ -7,7 +7,7 @@ function fake(): Sender & { msgs: ServerMessage[] } {
const msgs: ServerMessage[] = []; const msgs: ServerMessage[] = [];
return { msgs, send: (m) => msgs.push(m) }; 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({ const char: Character = characterSchema.parse({
id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia', id: 'ch1', campaignId: 'cmp1', system: '5e', name: 'Lia',
abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 13, cha: 8 }, abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 13, cha: 8 },
@@ -92,6 +92,31 @@ describe('RoomHub', () => {
expect(hub.roomCount()).toBe(0); expect(hub.roomCount()).toBe(0);
}); });
it('caps the number of concurrent rooms', () => {
const hub = new RoomHub(() => 0, { maxRooms: 2 });
const a = fake(); hub.host(a);
const b = fake(); hub.host(b);
expect(hub.roomCount()).toBe(2);
const c = fake(); hub.host(c);
expect(hub.roomCount()).toBe(2); // refused
expect(lastOf(c, 'error')).toMatchObject({ code: 'busy' });
expect(lastOf(c, 'hosted')).toBeUndefined();
});
it('caps per-room image count and total bytes', () => {
const hub = new RoomHub(() => 0, { maxImagesPerRoom: 2, maxRoomImageBytes: 100 });
const gm = fake(); hub.host(gm);
const { gmSecret } = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
hub.image(gm, gmSecret, 'm1', 'x'.repeat(40));
hub.image(gm, gmSecret, 'm2', 'x'.repeat(40)); // total 80 ≤ 100, count 2 ≤ 2
expect(lastOf(gm, 'error')).toBeUndefined();
hub.image(gm, gmSecret, 'm3', 'x'.repeat(10)); // 3rd distinct image → count cap
expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' });
// overwriting an existing id is allowed, but not past the byte budget
hub.image(gm, gmSecret, 'm1', 'x'.repeat(90)); // 90 + 40 = 130 > 100 → rejected
expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' });
});
it('routes a seat claim → GM grant → player gets their sheet', () => { it('routes a seat claim → GM grant → player gets their sheet', () => {
const hub = new RoomHub(); const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub); const { gm, player, secret } = hostAndJoin(hub);
@@ -128,6 +153,59 @@ describe('RoomHub', () => {
expect(gm.msgs.filter((m) => m.t === 'playerPatched').length).toBe(before); expect(gm.msgs.filter((m) => m.t === 'playerPatched').length).toBe(before);
}); });
it('survives a player reconnect: the seat is reclaimed with the same stable playerId', () => {
const hub = new RoomHub(() => 0);
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const PID = 'stable-player-1';
const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, PID);
hub.claimSeat(p1, 'ch1', char);
hub.seatGrant(gm, hosted.gmSecret, lastOf(gm, 'seatRequest')!.playerId, char);
expect(lastOf(p1, 'seatGranted')).toBeTruthy();
// Transient drop, then auto-reconnect with the SAME stable id within the grace window.
hub.disconnect(p1);
const p2 = fake(); hub.join(p2, hosted.joinCode, undefined, PID);
// The seat is re-granted to the new socket and its patches are accepted again
// (before the fix the player got a fresh playerId and was silently seatless).
expect(lastOf(p2, 'seatGranted')).toMatchObject({ character: { id: 'ch1' } });
hub.playerPatch(p2, 'ch1', { hp: { current: 5, max: 24, temp: 0 } });
expect(lastOf(gm, 'playerPatched')).toMatchObject({ characterId: 'ch1', diff: { hp: { current: 5 } } });
});
it('evicts a seat whose holder stays gone past the grace window', () => {
let t = 0;
const hub = new RoomHub(() => t);
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const PID = 'gone-player';
const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, PID);
hub.claimSeat(p1, 'ch1', char);
hub.seatGrant(gm, hosted.gmSecret, lastOf(gm, 'seatRequest')!.playerId, char);
hub.disconnect(p1);
t += 6 * 60 * 1000; // past the 5-minute seat grace
hub.sweep();
const p2 = fake(); hub.join(p2, hosted.joinCode, undefined, PID);
expect(lastOf(p2, 'seatGranted')).toBeUndefined(); // seat was reclaimed by no one
});
it('does not let a second live connection hijack an active seat with the same id', () => {
const hub = new RoomHub();
const gm = fake(); hub.host(gm);
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
const PID = 'shared-id';
const p1 = fake(); hub.join(p1, hosted.joinCode, undefined, PID);
hub.claimSeat(p1, 'ch1', char);
hub.seatGrant(gm, hosted.gmSecret, lastOf(gm, 'seatRequest')!.playerId, char);
// p1 is still live; a second socket presenting the same id must NOT inherit the seat.
const p2 = fake(); hub.join(p2, hosted.joinCode, undefined, PID);
expect(lastOf(p2, 'seatGranted')).toBeUndefined();
});
it('broadcasts a roster (GM + players) to everyone', () => { it('broadcasts a roster (GM + players) to everyone', () => {
const hub = new RoomHub(); const hub = new RoomHub();
const { gm, player } = hostAndJoin(hub); const { gm, player } = hostAndJoin(hub);
+85 -9
View File
@@ -10,8 +10,15 @@ interface Seat {
sender: Sender; sender: Sender;
characterId: string; characterId: string;
name: string; name: string;
/** the granted sheet, re-sent on reconnect so the player regains control seamlessly */
character: Character;
/** set when the holder's socket drops; the seat survives a short grace window */
disconnectedAt?: number;
} }
/** How long a seat survives after its player disconnects, so a reconnect can reclaim it. */
const SEAT_GRACE_MS = 5 * 60 * 1000;
interface SeatRequest { interface SeatRequest {
playerId: string; playerId: string;
characterId: string; characterId: string;
@@ -27,6 +34,8 @@ interface Room {
players: Set<Sender>; players: Set<Sender>;
snapshot: Snapshot | null; snapshot: Snapshot | null;
images: Map<string, string>; images: Map<string, string>;
/** running total of bytes in `images`, kept so the cap is O(1) to check */
imageBytes: number;
/** granted seats keyed by playerId */ /** granted seats keyed by playerId */
seats: Map<string, Seat>; seats: Map<string, Seat>;
/** seat requests awaiting GM approval (re-sent when the GM reconnects) */ /** seat requests awaiting GM approval (re-sent when the GM reconnects) */
@@ -36,6 +45,13 @@ interface Room {
const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
// Memory guardrails so an anonymous GM can't balloon the (in-RAM) hub on a small box.
export interface RoomLimits { maxRooms: number; maxImagesPerRoom: number; maxRoomImageBytes: number }
const DEFAULT_LIMITS: RoomLimits = {
maxRooms: Number(process.env.MAX_ROOMS) || 200,
maxImagesPerRoom: 40,
maxRoomImageBytes: 40 * 1024 * 1024, // 40 MB of map images per room
};
function sha256(s: string): string { function sha256(s: string): string {
return crypto.createHash('sha256').update(s).digest('hex'); return crypto.createHash('sha256').update(s).digest('hex');
@@ -55,7 +71,10 @@ export class RoomHub {
private rooms = new Map<string, Room>(); private rooms = new Map<string, Room>();
private byCode = new Map<string, string>(); private byCode = new Map<string, string>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string }>(); private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string }>();
constructor(private now: () => number = () => Date.now()) {} private limits: RoomLimits;
constructor(private now: () => number = () => Date.now(), limits: Partial<RoomLimits> = {}) {
this.limits = { ...DEFAULT_LIMITS, ...limits };
}
private mintJoinCode(): string { private mintJoinCode(): string {
for (let attempt = 0; attempt < 50; attempt++) { for (let attempt = 0; attempt < 50; attempt++) {
@@ -83,13 +102,19 @@ export class RoomHub {
} }
} }
} }
// Cap concurrent rooms so socket-spam can't exhaust memory. Idle rooms TTL out;
// a sweep runs first to reclaim any that just expired before we refuse.
if (this.rooms.size >= this.limits.maxRooms) {
this.sweep();
if (this.rooms.size >= this.limits.maxRooms) { socket.send({ t: 'error', code: 'busy', message: 'The server is at capacity — please try again shortly.' }); return; }
}
const roomId = crypto.randomUUID(); const roomId = crypto.randomUUID();
const joinCode = this.mintJoinCode(); const joinCode = this.mintJoinCode();
const gmSecret = crypto.randomBytes(32).toString('base64url'); const gmSecret = crypto.randomBytes(32).toString('base64url');
const room: Room = { const room: Room = {
roomId, joinCode, gmSecretHash: sha256(gmSecret), roomId, joinCode, gmSecretHash: sha256(gmSecret),
passwordHash: password ? sha256(password) : null, passwordHash: password ? sha256(password) : null,
gm: socket, players: new Set(), snapshot: null, images: new Map(), gm: socket, players: new Set(), snapshot: null, images: new Map(), imageBytes: 0,
seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(), seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(),
}; };
this.rooms.set(roomId, room); this.rooms.set(roomId, room);
@@ -98,7 +123,7 @@ export class RoomHub {
socket.send({ t: 'hosted', roomId, joinCode, gmSecret }); socket.send({ t: 'hosted', roomId, joinCode, gmSecret });
} }
join(socket: Sender, joinCode: string, password?: string): void { join(socket: Sender, joinCode: string, password?: string, clientPlayerId?: string): void {
const roomId = this.byCode.get(joinCode.trim().toUpperCase()); const roomId = this.byCode.get(joinCode.trim().toUpperCase());
const room = roomId ? this.rooms.get(roomId) : undefined; const room = roomId ? this.rooms.get(roomId) : undefined;
if (!room) { socket.send({ t: 'error', code: 'no-room', message: 'No session with that code.' }); return; } if (!room) { socket.send({ t: 'error', code: 'no-room', message: 'No session with that code.' }); return; }
@@ -108,9 +133,25 @@ export class RoomHub {
} }
room.players.add(socket); room.players.add(socket);
room.lastActivity = this.now(); room.lastActivity = this.now();
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId: crypto.randomUUID() }); // Reuse the client's stable id (so a transient drop keeps the seat) ONLY when it
// isn't already live on another connection — preventing a second tab/peer from
// seizing an active seat. The id is a client-generated UUID kept in localStorage.
let playerId: string = crypto.randomUUID();
if (clientPlayerId && clientPlayerId !== 'gm' && clientPlayerId.length <= 64) {
const liveElsewhere = [...this.conns.values()].some((c) => c.roomId === room.roomId && c.playerId === clientPlayerId);
if (!liveElsewhere) playerId = clientPlayerId;
}
this.conns.set(socket, { roomId: room.roomId, role: 'player', playerId });
socket.send({ t: 'joined', roomId: room.roomId }); socket.send({ t: 'joined', roomId: room.roomId });
if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot }); if (room.snapshot) socket.send({ t: 'snapshot', snapshot: room.snapshot });
// Reconnect: if this player still owns a seat, re-bind it to the new socket and
// re-grant the sheet so their HP/condition/roll messages are accepted again.
const seat = room.seats.get(playerId);
if (seat) {
seat.sender = socket;
delete seat.disconnectedAt;
socket.send({ t: 'seatGranted', character: seat.character });
}
this.sendRoster(room); this.sendRoster(room);
} }
@@ -125,7 +166,17 @@ export class RoomHub {
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void { image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
const room = this.gmRoom(socket, gmSecret); const room = this.gmRoom(socket, gmSecret);
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; } if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
// Bound per-room image memory: cap the count of distinct images and the total bytes.
const size = Buffer.byteLength(dataUrl);
const prev = room.images.get(id);
const prevSize = prev ? Buffer.byteLength(prev) : 0;
const isNew = prev === undefined;
if ((isNew && room.images.size >= this.limits.maxImagesPerRoom) || room.imageBytes - prevSize + size > this.limits.maxRoomImageBytes) {
socket.send({ t: 'error', code: 'image-limit', message: 'This session has reached its map-image limit.' });
return;
}
room.images.set(id, dataUrl); room.images.set(id, dataUrl);
room.imageBytes += size - prevSize;
room.lastActivity = this.now(); room.lastActivity = this.now();
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl }); for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
} }
@@ -158,7 +209,7 @@ export class RoomHub {
let target: Sender | undefined; let target: Sender | undefined;
for (const [s, c] of this.conns) { if (c.roomId === room.roomId && c.playerId === targetPlayerId) { target = s; break; } } for (const [s, c] of this.conns) { if (c.roomId === room.roomId && c.playerId === targetPlayerId) { target = s; break; } }
if (!target) return; if (!target) return;
room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name }); room.seats.set(targetPlayerId, { sender: target, characterId: character.id, name: character.name, character });
target.send({ t: 'seatGranted', character }); target.send({ t: 'seatGranted', character });
this.sendRoster(room); this.sendRoster(room);
} }
@@ -268,23 +319,48 @@ export class RoomHub {
if (room) { if (room) {
if (conn.role === 'gm' && room.gm === socket) room.gm = null; if (conn.role === 'gm' && room.gm === socket) room.gm = null;
room.players.delete(socket); room.players.delete(socket);
for (const [pid, seat] of room.seats) if (seat.sender === socket) room.seats.delete(pid); // Don't drop the seat on disconnect — start its grace timer so a reconnect (same
// stable playerId) can reclaim it. sweep() evicts seats that stay gone too long.
for (const [, seat] of room.seats) if (seat.sender === socket) seat.disconnectedAt = this.now();
// Refresh the roster for whoever remains (GM left → players see it; player left → GM sees it). // Refresh the roster for whoever remains (GM left → players see it; player left → GM sees it).
this.sendRoster(room); this.sendRoster(room);
} }
this.conns.delete(socket); this.conns.delete(socket);
} }
/** Evict rooms idle past the TTL. Call periodically. */ /** Evict rooms idle past the TTL, and seats whose holder has been gone past the grace window. */
sweep(): void { sweep(): void {
const cutoff = this.now() - ROOM_TTL_MS; const now = this.now();
const cutoff = now - ROOM_TTL_MS;
for (const [id, room] of this.rooms) { for (const [id, room] of this.rooms) {
if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); } if (room.lastActivity < cutoff) { this.byCode.delete(room.joinCode); this.rooms.delete(id); continue; }
let dropped = false;
for (const [pid, seat] of room.seats) {
if (seat.disconnectedAt !== undefined && now - seat.disconnectedAt > SEAT_GRACE_MS) {
room.seats.delete(pid);
dropped = true;
}
}
if (dropped) this.sendRoster(room);
} }
} }
roomCount(): number { return this.rooms.size; } roomCount(): number { return this.rooms.size; }
/** Admin overview of live rooms. Deliberately excludes join codes and secrets —
* the admin can see load, not enter private games. */
stats(): Array<{ players: number; seats: number; images: number; imageBytes: number; idleMs: number; hasGm: boolean }> {
const now = this.now();
return [...this.rooms.values()].map((r) => ({
players: r.players.size,
seats: r.seats.size,
images: r.images.size,
imageBytes: r.imageBytes,
idleMs: Math.max(0, now - r.lastActivity),
hasGm: r.gm !== null,
}));
}
private gmRoom(socket: Sender, gmSecret: string): Room | null { private gmRoom(socket: Sender, gmSecret: string): Room | null {
const conn = this.conns.get(socket); const conn = this.conns.get(socket);
if (!conn || conn.role !== 'gm') return null; if (!conn || conn.role !== 'gm') return null;
+12
View File
@@ -0,0 +1,12 @@
import { Link } from '@tanstack/react-router';
/** Shown for any URL outside the app's routes (previously rendered a blank shell). */
export function NotFound() {
return (
<div className="p-10 text-center">
<h1 className="font-display text-2xl font-semibold text-ink">Page not found</h1>
<p className="mt-2 text-muted">That page doesnt exist or has moved.</p>
<Link to="/" className="mt-4 inline-block text-accent hover:underline">Back to campaigns</Link>
</div>
);
}
+24 -3
View File
@@ -3,6 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import { import {
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower, Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight, LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, ShieldCheck,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
@@ -17,24 +18,36 @@ import { SessionControl } from '@/features/play/SessionControl';
import { HandoutControl } from '@/features/play/HandoutControl'; import { HandoutControl } from '@/features/play/HandoutControl';
import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster'; import { useSessionBroadcaster } from '@/features/play/useSessionBroadcaster';
import { usePlayerConnection } from '@/features/play/usePlayerConnection'; import { usePlayerConnection } from '@/features/play/usePlayerConnection';
import { useCloudAutosave } from '@/features/cloud/useCloudAutosave';
import { PlayerSessionBadge } from '@/features/play/PlayerConnection'; 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 { SessionSidebar } from '@/features/play/SessionSidebar';
import { useSessionStore } from '@/stores/sessionStore'; import { useSessionStore } from '@/stores/sessionStore';
import { useIsAdmin } from '@/features/admin/useIsAdmin';
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[] = [ const NAV: NavItem[] = [
{ to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' }, { to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' },
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' }, { to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
{ to: '/characters', label: 'Characters', icon: UserRound, exact: false, group: 'play' }, { to: '/characters', label: 'Characters', icon: UserRound, exact: false, group: 'play' },
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' }, { to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
{ to: '/director', label: 'AI Director', icon: Wand2, exact: false, group: 'play' },
{ to: '/maps', label: 'Battle Map', icon: MapIcon, 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: '/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: '/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' }, { to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' },
]; ];
const GROUPS: { id: NavItem['group']; label: string | null }[] = [ const GROUPS: { id: NavItem['group']; label: string | null }[] = [
{ id: 'top', label: null }, { id: 'top', label: null },
{ id: 'play', label: 'At the Table' }, { id: 'play', label: 'At the Table' },
{ id: 'world', label: 'World' },
{ id: 'reference', label: 'Reference' }, { id: 'reference', label: 'Reference' },
]; ];
@@ -49,7 +62,7 @@ function CampaignSwitcher() {
return ( return (
<Select <Select
aria-label="Active campaign" 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 ?? ''} value={active?.id ?? ''}
onChange={(e) => setActive(e.target.value || null)} onChange={(e) => setActive(e.target.value || null)}
> >
@@ -87,6 +100,12 @@ export function RootLayout() {
// While the GM is hosting, mirror state to players (inert unless hosting). // While the GM is hosting, mirror state to players (inert unless hosting).
useSessionBroadcaster(activeCampaign ?? null); useSessionBroadcaster(activeCampaign ?? null);
usePlayerConnection(); usePlayerConnection();
useCloudAutosave();
// Instance admins get an extra nav entry (server re-checks every /api/admin call).
const isAdmin = useIsAdmin();
const nav: NavItem[] = isAdmin
? [...NAV, { to: '/admin', label: 'Admin', icon: ShieldCheck, exact: false, group: 'reference' }]
: NAV;
// Global Ctrl/Cmd+K opens the command palette. // Global Ctrl/Cmd+K opens the command palette.
useEffect(() => { useEffect(() => {
@@ -135,7 +154,7 @@ export function RootLayout() {
{GROUPS.map((group) => ( {GROUPS.map((group) => (
<div key={group.id}> <div key={group.id}>
{group.label && showLabel && <div className="smallcaps hidden px-6 pt-4 pb-1.5 sm:block" style={{ fontSize: 9.5 }}>{group.label}</div>} {group.label && showLabel && <div className="smallcaps hidden px-6 pt-4 pb-1.5 sm:block" style={{ fontSize: 9.5 }}>{group.label}</div>}
{NAV.filter((n) => n.group === group.id).map((item) => { {nav.filter((n) => n.group === group.id).map((item) => {
const active = item.exact ? pathname === item.to : pathname.startsWith(item.to); const active = item.exact ? pathname === item.to : pathname.startsWith(item.to);
const Ico = item.icon; const Ico = item.icon;
return ( return (
@@ -203,6 +222,8 @@ export function RootLayout() {
</Button> </Button>
{activeCampaign && <HandoutControl />} {activeCampaign && <HandoutControl />}
<SessionControl /> <SessionControl />
<SyncStatusIndicator />
<SignalsBell />
<ThemeToggle /> <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"> <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 /> <SettingsIcon size={18} aria-hidden />
+22 -8
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, type ReactNode } from 'react'; import { useEffect, useRef, type ReactNode } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { Button } from './Button'; import { Button } from './Button';
@@ -15,17 +16,33 @@ interface ModalProps {
export function Modal({ open, onClose, title, children, footer, className }: ModalProps) { export function Modal({ open, onClose, title, children, footer, className }: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(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(() => { useEffect(() => {
if (!open) return; if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null; previouslyFocused.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current; 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) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
onClose(); onCloseRef.current();
return; return;
} }
if (e.key === 'Tab' && panel) { if (e.key === 'Tab' && panel) {
@@ -45,11 +62,8 @@ export function Modal({ open, onClose, title, children, footer, className }: Mod
} }
} }
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => { return () => document.removeEventListener('keydown', onKey);
document.removeEventListener('keydown', onKey); }, [open]);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null; 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"> <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> <h2 className="text-lg font-display font-semibold text-ink">{title}</h2>
<Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog"> <Button size="icon" variant="ghost" onClick={onClose} aria-label="Close dialog">
<X size={16} aria-hidden />
</Button> </Button>
</div> </div>
<div className="px-5 py-4">{children}</div> <div className="px-5 py-4">{children}</div>
+35 -7
View File
@@ -1,6 +1,12 @@
import { useState } from 'react';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
/** Integer input that never emits NaN — coerces blanks/garbage to a fallback. */ /**
* Integer input that never emits NaN. While focused it keeps a local string draft
* so you can transiently clear or partially type a value (e.g. retype a min-bounded
* field) without it snapping to the minimum on every keystroke. The committed value
* is always a finite, clamped integer.
*/
export function NumberField({ export function NumberField({
value, value,
onChange, onChange,
@@ -16,21 +22,43 @@ export function NumberField({
className?: string; className?: string;
'aria-label'?: string; 'aria-label'?: string;
}) { }) {
const [draft, setDraft] = useState<string | null>(null);
const clamp = (n: number) => {
let v = Math.trunc(n);
if (min !== undefined) v = Math.max(min, v);
if (max !== undefined) v = Math.min(max, v);
return v;
};
// While focused (draft !== null) show the raw text; otherwise the controlled value.
const display = draft !== null ? draft : Number.isFinite(value) ? String(value) : '0';
return ( return (
<input <input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
aria-label={ariaLabel} aria-label={ariaLabel}
value={Number.isFinite(value) ? value : 0} value={display}
min={min} min={min}
max={max} max={max}
onFocus={(e) => setDraft(e.target.value)}
onChange={(e) => { onChange={(e) => {
const raw = Number(e.target.value); const raw = e.target.value;
let n = Number.isFinite(raw) ? Math.trunc(raw) : 0; setDraft(raw);
if (min !== undefined) n = Math.max(min, n); // Emit a clamped value live (so previews update) only when the text parses;
if (max !== undefined) n = Math.min(max, n); // an empty/"-" intermediate leaves the committed value untouched.
onChange(n); if (raw.trim() !== '' && raw !== '-') {
const n = Number(raw);
if (Number.isFinite(n)) onChange(clamp(n));
}
}} }}
onBlur={(e) => {
const raw = e.target.value;
if (raw.trim() !== '' && raw !== '-') {
const n = Number(raw);
if (Number.isFinite(n)) onChange(clamp(n));
}
setDraft(null); // resume showing the controlled value
}}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
className={cn( className={cn(
'w-full rounded-md border border-line bg-surface px-2 py-1.5 text-center text-sm text-ink', 'w-full rounded-md border border-line bg-surface px-2 py-1.5 text-center text-sm text-ink',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
+4 -3
View File
@@ -1,3 +1,4 @@
import { Sparkles, Skull, X } from 'lucide-react';
import { useRollStore, type TrayRoll } from '@/stores/rollStore'; import { useRollStore, type TrayRoll } from '@/stores/rollStore';
import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check'; import { DEGREE_COLOR, DEGREE_LABEL } from '@/lib/dice/check';
import { naturalD20 } from '@/lib/dice/notation'; import { naturalD20 } from '@/lib/dice/notation';
@@ -35,8 +36,8 @@ export function RollTray() {
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
{last.label && <div className="truncate text-xs font-medium text-muted">{last.label}</div>} {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 === '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="text-xs font-bold uppercase tracking-wide text-danger"> Fumble </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 && ( {last.degree && (
<div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}> <div className={cn('text-xs font-semibold', DEGREE_COLOR[last.degree])}>
{DEGREE_LABEL[last.degree]} {DEGREE_LABEL[last.degree]}
@@ -44,7 +45,7 @@ export function RollTray() {
</div> </div>
)} )}
</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>
<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={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> <div className="mt-1 font-mono text-xs text-muted">{last.result.breakdown}</div>
+51
View File
@@ -0,0 +1,51 @@
import { useState } from 'react';
import { Modal } from './Modal';
import { Button } from './Button';
interface ConfirmOptions {
title: string;
message: React.ReactNode;
confirmLabel?: string;
danger?: boolean;
}
/**
* Promise-based confirmation using the themed Modal (no native window.confirm).
*
* const { confirm, confirmElement } = useConfirm();
* ...
* <Button onClick={async () => { if (await confirm({ title, message })) doIt(); }} />
* {confirmElement}
*/
export function useConfirm() {
const [state, setState] = useState<(ConfirmOptions & { resolve: (ok: boolean) => void }) | null>(null);
const confirm = (opts: ConfirmOptions) =>
new Promise<boolean>((resolve) => setState({ ...opts, resolve }));
const settle = (ok: boolean) => {
state?.resolve(ok);
setState(null);
};
const confirmElement = state ? (
<Modal
open
onClose={() => settle(false)}
title={state.title}
className="max-w-md"
footer={
<>
<Button variant="ghost" onClick={() => settle(false)}>Cancel</Button>
<Button variant={state.danger === false ? 'primary' : 'danger'} onClick={() => settle(true)}>
{state.confirmLabel ?? 'Delete'}
</Button>
</>
}
>
<div className="text-sm text-muted">{state.message}</div>
</Modal>
) : null;
return { confirm, confirmElement };
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+263
View File
@@ -0,0 +1,263 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { RefreshCw, ShieldCheck, Users, Database, RadioTower, Cpu, Star } from 'lucide-react';
import { cloudUsername } from '@/lib/cloud/client';
import {
adminOverview, adminUsers, adminCampaigns, adminRooms,
adminSetQuota, adminRevokeTokens, adminDeleteUser, adminDeleteCampaign,
type AdminOverview, type AdminUser, type AdminCampaign, type AdminRoom,
} from '@/lib/cloud/admin';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
function fmtBytes(b: number): string {
if (b >= 1e9) return `${(b / 1e9).toFixed(2)} GB`;
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
return `${Math.max(0, Math.round(b / 1024))} KB`;
}
function fmtAgo(ms: number): string {
const m = Math.floor(ms / 60_000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
return h < 48 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d`;
}
const fmtDate = (t: number) => new Date(t).toLocaleDateString();
/**
* Instance administration: server health, every account (storage, quota,
* sessions), shared campaigns, and live rooms. Server-gated to ADMIN_USERS —
* this page only renders what /api/admin/* is willing to return.
*/
export function AdminPage() {
const username = cloudUsername();
const [overview, setOverview] = useState<AdminOverview | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]);
const [campaigns, setCampaigns] = useState<AdminCampaign[]>([]);
const [rooms, setRooms] = useState<AdminRoom[]>([]);
const [denied, setDenied] = useState(false);
const [msg, setMsg] = useState('');
const refresh = useCallback(() => {
setMsg('');
adminOverview().then(setOverview).catch(() => setDenied(true));
adminUsers().then((r) => setUsers(r.users)).catch(() => {});
adminCampaigns().then((r) => setCampaigns(r.campaigns)).catch(() => {});
adminRooms().then((r) => setRooms(r.rooms)).catch(() => {});
}, []);
useEffect(() => { if (username) refresh(); }, [username, refresh]);
if (!username) {
return (
<Page>
<PageHeader title="Admin" subtitle="Sign in with the admin account to manage this instance." />
<p className="text-sm text-muted">Go to <Link to="/settings" className="text-accent underline">Settings</Link> and sign in to the cloud first.</p>
</Page>
);
}
if (denied) {
return (
<Page>
<PageHeader title="Admin" />
<p className="text-sm text-warning">This account isnt an instance admin. Admins are set with the <code className="rounded bg-elevated px-1">ADMIN_USERS</code> environment variable on the server.</p>
</Page>
);
}
return (
<Page>
<PageHeader
eyebrow="Instance"
title="Admin"
subtitle={`Signed in as ${username}`}
actions={<Button size="sm" variant="secondary" onClick={refresh}><RefreshCw size={14} aria-hidden /> Refresh</Button>}
/>
{/* Server health */}
{overview && (
<div className="mb-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard icon={Users} label="Accounts" value={`${overview.users.count}${overview.users.max ? ` / ${overview.users.max}` : ''}`}
hint={`default quota ${fmtBytes(overview.defaultQuotaBytes)}`} />
<StatCard icon={Database} label="Stored data" value={fmtBytes(overview.dataDirBytes)}
hint={`${overview.campaigns} campaigns · ${overview.characters} published characters`} />
<StatCard icon={RadioTower} label="Live rooms" value={String(overview.rooms.count)}
hint={`${overview.rooms.players} players · ${fmtBytes(overview.rooms.imageBytes)} images in RAM`} />
<StatCard icon={Cpu} label="Memory (RSS)" value={fmtBytes(overview.memory.rss)}
hint={`heap ${fmtBytes(overview.memory.heapUsed)} · up ${fmtAgo(overview.uptimeMs)}`} />
</div>
)}
{/* Accounts */}
<Section icon={ShieldCheck} title={`Accounts (${users.length})`}>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1.5 font-medium">User</th>
<th className="font-medium">Created</th>
<th className="font-medium">Storage</th>
<th className="font-medium">Quota (MB)</th>
<th className="font-medium">Sessions</th>
<th className="font-medium" aria-label="Actions" />
</tr>
</thead>
<tbody>
{users.map((u) => (
<UserRow key={u.username} u={u} onChanged={refresh} onMsg={setMsg} />
))}
</tbody>
</table>
{users.length === 0 && <p className="py-2 text-sm text-muted">No accounts yet.</p>}
</Section>
{/* Campaigns */}
<Section icon={Database} title={`Shared campaigns (${campaigns.length})`}>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1.5 font-medium">Campaign</th>
<th className="font-medium">Owner</th>
<th className="font-medium">Members</th>
<th className="font-medium">Characters</th>
<th className="font-medium">Size</th>
<th className="font-medium">Updated</th>
<th className="font-medium" aria-label="Actions" />
</tr>
</thead>
<tbody>
{campaigns.map((c) => (
<CampaignRow key={c.id} c={c} onChanged={refresh} onMsg={setMsg} />
))}
</tbody>
</table>
{campaigns.length === 0 && <p className="py-2 text-sm text-muted">No shared campaigns yet.</p>}
</Section>
{/* Live rooms */}
<Section icon={RadioTower} title={`Live rooms (${rooms.length})`}>
{rooms.length === 0 ? (
<p className="py-2 text-sm text-muted">No active play sessions. Rooms expire after 6h idle.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1.5 font-medium">GM</th>
<th className="font-medium">Players</th>
<th className="font-medium">Seats</th>
<th className="font-medium">Map images</th>
<th className="font-medium">Idle</th>
</tr>
</thead>
<tbody>
{rooms.map((r, i) => (
<tr key={i} className="border-t border-line">
<td className="py-1.5">{r.hasGm ? <span className="text-success">connected</span> : <span className="text-muted">away</span>}</td>
<td>{r.players}</td>
<td>{r.seats}</td>
<td className="text-muted">{r.images} · {fmtBytes(r.imageBytes)}</td>
<td className="text-muted">{fmtAgo(r.idleMs)}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="mt-1 text-[11px] text-muted">Rooms are anonymous by design join codes and contents are never shown here.</p>
</Section>
{msg && <p className="mt-3 text-sm text-accent">{msg}</p>}
</Page>
);
}
function StatCard({ icon: Icon, label, value, hint }: { icon: typeof Users; label: string; value: string; hint: string }) {
return (
<div className="rounded-xl border border-line bg-panel p-4">
<div className="flex items-center gap-1.5 smallcaps"><Icon size={13} aria-hidden /> {label}</div>
<div className="mt-1 font-display text-2xl font-semibold text-ink">{value}</div>
<div className="mt-0.5 text-xs text-muted">{hint}</div>
</div>
);
}
function Section({ icon: Icon, title, children }: { icon: typeof Users; title: string; children: React.ReactNode }) {
return (
<section className="mb-6 rounded-xl border border-line bg-panel p-4">
<h2 className="mb-2 flex items-center gap-1.5 smallcaps"><Icon size={13} aria-hidden /> {title}</h2>
{children}
</section>
);
}
/** Destructive actions arm on the first click and fire on the second. */
function ConfirmButton({ label, confirmLabel, onConfirm }: { label: string; confirmLabel: string; onConfirm: () => void }) {
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!armed) return;
const t = setTimeout(() => setArmed(false), 4000);
return () => clearTimeout(t);
}, [armed]);
return (
<Button size="sm" variant={armed ? 'danger' : 'ghost'} onClick={() => (armed ? onConfirm() : setArmed(true))}>
{armed ? confirmLabel : label}
</Button>
);
}
function UserRow({ u, onChanged, onMsg }: { u: AdminUser; onChanged: () => void; onMsg: (m: string) => void }) {
const [mb, setMb] = useState(u.quotaBytes ? String(Math.round(u.quotaBytes / 1e6)) : '');
const pct = Math.min(100, Math.round((u.usageBytes / Math.max(1, u.effectiveQuota)) * 100));
const act = (p: Promise<unknown>, done: string) => p.then(() => { onMsg(done); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.'));
return (
<tr className="border-t border-line align-middle">
<td className="py-1.5 text-ink">
{u.username}
{u.admin && <span className="ml-1 inline-flex items-center gap-0.5 rounded bg-accent/10 px-1 text-[10px] text-accent"><Star size={9} aria-hidden /> admin</span>}
</td>
<td className="text-muted">{fmtDate(u.createdAt)}</td>
<td>
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-elevated">
<div className={pct >= 90 ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted">{fmtBytes(u.usageBytes)} / {fmtBytes(u.effectiveQuota)}</span>
</div>
</td>
<td>
<div className="flex items-center gap-1">
<Input className="w-20" value={mb} placeholder="default" aria-label={`Quota for ${u.username} in MB`} onChange={(e) => setMb(e.target.value)} />
<Button size="sm" variant="ghost" onClick={() => act(adminSetQuota(u.username, (Number(mb) || 0) * 1e6), `Quota for ${u.username} ${Number(mb) ? `set to ${mb} MB` : 'reset to default'}.`)}>Set</Button>
</div>
</td>
<td>
<div className="flex items-center gap-1 text-muted">
{u.tokenCount}
{u.tokenCount > 0 && (
<ConfirmButton label="Revoke" confirmLabel="Log out everywhere?" onConfirm={() => act(adminRevokeTokens(u.username), `${u.username} signed out everywhere.`)} />
)}
</div>
</td>
<td className="text-right">
{!u.admin && (
<ConfirmButton label="Delete" confirmLabel="Delete account + data?" onConfirm={() => act(adminDeleteUser(u.username), `${u.username} deleted (account, backup, campaigns, characters).`)} />
)}
</td>
</tr>
);
}
function CampaignRow({ c, onChanged, onMsg }: { c: AdminCampaign; onChanged: () => void; onMsg: (m: string) => void }) {
return (
<tr className="border-t border-line">
<td className="py-1.5 text-ink">{c.name} <span className="text-[10px] uppercase text-muted">{c.system}</span></td>
<td className="text-muted">{c.owner}</td>
<td>{c.members}</td>
<td>{c.characters}</td>
<td className="text-muted">{fmtBytes(c.bytes)}</td>
<td className="text-muted">{fmtDate(c.updatedAt)}</td>
<td className="text-right">
<ConfirmButton label="Delete" confirmLabel="Delete campaign + characters?" onConfirm={() =>
adminDeleteCampaign(c.id).then(() => { onMsg(`Campaign “${c.name}” deleted.`); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.'))} />
</td>
</tr>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import { cloudUsername } from '@/lib/cloud/client';
import { getUsage } from '@/lib/cloud/campaigns';
// Cache across mounts so the sidebar doesn't re-ask /api/usage on every navigation.
let cached: boolean | null = null;
/** Whether the signed-in cloud account is an instance admin (false while unknown). */
export function useIsAdmin(): boolean {
const [isAdmin, setIsAdmin] = useState(cached === true);
useEffect(() => {
if (cached !== null || !cloudUsername()) return;
let on = true;
getUsage()
.then((u) => { cached = u.admin; if (on) setIsAdmin(u.admin); })
.catch(() => { /* signed out / offline — stay hidden */ });
return () => { on = false; };
}, []);
return isAdmin;
}
+21 -24
View File
@@ -1,14 +1,15 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas'; import type { Campaign } from '@/lib/schemas';
import { charactersRepo, encountersRepo, notesRepo } from '@/lib/db/repositories'; import { encountersRepo } from '@/lib/db/repositories';
import { getSystem, applyRest } from '@/lib/rules';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation'; import { rollDice } from '@/lib/dice/notation';
import { addCombatant } from '@/lib/combat/engine'; import { addCombatant } from '@/lib/combat/engine';
import { loadMonsters, loadPf2e } from '@/lib/compendium'; 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 { buildSuggestedEncounter } from '@/lib/assistant/encounter';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { useCharacters } from '@/features/characters/hooks'; 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 { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { CampaignInsights } from './CampaignInsights'; import { CampaignInsights } from './CampaignInsights';
import { SessionPrepCard } from './SessionPrepCard';
import { NpcGenCard } from './NpcGenCard';
export function AssistantPage() { export function AssistantPage() {
return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>; return <RequireCampaign>{(c) => <Assistant campaign={c} />}</RequireCampaign>;
@@ -41,25 +44,11 @@ function Assistant({ campaign }: { campaign: Campaign }) {
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const suggestions = useMemo<Suggestion[]>( const suggestions = useMemo<Suggestion[]>(
() => [...combatSuggestions(activeEnc), ...resourceSuggestions(characters), ...planningSuggestions(notes, quests)], () => buildSignals({ activeEncounter: activeEnc, characters, notes, quests }),
[activeEnc, characters, notes, quests], [activeEnc, characters, notes, quests],
); );
const runAction = async (a: SuggestAction) => { const runAction = useSignalAction(campaign.id, setMsg);
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 buildEncounter = async (difficulty: string) => { const buildEncounter = async (difficulty: string) => {
setBusy(true); setBusy(true);
@@ -93,6 +82,8 @@ function Assistant({ campaign }: { campaign: Campaign }) {
await encountersRepo.save(enc); await encountersRepo.save(enc);
setActiveEncounter(enc.id); setActiveEncounter(enc.id);
void navigate({ to: '/combat' }); void navigate({ to: '/combat' });
} catch {
setMsg('Could not build an encounter — the monster data failed to load. Try again.');
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -107,7 +98,7 @@ function Assistant({ campaign }: { campaign: Campaign }) {
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<section> <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"> <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> <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"> <div className="flex flex-wrap gap-2">
@@ -117,18 +108,24 @@ function Assistant({ campaign }: { campaign: Campaign }) {
</div> </div>
</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." /> <SuggestionList items={byCat('combat')} onAction={runAction} empty="No active combat." />
</section> </section>
<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." /> <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." /> <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} /> <CampaignInsights campaign={campaign} characters={characters} encounters={encounters} quests={quests} notes={notes} />
</section> </section>
</div> </div>
+14 -4
View File
@@ -1,3 +1,4 @@
import { Brain } from 'lucide-react';
import type { Campaign, Encounter } from '@/lib/schemas'; import type { Campaign, Encounter } from '@/lib/schemas';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
@@ -22,7 +23,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
data-testid="encounter-tip" data-testid="encounter-tip"
> >
<div className="flex items-start gap-3"> <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"> <div className="min-w-0 flex-1">
{theme ? ( {theme ? (
<> <>
@@ -45,7 +46,7 @@ export function EncounterTipCard({ campaign, encounter }: { campaign: Campaign;
{state === 'ready' && suggestion && ( {state === 'ready' && suggestion && (
<div className="mt-2 rounded-md border border-line bg-surface p-2"> <div className="mt-2 rounded-md border border-line bg-surface p-2">
<div className="mb-1 flex items-center gap-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'} {source === 'llm' ? 'AI suggestion' : 'Suggested'}
</span> </span>
<span className="text-xs text-muted"> {suggestion.targetDifficulty}</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> <p className="mb-2 text-sm text-ink">{suggestion.reasoning}</p>
<ul className="mb-2 text-sm text-ink"> <ul className="mb-2 text-sm text-ink">
{suggestion.add.map((a) => ( {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> </ul>
<div className="flex gap-2"> <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> <Button size="sm" variant="ghost" onClick={run}>Regenerate</Button>
</div> </div>
</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>
);
}
@@ -0,0 +1,49 @@
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import type { DirectorAction } from '@/lib/assistant/director';
import { actionSummary } from './actionSummary';
import type { ApplyResult } from './useDirectorAction';
/**
* A proposed state change. Approve-each: it only mutates the game when the human
* clicks Apply, which routes through the rules kernel. If no `onApply` is given it
* renders as a passive preview.
*/
export function ActionCard({
action,
onApply,
}: {
action: DirectorAction;
onApply?: (a: DirectorAction) => Promise<ApplyResult>;
}) {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<ApplyResult | null>(null);
const apply = async () => {
if (!onApply) return;
setBusy(true);
try {
setResult(await onApply(action));
} finally {
setBusy(false);
}
};
return (
<div className="rounded-md border border-line bg-panel/60 p-2 text-sm">
<div className="flex items-center gap-2">
<Badge tone={result ? (result.ok ? 'success' : 'ember') : 'arcane'}>
{result ? (result.ok ? 'applied' : 'skipped') : 'change'}
</Badge>
<span className="min-w-0 flex-1 truncate text-muted">{actionSummary(action)}</span>
{onApply && !result && (
<Button size="sm" disabled={busy} onClick={apply}>
{busy ? '…' : 'Apply'}
</Button>
)}
</div>
{result && !result.ok && <p className="mt-1 pl-1 text-xs text-danger">{result.message}</p>}
</div>
);
}
@@ -0,0 +1,120 @@
import { Link } from '@tanstack/react-router';
import type { Campaign } from '@/lib/schemas';
import { Page, PageHeader, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { useDirectorStore } from '@/stores/directorStore';
import { useCharacters } from '@/features/characters/hooks';
import { useDirectorSession } from './useDirectorSession';
import { SceneControls } from './SceneControls';
import { TranscriptPane } from './TranscriptPane';
import { RollRequestCard } from './RollRequestCard';
import { ActionCard } from './ActionCard';
import { SuggestionChips } from './SuggestionChips';
export function DirectorPage() {
return <RequireCampaign>{(c) => <Director campaign={c} />}</RequireCampaign>;
}
function Director({ campaign }: { campaign: Campaign }) {
const { persona, controlledCharacterId, needsSeat, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } =
useDirectorSession(campaign);
const characters = useCharacters(campaign.id);
const setConfig = useDirectorStore((s) => s.setConfig);
const shareWithPlayers = useDirectorStore((s) => s.shareWithPlayers);
const entries = session?.transcript ?? [];
const started = entries.length > 0;
return (
<Page>
<PageHeader
title="AI Director"
subtitle={`${campaign.name} · ${persona === 'dm' ? 'Dungeon Master' : 'Player'}`}
actions={
<div className="flex items-center gap-2">
{source && <Badge tone={source === 'llm' ? 'arcane' : 'default'}>{source === 'llm' ? 'AI' : 'deterministic'}</Badge>}
{started && (
<Button size="sm" variant="ghost" onClick={() => void restart()}>
Restart
</Button>
)}
</div>
}
/>
{!llmReady && (
<p className="mb-3 text-xs text-muted">
No AI key configured the director runs in deterministic mode. Add a key in{' '}
<Link to="/settings" className="text-accent underline-offset-2 hover:underline">
Settings
</Link>{' '}
for full narration.
</p>
)}
{message && (
<p className="mb-3 text-sm text-muted" aria-live="polite">
{message}
</p>
)}
{activeEnc && (
<p className="mb-3 text-xs text-muted">
Running combat: <span className="text-ink">{activeEnc.name}</span> (round {activeEnc.round}).
</p>
)}
<div className="grid gap-6 lg:grid-cols-[1fr_330px]">
<section className="space-y-4">
<TranscriptPane entries={entries} />
{lastTurn && lastTurn.rollRequests.length > 0 && (
<div className="space-y-2">
<div className="smallcaps">Rolls you roll these</div>
{lastTurn.rollRequests.map((r) => (
<RollRequestCard key={r.id} req={r} system={campaign.system} onRolled={recordRoll} />
))}
</div>
)}
{lastTurn && lastTurn.actions.length > 0 && (
<div className="space-y-2">
<div className="smallcaps">Proposed changes you approve each</div>
{lastTurn.actions.map((a, i) => (
<ActionCard key={i} action={a} onApply={applyAction} />
))}
</div>
)}
</section>
<aside className="space-y-4">
<SceneControls
persona={persona}
controlledCharacterId={controlledCharacterId ?? ''}
characters={characters}
shareWithPlayers={shareWithPlayers}
onPersona={(p) => setConfig({ persona: p })}
onSeat={(id) => setConfig({ controlledCharacterId: id })}
onShare={(v) => setConfig({ shareWithPlayers: v })}
/>
<div className="rounded-lg border border-line bg-panel p-3">
<div className="mb-2 smallcaps">Your move</div>
{needsSeat ? (
<p className="text-sm text-muted">Pick a character above for the AI to play.</p>
) : !started ? (
<Button className="w-full" disabled={busy} onClick={() => void run()}>
{busy ? 'Starting…' : persona === 'player' ? 'Take the first action' : 'Begin the scene'}
</Button>
) : (
<div className="space-y-3">
<Button className="w-full" disabled={busy} onClick={() => void run()}>
{busy ? 'Thinking…' : persona === 'player' ? 'Take turn' : 'Continue'}
</Button>
<SuggestionChips suggestions={lastTurn?.suggestions ?? []} disabled={busy} onPick={(t) => void run(t)} />
</div>
)}
</div>
</aside>
</div>
</Page>
);
}
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { Dices } from 'lucide-react';
import type { SystemId } from '@/lib/rules';
import { rollAndShow } from '@/lib/useRoll';
import type { Degree } from '@/lib/dice/check';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import type { RollRequest } from '@/lib/assistant/director';
/**
* The ONLY place the director feature touches the roll seam. A roll fires solely
* from this button's onClick — never automatically — honoring the hard no-auto-roll
* rule. The result is reported back so the director can adjudicate next turn.
*/
export function RollRequestCard({
req,
system,
onRolled,
}: {
req: RollRequest;
system: SystemId;
onRolled: (req: RollRequest, result: { total: number; degree?: Degree }) => void;
}) {
const [done, setDone] = useState<{ total: number; degree?: Degree } | null>(null);
const roll = () => {
const result = rollAndShow({
expression: req.expression,
label: req.label,
...(req.dc !== undefined ? { dc: req.dc, system } : {}),
});
setDone(result);
onRolled(req, result);
};
return (
<div className="flex items-center gap-3 rounded-md border border-line bg-surface-2 p-2.5">
<Dices size={16} className="shrink-0 text-accent" aria-hidden />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-ink">
{req.label}
{req.against ? <span className="text-muted"> vs {req.against}</span> : null}
</div>
<div className="font-mono text-xs text-muted">
{req.expression}
{req.dc !== undefined ? ` · DC ${req.dc}` : ''}
</div>
</div>
{done ? (
<Badge tone={done.degree ? (done.degree.includes('success') ? 'success' : 'ember') : 'gold'}>
{done.total}
{done.degree ? ` · ${done.degree.includes('critical') ? 'Crit ' : ''}${done.degree.includes('success') ? 'Success' : 'Fail'}` : ''}
</Badge>
) : (
<Button size="sm" onClick={roll}>Roll</Button>
)}
</div>
);
}
@@ -0,0 +1,64 @@
import type { Character, DirectorPersona } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Field, Select } from '@/components/ui/Input';
/** Mode switch (DM vs Player) and the AI-seat picker — which PC the director pilots. */
export function SceneControls({
persona,
controlledCharacterId,
characters,
shareWithPlayers,
onPersona,
onSeat,
onShare,
}: {
persona: DirectorPersona;
controlledCharacterId: string;
characters: Character[];
shareWithPlayers: boolean;
onPersona: (p: DirectorPersona) => void;
onSeat: (id: string) => void;
onShare: (v: boolean) => void;
}) {
const pcs = characters.filter((c) => c.kind === 'pc');
return (
<div className="space-y-3 rounded-lg border border-line bg-panel p-3">
<div>
<div className="mb-1.5 smallcaps">Mode</div>
<div className="flex gap-2">
<Button size="sm" variant={persona === 'dm' ? 'primary' : 'secondary'} onClick={() => onPersona('dm')}>
Dungeon Master
</Button>
<Button size="sm" variant={persona === 'player' ? 'primary' : 'secondary'} onClick={() => onPersona('player')}>
Player
</Button>
</div>
</div>
{persona === 'player' && (
<Field label="AI plays">
<Select value={controlledCharacterId} onChange={(e) => onSeat(e.target.value)} aria-label="Character the AI plays">
<option value=""> pick a character </option>
{pcs.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.className ? ` · ${c.className} ${c.level}` : ''}
</option>
))}
</Select>
</Field>
)}
<label className="flex items-center gap-2 text-xs text-muted">
<input type="checkbox" checked={shareWithPlayers} onChange={(e) => onShare(e.target.checked)} className="accent-[var(--app-accent)]" />
Share narration with players (when hosting a live session)
</label>
<p className="text-[11px] text-muted">
{persona === 'dm'
? 'The AI narrates the world and runs NPCs and monsters.'
: 'The AI plays the chosen hero in first person. You still roll its dice and approve its actions.'}
</p>
</div>
);
}
@@ -0,0 +1,52 @@
import { useState } from 'react';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Input';
/** Quick-reply suggestion chips + a free-text box for the human's action/intent. */
export function SuggestionChips({
suggestions,
disabled,
onPick,
}: {
suggestions: string[];
disabled: boolean;
onPick: (text: string) => void;
}) {
const [text, setText] = useState('');
const send = () => {
const t = text.trim();
if (!t) return;
setText('');
onPick(t);
};
return (
<div className="space-y-2">
{suggestions.length > 0 && (
<div className="flex flex-wrap gap-2">
{suggestions.map((s, i) => (
<Button key={i} size="sm" variant="subtle" disabled={disabled} onClick={() => onPick(s)}>
{s}
</Button>
))}
</div>
)}
<Textarea
rows={2}
value={text}
disabled={disabled}
placeholder="Say or do something… (Ctrl/Cmd+Enter to send)"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
send();
}
}}
/>
<Button className="w-full" disabled={disabled || !text.trim()} onClick={send}>
Send
</Button>
</div>
);
}
@@ -0,0 +1,55 @@
import { useEffect, useRef } from 'react';
import { Dices } from 'lucide-react';
import type { DirectorTranscriptEntry } from '@/lib/schemas';
/** The narrative feed — narration, the human's inputs, roll results, and system notes. */
export function TranscriptPane({ entries }: { entries: DirectorTranscriptEntry[] }) {
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [entries.length]);
if (entries.length === 0) {
return (
<p className="rounded-lg border border-dashed border-line bg-panel/50 p-8 text-center text-sm text-muted">
The story begins when you start the scene.
</p>
);
}
return (
<div className="max-h-[60vh] space-y-3 overflow-y-auto pr-1">
{entries.map((e) => (
<TranscriptLine key={e.id} entry={e} />
))}
<div ref={endRef} />
</div>
);
}
function TranscriptLine({ entry }: { entry: DirectorTranscriptEntry }) {
switch (entry.role) {
case 'narration':
return (
<div className="rounded-lg border border-line bg-panel p-3">
{entry.speaker && <span className="font-display text-sm font-semibold text-accent">{entry.speaker}. </span>}
<span className="text-sm leading-relaxed text-ink">{entry.text}</span>
</div>
);
case 'player-input':
return (
<div className="ml-6 rounded-lg border border-accent/30 bg-accent-glow/40 p-2.5">
<span className="text-[11px] uppercase tracking-wide text-accent-deep">{entry.speaker ?? 'You'}</span>
<p className="text-sm text-ink">{entry.text}</p>
</div>
);
case 'roll-result':
return (
<p className="flex items-center gap-1.5 pl-1 font-mono text-xs text-accent-deep">
<Dices size={13} aria-hidden /> {entry.text}
</p>
);
case 'system':
return <p className="pl-1 text-xs italic text-muted">{entry.text}</p>;
}
}
@@ -0,0 +1,26 @@
import type { DirectorAction } from '@/lib/assistant/director';
/** Human-readable one-liner for a proposed director action. Shared by the preview
* card and (phase D2) the apply bridge. */
export function actionSummary(a: DirectorAction): string {
switch (a.kind) {
case 'damage':
return `${a.target} takes ${a.amount}${a.damageType ? ` ${a.damageType}` : ''} damage`;
case 'heal':
return `${a.target} heals ${a.amount}`;
case 'tempHp':
return `${a.target} gains ${a.amount} temp HP`;
case 'condition':
return `${a.op === 'add' ? 'Add' : 'Remove'} ${a.name}${a.value ? ` ${a.value}` : ''} ${a.op === 'add' ? 'on' : 'from'} ${a.target}`;
case 'castSpell':
return `${a.caster} casts ${a.spell}${a.atLevel ? ` (level ${a.atLevel})` : ''}`;
case 'spendResource':
return `${a.actor} spends ${a.amount} ${a.resource}`;
case 'advanceTurn':
return 'Advance to the next turn';
case 'addCombatant':
return `Add ${a.name} to combat`;
case 'log':
return a.text;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { useLiveQuery } from 'dexie-react-hooks';
import { aiSessionsRepo } from '@/lib/db/repositories';
import type { AiSession, DirectorPersona } from '@/lib/schemas';
export function useAiSessions(campaignId: string): AiSession[] {
return useLiveQuery(() => aiSessionsRepo.listByCampaign(campaignId), [campaignId], []);
}
/** The most-recently-updated director session for a campaign + persona. */
export function useLatestAiSession(campaignId: string, persona: DirectorPersona): AiSession | undefined {
return useLiveQuery(() => aiSessionsRepo.latest(campaignId, persona), [campaignId, persona], undefined);
}
@@ -0,0 +1,112 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { db } from '@/lib/db/db';
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
import { buildDirectorScene, type SceneActor } from '@/lib/assistant/director';
import { characterSchema, encounterSchema, newSpellEntry, type Campaign, type Character, type Encounter } from '@/lib/schemas';
import { useDirectorAction } from './useDirectorAction';
const campaign: Campaign = { id: 'c1', name: 'T', system: '5e', description: '', createdAt: 't', updatedAt: 't' };
function seedCaster(): Character {
return characterSchema.parse({
id: 'ch1', campaignId: 'c1', system: '5e', kind: 'pc', name: 'Mira',
abilities: { str: 10, dex: 12, con: 14, int: 10, wis: 16, cha: 12 },
hp: { current: 20, max: 20, temp: 0 },
spellcasting: { slots: [{ level: 1, max: 2, current: 2 }], spells: [newSpellEntry({ id: 'sp1', name: 'Bless', level: 1, concentration: true })] },
resources: [{ id: 'r1', name: 'Channel Divinity', current: 1, max: 1, recovery: 'short' }],
createdAt: 't', updatedAt: 't',
});
}
function seedEncounter(): Encounter {
return encounterSchema.parse({
id: 'e1', campaignId: 'c1', name: 'Fight', status: 'active', round: 1, turnIndex: 0,
combatants: [
{ id: 'cb-mira', name: 'Mira', kind: 'pc', characterId: 'ch1', initiative: 15, ac: 14, hp: { current: 20, max: 20, temp: 0 }, concentrating: 'Bless' },
{ id: 'cb-gob', name: 'Goblin', kind: 'monster', initiative: 12, ac: 13, hp: { current: 7, max: 7, temp: 0 } },
],
createdAt: 't', updatedAt: 't',
});
}
let roster: SceneActor[];
let characters: Character[];
beforeEach(async () => {
await db.characters.clear();
await db.encounters.clear();
const caster = seedCaster();
const enc = seedEncounter();
await charactersRepo.insert(caster);
await encountersRepo.save(enc);
characters = [caster];
roster = buildDirectorScene({ campaign, characters, encounter: enc, persona: 'dm', transcriptWindow: [] }).roster;
});
function hook() {
return renderHook(() => useDirectorAction({ system: '5e', encounterId: 'e1', roster, characters })).result;
}
describe('useDirectorAction — approve-each apply bridge', () => {
it('applies damage through the engine and reports the new HP', async () => {
const r = await hook().current({ kind: 'damage', target: 'Goblin', amount: 5, damageType: 'slashing' });
expect(r.ok).toBe(true);
const enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.hp.current).toBe(2);
});
it('surfaces a concentration save (never auto-rolls) when damaging a concentrating creature', async () => {
const r = await hook().current({ kind: 'damage', target: 'Mira', amount: 10 });
expect(r.ok).toBe(true);
expect(r.rollRequests).toHaveLength(1);
expect(r.rollRequests![0]).toMatchObject({ kind: 'save', dc: 10 }); // concentrationDC(10) = max(10, 5)
const enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((c) => c.id === 'cb-mira')!.hp.current).toBe(10);
});
it('rejects an action targeting an entity not on the board', async () => {
const r = await hook().current({ kind: 'damage', target: 'Smaug', amount: 99 });
expect(r.ok).toBe(false);
const enc = await encountersRepo.get('e1');
expect(enc!.combatants.every((c) => c.hp.current === c.hp.max)).toBe(true); // nothing changed
});
it('adds and removes conditions', async () => {
await hook().current({ kind: 'condition', target: 'Goblin', op: 'add', name: 'prone' });
let enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.conditions.map((x) => x.name)).toContain('prone');
await hook().current({ kind: 'condition', target: 'Goblin', op: 'remove', name: 'prone' });
enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((c) => c.id === 'cb-gob')!.conditions.map((x) => x.name)).not.toContain('prone');
});
it('advances the turn through the engine', async () => {
const r = await hook().current({ kind: 'advanceTurn' });
expect(r.ok).toBe(true);
const enc = await encountersRepo.get('e1');
expect(enc!.turnIndex).toBe(1);
});
it('casts a known spell, consuming a slot and mirroring concentration to the combatant', async () => {
const r = await hook().current({ kind: 'castSpell', caster: 'Mira', spell: 'Bless', atLevel: 1 });
expect(r.ok).toBe(true);
const c = await charactersRepo.get('ch1');
expect(c!.spellcasting.slots[0]!.current).toBe(1); // one slot spent
expect(c!.concentration?.spellName).toBe('Bless');
const enc = await encountersRepo.get('e1');
expect(enc!.combatants.find((x) => x.id === 'cb-mira')!.concentrating).toBe('Bless');
});
it("rejects a spell the caster doesn't have", async () => {
const r = await hook().current({ kind: 'castSpell', caster: 'Mira', spell: 'Meteor Swarm' });
expect(r.ok).toBe(false);
});
it('spends a resource through the kernel', async () => {
const r = await hook().current({ kind: 'spendResource', actor: 'Mira', resource: 'Channel Divinity', amount: 1 });
expect(r.ok).toBe(true);
const c = await charactersRepo.get('ch1');
expect(c!.resources[0]!.current).toBe(0);
});
});
@@ -0,0 +1,168 @@
import { useCallback } from 'react';
import { newId } from '@/lib/ids';
import type { SystemId } from '@/lib/rules';
import type { Character } from '@/lib/schemas';
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
import {
applyDamage,
applyHealing,
setTempHp,
updateCombatant,
nextTurn,
logEvent,
isMassiveDamageDeath,
} from '@/lib/combat/engine';
import { castSpell, spendResource, concentrationDC } from '@/lib/mechanics';
import {
resolveCombatant,
resolveActor,
resolveSpellByName,
resolveResourceByName,
type DirectorAction,
type RollRequest,
type SceneActor,
} from '@/lib/assistant/director';
export interface ApplyResult {
ok: boolean;
message: string;
/** follow-up rolls a human must make (e.g. a concentration save) — surfaced, never auto-rolled */
rollRequests?: RollRequest[];
}
function concentrationSave(name: string, spellName: string, damage: number): RollRequest {
return { id: newId(), actor: name, label: `Concentration save (${spellName})`, kind: 'save', expression: '1d20', dc: concentrationDC(damage) };
}
/**
* The third anti-hallucination gate and the only writer of game state for the
* director. Every action re-resolves its target against the live roster/encounter
* and routes through the pure rules kernel + combat engine — approve-each, so it
* runs solely from an ActionCard's Apply click. Damage to a concentrating creature
* SURFACES a save (it never auto-resolves it).
*/
export function useDirectorAction(opts: {
system: SystemId;
encounterId?: string | undefined;
roster: SceneActor[];
characters: Character[];
}) {
const { system, encounterId, roster, characters } = opts;
return useCallback(
async (action: DirectorAction): Promise<ApplyResult> => {
const noCombat: ApplyResult = { ok: false, message: 'No active encounter to apply this to.' };
const pcFor = (name: string): Character | undefined => {
const actor = resolveActor(roster, name);
return actor?.characterId ? characters.find((c) => c.id === actor.characterId) : undefined;
};
switch (action.kind) {
case 'damage': {
if (!encounterId) return noCombat;
let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` };
const followUps: RollRequest[] = [];
await encountersRepo.mutate(encounterId, (e) => {
const c = resolveCombatant(e, action.target);
if (!c) return e;
const after = applyDamage(c, action.amount, action.damageType);
const massive = isMassiveDamageDeath(c.hp.max, after.hp.current);
out = {
ok: true,
message: `${c.name}: ${action.amount}${action.damageType ? ` ${action.damageType}` : ''} damage → ${after.hp.current}/${after.hp.max} HP${massive ? ' (massive damage — instant death!)' : ''}`,
};
if (system === '5e' && c.concentrating) followUps.push(concentrationSave(c.name, c.concentrating, action.amount));
return updateCombatant(e, c.id, { hp: after.hp });
});
return followUps.length ? { ...out, rollRequests: followUps } : out;
}
case 'heal': {
if (!encounterId) return noCombat;
let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` };
await encountersRepo.mutate(encounterId, (e) => {
const c = resolveCombatant(e, action.target);
if (!c) return e;
const after = applyHealing(c, action.amount);
out = { ok: true, message: `${c.name} healed ${action.amount}${after.hp.current}/${after.hp.max} HP` };
return updateCombatant(e, c.id, { hp: after.hp });
});
return out;
}
case 'tempHp': {
if (!encounterId) return noCombat;
let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` };
await encountersRepo.mutate(encounterId, (e) => {
const c = resolveCombatant(e, action.target);
if (!c) return e;
const after = setTempHp(c, action.amount);
out = { ok: true, message: `${c.name} gains ${action.amount} temporary HP` };
return updateCombatant(e, c.id, { hp: after.hp });
});
return out;
}
case 'condition': {
if (!encounterId) return noCombat;
let out: ApplyResult = { ok: false, message: `"${action.target}" isn't on the board.` };
await encountersRepo.mutate(encounterId, (e) => {
const c = resolveCombatant(e, action.target);
if (!c) return e;
const others = c.conditions.filter((x) => x.name.trim().toLowerCase() !== action.name.trim().toLowerCase());
const conditions =
action.op === 'add'
? [...others, { name: action.name, ...(action.value && action.value > 0 ? { value: action.value } : {}) }]
: others;
out = { ok: true, message: `${action.op === 'add' ? 'Added' : 'Removed'} ${action.name}${action.value ? ` ${action.value}` : ''} ${action.op === 'add' ? 'on' : 'from'} ${c.name}` };
return updateCombatant(e, c.id, { conditions });
});
return out;
}
case 'advanceTurn': {
if (!encounterId) return noCombat;
await encountersRepo.mutate(encounterId, (e) => nextTurn(e, system));
return { ok: true, message: 'Advanced to the next turn.' };
}
case 'log': {
if (encounterId) await encountersRepo.mutate(encounterId, (e) => logEvent(e, action.text));
return { ok: true, message: action.text };
}
case 'castSpell': {
const c = pcFor(action.caster);
if (!c) return { ok: false, message: `"${action.caster}" isn't a tracked character.` };
const spell = resolveSpellByName(c, action.spell);
if (!spell) return { ok: false, message: `${c.name} doesn't have "${action.spell}" prepared.` };
const r = castSpell(c, spell.id, action.atLevel);
if (!r.ok) return { ok: false, message: r.reason };
await charactersRepo.update(c.id, r.patch);
// Mirror new concentration onto the combatant so post-damage saves surface.
const conc = r.patch.concentration;
const actor = resolveActor(roster, action.caster);
if (encounterId && actor?.combatantId && conc) {
await encountersRepo.mutate(encounterId, (e) => updateCombatant(e, actor.combatantId!, { concentrating: conc.spellName }));
}
return { ok: true, message: r.log.join(' ') || `${c.name} cast ${spell.name}.` };
}
case 'spendResource': {
const c = pcFor(action.actor);
if (!c) return { ok: false, message: `"${action.actor}" isn't a tracked character.` };
const res = resolveResourceByName(c, action.resource);
if (!res) return { ok: false, message: `${c.name} has no "${action.resource}" resource.` };
const r = spendResource(c, res.id, action.amount);
if (!r.ok) return { ok: false, message: r.reason };
await charactersRepo.update(c.id, r.patch);
return { ok: true, message: r.log.join(' ') || `${c.name} spent ${action.amount} ${res.name}.` };
}
case 'addCombatant':
return { ok: false, message: 'Adding monsters from the director arrives in a later update.' };
}
},
[system, encounterId, roster, characters],
);
}
@@ -0,0 +1,189 @@
import { useCallback, useMemo, useState } from 'react';
import type { Campaign } from '@/lib/schemas';
import { aiSessionsRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids';
import { getLlmConfig, useAssistantStore } from '@/stores/assistantStore';
import { useDirectorStore } from '@/stores/directorStore';
import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/hooks';
import {
buildDirectorScene,
runDirectorTurn,
planContext,
deterministicSummary,
buildSummaryPrompt,
type DirectorAction,
type RollRequest,
} from '@/lib/assistant/director';
import { complete } from '@/lib/llm/client';
import { sendChat } from '@/lib/sync/wsSync';
import { useSessionStore } from '@/stores/sessionStore';
import type { Degree } from '@/lib/dice/check';
import { useLatestAiSession } from './hooks';
import { useDirectorAction, type ApplyResult } from './useDirectorAction';
const nowIso = (): string => new Date().toISOString();
/** When hosting a live session and sharing is on, push the director's narration to
* the players' screens via the table chat channel. A no-op when not hosting. */
function broadcastNarration(text: string): void {
const { role, status } = useSessionStore.getState();
if (role === 'gm' && status === 'connected' && useDirectorStore.getState().shareWithPlayers && text.trim()) {
sendChat(text.trim());
}
}
/**
* Fold older transcript turns into the session's rolling summary when the live
* window has grown past its budget. Uses the LLM when configured, else a
* deterministic compaction — so the context stays bounded with or without a key.
*/
async function maybeSummarize(sessionId: string, systemConstraint: string, windowSize: number): Promise<void> {
const s = await aiSessionsRepo.get(sessionId);
if (!s) return;
const plan = planContext(s.transcript, s.summarizedThrough, windowSize);
if (plan.foldEntries.length === 0) return;
const cfg = getLlmConfig();
let summary = deterministicSummary(s.summary, plan.foldEntries);
if (cfg.enabled && cfg.apiKey.trim()) {
const { system, user } = buildSummaryPrompt(systemConstraint, s.summary, plan.foldEntries);
const res = await complete(cfg, { system, user, maxTokens: 512 });
if (res.ok && 'text' in res && res.text.trim()) summary = res.text.trim();
}
await aiSessionsRepo.setSummary(sessionId, summary, plan.newSummarizedThrough);
}
const DEGREE_LABEL: Record<Degree, string> = {
'critical-success': 'Critical Success',
success: 'Success',
failure: 'Failure',
'critical-failure': 'Critical Failure',
};
export interface LatestTurn {
rollRequests: RollRequest[];
actions: DirectorAction[];
suggestions: string[];
}
/** Orchestrates one director session: building the grounded scene, running turns,
* recording the human's rolls, and persisting the transcript. UI-layer (it may
* touch the roll seam via the cards), never the pure engine. */
export function useDirectorSession(campaign: Campaign) {
const persona = useDirectorStore((s) => s.persona);
const controlledId = useDirectorStore((s) => s.controlledCharacterId);
const narrationStyle = useDirectorStore((s) => s.narrationStyle);
const windowSize = useDirectorStore((s) => s.windowSize);
const llmReady = useAssistantStore((s) => s.enabled && !!s.apiKey);
// The AI seat only applies in the player persona.
const controlledCharacterId = persona === 'player' ? controlledId || undefined : undefined;
const session = useLatestAiSession(campaign.id, persona);
const characters = useCharacters(campaign.id);
const encounters = useEncounters(campaign.id);
const activeEnc = encounters.find((e) => e.status === 'active');
const [busy, setBusy] = useState(false);
const [source, setSource] = useState<'llm' | 'fallback' | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [lastTurn, setLastTurn] = useState<LatestTurn | null>(null);
// Roster for name-resolution when applying actions (rebuilt from live data).
const roster = useMemo(
() => buildDirectorScene({ campaign, characters, encounter: activeEnc, persona, controlledCharacterId, transcriptWindow: [] }).roster,
[campaign, characters, activeEnc, persona, controlledCharacterId],
);
const apply = useDirectorAction({ system: campaign.system, encounterId: activeEnc?.id, roster, characters });
const run = useCallback(
async (input?: string) => {
setBusy(true);
setMessage(null);
try {
let sid = session?.id;
if (!sid) sid = (await aiSessionsRepo.create(campaign.id, persona)).id;
if (input && input.trim()) {
await aiSessionsRepo.appendEntries(sid, [{ id: newId(), role: 'player-input', text: input.trim(), ts: nowIso() }]);
}
const fresh = await aiSessionsRepo.get(sid);
const plan = planContext(fresh?.transcript ?? [], fresh?.summarizedThrough ?? 0, windowSize);
const scene = buildDirectorScene({
campaign,
characters,
encounter: activeEnc,
persona,
controlledCharacterId,
narrationStyle,
transcriptWindow: plan.window,
summary: fresh?.summary,
});
const res = await runDirectorTurn(getLlmConfig(), scene);
setSource(res.source);
if (res.message) setMessage(res.message);
if (res.turn.narration.trim()) {
await aiSessionsRepo.appendEntries(sid, [{
id: newId(),
role: 'narration',
...(res.turn.speaker ? { speaker: res.turn.speaker } : {}),
text: res.turn.narration,
ts: nowIso(),
}]);
broadcastNarration(res.turn.speaker ? `${res.turn.speaker}: ${res.turn.narration}` : res.turn.narration);
}
setLastTurn({ rollRequests: res.turn.rollRequests, actions: res.turn.actions, suggestions: res.turn.suggestions });
// Token budget: fold older turns into a rolling summary so the context stays bounded.
await maybeSummarize(sid, scene.systemConstraint, windowSize);
} finally {
setBusy(false);
}
},
[session?.id, campaign, persona, controlledCharacterId, characters, activeEnc, narrationStyle, windowSize],
);
const recordRoll = useCallback(
async (req: RollRequest, result: { total: number; degree?: Degree }) => {
if (!session) return;
const dc = req.dc !== undefined ? ` (DC ${req.dc})` : '';
const deg = result.degree ? `${DEGREE_LABEL[result.degree]}` : '';
const text = `${req.label}${dc}: ${result.total}${deg}`;
await aiSessionsRepo.appendEntries(session.id, [{
id: newId(), role: 'roll-result', ...(req.actor ? { speaker: req.actor } : {}), text, ts: nowIso(),
}]);
setLastTurn((prev) => (prev ? { ...prev, rollRequests: prev.rollRequests.filter((r) => r.id !== req.id) } : prev));
},
[session],
);
const applyAction = useCallback(
async (action: DirectorAction): Promise<ApplyResult> => {
const r = await apply(action);
if (r.ok && session) {
await aiSessionsRepo.appendEntries(session.id, [{ id: newId(), role: 'system', text: `Applied: ${r.message}`, ts: nowIso() }]);
}
if (r.rollRequests?.length) {
setLastTurn((prev) => (prev ? { ...prev, rollRequests: [...prev.rollRequests, ...r.rollRequests!] } : prev));
}
if (!r.ok) setMessage(r.message);
return r;
},
[apply, session],
);
const restart = useCallback(async () => {
if (!session) return;
await aiSessionsRepo.mutate(session.id, (s) => ({ ...s, transcript: [], summary: '', summarizedThrough: 0 }));
setLastTurn(null);
setSource(null);
setMessage(null);
}, [session]);
const needsSeat = persona === 'player' && !controlledCharacterId;
return { persona, controlledCharacterId, needsSeat, session, busy, source, message, lastTurn, llmReady, activeEnc, run, recordRoll, applyAction, restart } as const;
}
+19 -4
View File
@@ -4,7 +4,7 @@ import { encountersRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation'; 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 { computeBudget } from '@/lib/combat/budget';
import { loadMonsters, loadPf2e } from '@/lib/compendium'; import { loadMonsters, loadPf2e } from '@/lib/compendium';
import { complete } from '@/lib/llm/client'; 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 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 current = computeBudget(campaign.system, partyLevels, existing.map((m) => ({ cr: m.cr, level: m.level }))).difficulty;
const target = nextTarget(campaign.system, current); 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 // Candidates the AI may choose from: the creatures already in the fight
// (so it can say "add another goblin") plus level-appropriate bestiary picks. // (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]; const candidates = [...existingCands, ...poolCands];
if (candidates.length === 0) { setMessage('No suitable creatures found for this party.'); setState('error'); return; } 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 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) { if (res.ok && 'data' in res) {
const valid = res.data.add.filter((a) => candidates.some((c) => c.name === a.name)); const valid = res.data.add.filter((a) => candidates.some((c) => c.name === a.name));
if (valid.length) { if (valid.length) {
@@ -137,7 +142,7 @@ export function useEncounterAdvisor(campaign: Campaign, encounter: Encounter) {
return; return;
} }
} else if (!res.ok) { } 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; if (!suggestion) return;
await encountersRepo.mutate(encounter.id, (e) => { await encountersRepo.mutate(encounter.id, (e) => {
let next = 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) { for (const a of suggestion.add) {
// Prefer cloning a creature already in the fight (handles "add another goblin" // Prefer cloning a creature already in the fight (handles "add another goblin"
// and works even for custom/homebrew combatants); otherwise pull from the pool. // 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'); setState('ready');
return; 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)); setRoutes(deterministicRoutes(campaign.system, character.className));
setSource('deterministic'); 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 { 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 { cn } from '@/lib/cn';
import { campaignsRepo } from '@/lib/db/repositories'; import { campaignsRepo } from '@/lib/db/repositories';
import { seedSampleCampaign } from '@/lib/sample'; import { seedSampleCampaign } from '@/lib/sample';
@@ -70,9 +70,9 @@ function Welcome({ onCreate }: { onCreate: () => void }) {
</p> </p>
<hr className="gilt-rule mx-auto mt-5 max-w-xs" /> <hr className="gilt-rule mx-auto mt-5 max-w-xs" />
<div className="mt-5 grid gap-2 text-left sm:grid-cols-3"> <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={<Users size={20} />} 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={<Map size={20} />} 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={<RadioTower size={20} />} title="Play live" desc="Host a room; players manage their own character." />
</div> </div>
<div className="mt-6 flex flex-wrap items-center justify-center gap-2"> <div className="mt-6 flex flex-wrap items-center justify-center gap-2">
<Button variant="primary" onClick={onCreate}>Start your first campaign</Button> <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 ( return (
<div className="rounded-xl border border-line bg-surface-2 p-3 transition-colors hover:border-line-strong"> <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="mt-1 font-display font-semibold text-ink">{title}</div>
<div className="text-xs text-muted">{desc}</div> <div className="text-xs text-muted">{desc}</div>
</div> </div>
@@ -258,12 +258,10 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
placeholder="Curse of Strahd" placeholder="Curse of Strahd"
/> />
</Field> </Field>
{/* System is fixed once a campaign exists (content + characters depend on it). */}
{!campaign && (
<Field label="System"> <Field label="System">
<Select <Select value={system} onChange={(e) => setSystem(e.target.value as typeof system)}>
value={system}
disabled={!!campaign}
onChange={(e) => setSystem(e.target.value as typeof system)}
>
{SYSTEM_OPTIONS.map((o) => ( {SYSTEM_OPTIONS.map((o) => (
<option key={o.id} value={o.id}> <option key={o.id} value={o.id}>
{o.label} {o.label}
@@ -271,6 +269,7 @@ function CampaignFormModal({ campaign, onClose }: { campaign?: Campaign; onClose
))} ))}
</Select> </Select>
</Field> </Field>
)}
<Field label="Description"> <Field label="Description">
<Textarea <Textarea
value={description} value={description}
@@ -0,0 +1,565 @@
/**
* MPMB-style ability score tables: one row per ability, one column per source.
* Three variants:
* SheetAbilityTable sheet breakdown modal (build sources + manual override)
* Wizard5eAbilityTable 5e wizard Abilities step
* WizardPf2eAbilityTable PF2e wizard boost-picker
*/
import { useState } from 'react';
import { ABILITY_ABBR, ABILITY_LABELS, abilityModifier } from '@/lib/rules';
import { abilityBreakdown } from '@/lib/rules/abilityBuild';
import type { AbilityBuild } from '@/lib/schemas/character';
import type { AbilityKey, AbilityScores } from '@/lib/rules/types';
import { POINT_BUY_MIN, POINT_BUY_MAX, pointBuyRemaining } from '@/lib/rules/abilityGen';
import { NumberField } from '@/components/ui/NumberField';
import { Select } from '@/components/ui/Input';
import { cn } from '@/lib/cn';
import { formatModifier } from '@/lib/format';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
// ─── shared cell/header styles ───────────────────────────────────────────────
const TH = 'px-2 py-1.5 text-[10px] font-medium smallcaps text-muted text-center whitespace-nowrap select-none';
const TH_L = 'px-2 py-1.5 text-[10px] font-medium smallcaps text-muted text-left whitespace-nowrap select-none';
const TD = 'px-2 py-1 text-center tabular-nums';
const TD_L = 'px-2 py-1 text-left';
function DeltaBadge({ delta, kind }: { delta: number; kind?: 'boost' | 'flat' | undefined }) {
if (delta === 0) return <span className="text-muted/30"></span>;
const pos = delta > 0;
return (
<span className={cn(
'inline-flex items-center justify-center rounded px-1 min-w-[28px] h-5 text-xs font-medium',
pos
? kind === 'boost'
? 'bg-violet-500/15 text-violet-400'
: 'bg-accent/15 text-accent'
: 'bg-danger/15 text-danger',
)}>
{pos ? '+' : ''}{delta}
</span>
);
}
// ─── Sheet breakdown table ────────────────────────────────────────────────────
export interface SheetAbilityTableProps {
build: AbilityBuild;
/** When provided, shows the Manual override column and calls this on change. */
onSetManual?: (ability: AbilityKey, delta: number) => void;
keyAbilities?: AbilityKey[] | undefined;
}
export function SheetAbilityTable({ build, onSetManual, keyAbilities }: SheetAbilityTableProps) {
const bd = abilityBreakdown(build);
// Collect unique source labels; show Manual last (it's the override column)
const nonManualLabels = [...new Set(
build.adjustments.filter((a) => a.label !== 'Manual').map((a) => a.label),
)];
function ManualCell({ ability }: { ability: AbilityKey }) {
const adj = build.adjustments.find((a) => a.label === 'Manual' && a.ability === ability);
const [editing, setEditing] = useState(false);
const delta = adj?.amount ?? 0;
if (!onSetManual) {
return <span className={cn('text-sm tabular-nums', delta ? (delta > 0 ? 'text-accent' : 'text-danger') : 'text-muted/30')}>{delta ? (delta > 0 ? `+${delta}` : delta) : '—'}</span>;
}
if (editing) {
return (
<NumberField
value={delta}
min={-30}
max={30}
className="w-16"
aria-label={`Manual override for ${ABILITY_LABELS[ability]}`}
onChange={(v) => onSetManual(ability, v)}
/>
);
}
return (
<button
type="button"
onClick={() => setEditing(true)}
onBlur={() => setEditing(false)}
className={cn(
'inline-flex items-center justify-center rounded border border-dashed border-line hover:border-accent w-12 h-6 text-xs tabular-nums transition-colors',
delta ? (delta > 0 ? 'text-accent border-accent/30' : 'text-danger border-danger/30') : 'text-muted/40',
)}
title="Click to add a manual adjustment"
>
{delta ? (delta > 0 ? `+${delta}` : delta) : '±'}
</button>
);
}
return (
<div className="overflow-x-auto rounded-lg border border-line">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-elevated border-b border-line">
<th className={TH_L}>Ability</th>
<th className={TH}>Base</th>
{nonManualLabels.map((label) => (
<th key={label} className={TH}>{label}</th>
))}
{onSetManual && <th className={TH}>Manual</th>}
<th className={cn(TH, 'font-semibold text-ink')}>Total</th>
<th className={TH}>Mod</th>
</tr>
</thead>
<tbody>
{ABILITIES.map((ability, idx) => {
const b = bd[ability];
const total = b.total;
const mod = abilityModifier(total);
const isKey = keyAbilities?.includes(ability);
return (
<tr key={ability} className={cn(
'border-b border-line/50 last:border-b-0',
idx % 2 === 0 ? 'bg-surface' : 'bg-panel',
isKey && 'ring-1 ring-inset ring-accent/20',
)}>
<td className={TD_L}>
<span className="font-medium text-ink">{ABILITY_LABELS[ability]}</span>
<span className="ml-1.5 text-[10px] text-muted smallcaps">{ABILITY_ABBR[ability]}</span>
{isKey && <span className="ml-1 text-[9px] text-accent"></span>}
</td>
<td className={cn(TD, 'font-mono text-muted')}>{b.base}</td>
{nonManualLabels.map((label) => {
const parts = b.parts.filter((p) => p.label === label);
const totalDelta = parts.reduce((s, p) => s + p.delta, 0);
const adj = build.adjustments.find((a) => a.label === label && a.ability === ability);
return (
<td key={label} className={TD}>
{totalDelta !== 0
? <DeltaBadge delta={totalDelta} kind={adj?.kind} />
: <span className="text-muted/20"></span>}
</td>
);
})}
{onSetManual && (
<td className={TD}>
<ManualCell ability={ability} />
</td>
)}
<td className={TD}>
<span className={cn(
'font-display font-semibold text-base',
total >= 18 ? 'text-accent-deep' : 'text-ink',
)}>{total}</span>
</td>
<td className={TD}>
<span className={cn(
'font-mono text-sm font-medium',
mod >= 0 ? 'text-accent' : 'text-danger',
)}>{formatModifier(mod)}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// ─── 5e Wizard table ──────────────────────────────────────────────────────────
export interface Wizard5eAbilityTableProps {
method: 'standard' | 'pointbuy' | 'roll' | 'manual';
pool: number[]; // sorted pool for standard/roll
assignment: number[]; // pool[assignment[i]] = base score for ABILITIES[i]
pb: number[]; // point-buy raw bases per ability (same order as ABILITIES)
racial: Partial<AbilityScores>;
asiAlloc: Partial<AbilityScores>;
asiCount: number; // total ASI points available (asiCount*2 pts from feat/ASI)
keyAbilities?: string[] | undefined;
onChangeAssignment: (abilityIdx: number, poolIdx: number) => void;
onChangePb: (abilityIdx: number, value: number) => void;
onChangeAsi: (ability: AbilityKey, delta: number) => void;
}
export function Wizard5eAbilityTable({
method, pool, assignment, pb, racial, asiAlloc, asiCount, keyAbilities,
onChangeAssignment, onChangePb, onChangeAsi,
}: Wizard5eAbilityTableProps) {
const usesPool = method === 'standard' || method === 'roll';
const hasRacial = ABILITIES.some((a) => (racial[a] ?? 0) !== 0);
const hasAsi = asiCount > 0;
const asiUsed = ABILITIES.reduce((s, a) => s + (asiAlloc[a] ?? 0), 0);
const asiRemaining = asiCount * 2 - asiUsed;
const pbRemaining = pointBuyRemaining(pb);
return (
<div className="overflow-x-auto rounded-lg border border-line">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-elevated border-b border-line">
<th className={TH_L}>Ability</th>
<th className={TH}>
{method === 'standard' ? 'Array' : method === 'roll' ? 'Roll' : 'Base'}
</th>
{hasRacial && <th className={TH}>Race</th>}
{hasAsi && (
<th className={TH}>
ASI
<span className={cn('ml-1 text-[9px]', asiRemaining < 0 ? 'text-danger' : 'text-muted')}>
({asiRemaining} left)
</span>
</th>
)}
<th className={cn(TH, 'font-semibold text-ink')}>Total</th>
<th className={TH}>Mod</th>
</tr>
</thead>
<tbody>
{ABILITIES.map((ability, i) => {
const base = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!;
const race = racial[ability] ?? 0;
const asi = asiAlloc[ability] ?? 0;
const total = base + race + asi;
const mod = abilityModifier(total);
const isKey = keyAbilities?.includes(ability);
const pbMax = method === 'pointbuy'
? (() => {
let best = pb[i]!;
for (let c = pb[i]! + 1; c <= POINT_BUY_MAX; c++) {
if (pointBuyRemaining(pb.map((x, j) => (j === i ? c : x))) >= 0) best = c; else break;
}
return best;
})()
: 30;
return (
<tr key={ability} className={cn(
'border-b border-line/50 last:border-b-0',
i % 2 === 0 ? 'bg-surface' : 'bg-panel',
isKey && 'ring-1 ring-inset ring-accent/20',
)}>
{/* Ability name */}
<td className={TD_L}>
<span className="font-medium text-ink">{ABILITY_LABELS[ability]}</span>
<span className="ml-1.5 text-[10px] text-muted smallcaps">{ABILITY_ABBR[ability]}</span>
{isKey && <span className="ml-1 text-[9px] text-accent"></span>}
</td>
{/* Base score input */}
<td className={TD}>
{usesPool ? (
<Select
className="w-16 text-sm"
aria-label={`${ABILITY_ABBR[ability]} array value`}
value={assignment[i]}
onChange={(e) => {
const v = Number(e.target.value);
onChangeAssignment(i, v);
}}
>
{pool.map((p, idx) => (
<option key={idx} value={idx}>{p}</option>
))}
</Select>
) : (
<NumberField
className="w-16"
value={pb[i]!}
min={method === 'pointbuy' ? POINT_BUY_MIN : 1}
max={pbMax}
aria-label={`${ABILITY_ABBR[ability]} base score`}
onChange={(v) => onChangePb(i, v)}
/>
)}
</td>
{/* Racial bonus */}
{hasRacial && (
<td className={TD}>
{race !== 0
? <DeltaBadge delta={race} kind="flat" />
: <span className="text-muted/20"></span>}
</td>
)}
{/* ASI stepper */}
{hasAsi && (
<td className={TD}>
<div className="inline-flex items-center gap-0.5">
<button
type="button"
disabled={asi <= 0}
onClick={() => onChangeAsi(ability, -1)}
className="w-5 h-5 rounded text-muted hover:text-ink disabled:opacity-25 text-xs leading-none"
></button>
<span className={cn(
'w-6 text-center text-xs tabular-nums',
asi > 0 ? 'text-accent font-medium' : 'text-muted/40',
)}>
{asi > 0 ? `+${asi}` : '0'}
</span>
<button
type="button"
disabled={asiRemaining <= 0 || base + race + asi >= 20}
onClick={() => onChangeAsi(ability, +1)}
className="w-5 h-5 rounded text-muted hover:text-ink disabled:opacity-25 text-xs leading-none"
>+</button>
</div>
</td>
)}
{/* Total */}
<td className={TD}>
<span className={cn(
'font-display font-semibold text-base',
total >= 18 ? 'text-accent-deep' : 'text-ink',
)}>{total}</span>
</td>
{/* Modifier */}
<td className={TD}>
<span className={cn(
'font-mono text-sm font-medium',
mod >= 0 ? 'text-accent' : 'text-danger',
)}>{formatModifier(mod)}</span>
</td>
</tr>
);
})}
</tbody>
{method === 'pointbuy' && (
<tfoot>
<tr className="bg-elevated border-t border-line">
<td colSpan={2 + (hasRacial ? 1 : 0) + (hasAsi ? 1 : 0)} className="px-2 py-1.5 text-left">
<span className="text-xs text-muted">
Point buy remaining:{' '}
<span className={cn('font-semibold', pbRemaining < 0 ? 'text-danger' : 'text-ink')}>
{pbRemaining} / 27
</span>
<span className="ml-2 text-muted/60">(scores 815, standard array: 15 14 13 12 10 8)</span>
</span>
</td>
<td colSpan={2} />
</tr>
</tfoot>
)}
{method === 'standard' && (
<tfoot>
<tr className="bg-elevated border-t border-line">
<td colSpan={2 + (hasRacial ? 1 : 0) + (hasAsi ? 1 : 0) + 2} className="px-2 py-1.5 text-left">
<span className="text-xs text-muted/60">Standard array: 15 · 14 · 13 · 12 · 10 · 8 assign each value once</span>
</td>
</tr>
</tfoot>
)}
</table>
</div>
);
}
// ─── PF2e Wizard boost-picker table ──────────────────────────────────────────
export interface PF2eBoostSlot {
id: string;
label: string;
options: AbilityKey[];
}
export interface WizardPf2eAbilityTableProps {
fixed: AbilityKey[]; // ancestry fixed boosts
flaw?: AbilityKey | undefined;
classBoost?: AbilityKey | undefined;
slots: PF2eBoostSlot[]; // interactive free-boost slots
picks: Record<string, AbilityKey>;
abilities: AbilityScores; // final computed scores (for Total column)
keyAbilities?: string[] | undefined;
onPick: (slotId: string, ability: AbilityKey) => void;
}
function slotGroup(id: string): string {
if (id.startsWith('anc')) return 'ancestry';
if (id.startsWith('bg')) return 'background';
if (id === 'class') return 'class';
if (id.startsWith('free')) return 'free';
// lvl5-0, lvl10-1, etc.
const m = id.match(/^(lvl\d+)/);
return m ? m[1]! : id;
}
function slotHeader(s: PF2eBoostSlot): string {
const { id, label } = s;
if (id.startsWith('anc-free')) {
const n = parseInt(id.split('-')[2] ?? '0') + 1;
return `Anc. ${n}`;
}
if (id === 'bg-choice') return 'Bg.';
if (id.startsWith('bg-free')) {
const n = parseInt(id.split('-')[2] ?? '0') + 1;
return `Bg. ${n}`;
}
if (id === 'class') return 'Class';
if (id.startsWith('free-')) return `Free ${parseInt(id.split('-')[1] ?? '0') + 1}`;
const m = id.match(/^lvl(\d+)-(\d+)/);
if (m) return `L${m[1]} ${parseInt(m[2]!) + 1}`;
return label.replace('boost', '').trim();
}
export function WizardPf2eAbilityTable({
fixed, flaw, classBoost, slots, picks, abilities, keyAbilities, onPick,
}: WizardPf2eAbilityTableProps) {
const fixedSet = new Set(fixed);
const hasFlaw = !!flaw;
const hasClassBoost = !!classBoost;
return (
<div className="overflow-x-auto rounded-lg border border-line">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-elevated border-b border-line">
<th className={TH_L}>Ability</th>
<th className={TH}>Base</th>
{fixed.length > 0 && <th className={TH}>Ancestry</th>}
{hasFlaw && <th className={TH}>Flaw</th>}
{hasClassBoost && <th className={TH}>Class</th>}
{slots.map((s) => (
<th key={s.id} className={TH} title={s.label}>
{slotHeader(s)}
</th>
))}
<th className={cn(TH, 'font-semibold text-ink')}>Total</th>
<th className={TH}>Mod</th>
</tr>
</thead>
<tbody>
{ABILITIES.map((ability, idx) => {
const total = abilities[ability];
const mod = abilityModifier(total);
const isKey = keyAbilities?.includes(ability);
return (
<tr key={ability} className={cn(
'border-b border-line/50 last:border-b-0',
idx % 2 === 0 ? 'bg-surface' : 'bg-panel',
isKey && 'ring-1 ring-inset ring-accent/20',
)}>
{/* Name */}
<td className={TD_L}>
<span className="font-medium text-ink">{ABILITY_LABELS[ability]}</span>
<span className="ml-1.5 text-[10px] text-muted smallcaps">{ABILITY_ABBR[ability]}</span>
{isKey && <span className="ml-1 text-[9px] text-accent"></span>}
</td>
{/* Base = 10 */}
<td className={cn(TD, 'text-muted/60')}>10</td>
{/* Fixed ancestry boosts */}
{fixed.length > 0 && (
<td className={TD}>
{fixedSet.has(ability)
? <DeltaBadge delta={2} kind="boost" />
: <span className="text-muted/20"></span>}
</td>
)}
{/* Flaw */}
{hasFlaw && (
<td className={TD}>
{flaw === ability
? <DeltaBadge delta={-2} kind="flat" />
: <span className="text-muted/20"></span>}
</td>
)}
{/* Class key ability boost */}
{hasClassBoost && (
<td className={TD}>
{classBoost === ability
? <DeltaBadge delta={2} kind="boost" />
: <span className="text-muted/20"></span>}
</td>
)}
{/* Interactive free boost slots */}
{slots.map((s) => {
const isAllowed = s.options.includes(ability);
const pickedHere = picks[s.id] === ability;
const group = slotGroup(s.id);
// Can't pick this ability in this slot if:
// (a) it's in fixed ancestry boosts and the slot is an ancestry slot
const blockedByFixed = group === 'ancestry' && fixedSet.has(ability);
// (b) another slot in the same group already picked this ability
const blockedByGroup = !pickedHere && slots.some(
(o) => o.id !== s.id && slotGroup(o.id) === group && picks[o.id] === ability,
);
const canPick = isAllowed && !blockedByFixed && !blockedByGroup;
return (
<td key={s.id} className={TD}>
{isAllowed ? (
<button
type="button"
onClick={() => {
if (pickedHere) {
// clicking again clears the pick — pass a sentinel
// We do this by re-calling with the same ability so the
// handler can toggle; caller must check pickedHere.
onPick(s.id, ability);
} else if (canPick) {
onPick(s.id, ability);
}
}}
disabled={!canPick && !pickedHere}
aria-pressed={pickedHere}
title={pickedHere ? 'Click to unassign' : canPick ? `Assign boost to ${ABILITY_LABELS[ability]}` : 'Already boosted by this source'}
className={cn(
'inline-flex items-center justify-center rounded w-8 h-6 text-xs font-medium transition-colors',
pickedHere
? 'bg-violet-500/20 text-violet-300 border border-violet-500/40'
: canPick
? 'bg-surface border border-line text-muted/50 hover:border-accent/60 hover:text-accent'
: 'text-muted/20 cursor-not-allowed',
)}
>
{pickedHere ? '+2' : '·'}
</button>
) : (
<span className="text-muted/20 text-xs"></span>
)}
</td>
);
})}
{/* Total */}
<td className={TD}>
<span className={cn(
'font-display font-semibold text-base',
total >= 18 ? 'text-accent-deep' : 'text-ink',
)}>{total}</span>
</td>
{/* Modifier */}
<td className={TD}>
<span className={cn(
'font-mono text-sm font-medium',
mod >= 0 ? 'text-accent' : 'text-danger',
)}>{formatModifier(mod)}</span>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="bg-elevated border-t border-line">
<td colSpan={2 + (fixed.length > 0 ? 1 : 0) + (hasFlaw ? 1 : 0) + (hasClassBoost ? 1 : 0) + slots.length + 2} className="px-2 py-1.5 text-left">
<span className="text-xs text-muted/60">
Each boost adds +2 (or +1 if the score is already 18 or above). A source cannot boost the same ability twice.
</span>
</td>
</tr>
</tfoot>
</table>
</div>
);
}
+229 -38
View File
@@ -1,9 +1,10 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router'; 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 { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules'; import type { AbilityKey, AbilityScores, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules'; import { getSystem, ABILITY_ABBR, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, computeAbilities } from '@/lib/rules';
import { allArmorMechanics5e, deriveEffectiveMaxHp, type ArmorMechanics } from '@/lib/mechanics';
import { charactersRepo, campaignsRepo } from '@/lib/db/repositories'; import { charactersRepo, campaignsRepo } from '@/lib/db/repositories';
import { encodeClaim } from '@/lib/sync/playerLink'; import { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize'; import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
@@ -13,36 +14,41 @@ import { rollCheck } from '@/lib/useRoll';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { Page } from '@/components/ui/Page'; import { Page } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Badge, Meter } from '@/components/ui/Codex'; import { Badge, Meter } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField'; import { NumberField } from '@/components/ui/NumberField';
import { formatModifier } from '@/lib/format'; import { formatModifier } from '@/lib/format';
import { useCampaigns } from '@/features/campaigns/hooks';
import { rankLabel } from './sheet/labels';
import { ClassesEditor } from './sheet/ClassesEditor';
import { InventorySection } from './sheet/InventorySection'; import { InventorySection } from './sheet/InventorySection';
import { AttacksSection } from './sheet/AttacksSection'; import { AttacksSection } from './sheet/AttacksSection';
import { ResourcesSection } from './sheet/ResourcesSection'; import { ResourcesSection } from './sheet/ResourcesSection';
import { SpellcastingSection } from './sheet/SpellcastingSection'; import { SpellcastingSection } from './sheet/SpellcastingSection';
import { DefensesSection } from './sheet/DefensesSection'; import { DefensesSection } from './sheet/DefensesSection';
import { FeatsSection } from './sheet/FeatsSection';
import { ClassFeaturesSection } from './sheet/ClassFeaturesSection';
import { AbilityGenModal } from './sheet/AbilityGenModal'; import { AbilityGenModal } from './sheet/AbilityGenModal';
import { SheetAbilityTable } from './AbilityScoreTable';
import { LevelUpModal } from './sheet/LevelUpModal'; import { LevelUpModal } from './sheet/LevelUpModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert']; const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary']; const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
const RANK_LABEL: Record<ProficiencyRank, string> = {
untrained: 'Untrained',
trained: 'Trained',
expert: 'Expert',
master: 'Master',
legendary: 'Legendary',
};
export function CharacterSheet({ character }: { character: Character }) { export function CharacterSheet({ character }: { character: Character }) {
// Local editable copy; write-through to the DB on change (debounced + flush on unmount). // Local editable copy; write-through to the DB on change (debounced + flush on unmount).
const [c, setC] = useState<Character>(character); const [c, setC] = useState<Character>(character);
const campaigns = useCampaigns();
const [levelUp, setLevelUp] = useState(false); const [levelUp, setLevelUp] = useState(false);
const [genScores, setGenScores] = useState(false); const [genScores, setGenScores] = useState(false);
const [showAbilityTable, setShowAbilityTable] = useState(false);
const [shared, setShared] = 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 onPortrait = async (file: File) => {
const thumb = await squareThumbnail(await fileToDataUrl(file), 256); const thumb = await squareThumbnail(await fileToDataUrl(file), 256);
@@ -52,9 +58,27 @@ export function CharacterSheet({ character }: { character: Character }) {
const shareWithPlayer = async () => { const shareWithPlayer = async () => {
const campaign = await campaignsRepo.get(c.campaignId); const campaign = await campaignsRepo.get(c.campaignId);
const link = `${location.origin}/player?c=${encodeClaim({ character: c, campaignName: campaign?.name ?? 'Campaign', campaignSystem: c.system })}`; 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); setShared(true);
setTimeout(() => setShared(false), 1500); setTimeout(() => setShared(false), 1500);
return;
}
} catch { /* clipboard blocked (e.g. insecure context on iPad) */ }
setShareLink(link);
}; };
const save = useDebouncedCallback((next: Character) => { const save = useDebouncedCallback((next: Character) => {
// Persist everything except identity/timestamps (update() stamps updatedAt). // Persist everything except identity/timestamps (update() stamps updatedAt).
@@ -77,10 +101,26 @@ export function CharacterSheet({ character }: { character: Character }) {
skillRanks: c.skillRanks as Record<string, ProficiencyRank>, skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>, saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
armorBonus: c.armorBonus, armorBonus: c.armorBonus,
...(c.equippedArmor ? { equippedArmor: c.equippedArmor } : {}),
perceptionRank: c.perceptionRank, perceptionRank: c.perceptionRank,
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}), ...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
}; };
// Ability-score source breakdown: use the persisted build, or synthesize a manual
// one from the finals for legacy characters so the sheet still shows totals.
const abilityBuild = c.abilityBuild ?? synthManualBuild(c.abilities);
const abilityBuildView = abilityBreakdown(abilityBuild);
/** Edit an ability total on the sheet → records a Manual adjustment, keeps build+finals in sync. */
const setAbilityTotal = (a: AbilityKey, total: number) => {
const nb = setManualTotal(abilityBuild, a, total);
update({ abilityBuild: nb, abilities: computeAbilities(nb) });
};
/** Re-generated base scores (AbilityGenModal) replace the base, keeping all sources. */
const applyGeneratedScores = (scores: AbilityScores) => {
const nb = setBuildBase(abilityBuild, scores);
update({ abilityBuild: nb, abilities: computeAbilities(nb) });
};
const ac = sys.baseArmorClass(rulesInput); const ac = sys.baseArmorClass(rulesInput);
const initiative = sys.initiativeModifier(rulesInput); const initiative = sys.initiativeModifier(rulesInput);
const skills = sys.skillModifiers(rulesInput); const skills = sys.skillModifiers(rulesInput);
@@ -110,7 +150,7 @@ export function CharacterSheet({ character }: { character: Character }) {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div> <div className="font-display text-sm italic text-accent">{c.ancestry || sys.label}</div>
<Input <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} value={c.name}
aria-label="Character name" aria-label="Character name"
onChange={(e) => update({ name: e.target.value })} onChange={(e) => update({ name: e.target.value })}
@@ -124,13 +164,13 @@ export function CharacterSheet({ character }: { character: Character }) {
</Badge> </Badge>
</div> </div>
</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: Shield, value: ac, label: 'Armor' },
{ icon: Gauge, value: formatModifier(initiative), label: 'Init' }, { icon: Gauge, value: formatModifier(initiative), label: 'Init' },
{ icon: Footprints, value: c.speed, label: 'Speed' }, { icon: Footprints, value: c.speed, label: 'Speed' },
].map(({ icon: Ic, value, label }) => ( ].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 /> <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="font-mono font-display text-xl font-semibold leading-none text-ink">{value}</span>
<span className="smallcaps text-[8.5px]">{label}</span> <span className="smallcaps text-[8.5px]">{label}</span>
@@ -139,7 +179,7 @@ export function CharacterSheet({ character }: { character: Character }) {
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3"> <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="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button> <Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
</div> </div>
@@ -149,24 +189,45 @@ export function CharacterSheet({ character }: { character: Character }) {
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}> <Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} /> <Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled> </Labeled>
{c.system === 'pf2e' && (
<Labeled label="Heritage">
{/* pre-migration rows lack the field — keep the input controlled */}
<Input value={c.heritage ?? ''} onChange={(e) => update({ heritage: e.target.value })} />
</Labeled>
)}
{c.system === '5e' ? (
<div className="sm:col-span-2"><ClassesEditor c={c} update={update} /></div>
) : (
<>
<Labeled label="Class"> <Labeled label="Class">
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} /> <Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
</Labeled> </Labeled>
<Labeled label="Level"> <Labeled label="Level">
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} /> <NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
</Labeled> </Labeled>
</>
)}
<Labeled label="Speed"> <Labeled label="Speed">
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} /> <NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
</Labeled> </Labeled>
<Labeled label="Campaign">
<Select value={c.campaignId} onChange={(e) => update({ campaignId: e.target.value })}>
<option value="">Unassigned</option>
{campaigns.map((cm) => <option key={cm.id} value={cm.id}>{cm.name}</option>)}
</Select>
</Labeled>
</div> </div>
{/* Vital stats */} {/* Vital stats */}
<div className="mb-6 grid gap-3 sm:grid-cols-3"> <div className="mb-6 grid gap-3 sm:grid-cols-3">
<StatCard label="Armor Class" value={ac} hint={`10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}> <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 items-center justify-center gap-2 text-xs text-muted"> <div className="mt-2 flex flex-col items-center gap-2 text-xs text-muted">
<span>Armor/shield bonus</span> {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" /> <NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
</div> </div>
</div>
</StatCard> </StatCard>
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} /> <StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
@@ -178,29 +239,76 @@ export function CharacterSheet({ character }: { character: Character }) {
<section className="mb-6"> <section className="mb-6">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<SectionTitle>Ability Scores</SectionTitle> <SectionTitle>Ability Scores</SectionTitle>
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button> <div className="flex gap-2">
<Button size="sm" variant="ghost" onClick={() => setShowAbilityTable(true)}>Breakdown</Button>
{c.system === '5e' && <Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>}
</div> </div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6"> </div>
{/* Compact at-a-glance row */}
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{ABILITIES.map((a) => { {ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]); const mod = sys.abilityModifier(c.abilities[a]);
const bd = abilityBuildView[a];
return ( return (
<div key={a} className="flex flex-col items-center gap-1 rounded-xl border border-line bg-panel px-2 py-3 text-center"> <button
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div> key={a}
<div className="font-mono font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div> type="button"
<NumberField onClick={() => setShowAbilityTable(true)}
className="mt-1" className="flex flex-col items-center gap-0.5 rounded-xl border border-line bg-panel px-2 py-3 text-center hover:border-accent/50 transition-colors group"
value={c.abilities[a]} >
min={1} <div className="smallcaps text-[10px] text-muted">{ABILITY_ABBR[a]}</div>
max={30} <div className="font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
aria-label={`${ABILITY_ABBR[a]} score`} <div className="font-mono text-sm text-ink">{c.abilities[a]}</div>
onChange={(v) => update({ abilities: { ...c.abilities, [a]: v } })} {bd.parts.length > 0 && (
/> <div className="mt-0.5 text-[9px] leading-tight text-muted/60">
{bd.base}{bd.parts.map((p) => ` ${p.delta >= 0 ? '+' : ''}${p.delta}`).join('')}
</div> </div>
)}
</button>
); );
})} })}
</div> </div>
</section> </section>
{/* Ability score breakdown modal (MPMB-style) */}
<Modal
open={showAbilityTable}
onClose={() => setShowAbilityTable(false)}
title="Ability Scores"
className="max-w-4xl"
>
<SheetAbilityTable
build={abilityBuild}
onSetManual={(ability, delta) => {
const otherAdjs = abilityBuild.adjustments.filter(
(adj) => !(adj.label === 'Manual' && adj.ability === ability),
);
const baseTotal = computeAbilities({ base: abilityBuild.base, adjustments: otherAdjs })[ability];
setAbilityTotal(ability, baseTotal + delta);
}}
/>
<p className="mt-3 text-xs text-muted">
Click <strong>Manual</strong> to add an override adjustment. Violet = PF2e boost (+2/+1 rule). Click any score on the sheet to open this table.
</p>
</Modal>
{/* 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 */} {/* Saves */}
<section className="mb-6"> <section className="mb-6">
<SectionTitle>Saving Throws</SectionTitle> <SectionTitle>Saving Throws</SectionTitle>
@@ -261,9 +369,37 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Class resources + rest */} {/* Class resources + rest */}
<ResourcesSection c={c} update={update} /> <ResourcesSection c={c} update={update} />
{/* Feats & features */}
<ClassFeaturesSection c={c} update={update} />
<FeatsSection c={c} update={update} />
{/* Inventory, currency, encumbrance */} {/* Inventory, currency, encumbrance */}
<InventorySection c={c} update={update} /> <InventorySection c={c} update={update} />
{/* Details */}
<section className="mb-6">
<SectionTitle>Details</SectionTitle>
<div className="grid gap-3 sm:grid-cols-2">
<Labeled label="Background">
<Input value={c.background} onChange={(e) => update({ background: e.target.value })} placeholder="Acolyte, Soldier…" />
</Labeled>
<Labeled label="Alignment">
<Input value={c.alignment} onChange={(e) => update({ alignment: e.target.value })} placeholder="Chaotic Good, Unaligned…" />
</Labeled>
<Labeled label="Appearance">
<textarea value={c.appearance} onChange={(e) => update({ appearance: e.target.value })} rows={3}
placeholder="Looks, age, distinguishing features…"
className="w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
</Labeled>
<Labeled label="Personality & behaviour">
<textarea value={c.personality} onChange={(e) => update({ personality: e.target.value })} rows={3}
placeholder="Traits, ideals, bonds, flaws…"
className="w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
</Labeled>
</div>
</section>
{/* Notes */} {/* Notes */}
<section className="mb-6"> <section className="mb-6">
<SectionTitle>Notes</SectionTitle> <SectionTitle>Notes</SectionTitle>
@@ -276,38 +412,71 @@ export function CharacterSheet({ character }: { character: Character }) {
</section> </section>
{genScores && ( {genScores && (
<AbilityGenModal onClose={() => setGenScores(false)} onApply={(abilities) => update({ abilities })} /> <AbilityGenModal onClose={() => setGenScores(false)} onApply={applyGeneratedScores} />
)} )}
{levelUp && ( {levelUp && (
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} /> <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> </Page>
); );
} }
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) { function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
const [delta, setDelta] = useState(0); const [delta, setDelta] = useState(0);
const eff = deriveEffectiveMaxHp({ system: c.system, baseMaxHp: c.hp.max, level: c.level, exhaustion: c.defenses.exhaustion, conditions: c.conditions });
const effMax = eff.max;
const apply = (mode: 'damage' | 'heal') => { const apply = (mode: 'damage' | 'heal') => {
if (!Number.isFinite(delta) || delta <= 0) return; if (!Number.isFinite(delta) || delta <= 0) return;
if (mode === 'damage') { if (mode === 'damage') {
const absorbed = Math.min(c.hp.temp, delta); const absorbed = Math.min(c.hp.temp, delta);
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } }); update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
} else { } else {
update({ hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + delta) } }); // Heal caps at the EFFECTIVE max (drained / exhaustion 4 reduce it).
const current = Math.min(effMax, c.hp.current + delta);
const patch: Partial<Character> = { hp: { ...c.hp, current } };
// pf2e: healing above 0 HP ends dying and wakes the character (pure bookkeeping).
// Losing the dying condition always increases wounded by 1.
if (c.system === 'pf2e' && c.hp.current <= 0 && current > 0) {
if (c.defenses.dying > 0) patch.defenses = { ...c.defenses, dying: 0, wounded: c.defenses.wounded + 1 };
patch.conditions = c.conditions.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
}
update(patch);
} }
setDelta(0); setDelta(0);
}; };
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0; const ratio = effMax > 0 ? c.hp.current / effMax : 0;
return ( return (
<div className="rounded-xl border border-line bg-panel p-4 text-center"> <div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">Hit Points</div> <div className="smallcaps">Hit Points</div>
<div className="my-1 font-mono font-display text-2xl font-semibold text-ink"> <div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
{c.hp.current} {c.hp.current}
<span className="text-base text-muted"> / {c.hp.max}</span> <span className="text-base text-muted"> / {effMax}</span>
{eff.reduction > 0 && <span className="ml-1 text-xs text-muted line-through">{c.hp.max}</span>}
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>} {c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
</div> </div>
{eff.reduction > 0 && (
<div className="mb-1 text-[11px] text-warning" title={eff.reasons.join('; ')}>{eff.reasons.join(' · ')}</div>
)}
<div className="mb-2"> <div className="mb-2">
<Meter value={c.hp.current} max={c.hp.max} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} /> <Meter value={c.hp.current} max={effMax} tone={ratio < 0.35 ? 'var(--app-danger)' : 'var(--app-verdigris)'} height={8} />
</div> </div>
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted"> <div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label> <label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
@@ -360,7 +529,7 @@ function RankRow({
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}> <Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
{ranks.map((r) => ( {ranks.map((r) => (
<option key={r} value={r}> <option key={r} value={r}>
{RANK_LABEL[r]} {rankLabel(r, system)}
</option> </option>
))} ))}
</Select> </Select>
@@ -388,6 +557,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 }) { function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>; return <h2 className="mb-3 font-display text-lg font-semibold text-ink">{children}</h2>;
} }
+17 -11
View File
@@ -6,30 +6,33 @@ import type { Campaign, Character } from '@/lib/schemas';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character'; import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
import { pickTextFile } from '@/lib/io/file'; 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 { useActiveCampaign } from '@/features/campaigns/hooks';
import { Page, PageHeader, EmptyState } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Avatar, Badge } from '@/components/ui/Codex'; import { Avatar, Badge } from '@/components/ui/Codex';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { CreationWizard } from './builder/CreationWizard'; import { CreationWizard } from './builder/CreationWizard';
// A campaign is NOT required: PCs are campaign-agnostic and can be created unassigned.
export function CharactersPage() { export function CharactersPage() {
return <RequireCampaign>{(campaign) => <CharactersList campaign={campaign} />}</RequireCampaign>; const campaign = useActiveCampaign();
return <CharactersList campaign={campaign} />;
} }
function CharactersList({ campaign }: { campaign: Campaign }) { function CharactersList({ campaign }: { campaign?: Campaign | undefined }) {
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 [creating, setCreating] = useState(false);
const [importError, setImportError] = useState<string | null>(null); 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 () => { const importCharacter = async () => {
setImportError(null); setImportError(null);
const text = await pickTextFile(); const text = await pickTextFile();
if (text === null) return; if (text === null) return;
try { try {
const character = parseCharacterImport(text, campaign.id); const character = parseCharacterImport(text, campaign?.id ?? '');
await charactersRepo.insert(character); await charactersRepo.insert(character);
} catch (e) { } catch (e) {
setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.'); setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.');
@@ -41,7 +44,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
<PageHeader <PageHeader
eyebrow="The Roster" eyebrow="The Roster"
title="Characters" title="Characters"
subtitle={campaign.name} subtitle={campaign?.name ?? 'All player characters'}
actions={ actions={
<> <>
<Button variant="secondary" onClick={importCharacter}> <Button variant="secondary" onClick={importCharacter}>
@@ -60,7 +63,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
</p> </p>
)} )}
{characters.length === 0 ? ( {pcs.length === 0 && npcs.length === 0 ? (
<EmptyState <EmptyState
title="No characters yet" title="No characters yet"
hint="Add player characters and NPCs, or import a character file." hint="Add player characters and NPCs, or import a character file."
@@ -78,6 +81,9 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
)} )}
{creating && <CreationWizard campaign={campaign} onClose={() => setCreating(false)} />} {creating && <CreationWizard campaign={campaign} onClose={() => setCreating(false)} />}
{npcs.length === 0 && !campaign && pcs.length > 0 && (
<p className="mt-4 text-xs text-muted">Tip: select or create a campaign to add NPCs and link this roster.</p>
)}
</Page> </Page>
); );
} }
@@ -98,7 +104,7 @@ function CharacterGroup({ title, items }: { title: string; items: Character[] })
function CharacterCard({ c }: { c: Character }) { function CharacterCard({ c }: { c: Character }) {
const [confirming, setConfirming] = useState(false); 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 ( 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)]"> <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,27 +1,49 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import type { Campaign, Character, SpellEntry } from '@/lib/schemas'; import { Lightbulb, RotateCcw } from 'lucide-react';
import { type Campaign, type Character, type SpellEntry, type AbilityBuild, type AbilityAdjustment, newSpellEntry } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
import { import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
type AbilityKey, type AbilityScores, type ProficiencyRank, pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
bumpRank, collectChoices,
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
} from '@/lib/rules'; } from '@/lib/rules';
import { pf2eSkillIncreaseLevels } from '@/lib/rules/pf2e/progression';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen'; import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium'; import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
import type { RulesetClass } from '@/lib/ruleset/normalize'; import type { RulesetClass } from '@/lib/ruleset/normalize';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { formatModifier } from '@/lib/format'; import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
import { briefOverview, dedupeByName } from './overview';
import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin';
import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
import { DND5E_SUBCLASSES, dnd5eAsiLevels } from '@/lib/rules/dnd5e/progression';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Wizard5eAbilityTable, WizardPf2eAbilityTable } from '../AbilityScoreTable';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Field, Input, Select } from '@/components/ui/Input'; import { Field, Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField'; import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
type AbilityMethod = 'standard' | 'pointbuy' | 'roll'; type AbilityMethod = 'standard' | 'pointbuy' | 'roll' | 'manual';
interface Origin { name: string; desc: string; meta?: string; hp?: number; speed?: number } interface Origin {
name: string; desc: string; meta?: string; hp?: number; speed?: number;
/** 5e racial ability score increases, applied to the final scores. */
asiBonuses?: Partial<AbilityScores>;
/** skill keys this origin grants as trained (background skills, racial proficiencies). */
skillGrants?: string[];
/** pf2e ancestry boosts: specific abilities + count of free boosts. */
ancestryBoosts?: { fixed: AbilityKey[]; free: number };
/** pf2e legacy ancestry flaw (-2), if any. */
ancestryFlaw?: AbilityKey;
/** pf2e background boosts: choose-one options + free-boost count. */
backgroundBoosts?: { options: AbilityKey[]; free: number };
}
interface SpellOpt { name: string; level: number; meta: string } interface SpellOpt { name: string; level: number; meta: string }
/** A class's "what it plays like" tag, to help newcomers pick. */ /** A class's "what it plays like" tag, to help newcomers pick. */
@@ -33,22 +55,25 @@ function playstyle(c: RulesetClass): string {
const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = { const TEMPLATES: Record<string, { label: string; hint: string; className: string; ability: AbilityScores }[]> = {
'5e': [ '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: '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: '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: '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: 'Helpful Cleric', hint: 'Heals and supports the party.', className: 'Cleric', ability: { str: 14, dex: 10, con: 13, int: 8, wis: 15, cha: 12 } },
], ],
pf2e: [ 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: '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: '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: '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: '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 }) { export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | undefined; onClose: () => void }) {
const navigate = useNavigate(); 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 ?? '5e');
const sys = getSystem(system);
const allSkillKeys = sys.skills.map((s) => s.key); const allSkillKeys = sys.skills.map((s) => s.key);
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key; const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
@@ -58,31 +83,131 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const [backgrounds, setBackgrounds] = useState<Origin[]>([]); const [backgrounds, setBackgrounds] = useState<Origin[]>([]);
const [allSpells, setAllSpells] = useState<SpellOpt[]>([]); 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(() => { useEffect(() => {
let on = true; let on = true;
if (campaign.system === '5e') { void loadClasses(system).then((c) => {
void loadRaces5e().then((rs) => on && setOrigins(rs.map((r) => ({ name: r.name, desc: r.desc, meta: r.asi || r.speed })))); if (!on) return;
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => ({ name: b.name, desc: b.desc, meta: b.skills })))); // 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.map((rc) => {
// Merge curated official subclasses (classes.json is missing most PHB/XGE
// archetypes) ahead of the on-disk list, deduped by name.
const extra = DND5E_SUBCLASSES[rc.name] ?? [];
if (!extra.length) return rc;
const have = new Set(rc.subclasses.map((s) => s.name.toLowerCase()));
return { ...rc, subclasses: [...extra.filter((s) => !have.has(s.name.toLowerCase())), ...rc.subclasses] };
});
setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name)));
});
return () => { on = false; };
}, [system]);
useEffect(() => {
let on = true;
if (system === '5e') {
const resolve = makeSkillResolver(sys.skills);
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 asiBonuses = parseAsiBonuses(r.asi ?? '');
const asiSummary = ABILITIES.filter((a) => asiBonuses[a]).map((a) => `+${asiBonuses[a]} ${ABILITY_ABBR[a]}`).join(', ');
const meta = [asiSummary, speedFt ? `${speedFt} ft` : ''].filter(Boolean).join(' · ');
const skillGrants = parseTraitSkills(r.traits ?? '', resolve);
return { name: r.name, desc, meta, asiBonuses, ...(skillGrants.length ? { skillGrants } : {}) };
}));
});
void loadBackgrounds5e().then((bs) => on && setBackgrounds(bs.map((b) => {
const skillGrants = parseBackgroundSkills(b.skills ?? '', resolve);
return { name: b.name, desc: b.desc, meta: b.skills, ...(skillGrants.length ? { skillGrants } : {}) };
})));
} else { } 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('ancestries').then((rs) => {
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(bs.map((b) => ({ name: String(b.name), desc: String((b.description ?? b.text) ?? '') })))); 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 ancestryBoosts = parseAncestryBoosts(r.attribute);
const ancestryFlaw = parseFlaw((r as Record<string, unknown>).attribute_flaw ?? (r as Record<string, unknown>).flaw);
const fixedSummary = ancestryBoosts.fixed.map((a) => `+${ABILITY_ABBR[a]}`).join('/');
const freeSummary = ancestryBoosts.free ? `+${ancestryBoosts.free} free` : '';
const meta = [fixedSummary, freeSummary, ancestryFlaw ? `${ABILITY_ABBR[ancestryFlaw]}` : '', hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
return {
name: String(r.name),
desc: briefOverview(String((r.summary ?? r.text) ?? '')),
...(hp !== undefined ? { hp } : {}),
...(speed ? { speed } : {}),
ancestryBoosts,
...(ancestryFlaw ? { ancestryFlaw } : {}),
meta,
};
})));
});
const resolvePf2e = makeSkillResolver(sys.skills);
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => {
// PF2e backgrounds grant Trained in listed skills (Lore variants don't map to the
// 16 core skills and are dropped here — they're tracked as custom Lore separately).
const rawSkills = Array.isArray((b as Record<string, unknown>).skill) ? ((b as Record<string, unknown>).skill as string[]) : [];
const skillGrants = rawSkills.map((s) => resolvePf2e(String(s))).filter((k): k is string => !!k);
return {
name: String(b.name),
desc: briefOverview(String((b.description ?? b.text) ?? '')),
backgroundBoosts: parseBackgroundBoosts(b.attribute),
...(skillGrants.length ? { skillGrants } : {}),
};
}))));
} }
return () => { on = false; }; return () => { on = false; };
}, [campaign.system]); // sys.skills is a stable singleton keyed by `system`, so `system` covers it.
}, [system, sys.skills]);
// ---- selections ---- // ---- selections ----
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [kind, setKind] = useState<Character['kind']>('pc'); const [kind, setKind] = useState<Character['kind']>('pc');
const [level, setLevel] = useState(1); const [level, setLevel] = useState(1);
const [alignment, setAlignment] = useState('');
const [appearance, setAppearance] = useState('');
const [personality, setPersonality] = useState('');
const [classSlug, setClassSlug] = useState(''); const [classSlug, setClassSlug] = useState('');
const [subclass, setSubclass] = useState(''); const [subclass, setSubclass] = useState('');
const [ancestry, setAncestry] = useState(''); const [ancestry, setAncestry] = useState('');
// PF2e heritage — chosen at level 1, refines the ancestry. Loaded lazily; the data
// carries no ancestry link, so we match by name ("Ancient-Blooded Dwarf") and always
// include versatile heritages (selectable by any ancestry).
const [heritage, setHeritage] = useState('');
const [allHeritages, setAllHeritages] = useState<{ name: string; summary: string; versatile: boolean }[]>([]);
useEffect(() => {
if (system !== 'pf2e' || allHeritages.length) return;
let on = true;
void loadPf2e('heritages').then((hs) => on && setAllHeritages(hs.map((h) => ({
name: String(h.name),
summary: briefOverview(String((h.summary ?? h.text) ?? '')),
versatile: /versatile heritage/i.test(String(h.text ?? h.summary ?? '')),
}))));
return () => { on = false; };
}, [system, allHeritages.length]);
const heritageOptions = useMemo(() => {
if (system !== 'pf2e' || !ancestry.trim()) return [];
const want = ancestry.trim().toLowerCase();
return allHeritages.filter((h) => h.name.toLowerCase().includes(want) || h.versatile);
}, [system, ancestry, allHeritages]);
useEffect(() => { setHeritage(''); }, [ancestry]); // a new ancestry invalidates the heritage
const [background, setBackground] = useState(''); const [background, setBackground] = useState('');
const selectedClass = classes.find((c) => c.slug === classSlug) ?? null; const selectedClass = classes.find((c) => c.slug === classSlug) ?? null;
const selectedOrigin = origins.find((o) => o.name === ancestry) ?? null; const selectedOrigin = origins.find((o) => o.name === ancestry) ?? null;
const selectedBackground = backgrounds.find((b) => b.name === background) ?? null;
const classDef = selectedClass ? getClassDef(system, selectedClass.name) : undefined;
const isCaster = !!selectedClass && selectedClass.caster !== 'none'; const isCaster = !!selectedClass && selectedClass.caster !== 'none';
// ---- abilities ---- // ---- abilities ----
@@ -96,41 +221,180 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); } if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
if (m === 'roll') { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); } if (m === 'roll') { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); }
}; };
// ---- pf2e ability boosts (start at 10; ancestry/background/class/free boosts) ----
const boostSlots = useMemo(() => {
if (system !== 'pf2e') return null;
const anc = selectedOrigin?.ancestryBoosts ?? { fixed: [], free: 0 };
const bg = selectedBackground?.backgroundBoosts ?? { options: [], free: 0 };
const keyOpts = (classDef?.keyAbilities ?? selectedClass?.keyAbilities ?? []) as AbilityKey[];
const slots: { id: string; label: string; options: AbilityKey[] }[] = [];
for (let i = 0; i < anc.free; i++) slots.push({ id: `anc-free-${i}`, label: 'Ancestry free boost', options: ABILITIES });
if (bg.options.length) slots.push({ id: 'bg-choice', label: 'Background boost', options: bg.options });
for (let i = 0; i < bg.free; i++) slots.push({ id: `bg-free-${i}`, label: 'Background free boost', options: ABILITIES });
if (keyOpts.length) slots.push({ id: 'class', label: 'Class key ability', options: keyOpts });
for (let i = 0; i < 4; i++) slots.push({ id: `free-${i}`, label: 'Free boost', options: ABILITIES });
// Higher-level characters also gained 4 ability boosts at levels 5/10/15/20.
for (const L of [5, 10, 15, 20]) if (L <= level) for (let i = 0; i < 4; i++) slots.push({ id: `lvl${L}-${i}`, label: `Level ${L} boost`, options: ABILITIES });
return { fixed: anc.fixed, flaw: selectedOrigin?.ancestryFlaw, slots };
}, [system, selectedOrigin, selectedBackground, classDef, selectedClass, level]);
const [boostPicks, setBoostPicks] = useState<Record<string, AbilityKey>>({});
// A boost slot's "source" — boosts within one source must target different abilities.
const boostSource = (id: string) => (id.startsWith('anc') ? 'ancestry' : id.startsWith('bg') ? 'background' : id === 'class' ? 'class' : id.startsWith('lvl') ? id.split('-')[0]! : 'free');
// Seed a legal default assignment (distinct within each source) when slots change.
useEffect(() => {
if (!boostSlots) return;
const keyOpts = (classDef?.keyAbilities ?? []) as AbilityKey[];
const favored = keyOpts[0] ?? 'str';
const order: AbilityKey[] = [favored, ...ABILITIES.filter((a) => a !== favored)];
const used: Record<string, Set<AbilityKey>> = { ancestry: new Set(boostSlots.fixed) };
const next: Record<string, AbilityKey> = {};
for (const s of boostSlots.slots) {
const u = (used[boostSource(s.id)] ??= new Set<AbilityKey>());
const pick = (s.options.length < 6 ? s.options : order).find((o) => !u.has(o))
?? order.find((o) => !u.has(o)) ?? s.options[0] ?? favored;
next[s.id] = pick;
u.add(pick);
}
setBoostPicks(next);
}, [boostSlots, classDef]);
const pf2eAbilities = useMemo(() => {
if (!boostSlots) return null;
const chosen = boostSlots.slots.map((s) => boostPicks[s.id]).filter((b): b is AbilityKey => !!b);
return pf2eApplyBoosts([...boostSlots.fixed, ...chosen], boostSlots.flaw);
}, [boostSlots, boostPicks]);
// 5e: ability-score improvements gained by the input level for this class (4/8/12/16/19, +class extras).
const asiCount = system === '5e' && selectedClass ? dnd5eAsiLevels(selectedClass.name).filter((l) => l <= level).length : 0;
const [asiAlloc, setAsiAlloc] = useState<Partial<Record<AbilityKey, number>>>({});
useEffect(() => { setAsiAlloc({}); }, [classSlug, level, system]); // reset when class/level changes
const asiUsed = ABILITIES.reduce((n, a) => n + (asiAlloc[a] ?? 0), 0);
const asiRemaining = asiCount * 2 - asiUsed;
const abilities = useMemo<AbilityScores>(() => { const abilities = useMemo<AbilityScores>(() => {
if (system === 'pf2e') return pf2eAbilities ?? { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
const out = {} as AbilityScores; const out = {} as AbilityScores;
ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; }); ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
// 5e: fold the chosen race's ability score increases + any level-up ASIs into the final scores.
if (selectedOrigin?.asiBonuses) {
for (const a of ABILITIES) out[a] += selectedOrigin.asiBonuses[a] ?? 0;
}
for (const a of ABILITIES) out[a] += asiAlloc[a] ?? 0;
return out; return out;
}, [usesPool, pool, assignment, pb]); }, [system, pf2eAbilities, usesPool, pool, assignment, pb, selectedOrigin, asiAlloc]);
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length; const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0; const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
// Capture the ability scores as a source breakdown (base + every boost/ASI/flaw) so
// the sheet can show where each point came from — final = computeAbilities(build).
const buildAbilityBuild = (): AbilityBuild => {
if (system === 'pf2e' && boostSlots) {
const adjustments: AbilityAdjustment[] = [];
if (boostSlots.flaw) adjustments.push({ label: 'Ancestry flaw', ability: boostSlots.flaw, kind: 'flat', amount: -2 });
for (const ab of boostSlots.fixed) adjustments.push({ label: 'Ancestry', ability: ab, kind: 'boost', amount: 0 });
for (const s of boostSlots.slots) {
const ab = boostPicks[s.id];
if (ab) adjustments.push({ label: s.label, ability: ab, kind: 'boost', amount: 0 });
}
return { base: { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 }, adjustments };
}
const base = {} as AbilityScores;
ABILITIES.forEach((a, i) => { base[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
const adjustments: AbilityAdjustment[] = [];
if (selectedOrigin?.asiBonuses) for (const a of ABILITIES) { const v = selectedOrigin.asiBonuses[a] ?? 0; if (v) adjustments.push({ label: 'Race', ability: a, kind: 'flat', amount: v }); }
for (const a of ABILITIES) { const v = asiAlloc[a] ?? 0; if (v) adjustments.push({ label: 'ASI', ability: a, kind: 'flat', amount: v }); }
return { base, adjustments };
};
// ---- skills ---- // ---- skills ----
const [skills, setSkills] = useState<string[]>([]); const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int); const intMod = abilityModifier(abilities.int);
// Prefer the curated class table (canonical free-choice count) over the parsed
// data file, which over-counts pf2e classes and mangles some 5e skill names.
const baseSkillCount = classDef?.skillCount ?? selectedClass?.skillCount ?? 2;
const skillCount = selectedClass const skillCount = selectedClass
? selectedClass.skillCount + (campaign.system === 'pf2e' ? Math.max(0, intMod) : 0) ? baseSkillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
: 2; : 2;
const skillOptions = useMemo(() => { const skillOptions = useMemo(() => {
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys; if (!selectedClass) return allSkillKeys;
// map class skill labels → our skill keys // Curated class skill list (canonical for 5e); empty curated list = choose from all.
const wanted = new Set(selectedClass.skillChoices.map((s) => s.toLowerCase())); if (classDef && classDef.skillKeys.length > 0) return classDef.skillKeys;
if (selectedClass.skillChoices.length === 0) return allSkillKeys;
// Fallback to the parsed data, tolerating Oxford "and X" remnants.
const wanted = new Set(selectedClass.skillChoices.map((s) => s.toLowerCase().replace(/^and\s+/, '')));
const labelOf = (k: string) => sys.skills.find((s) => s.key === k)?.label.toLowerCase() ?? k.toLowerCase(); const labelOf = (k: string) => sys.skills.find((s) => s.key === k)?.label.toLowerCase() ?? k.toLowerCase();
const matched = allSkillKeys.filter((k) => wanted.has(labelOf(k)) || wanted.has(k.toLowerCase())); const matched = allSkillKeys.filter((k) => wanted.has(labelOf(k)) || wanted.has(k.toLowerCase()));
return matched.length ? matched : allSkillKeys; return matched.length ? matched : allSkillKeys;
}, [selectedClass, allSkillKeys, sys]); }, [selectedClass, classDef, allSkillKeys, sys]);
const toggleSkill = (key: string) => const toggleSkill = (key: string) =>
setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev)); setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev));
// Skill proficiencies granted (trained) by ancestry/race + background — shown to the
// user so they don't waste free picks, and merged on top of class picks at build time.
const grantedSkillSources = useMemo(() => {
const out: { key: string; source: string }[] = [];
const add = (keys: string[] | undefined, source: string) => {
for (const k of keys ?? []) if (!out.some((o) => o.key === k)) out.push({ key: k, source });
};
add(selectedOrigin?.skillGrants, selectedOrigin?.name ?? (system === 'pf2e' ? 'Ancestry' : 'Race'));
add(selectedBackground?.skillGrants, selectedBackground?.name ?? 'Background');
return out;
}, [system, selectedOrigin, selectedBackground]);
const grantedSkills = useMemo(() => grantedSkillSources.map((g) => g.key), [grantedSkillSources]);
// Free picks exclude already-granted skills, so the user can't waste a choice on one.
const freeSkillOptions = useMemo(() => skillOptions.filter((k) => !grantedSkills.includes(k)), [skillOptions, grantedSkills]);
// Drop any free pick that becomes granted (e.g. after switching background).
useEffect(() => { setSkills((prev) => prev.filter((k) => !grantedSkills.includes(k))); }, [grantedSkills]);
// Higher-level creation owes the picks earned along the way — collect them here
// rather than deferring to the level-up flow: PF2e skill increases (levels 3,5,7…)
// and 5e Expertise (Bard/Rogue, scaling with level).
const skillIncLevels = useMemo(
() => (system === 'pf2e' ? pf2eSkillIncreaseLevels().filter((l) => l <= level) : []),
[system, level],
);
const expertiseCount = useMemo(() => {
if (system !== '5e' || !selectedClass) return 0;
const ch = collectChoices('5e', [{ className: selectedClass.name, level }]).find((x) => x.key.endsWith(':expertise'));
return ch?.count ?? 0;
}, [system, selectedClass, level]);
const [skillIncPicks, setSkillIncPicks] = useState<string[]>([]);
const [expertisePicks, setExpertisePicks] = useState<string[]>([]);
useEffect(() => { setSkillIncPicks([]); setExpertisePicks([]); }, [classSlug, level, system]);
// Seed sensible defaults from the chosen/granted skills (kept once the user edits).
useEffect(() => {
setExpertisePicks((prev) => Array.from({ length: expertiseCount }, (_, i) => prev[i] || skills[i] || skills[0] || ''));
}, [expertiseCount, skills]);
useEffect(() => {
const pool = [...skills, ...grantedSkills];
setSkillIncPicks((prev) => skillIncLevels.map((_, i) => prev[i] || pool[i % Math.max(1, pool.length)] || ''));
}, [skillIncLevels, skills, grantedSkills]);
/** Skill ranks after base training + the first `uptoIdx` increases (for previews). */
const ranksAfterIncreases = (uptoIdx: number): Record<string, ProficiencyRank> => {
const r: Record<string, ProficiencyRank> = {};
for (const k of [...skills, ...grantedSkills]) r[k] = 'trained';
for (let i = 0; i < uptoIdx; i++) { const k = skillIncPicks[i]; if (k) r[k] = bumpRank(r[k] ?? 'untrained'); }
return r;
};
/** PF2e rank caps: master needs the increase earned at level 7+, legendary 15+. */
const incAllowed = (rank: ProficiencyRank, earnedAt: number): boolean =>
rank === 'untrained' || rank === 'trained' ? true
: rank === 'expert' ? earnedAt >= 7
: rank === 'master' ? earnedAt >= 15
: false;
// ---- spells ---- // ---- spells ----
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]); const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
const [spellQuery, setSpellQuery] = useState(''); const [spellQuery, setSpellQuery] = useState('');
useEffect(() => { useEffect(() => {
if (!isCaster || allSpells.length) return; if (!isCaster || allSpells.length) return;
let on = true; 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(', ') : '' })))); 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; }; return () => { on = false; };
}, [isCaster, campaign.system, allSpells.length]); }, [isCaster, system, allSpells.length]);
const spellResults = useMemo(() => { const spellResults = useMemo(() => {
const q = spellQuery.trim().toLowerCase(); const q = spellQuery.trim().toLowerCase();
const maxLevel = Math.min(9, Math.ceil(level / 2)); const maxLevel = Math.min(9, Math.ceil(level / 2));
@@ -140,19 +404,44 @@ 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])); setSpellPicks((prev) => (prev.some((x) => x.name === s.name) ? prev.filter((x) => x.name !== s.name) : [...prev, s]));
// ---- derived build ---- // ---- derived build ----
const built = useMemo(() => buildCharacter(campaign.system, { const built = useMemo(() => buildCharacter(system, {
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills, className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
...(grantedSkills.length ? { grantedSkills } : {}),
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}), ...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}), ...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
}), [campaign.system, selectedClass, level, abilities, skills, selectedOrigin]); }), [system, selectedClass, level, abilities, skills, grantedSkills, selectedOrigin]);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]); // 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'] : []), 'Details', 'Review'], [isCaster]);
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!; 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 applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
const c = classes.find((x) => x.name === t.className); const c = classes.find((x) => x.name === t.className);
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); } if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
setMethod('pointbuy'); // Templates can use scores above the point-buy cap (PF2e starts a key stat at 18),
// which would softlock the point-buy step ("-Infinity / 27"). Use manual entry then.
const fitsPointBuy = ABILITIES.every((a) => t.ability[a] >= POINT_BUY_MIN && t.ability[a] <= POINT_BUY_MAX);
setMethod(fitsPointBuy ? 'pointbuy' : 'manual');
setPb(ABILITIES.map((a) => t.ability[a])); setPb(ABILITIES.map((a) => t.ability[a]));
if (!name) setName(t.label.replace(/^[^A-Za-z]+/, '')); if (!name) setName(t.label.replace(/^[^A-Za-z]+/, ''));
}; };
@@ -160,16 +449,19 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const stepValid: Record<string, boolean> = { const stepValid: Record<string, boolean> = {
Class: !!selectedClass, Class: !!selectedClass,
Origin: true, Origin: true,
Abilities: poolValid && pbValid, Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0,
Skills: skills.length === Math.min(skillCount, skillOptions.length), Skills: skills.length === Math.min(skillCount, freeSkillOptions.length)
&& skillIncPicks.every(Boolean)
&& expertisePicks.every(Boolean),
Spells: true, Spells: true,
Details: true,
Review: true, Review: true,
}; };
const finish = async () => { const finish = async () => {
if (!selectedClass) return; if (!selectedClass) return;
const created = await charactersRepo.create(campaign.id, { 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, ancestry: ancestry.trim(), className: selectedClass.name, level,
}); });
// For classes outside the curated tables (esp. PF2e), fill saves from data. // For classes outside the curated tables (esp. PF2e), fill saves from data.
@@ -177,13 +469,23 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) { if (Object.keys(saveRanks).length === 0 && selectedClass.saveRanks) {
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank; 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'); // Layer expertise (5e) and skill increases (pf2e) on top of the trained ranks.
const skillRanks: Record<string, ProficiencyRank> = { ...built.skillRanks };
for (const k of expertisePicks) if (k) skillRanks[k] = 'expert';
for (const k of skillIncPicks) if (k) skillRanks[k] = bumpRank(skillRanks[k] ?? 'untrained');
await charactersRepo.update(created.id, { await charactersRepo.update(created.id, {
...built, ...built,
skillRanks,
abilityBuild: buildAbilityBuild(),
...(Object.keys(saveRanks).length ? { saveRanks } : {}), ...(Object.keys(saveRanks).length ? { saveRanks } : {}),
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
spellcasting: { ...built.spellcasting, spells }, spellcasting: { ...built.spellcasting, spells },
...(notes ? { notes } : {}), ...(background ? { background } : {}),
...(heritage ? { heritage } : {}),
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
...(personality.trim() ? { personality: personality.trim() } : {}),
...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}), ...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}),
}); });
onClose(); onClose();
@@ -222,17 +524,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> <Field label="Level"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field>
</div> </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="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"> <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> <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>
<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"> <div className="grid max-h-72 grid-cols-1 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
{classes.map((c) => ( {classes.map((c) => (
<button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); }} <button key={c.slug} data-testid="class-card" onClick={() => { setClassSlug(c.slug); setSubclass(''); setSkills([]); }}
@@ -241,70 +555,214 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
<span className="font-display font-semibold text-ink">{c.name}</span> <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> <span className="rounded-full bg-elevated px-1.5 py-0.5 text-[10px] uppercase text-muted">{playstyle(c)}</span>
</div> </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> <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">{c.description}</p>} {c.description && <p className="mt-1 line-clamp-2 text-xs text-muted">{briefOverview(c.description)}</p>}
</button> </button>
))} ))}
{classes.length === 0 && <p className="text-sm text-muted">Loading classes</p>} {classes.length === 0 && <p className="text-sm text-muted">Loading classes</p>}
</div> </div>
</div> </div>
{selectedClass && selectedClass.subclasses.length > 0 && ( {selectedClass && (() => {
<Field label={campaign.system === 'pf2e' ? 'Subclass / focus (optional)' : 'Subclass (optional)'}> 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)}> <Select value={subclass} onChange={(e) => setSubclass(e.target.value)}>
<option value=""> decide later </option> <option value=""> decide later </option>
{selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)} {selectedClass.subclasses.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
</Select> </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> </div>
)} )}
{stepName === 'Origin' && ( {stepName === 'Origin' && (
<div className="space-y-3">
<div className="grid gap-4 sm:grid-cols-2"> <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} /> <OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
</div> </div>
{system === 'pf2e' && ancestry.trim() && heritageOptions.length > 0 && (
<div>
<Field label="Heritage (chosen at 1st level)">
<Select value={heritage} onChange={(e) => setHeritage(e.target.value)} aria-label="Heritage">
<option value=""> pick a heritage </option>
{heritageOptions.map((h) => <option key={h.name} value={h.name}>{h.name}{h.versatile ? ' (versatile)' : ''}</option>)}
</Select>
</Field>
{heritage && (
<p className="mt-1 text-xs text-muted">{heritageOptions.find((h) => h.name === heritage)?.summary}</p>
)}
</div>
)}
{!ancestry.trim() && (
<p className="text-xs text-warning">
No {system === 'pf2e' ? 'ancestry' : 'race'} selected you can continue (handy for homebrew), but its ability bonuses, HP, speed, and skills wont be applied.
</p>
)}
{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' && ( {stepName === 'Abilities' && (
<div> <div className="space-y-4">
<div className="mb-3 flex gap-1"> {system === 'pf2e' && boostSlots && (
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest']] as const).map(([m, label]) => ( <>
<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> <p className="text-sm text-muted">
))} PF2e scores start at 10. Each <span className="text-ink font-medium">boost</span> adds +2 (or +1 if the score is already 18+).
</div> Click a cell in a free-boost column to assign it highlighted cells are active.
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}> Reroll</Button>} </p>
{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>} <WizardPf2eAbilityTable
{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>} fixed={boostSlots.fixed}
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>} flaw={boostSlots.flaw}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> slots={boostSlots.slots}
{ABILITIES.map((a, i) => { picks={boostPicks}
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!; abilities={abilities}
const isKey = selectedClass?.keyAbilities.includes(a); keyAbilities={selectedClass?.keyAbilities}
return ( onPick={(slotId, ability) => {
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}> setBoostPicks((prev) => {
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}{isKey ? ' ★' : ''}</div> if (prev[slotId] === ability) {
{usesPool ? ( const next = { ...prev };
<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)))}> delete next[slotId];
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)} return next;
</Select> }
) : ( return { ...prev, [slotId]: ability };
<NumberField className="mt-1" value={pb[i]!} min={POINT_BUY_MIN} max={POINT_BUY_MAX} aria-label={`${ABILITY_ABBR[a]} score`} onChange={(v) => setPb((prev) => prev.map((x, j) => (j === i ? Math.max(POINT_BUY_MIN, Math.min(POINT_BUY_MAX, v)) : x)))} /> });
}}
/>
</>
)}
{system !== 'pf2e' && (
<>
{/* Method selector */}
<div className="flex flex-wrap items-center gap-2">
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest'], ['manual', 'Manual']] as const).map(([m, label]) => (
<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>
))}
{method === 'roll' && (
<Button size="sm" variant="secondary" onClick={() => chooseMethod('roll')}>
<RotateCcw size={13} aria-hidden /> Reroll
</Button>
)} )}
<div className="mt-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</div>
</div>
);
})}
</div> </div>
{/* Class tip */}
{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="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="text-sm text-warning">Assign each value to a different ability.</p>
)}
{/* MPMB-style ability table */}
<Wizard5eAbilityTable
method={method}
pool={pool}
assignment={assignment}
pb={pb}
racial={selectedOrigin?.asiBonuses ?? {}}
asiAlloc={asiAlloc}
asiCount={asiCount}
keyAbilities={selectedClass?.keyAbilities}
onChangeAssignment={(abilityIdx, poolIdx) => {
setAssignment((prev) => {
const next = [...prev];
// Swap on collision so a value can be moved between abilities directly.
const j = next.findIndex((x, k) => x === poolIdx && k !== abilityIdx);
if (j !== -1) next[j] = next[abilityIdx]!;
next[abilityIdx] = poolIdx;
return next;
});
}}
onChangePb={(abilityIdx, value) => {
setPb((prev) => prev.map((x, j) => (j === abilityIdx ? value : x)));
}}
onChangeAsi={(ability, delta) => {
setAsiAlloc((p) => ({ ...p, [ability]: Math.max(0, (p[ability] ?? 0) + delta) }));
}}
/>
</>
)}
</div> </div>
)} )}
{stepName === 'Skills' && ( {stepName === 'Skills' && (
<div> <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, freeSkillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected{grantedSkillSources.length > 0 ? `, plus ${grantedSkillSources.length} already granted below` : ''}).
</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>
{grantedSkillSources.length > 0 && (
<div className="mb-3 rounded-md border border-line bg-panel px-3 py-2">
<div className="mb-1 smallcaps text-[11px]">Already Trained (from your origin)</div>
<div className="flex flex-wrap gap-1.5">
{grantedSkillSources.map((g) => (
<span key={g.key} className="rounded border border-accent/40 bg-accent/5 px-2 py-0.5 text-xs text-ink" title={`Granted by ${g.source}`}>
{skillLabel(g.key)} <span className="text-muted">· {g.source}</span>
</span>
))}
</div>
</div>
)}
{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"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillOptions.map((key) => { {freeSkillOptions.map((key) => {
const checked = skills.includes(key); const checked = skills.includes(key);
const disabled = !checked && skills.length >= skillCount; const disabled = !checked && skills.length >= skillCount;
return ( return (
@@ -315,12 +773,73 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
); );
})} })}
</div> </div>
{skills.length < Math.min(skillCount, freeSkillOptions.length) && (
<p className="mt-2 text-xs text-warning">Select {Math.min(skillCount, freeSkillOptions.length) - skills.length} more {Math.min(skillCount, freeSkillOptions.length) - skills.length === 1 ? 'skill' : 'skills'} to continue.</p>
)}
{/* PF2e: skill increases earned by this level (3, 5, 7, …) — rank bumps. */}
{skillIncLevels.length > 0 && (
<div className="mt-4">
<p className="mb-1 text-sm text-ink">Skill increases <span className="text-muted">(earned at levels {skillIncLevels.join(', ')})</span></p>
<p className="mb-2 text-xs text-muted">Each increase raises one skill a rank (Master from level 7, Legendary from level 15).</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillIncLevels.map((earnedAt, i) => {
const ranks = ranksAfterIncreases(i);
return (
<label key={i} className="text-xs text-muted">
Level {earnedAt} increase
<Select className="mt-0.5" aria-label={`Skill increase at level ${earnedAt}`} value={skillIncPicks[i] ?? ''} onChange={(e) => setSkillIncPicks((p) => p.map((v, j) => (j === i ? e.target.value : v)))}>
{sys.skills.map((s) => {
const cur = ranks[s.key] ?? 'untrained';
const ok = incAllowed(cur, earnedAt);
return <option key={s.key} value={s.key} disabled={!ok}>{s.label}: {cur} {ok ? bumpRank(cur) : '(capped)'}</option>;
})}
</Select>
</label>
);
})}
</div>
</div>
)}
{/* 5e: Expertise picks (Bard/Rogue) — double proficiency on chosen skills. */}
{expertiseCount > 0 && (
<div className="mt-4">
<p className="mb-1 text-sm text-ink">Expertise <span className="text-muted">(choose {expertiseCount} proficiency doubled)</span></p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Array.from({ length: expertiseCount }, (_, i) => (
<Select key={i} aria-label={`Expertise skill ${i + 1}`} value={expertisePicks[i] ?? ''} onChange={(e) => setExpertisePicks((p) => p.map((v, j) => (j === i ? e.target.value : v)))}>
<option value=""> pick a skill </option>
{[...skills, ...grantedSkills].map((k) => (
<option key={k} value={k} disabled={expertisePicks.includes(k) && expertisePicks[i] !== k}>{skillLabel(k)}</option>
))}
</Select>
))}
</div>
</div>
)}
</div> </div>
)} )}
{stepName === 'Spells' && ( {stepName === 'Spells' && (
<div> <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">
{(() => {
// Prepared casters re-select daily from a book/list; known casters have a
// fixed repertoire. Different mental model, so spell out which applies.
const prepared = (system === '5e' ? ['cleric', 'druid', 'paladin', 'wizard', 'artificer'] : ['wizard', 'cleric', 'druid', 'witch', 'magus', 'animist'])
.includes((selectedClass?.name ?? '').toLowerCase());
return prepared ? (
<>As a <span className="text-ink">prepared caster</span>, these go into your {system === 'pf2e' ? 'spellbook/list' : 'spellbook'} each day you prepare a subset on the sheet, so pick a versatile starting set.</>
) : (
<>These are the spells you <span className="text-ink">know (your repertoire)</span> a fixed list you cast from slots; you can swap picks when you level up.</>
);
})()}
</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" /> <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"> <div className="grid max-h-64 grid-cols-1 gap-1 overflow-y-auto pr-1 sm:grid-cols-2">
{spellResults.map((s) => { {spellResults.map((s) => {
@@ -338,6 +857,32 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div> </div>
)} )}
{stepName === 'Details' && (
<div className="space-y-3">
<p className="text-sm text-muted">Optional flavour you can fill any of this in later on the sheet.</p>
<Field label="Alignment">
<Select value={alignment} onChange={(e) => setAlignment(e.target.value)}>
<option value=""> none </option>
{['Lawful Good', 'Neutral Good', 'Chaotic Good', 'Lawful Neutral', 'True Neutral', 'Chaotic Neutral', 'Lawful Evil', 'Neutral Evil', 'Chaotic Evil', 'Unaligned'].map((a) => (
<option key={a} value={a}>{a}</option>
))}
</Select>
</Field>
<label className="block text-xs text-muted">
Appearance
<textarea value={appearance} onChange={(e) => setAppearance(e.target.value)} rows={3}
placeholder="Looks, age, distinguishing features…"
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
</label>
<label className="block text-xs text-muted">
Personality &amp; behaviour
<textarea value={personality} onChange={(e) => setPersonality(e.target.value)} rows={3}
placeholder="Traits, ideals, bonds, flaws, how they act…"
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
</label>
</div>
)}
{stepName === 'Review' && selectedClass && ( {stepName === 'Review' && selectedClass && (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div className="rounded-lg border border-line bg-surface p-3"> <div className="rounded-lg border border-line bg-surface p-3">
@@ -349,7 +894,11 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div> </div>
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} /> <Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
{background && <Review label="Background" value={background} />} {background && <Review label="Background" value={background} />}
<Review label="Trained skills" value={skills.map(skillLabel).join(', ') || '—'} /> {heritage && <Review label="Heritage" value={heritage} />}
<Review label="Trained skills" value={[
...skills.map(skillLabel),
...grantedSkillSources.map((g) => `${skillLabel(g.key)} (${g.source})`),
].join(', ') || '—'} />
{built.spellcasting.slots.length > 0 && <Review label="Spell slots" value={built.spellcasting.slots.map((s) => `${s.max}×L${s.level}`).join(' ')} />} {built.spellcasting.slots.length > 0 && <Review label="Spell slots" value={built.spellcasting.slots.map((s) => `${s.max}×L${s.level}`).join(' ')} />}
{spellPicks.length > 0 && <Review label="Spells" value={spellPicks.map((s) => s.name).join(', ')} />} {spellPicks.length > 0 && <Review label="Spells" value={spellPicks.map((s) => s.name).join(', ')} />}
<p className="text-xs text-muted">You can fine-tune equipment, feats, and more on the sheet next.</p> <p className="text-xs text-muted">You can fine-tune equipment, feats, and more on the sheet next.</p>
@@ -366,7 +915,7 @@ function OriginPicker({ title, options, value, onPick }: { title: string; option
return ( return (
<div> <div>
<div className="mb-1 flex items-center justify-between"> <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>} {value && <button className="text-[11px] text-muted hover:text-ink" onClick={() => onPick('')}>clear</button>}
</div> </div>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${title.toLowerCase()}`} className="mb-1" aria-label={`Search ${title}`} /> <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Search ${title.toLowerCase()}`} className="mb-1" aria-label={`Search ${title}`} />
@@ -378,7 +927,7 @@ function OriginPicker({ title, options, value, onPick }: { title: string; option
))} ))}
{options.length === 0 && <p className="text-xs text-muted">Loading</p>} {options.length === 0 && <p className="text-xs text-muted">Loading</p>}
</div> </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> </div>
); );
} }
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { parseAsiBonuses, makeSkillResolver, parseBackgroundSkills, parseTraitSkills } from './origin';
import { getSystem } from '@/lib/rules';
const resolve = makeSkillResolver(getSystem('5e').skills);
describe('parseAsiBonuses', () => {
it('parses a single ability increase', () => {
expect(parseAsiBonuses('Your Dexterity score increases by 2.')).toEqual({ dex: 2 });
});
it('parses two abilities (Half-Orc)', () => {
expect(parseAsiBonuses('Your Strength score increases by 2, and your Constitution score increases by 1.'))
.toEqual({ str: 2, con: 1 });
});
it('parses Human "+1 to all"', () => {
expect(parseAsiBonuses('Your ability scores each increase by 1.'))
.toEqual({ str: 1, dex: 1, con: 1, int: 1, wis: 1, cha: 1 });
});
it('is empty for prose with no ASI', () => {
expect(parseAsiBonuses('You have darkvision.')).toEqual({});
});
});
describe('parseBackgroundSkills', () => {
it('takes both fixed skills (Acolyte)', () => {
expect(parseBackgroundSkills('Insight, Religion', resolve)).toEqual(['insight', 'religion']);
});
it('stops at a choose-one clause (Charlatan)', () => {
expect(parseBackgroundSkills('Deception, and either Culture, Insight, or Sleight of Hand.', resolve)).toEqual(['deception']);
});
it('handles "plus your choice" (Crime Syndicate Member)', () => {
expect(parseBackgroundSkills('Deception, plus your choice of one between Sleight of Hand or Stealth.', resolve)).toEqual(['deception']);
});
});
describe('parseTraitSkills', () => {
it('grants Perception from Elf Keen Senses', () => {
expect(parseTraitSkills('Keen Senses. You have proficiency in the Perception skill.', resolve)).toEqual(['perception']);
});
it('grants Intimidation from Half-Orc Menacing', () => {
expect(parseTraitSkills('Menacing. You gain proficiency in the Intimidation skill.', resolve)).toEqual(['intimidation']);
});
});
+58
View File
@@ -0,0 +1,58 @@
import type { AbilityKey, AbilityScores } from '@/lib/rules';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const ABILITY_BY_WORD: Record<string, AbilityKey> = {
strength: 'str', dexterity: 'dex', constitution: 'con',
intelligence: 'int', wisdom: 'wis', charisma: 'cha',
};
/**
* Parse a 5e race's ASI prose into structured ability bonuses. Handles the Human
* "Your ability scores each increase by 1" case and the usual "Your X score
* increases by N[, and your Y score increases by M]" form.
*/
export function parseAsiBonuses(asi: string): Partial<AbilityScores> {
const out: Partial<AbilityScores> = {};
const each = /ability scores?\s+each\s+increase(?:s)?\s+by\s+(\d+)/i.exec(asi);
if (each) {
const n = Number(each[1]);
for (const a of ABILITIES) out[a] = (out[a] ?? 0) + n;
return out;
}
for (const m of asi.matchAll(/(strength|dexterity|constitution|intelligence|wisdom|charisma)\s+score\s+(?:each\s+)?increase(?:s)?\s+by\s+(\d+)/gi)) {
const key = ABILITY_BY_WORD[m[1]!.toLowerCase()];
if (key) out[key] = (out[key] ?? 0) + Number(m[2]);
}
return out;
}
/** A "skill label/key → key" resolver for the active system. */
export function makeSkillResolver(skills: readonly { key: string; label: string }[]): (name: string) => string | undefined {
const byLabel = new Map<string, string>();
for (const s of skills) { byLabel.set(s.label.toLowerCase(), s.key); byLabel.set(s.key.toLowerCase(), s.key); }
return (name) => byLabel.get(name.trim().toLowerCase());
}
/** Fixed skill proficiencies a 5e background grants (ignores "either/or/choice" clauses). */
export function parseBackgroundSkills(skills: string, resolve: (n: string) => string | undefined): string[] {
const out: string[] = [];
for (let token of skills.split(',')) {
token = token.replace(/\b(and|plus)\b/gi, '').replace(/\.$/, '').trim();
if (!token) continue;
if (/either|\bor\b|choice|between/i.test(token)) break; // a choose-one clause — stop here
const key = resolve(token);
if (key && !out.includes(key)) out.push(key);
}
return out;
}
/** Skill proficiencies granted by racial traits ("proficiency in the X skill"). */
export function parseTraitSkills(traits: string, resolve: (n: string) => string | undefined): string[] {
const out: string[] = [];
for (const m of traits.matchAll(/proficiency in the ([A-Za-z][A-Za-z ]*?) skill/gi)) {
const key = resolve(m[1]!);
if (key && !out.includes(key)) out.push(key);
}
return out;
}
@@ -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], []); 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 { export function useCharacter(id: string): Character | undefined {
return useLiveQuery(() => charactersRepo.get(id), [id], undefined); return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
} }
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { RotateCcw } from 'lucide-react';
import type { AbilityKey, AbilityScores } from '@/lib/rules'; import type { AbilityKey, AbilityScores } from '@/lib/rules';
import { ABILITY_ABBR, abilityModifier } from '@/lib/rules'; import { ABILITY_ABBR, abilityModifier } from '@/lib/rules';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
@@ -75,7 +76,7 @@ export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: Abilit
</div> </div>
{method === 'roll' && ( {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' && ( {method === 'pointbuy' && (
<p className={cn('mb-3 text-sm', remaining < 0 ? 'text-danger' : 'text-muted')}> <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]!; const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
return ( return (
<div key={a} className="rounded-lg border border-line bg-surface p-3 text-center"> <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 ? ( {usesPool ? (
<Select <Select
className="mt-1" className="mt-1"
@@ -1,4 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { getSystem, ABILITY_ABBR } from '@/lib/rules'; import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules'; import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
@@ -9,6 +11,8 @@ import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField'; import { NumberField } from '@/components/ui/NumberField';
import { SheetSection, type SectionProps } from './common'; import { SheetSection, type SectionProps } from './common';
import { rankLabel } from './labels';
import { WeaponPickerModal } from './WeaponPickerModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert']; const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
@@ -16,6 +20,7 @@ const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master
export function AttacksSection({ c, update }: SectionProps) { export function AttacksSection({ c, update }: SectionProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [picking, setPicking] = useState(false);
const sys = getSystem(c.system); const sys = getSystem(c.system);
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E; const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities }; const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
@@ -44,8 +49,10 @@ export function AttacksSection({ c, update }: SectionProps) {
New attack New attack
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" /> <Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
</label> </label>
{c.system === '5e' && <Button variant="secondary" onClick={() => setPicking(true)}>From weapon</Button>}
<Button variant="primary" onClick={add}>Add</Button> <Button variant="primary" onClick={add}>Add</Button>
</div> </div>
{picking && <WeaponPickerModal onPick={(atk) => update({ attacks: [...c.attacks, atk] })} onClose={() => setPicking(false)} />}
{c.attacks.length === 0 ? ( {c.attacks.length === 0 ? (
<p className="text-sm text-muted">No attacks defined.</p> <p className="text-sm text-muted">No attacks defined.</p>
@@ -63,7 +70,7 @@ export function AttacksSection({ c, update }: SectionProps) {
{ABILITIES.map((ab) => <option key={ab} value={ab}>{ABILITY_ABBR[ab]}</option>)} {ABILITIES.map((ab) => <option key={ab} value={ab}>{ABILITY_ABBR[ab]}</option>)}
</Select> </Select>
<Select className="w-auto py-1" value={a.rank} onChange={(e) => patch(a.id, { rank: e.target.value as ProficiencyRank })} aria-label="Attack proficiency"> <Select className="w-auto py-1" value={a.rank} onChange={(e) => patch(a.id, { rank: e.target.value as ProficiencyRank })} aria-label="Attack proficiency">
{ranks.map((r) => <option key={r} value={r}>{r}</option>)} {ranks.map((r) => <option key={r} value={r}>{rankLabel(r, c.system)}</option>)}
</Select> </Select>
<label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={a.damageDice} onChange={(e) => patch(a.id, { damageDice: e.target.value })} aria-label="Damage dice" /></label> <label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={a.damageDice} onChange={(e) => patch(a.id, { damageDice: e.target.value })} aria-label="Damage dice" /></label>
<label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label> <label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label>
@@ -75,6 +82,11 @@ export function AttacksSection({ c, update }: SectionProps) {
> >
{formatModifier(result.toHit)} {formatModifier(result.toHit)}
</button> </button>
{result.map && (
<span className="text-[11px] text-muted" title="Multiple Attack Penalty (2nd / 3rd attack this turn)">
/{formatModifier(result.map.second)}/{formatModifier(result.map.third)}
</span>
)}
<span className="text-muted">to hit ·</span> <span className="text-muted">to hit ·</span>
<button <button
onClick={() => rollAndShow({ expression: result.damage, label: `${a.name} — damage` })} onClick={() => rollAndShow({ expression: result.damage, label: `${a.name} — damage` })}
@@ -84,7 +96,7 @@ export function AttacksSection({ c, update }: SectionProps) {
{result.damage}{a.damageType ? ` ${a.damageType}` : ''} {result.damage}{a.damageType ? ` ${a.damageType}` : ''}
</button> </button>
</span> </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> </li>
); );
})} })}
@@ -0,0 +1,92 @@
import { collectChoices, collectFeatures } from '@/lib/rules';
import type { ClassEntry } from '@/lib/schemas';
import { Input, Select } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Codex';
import { SheetSection, type SectionProps } from './common';
/**
* Class features + every choice the character must make 5e (Fighting Style, Pact Boon,
* Invocations, Metamagic, Expertise, subclass maneuvers) and PF2e (class/skill/general/
* ancestry feats, instinct/doctrine/bloodline) so no unlock-able option is missed.
*/
export function ClassFeaturesSection({ c, update }: SectionProps) {
// 5e multiclasses via classes[]; pf2e is single-class with its subclass resolved in choices[].
const classes: ClassEntry[] = c.classes.length
? c.classes
: c.className
? [{ className: c.className, level: c.level, ...(c.system === 'pf2e' ? { subclass: c.choices.find((ch) => ch.key.endsWith(':subclass'))?.values[0] } : {}) }]
: [];
const features = collectFeatures(c.system, classes);
const choices = collectChoices(c.system, classes);
if (features.length === 0 && choices.length === 0) return null;
const getVals = (key: string) => c.choices.find((ch) => ch.key === key)?.values ?? [];
const setVal = (key: string, idx: number, value: string) => {
const cur = [...getVals(key)];
while (cur.length <= idx) cur.push('');
cur[idx] = value;
update({ choices: [...c.choices.filter((ch) => ch.key !== key), { key, values: cur }] });
};
const pendingTotal = choices.reduce((n, ch) => n + Math.max(0, ch.count - getVals(ch.key).filter(Boolean).length), 0);
return (
<SheetSection title="Class Features & Choices">
{choices.length > 0 && (
<div className="mb-4">
<div className="mb-2 flex items-center gap-2">
<span className="smallcaps">Choices</span>
{pendingTotal > 0 && <Badge tone="ember">{pendingTotal} to choose</Badge>}
</div>
<div className="space-y-2">
{choices.map((ch) => {
const vals = getVals(ch.key);
const filled = vals.filter(Boolean).length;
return (
<div key={ch.key} className="rounded-md border border-line bg-panel p-2">
<div className="mb-1 flex items-center gap-2 text-sm">
<span className="font-medium text-ink">{ch.label}</span>
<span className="text-[10px] uppercase text-muted">{ch.source}</span>
{filled < ch.count && <Badge tone="ember">choose {ch.count - filled}</Badge>}
</div>
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{Array.from({ length: ch.count }).map((_, i) => {
const val = vals[i] ?? '';
return ch.options ? (
<Select key={i} className="text-xs" value={val} onChange={(e) => setVal(ch.key, i, e.target.value)} aria-label={`${ch.label} ${i + 1}`}>
<option value=""> choose </option>
{val && !ch.options.includes(val) && <option value={val}>{val}</option>}
{ch.options.map((o) => <option key={o} value={o}>{o}</option>)}
</Select>
) : (
<Input key={i} className="h-8 text-xs" value={val} onChange={(e) => setVal(ch.key, i, e.target.value)} aria-label={`${ch.label} ${i + 1}`} placeholder={`${ch.label} ${i + 1}`} />
);
})}
</div>
{ch.hint && <p className="mt-1 text-[10px] text-muted">{ch.hint}</p>}
</div>
);
})}
</div>
</div>
)}
{features.length > 0 && (
<div>
<div className="mb-2 smallcaps">Features</div>
<ul className="space-y-1.5">
{features.map((f, i) => (
<li key={`${f.source}-${f.name}-${i}`} className="rounded-md border border-line bg-panel px-2 py-1.5 text-sm">
<div className="flex items-baseline gap-2">
<span className="font-medium text-ink">{f.name}</span>
<span className="text-[10px] uppercase text-muted">{f.source} · L{f.level}</span>
</div>
{f.text && <p className="mt-0.5 text-xs text-muted">{f.text}</p>}
</li>
))}
</ul>
</div>
)}
</SheetSection>
);
}
@@ -0,0 +1,90 @@
import { Plus, X, RefreshCw } from 'lucide-react';
import type { Character, ClassEntry, Spellcasting } from '@/lib/schemas';
import { normalizeClassMirror } from '@/lib/schemas';
import { getClassNames, abilityModifier } from '@/lib/rules';
import { dnd5eClassesSlots, dnd5eMulticlassHp, DND5E_SUBCLASSES } from '@/lib/rules/dnd5e/progression';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
type SlotRow = { level: number; max: number; current: number };
/** Update slot maxes from the derived set while preserving how many are currently spent. */
function reconcileSlots(prev: Spellcasting, derived: { slots: SlotRow[]; pact?: SlotRow }): { slots: SlotRow[]; pact?: SlotRow } {
const slots = derived.slots.map((r) => {
const ex = prev.slots.find((s) => s.level === r.level);
return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max };
});
const pact = derived.pact
? { ...derived.pact, current: Math.min(derived.pact.max, prev.pact?.current ?? derived.pact.max) }
: undefined;
return pact ? { slots, pact } : { slots };
}
/**
* 5e multiclass editor: one row per class. Editing classes recomputes the level/class
* mirrors and auto-reconciles spell-slot maxes (preserving spent slots). HP is recomputed
* only on the explicit button so hand-rolled HP isn't clobbered.
*/
export function ClassesEditor({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
// Fall back to the legacy className/level for any character not yet on classes[].
const classes: ClassEntry[] = c.classes.length
? c.classes
: c.className ? [{ className: c.className, level: c.level }] : [];
const names = getClassNames('5e');
const total = classes.reduce((n, e) => n + (e.level || 0), 0);
const commit = (next: ClassEntry[]) => {
const mirror = normalizeClassMirror({ classes: next });
const { slots, pact } = reconcileSlots(c.spellcasting, dnd5eClassesSlots(next));
const spellcasting: Spellcasting = { ...c.spellcasting, slots };
if (pact) spellcasting.pact = pact; else delete spellcasting.pact;
update({ classes: next, ...mirror, spellcasting });
};
const patchEntry = (i: number, p: Partial<ClassEntry>) => commit(classes.map((e, j) => (j === i ? { ...e, ...p } : e)));
const addClass = () => commit([...classes, { className: names.find((n) => !classes.some((e) => e.className === n)) ?? 'Fighter', level: 1 }]);
const removeClass = (i: number) => commit(classes.filter((_, j) => j !== i));
const recalcHp = () => {
const max = dnd5eMulticlassHp(classes, abilityModifier(c.abilities.con));
update({ hp: { ...c.hp, max, current: Math.min(c.hp.current, max) } });
};
return (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="smallcaps">Classes · level {Math.min(20, total)}</span>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={recalcHp} title="Recompute max HP from these classes"><RefreshCw size={12} aria-hidden /> HP</Button>
<Button size="sm" variant="ghost" onClick={addClass}><Plus size={12} aria-hidden /> class</Button>
</div>
</div>
<div className="space-y-1.5">
{classes.map((e, i) => {
const subs = DND5E_SUBCLASSES[e.className] ?? [];
return (
<div key={i} className="flex flex-wrap items-center gap-1.5 rounded-md border border-line bg-panel px-2 py-1.5">
<Input className="h-8 min-w-28 flex-1" list="dnd5e-class-names" value={e.className} onChange={(ev) => patchEntry(i, { className: ev.target.value })} aria-label={`Class ${i + 1}`} placeholder="Class" />
{subs.length > 0 ? (
<Select className="h-8 w-auto py-0 text-xs" value={e.subclass ?? ''} onChange={(ev) => patchEntry(i, { subclass: ev.target.value || undefined })} aria-label={`Subclass ${i + 1}`}>
<option value=""> subclass </option>
{subs.some((s) => s.name === e.subclass) ? null : e.subclass ? <option value={e.subclass}>{e.subclass}</option> : null}
{subs.map((s) => <option key={s.name} value={s.name}>{s.name}</option>)}
</Select>
) : (
<Input className="h-8 w-28 text-xs" value={e.subclass ?? ''} onChange={(ev) => patchEntry(i, { subclass: ev.target.value || undefined })} aria-label={`Subclass ${i + 1}`} placeholder="Subclass" />
)}
<label className="text-[10px] text-muted">lvl<NumberField className="ml-1 w-12" value={e.level} min={1} max={20} onChange={(v) => patchEntry(i, { level: v })} aria-label={`${e.className} level`} /></label>
{classes.length > 1 && (
<Button size="icon" variant="ghost" className="h-7 w-7 text-danger" onClick={() => removeClass(i)} aria-label={`Remove ${e.className}`}><X size={13} aria-hidden /></Button>
)}
</div>
);
})}
{classes.length === 0 && <p className="text-xs text-muted">No class set.</p>}
</div>
<datalist id="dnd5e-class-names">{names.map((n) => <option key={n} value={n} />)}</datalist>
</div>
);
}
+106 -24
View File
@@ -1,5 +1,11 @@
import { useState } from 'react';
import { Minus, Plus, Skull, Star } from 'lucide-react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { NumberField } from '@/components/ui/NumberField'; import { NumberField } from '@/components/ui/NumberField';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue } from '@/lib/mechanics';
import type { Degree } from '@/lib/dice/check';
import type { Condition } from '@/lib/schemas/common';
import { SheetSection, type SectionProps } from './common'; import { SheetSection, type SectionProps } from './common';
function Pips({ count, filled, onToggle, label }: { count: number; filled: number; onToggle: (n: number) => void; label: string }) { function Pips({ count, filled, onToggle, label }: { count: number; filled: number; onToggle: (n: number) => void; label: string }) {
@@ -21,15 +27,108 @@ function Pips({ count, filled, onToggle, label }: { count: number; filled: numbe
); );
} }
/** PF2e dying/wounded/doomed with knock-out + human-rolled recovery checks. */
function Pf2eDefenses({ c, update }: SectionProps) {
const d = c.defenses;
const [fromCrit, setFromCrit] = useState(false);
const dead = isDead(d.dying, d.doomed);
const withUnconscious = (conds: Condition[]): Condition[] =>
conds.some((x) => x.name.trim().toLowerCase() === 'unconscious') ? conds : [...conds, { name: 'Unconscious' }];
const withoutUnconscious = (conds: Condition[]): Condition[] =>
conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
const setDefenses = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
// You stay Unconscious at 0 HP even after recovering out of dying — only healing
// above 0 HP (or the GM) wakes you. So the sync is: dying>0 OR hp<=0 ⇒ unconscious.
const syncUnconscious = (dying: number, hpCurrent: number) =>
dying > 0 || hpCurrent <= 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions);
/** Set dying and keep the Unconscious condition in sync. */
const setDying = (dying: number) =>
update({ defenses: { ...d, dying }, conditions: syncUnconscious(dying, c.hp.current) });
const doKnockOut = () => {
const { dying } = knockOut(d, fromCrit);
// Knocked out = at 0 HP and dying; drop HP to 0 so the sheet state is coherent.
update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions), hp: { ...c.hp, current: Math.min(0, c.hp.current) } });
};
const doRecover = (degree: Degree) => {
const res = applyRecovery(d, degree);
update({ defenses: { ...d, ...res }, conditions: syncUnconscious(res.dying, c.hp.current) });
};
const doHeroRescue = () => {
const res = heroPointRescue(d);
if (!res) return;
update({ defenses: { ...d, ...res }, conditions: syncUnconscious(0, c.hp.current) });
};
return (
<div className="grid gap-3 sm:grid-cols-2">
<Field label={`Dying (0${maxDying(d.doomed)})`}>
<div className="flex items-center gap-2">
<NumberField className="w-16" value={d.dying} min={0} max={4} onChange={setDying} aria-label="Dying value" />
{dead && <span className="flex items-center gap-1 rounded bg-danger/15 px-1.5 py-0.5 text-xs font-semibold text-danger"><Skull size={12} aria-hidden /> Dead</span>}
</div>
</Field>
<Field label="Wounded">
<NumberField className="w-20" value={d.wounded} min={0} onChange={(v) => setDefenses({ wounded: v })} aria-label="Wounded value" />
</Field>
<Field label="Doomed">
<NumberField className="w-20" value={d.doomed} min={0} max={3} onChange={(v) => setDefenses({ doomed: v })} aria-label="Doomed value" />
</Field>
<Field label="Hero Points">
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" onClick={() => setDefenses({ 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={() => setDefenses({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
</div>
</Field>
<div className="sm:col-span-2 rounded-md border border-line bg-panel px-3 py-2">
{d.dying === 0 ? (
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted">Knocked to 0 HP? Enter dying (adds your Wounded value{d.wounded > 0 ? ` of ${d.wounded}` : ''}).</span>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[11px] text-muted">
<input type="checkbox" checked={fromCrit} onChange={(e) => setFromCrit(e.target.checked)} /> crit
</label>
<Button size="sm" variant="danger" onClick={doKnockOut}>Knock out</Button>
</div>
</div>
) : (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="smallcaps">Recovery check</span>
<span className="font-mono text-sm text-ink">DC {recoveryDc(d.dying)}</span>
</div>
<p className="mb-2 text-[11px] text-muted">Roll a flat d20 vs the DC, then record the result (success lowers Dying; reaching 0 makes you Wounded):</p>
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4">
<Button size="sm" variant="secondary" onClick={() => doRecover('critical-success')}>Crit success 2</Button>
<Button size="sm" variant="secondary" onClick={() => doRecover('success')}>Success 1</Button>
<Button size="sm" variant="secondary" onClick={() => doRecover('failure')}>Failure +1</Button>
<Button size="sm" variant="danger" onClick={() => doRecover('critical-failure')}>Crit fail +2</Button>
</div>
{d.heroPoints >= 1 && (
<div className="mt-2 flex items-center justify-between gap-2 border-t border-line pt-2">
<span className="text-[11px] text-muted">Heroic Recovery: spend ALL your Hero Points ({d.heroPoints}) to drop to Dying 0 and stabilize.</span>
<Button size="sm" variant="primary" onClick={doHeroRescue}>Spend &amp; stabilize</Button>
</div>
)}
</div>
)}
</div>
</div>
);
}
export function DefensesSection({ c, update }: SectionProps) { export function DefensesSection({ c, update }: SectionProps) {
const d = c.defenses; const d = c.defenses;
const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } }); const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
return ( return (
<SheetSection title="Status & Defenses"> <SheetSection title="Status & Defenses">
<div className="grid gap-3 sm:grid-cols-2">
{c.system === '5e' ? ( {c.system === '5e' ? (
<> <div className="grid gap-3 sm:grid-cols-2">
<Field label="Death Saves"> <Field label="Death Saves">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="flex items-center gap-1 text-xs text-success"> <span className="flex items-center gap-1 text-xs text-success">
@@ -44,34 +143,17 @@ export function DefensesSection({ c, update }: SectionProps) {
</Field> </Field>
<Field label="Inspiration"> <Field label="Inspiration">
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.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> </Button>
</Field> </Field>
<Field label="Exhaustion (06)"> <Field label="Exhaustion (06)">
<NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" /> <NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" />
{d.exhaustion >= 4 && <span className="ml-2 text-[11px] text-warning">Max HP halved</span>}
</Field> </Field>
</> </div>
) : ( ) : (
<> <Pf2eDefenses c={c} update={update} />
<Field label="Dying (04)">
<NumberField className="w-20" value={d.dying} min={0} max={4} onChange={(v) => setD({ dying: v })} aria-label="Dying value" />
</Field>
<Field label="Wounded">
<NumberField className="w-20" value={d.wounded} min={0} onChange={(v) => setD({ wounded: v })} aria-label="Wounded value" />
</Field>
<Field label="Doomed">
<NumberField className="w-20" value={d.doomed} min={0} onChange={(v) => setD({ doomed: v })} aria-label="Doomed value" />
</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>
<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>
</div>
</Field>
</>
)} )}
</div>
</SheetSection> </SheetSection>
); );
} }
@@ -79,7 +161,7 @@ export function DefensesSection({ c, update }: SectionProps) {
function Field({ label, children }: { label: string; children: React.ReactNode }) { function Field({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="rounded-md border border-line bg-panel px-3 py-2"> <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} {children}
</div> </div>
); );
@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from 'react';
import { loadFeats5e } from '@/lib/compendium';
import type { Feat5e } from '@/lib/compendium/types';
import { sourceLabel } from '@/lib/compendium/mpmb';
import { newId } from '@/lib/ids';
import type { Feat } from '@/lib/schemas';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
/** Browse the 5e feat compendium (PHB/XGtE/TCE) and add one as a tracked feat. */
export function FeatPickerModal({ onPick, onClose }: { onPick: (f: Feat) => void; onClose: () => void }) {
const [feats, setFeats] = useState<Feat5e[]>([]);
const [q, setQ] = useState('');
useEffect(() => {
let on = true;
void loadFeats5e().then((f) => on && setFeats([...f].sort((a, b) => a.name.localeCompare(b.name))));
return () => { on = false; };
}, []);
const results = useMemo(() => {
const s = q.trim().toLowerCase();
return feats.filter((f) => !s || f.name.toLowerCase().includes(s)).slice(0, 80);
}, [feats, q]);
const pick = (f: Feat5e) => {
onPick({
id: newId(),
name: f.name,
source: f.source?.[0]?.source ? sourceLabel(f.source[0].source) : '',
description: f.description ?? '',
});
onClose();
};
return (
<Modal open onClose={onClose} title="Add a feat" className="max-w-lg"
footer={<Button variant="ghost" onClick={onClose}>Close</Button>}>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search feats…" className="mb-2" aria-label="Search feats" data-autofocus />
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
{feats.length === 0 && <p className="text-sm text-muted">Loading</p>}
{results.map((f) => (
<button key={f.name} onClick={() => pick(f)}
className="block w-full rounded-md border border-line bg-surface px-2 py-1.5 text-left hover:border-accent/60">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-ink">{f.name}</span>
{f.source?.[0]?.source && <span className="shrink-0 text-[10px] text-muted">{sourceLabel(f.source[0].source)}</span>}
</div>
{f.description && <p className="line-clamp-2 text-[11px] text-muted">{f.description}</p>}
</button>
))}
</div>
</Modal>
);
}
@@ -0,0 +1,54 @@
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';
import { FeatPickerModal } from './FeatPickerModal';
/** Feats tracked as first-class records (name + effect) rather than buried in notes. */
export function FeatsSection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const [picking, setPicking] = useState(false);
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>
{c.system === '5e' && <Button variant="secondary" onClick={() => setPicking(true)}>Browse feats</Button>}
<Button variant="primary" onClick={add}>Add</Button>
</div>
{picking && <FeatPickerModal onPick={(feat) => update({ feats: [...c.feats, feat] })} onClose={() => setPicking(false)} />}
{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 { useState } from 'react';
import { X } from 'lucide-react';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
import type { InventoryItem } from '@/lib/schemas'; import type { InventoryItem } from '@/lib/schemas';
@@ -118,7 +120,7 @@ export function InventorySection({ c, update }: SectionProps) {
Attuned Attuned
</label> </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> </li>
))} ))}
</ul> </ul>
@@ -28,7 +28,7 @@ export function LevelUpAdvisor({ campaign, character }: { campaign: Campaign; ch
return ( return (
<div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor"> <div className="rounded-md border border-line bg-surface p-3" data-testid="levelup-advisor">
<div className="mb-2 flex items-center justify-between"> <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'} {source === 'llm' ? 'AI build routes' : 'Build routes'}
</span> </span>
{state === 'loading' && <span className="text-xs text-muted">Thinking</span>} {state === 'loading' && <span className="text-xs text-muted">Thinking</span>}
+307 -22
View File
@@ -1,14 +1,19 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { Campaign, Character } from '@/lib/schemas'; import type { Campaign, Character, ClassEntry, Feat, Spellcasting } from '@/lib/schemas';
import { normalizeClassMirror, totalLevel } from '@/lib/schemas';
import { import {
abilityModifier, getSystem, ABILITY_ABBR, type AbilityKey, type ProficiencyRank, abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
planLevelUp, applyIncreases, bumpRank, getClassDef, planLevelUp, appendLevelIncreases, bumpRank, getClassDef, hitDiceResource,
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
} from '@/lib/rules'; } from '@/lib/rules';
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
import { loadPf2e } from '@/lib/compendium';
import { rollDice } from '@/lib/dice/notation'; import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
import { FeatPickerModal } from './FeatPickerModal';
import { LevelUpAdvisor } from './LevelUpAdvisor'; import { LevelUpAdvisor } from './LevelUpAdvisor';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha']; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
@@ -19,12 +24,28 @@ export function LevelUpModal({ character, onApply, onClose }: {
onClose: () => void; onClose: () => void;
}) { }) {
const conMod = abilityModifier(character.abilities.con); const conMod = abilityModifier(character.abilities.con);
const is5e = character.system === '5e';
// 5e: level up a SPECIFIC class (or multiclass into a new one). pf2e stays single-class.
const classList: ClassEntry[] = useMemo(
() => (character.classes.length
? character.classes
: character.className ? [{ className: character.className, level: character.level }] : []),
[character.classes, character.className, character.level],
);
const NEW = '__new__';
const [levelClass, setLevelClass] = useState<string>(classList[0]?.className ?? character.className);
const [newClassName, setNewClassName] = useState<string>(getClassNames('5e').find((n) => !classList.some((e) => e.className === n)) ?? 'Fighter');
const levelingName = is5e ? (levelClass === NEW ? newClassName : levelClass) : character.className;
const classCurrentLevel = is5e ? (classList.find((e) => e.className === levelingName)?.level ?? 0) : character.level;
const plan = useMemo( const plan = useMemo(
() => planLevelUp(character.system, character.className, character.level, conMod), () => planLevelUp(character.system, levelingName, classCurrentLevel, conMod),
[character.system, character.className, character.level, conMod], [character.system, levelingName, classCurrentLevel, conMod],
); );
const sys = getSystem(character.system); const sys = getSystem(character.system);
const def = getClassDef(character.system, character.className); const def = getClassDef(character.system, levelingName);
const total = totalLevel(character);
const [hpMethod, setHpMethod] = useState<'average' | 'roll'>('average'); const [hpMethod, setHpMethod] = useState<'average' | 'roll'>('average');
const asi = plan.choices.find((c) => c.kind === 'asi'); const asi = plan.choices.find((c) => c.kind === 'asi');
@@ -35,33 +56,177 @@ export function LevelUpModal({ character, onApply, onClose }: {
const keyDefaults = def?.keyAbilities ?? ['str', 'dex']; const keyDefaults = def?.keyAbilities ?? ['str', 'dex'];
const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi'); const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi');
const [asiPicks, setAsiPicks] = useState<AbilityKey[]>([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']); const [asiPicks, setAsiPicks] = useState<AbilityKey[]>([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']);
// Feat-instead-of-ASI (5e): picked right here, recorded onto the sheet on apply.
const [featPick, setFeatPick] = useState<Feat | null>(null);
const [featBrowse, setFeatBrowse] = useState(false);
// Boosts (pf2e): four distinct picks. // Boosts (pf2e): four distinct picks.
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']); const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
// Skill increase (pf2e): one skill to bump. // Skill increase (pf2e): one skill to bump.
const [skillKey, setSkillKey] = useState<string>(sys.skills[0]?.key ?? ''); const [skillKey, setSkillKey] = useState<string>(sys.skills[0]?.key ?? '');
const atMax = character.level >= 20; // Classes after this level-up — drives feature/choice enumeration.
const nextClasses: ClassEntry[] = useMemo(() => {
if (is5e) {
return levelClass === NEW
? [...classList, { className: newClassName, level: 1 }]
: classList.map((e) => (e.className === levelingName ? { ...e, level: Math.min(20, e.level + 1) } : e));
}
return classList.length
? classList.map((e, i) => (i === 0 ? { ...e, level: plan.nextLevel } : e))
: [{ className: character.className, level: plan.nextLevel }];
}, [is5e, levelClass, newClassName, classList, levelingName, plan.nextLevel, character.className]);
// Features GAINED at this level (read-only "what you get").
const featuresGained = useMemo(() => {
const k = (f: UnlockedFeature) => `${f.source}|${f.name}|${f.level}`;
const before = new Set(collectFeatures(character.system, classList).map(k));
return collectFeatures(character.system, nextClasses).filter((f) => !before.has(k(f)));
}, [character.system, classList, nextClasses]);
// How many of a choice the character has already recorded (subclass also counts the
// class entry's own subclass field, set before the choices array existed).
const resolvedCount = (key: string) => {
const fromChoices = character.choices.find((c) => c.key === key)?.values.length ?? 0;
if (key.endsWith(':subclass')) {
const cn = key.slice(0, -':subclass'.length).toLowerCase();
return Math.max(fromChoices, nextClasses.some((e) => e.className.toLowerCase() === cn && e.subclass) ? 1 : 0);
}
return fromChoices;
};
// CHOICES still owed at the new level (Fighting Style, Pact Boon, Invocations, feats,
// pf2e subclass…) — the count not yet recorded.
const pendingChoices = useMemo(
() => collectChoices(character.system, nextClasses)
.map((choice) => ({ choice, pick: Math.max(0, choice.count - resolvedCount(choice.key)) }))
.filter((x) => x.pick > 0),
// eslint-disable-next-line react-hooks/exhaustive-deps
[character.system, nextClasses, character.choices],
);
// 5e subclass selection (a class field, not a generic choice) when it first unlocks.
const sub5e = useMemo(() => {
if (!is5e) return undefined;
const p = subclassPrompt('5e', levelingName);
const entry = nextClasses.find((e) => e.className === levelingName);
if (!p || !entry || entry.level < p.level || entry.subclass) return undefined;
return p;
}, [is5e, levelingName, nextClasses]);
const [choicePicks, setChoicePicks] = useState<Record<string, string[]>>({});
const [subclassPick, setSubclassPick] = useState<string>('');
// PF2e feat-slot inputs autocomplete against the compendium so names land typo-free.
const [pf2eFeatNames, setPf2eFeatNames] = useState<string[]>([]);
const wantsFeatSuggestions = !is5e && pendingChoices.some(({ choice }) => !choice.options && /-feat$/.test(choice.key.split(':')[1] ?? ''));
useEffect(() => {
if (!wantsFeatSuggestions || pf2eFeatNames.length) return;
let on = true;
void loadPf2e('feats').then((fs) => on && setPf2eFeatNames(fs.map((f) => String(f.name)).filter(Boolean)));
return () => { on = false; };
}, [wantsFeatSuggestions, pf2eFeatNames.length]);
const setChoicePick = (key: string, i: number, val: string) =>
setChoicePicks((p) => { const arr = [...(p[key] ?? [])]; arr[i] = val; return { ...p, [key]: arr }; });
// Seed defaults for option-based choices so an untouched picker still records its pick.
useEffect(() => {
setChoicePicks((prev) => {
const next = { ...prev };
for (const { choice, pick } of pendingChoices) {
if (!choice.options) continue;
const arr = [...(next[choice.key] ?? [])];
for (let i = 0; i < pick; i++) if (arr[i] == null) arr[i] = choice.options[0]!;
next[choice.key] = arr;
}
return next;
});
}, [pendingChoices]);
const atMax = total >= 20;
const apply = () => { const apply = () => {
const gain = character.system === 'pf2e' const gain = character.system === 'pf2e'
? plan.hpGainAverage ? plan.hpGainAverage
: hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod); : hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod);
// Ability increases go through the build so the sheet's per-source breakdown
// stays in sync with the totals (the build is the single source of truth).
let abilities = character.abilities; let abilities = character.abilities;
if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e'); let abilityBuild = character.abilityBuild;
if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e'); if (asi && asiMode === 'asi') {
({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, asiPicks, '5e', `ASI L${plan.nextLevel}`));
}
if (boosts) {
({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, boostPicks, 'pf2e', `L${plan.nextLevel} boost`));
}
// Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry.
const subFromChoice = Object.entries(choicePicks)
.find(([k, v]) => k.endsWith(':subclass') && v.some((x) => x.trim()))?.[1].find((x) => x.trim())?.trim();
const subToApply = (sub5e ? (subclassPick || sub5e.options[0]) : undefined) ?? subFromChoice;
const nextClassesFinal = subToApply
? nextClasses.map((e) => (e.className === levelingName && !e.subclass ? { ...e, subclass: subToApply } : e))
: nextClasses;
// Merge newly-picked choices into the character's resolved-choices array.
const merged = character.choices.map((c) => ({ key: c.key, values: [...c.values] }));
for (const [key, vals] of Object.entries(choicePicks)) {
const clean = vals.map((v) => v.trim()).filter(Boolean);
if (!clean.length) continue;
const existing = merged.find((c) => c.key === key);
if (existing) existing.values.push(...clean);
else merged.push({ key, values: clean });
}
const patch: Partial<Character> = { const patch: Partial<Character> = {
level: plan.nextLevel,
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain }, hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
abilities, abilities,
...(abilityBuild ? { abilityBuild } : {}),
classes: nextClassesFinal,
...normalizeClassMirror({ classes: nextClassesFinal }),
choices: merged,
}; };
if (plan.slots) {
if (is5e) {
// Re-derive combined spell slots, preserving current values.
const derived = dnd5eClassesSlots(nextClassesFinal);
const slots = derived.slots.map((r) => {
const ex = character.spellcasting.slots.find((s) => s.level === r.level);
return { level: r.level, max: r.max, current: ex ? Math.min(r.max, ex.current) : r.max };
});
const spellcasting: Spellcasting = { ...character.spellcasting, slots };
if (derived.pact) spellcasting.pact = { ...derived.pact, current: Math.min(derived.pact.max, character.spellcasting.pact?.current ?? derived.pact.max) };
else delete spellcasting.pact;
patch.spellcasting = spellcasting;
} else if (plan.slots) {
patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) }; patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) };
} }
if (skillInc && skillKey) { if (skillInc && skillKey) {
const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained'; const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained';
patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) }; patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) };
} }
// 5e Expertise picks aren't just recorded — they raise the skill rank to expert
// so the doubled proficiency actually lands in the math.
const expertisePicks = Object.entries(choicePicks).filter(([k, v]) => k.endsWith(':expertise') && v.some((x) => x.trim()));
if (expertisePicks.length) {
const ranks: Record<string, ProficiencyRank> = { ...(patch.skillRanks ?? character.skillRanks) as Record<string, ProficiencyRank> };
for (const [, vals] of expertisePicks) {
for (const label of vals) {
const want = label.trim().toLowerCase();
const key = sys.skills.find((s) => s.label.toLowerCase() === want || s.key === want)?.key;
if (key) ranks[key] = 'expert';
}
}
patch.skillRanks = ranks;
}
// 5e: each level grants a Hit Die — keep the tracked resource in step.
if (is5e) {
const newTotal = Math.min(20, total + 1);
const i = character.resources.findIndex((r) => r.name.trim().toLowerCase() === 'hit dice');
patch.resources = i >= 0
? character.resources.map((r, j) => (j === i ? { ...r, max: r.max + 1, current: Math.min(r.max + 1, r.current + 1), recoverStep: Math.ceil(newTotal / 2) } : r))
: [...character.resources, hitDiceResource(newTotal)];
}
if (asi && asiMode === 'feat' && featPick) {
patch.feats = [...character.feats, featPick];
}
onApply(patch); onApply(patch);
onClose(); onClose();
}; };
@@ -74,12 +239,12 @@ export function LevelUpModal({ character, onApply, onClose }: {
<Modal <Modal
open open
onClose={onClose} onClose={onClose}
title={`Level up to ${plan.nextLevel}`} title={is5e ? `Level up — total level ${Math.min(20, total + 1)}` : `Level up to ${plan.nextLevel}`}
className="max-w-xl" className="max-w-xl"
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}>Cancel</Button> <Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={atMax} onClick={apply}>Apply level {plan.nextLevel}</Button> <Button variant="primary" disabled={atMax} onClick={apply}>Apply</Button>
</> </>
} }
> >
@@ -87,9 +252,27 @@ export function LevelUpModal({ character, onApply, onClose }: {
<p className="text-sm text-warning">Already at level 20.</p> <p className="text-sm text-warning">Already at level 20.</p>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* Which class (5e multiclass) */}
{is5e && classList.length > 0 && (
<section>
<h3 className="mb-1 smallcaps">Class to advance</h3>
<div className="flex flex-wrap items-center gap-2">
<Select value={levelClass} onChange={(e) => setLevelClass(e.target.value)} aria-label="Class to level up">
{classList.map((e) => <option key={e.className} value={e.className}>{e.className} {e.level} {Math.min(20, e.level + 1)}</option>)}
<option value={NEW}>+ Multiclass into</option>
</Select>
{levelClass === NEW && (
<Select value={newClassName} onChange={(e) => setNewClassName(e.target.value)} aria-label="New class">
{getClassNames('5e').filter((n) => !classList.some((e) => e.className === n)).map((n) => <option key={n} value={n}>{n}</option>)}
</Select>
)}
</div>
</section>
)}
{/* HP */} {/* HP */}
<section> <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' ? ( {character.system === '5e' ? (
<div className="flex items-center gap-2"> <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"> <Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
@@ -108,7 +291,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
{/* ASI (5e) */} {/* ASI (5e) */}
{asi && ( {asi && (
<section> <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"> <div className="mb-2 flex gap-1">
{(['asi', 'feat'] as const).map((m) => ( {(['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'}`}> <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'}`}>
@@ -126,15 +309,23 @@ export function LevelUpModal({ character, onApply, onClose }: {
<span className="self-center text-xs text-muted">pick the same twice for +2</span> <span className="self-center text-xs text-muted">pick the same twice for +2</span>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted">Add your chosen feat on the sheet after leveling.</p> <div className="flex flex-wrap items-center gap-2">
{featPick ? (
<span className="rounded-md border border-accent/50 bg-accent/5 px-2 py-1 text-sm text-ink">{featPick.name}</span>
) : (
<span className="text-sm text-muted">No feat chosen yet.</span>
)}
<Button size="sm" variant="secondary" onClick={() => setFeatBrowse(true)}>{featPick ? 'Change feat…' : 'Browse feats…'}</Button>
</div>
)} )}
</section> </section>
)} )}
{featBrowse && <FeatPickerModal onPick={(f) => setFeatPick(f)} onClose={() => setFeatBrowse(false)} />}
{/* Boosts (pf2e) */} {/* Boosts (pf2e) */}
{boosts && ( {boosts && (
<section> <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"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[0, 1, 2, 3].map((i) => ( {[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)))}> <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 +339,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
{/* Skill increase (pf2e) */} {/* Skill increase (pf2e) */}
{skillInc && ( {skillInc && (
<section> <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"> <Select value={skillKey} onChange={(e) => setSkillKey(e.target.value)} aria-label="Skill to increase">
{sys.skills.map((s) => { {sys.skills.map((s) => {
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained'; const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
@@ -158,9 +349,63 @@ export function LevelUpModal({ character, onApply, onClose }: {
</section> </section>
)} )}
{plan.choices.filter((c) => c.kind === 'feat').map((c) => ( {/* What you gain at this level (read-only) */}
<p key={c.label} className="text-sm text-muted"> {c.label}</p> {featuresGained.length > 0 && (
<section>
<h3 className="mb-1 smallcaps">What you gain</h3>
<ul className="space-y-1">
{featuresGained.map((f, i) => (
<li key={i} className="text-sm leading-snug">
<span className="text-ink">{f.name}</span>
{f.text ? <span className="text-muted"> {f.text}</span> : null}
</li>
))} ))}
</ul>
</section>
)}
{/* 5e subclass selection (at its archetype level) */}
{sub5e && (
<section>
<h3 className="mb-1 smallcaps">{sub5e.label}</h3>
<Select value={subclassPick || sub5e.options[0]} onChange={(e) => setSubclassPick(e.target.value)} aria-label="Subclass">
{sub5e.options.map((o) => <option key={o} value={o}>{o}</option>)}
</Select>
</section>
)}
{/* Choices still owed at this level (feats, fighting style, pact boon, …) */}
{pendingChoices.length > 0 && (
<section>
<h3 className="mb-1 smallcaps">Choices to make</h3>
<div className="space-y-2">
{pendingChoices.map(({ choice, pick }) => (
<div key={choice.key} className="rounded-md border border-line bg-panel px-3 py-2">
<div className="text-sm text-ink">{choice.label}{pick > 1 ? ` — choose ${pick}` : ''}</div>
{choice.hint && <div className="mb-1 text-xs text-muted">{choice.hint}</div>}
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
{Array.from({ length: pick }, (_, i) => (
choice.options ? (
<Select key={i} aria-label={`${choice.label} ${i + 1}`} value={choicePicks[choice.key]?.[i] ?? choice.options[0]} onChange={(e) => setChoicePick(choice.key, i, e.target.value)}>
{choice.options.map((o) => <option key={o} value={o}>{o}</option>)}
</Select>
) : (
<SuggestInput
key={i}
ariaLabel={`${choice.label} ${i + 1}`}
placeholder="Type your choice…"
value={choicePicks[choice.key]?.[i] ?? ''}
onChange={(v) => setChoicePick(choice.key, i, v)}
suggestions={/-feat$/.test(choice.key.split(':')[1] ?? '') ? pf2eFeatNames : []}
/>
)
))}
</div>
</div>
))}
</div>
</section>
)}
{/* Strategic build-route advice */} {/* Strategic build-route advice */}
<LevelUpAdvisor campaign={{ id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '' } as Campaign} character={character} /> <LevelUpAdvisor campaign={{ id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '' } as Campaign} character={character} />
@@ -169,3 +414,43 @@ export function LevelUpModal({ character, onApply, onClose }: {
</Modal> </Modal>
); );
} }
/** Free-text input with a lightweight suggestion dropdown (used for PF2e feat names). */
function SuggestInput({ value, onChange, suggestions, ariaLabel, placeholder }: {
value: string;
onChange: (v: string) => void;
suggestions: string[];
ariaLabel: string;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const q = value.trim().toLowerCase();
const matches = q.length >= 2 ? suggestions.filter((n) => n.toLowerCase().includes(q) && n !== value).slice(0, 8) : [];
return (
<div className="relative">
<Input
value={value}
aria-label={ariaLabel}
{...(placeholder ? { placeholder } : {})}
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{open && matches.length > 0 && (
<div className="absolute z-20 mt-1 max-h-44 w-full overflow-y-auto rounded-md border border-line bg-panel shadow-xl">
{matches.map((n) => (
<button
key={n}
type="button"
className="block w-full px-2 py-1 text-left text-sm text-ink hover:bg-elevated"
// mousedown beats the input's blur so the click still lands
onMouseDown={(e) => { e.preventDefault(); onChange(n); setOpen(false); }}
>
{n}
</button>
))}
</div>
)}
</div>
);
}
@@ -1,6 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { X, Minus, Plus } from 'lucide-react';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { getSystem, applyRest } from '@/lib/rules'; import { getSystem, applyRest } from '@/lib/rules';
import { spendResource, regainResource } from '@/lib/mechanics';
import type { CharacterResource } from '@/lib/schemas'; import type { CharacterResource } from '@/lib/schemas';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
@@ -14,6 +17,17 @@ const RECOVERY_LABEL: Record<CharacterResource['recovery'], string> = {
none: 'Manual', none: 'Manual',
}; };
/** Tooltip spelling out EVERYTHING the rest does, not just resource tags. */
function restTitle(system: string, optId: string, recovers: readonly string[]): string {
if (system === 'pf2e' && optId === 'rest') {
return 'Rest for the Night: restore HP (capped by Drained), refill spell slots, clear Wounded, reduce Drained and Doomed by 1, remove Fatigued.';
}
if (system === 'pf2e' && optId === 'refocus') return 'Refocus (10 min): regain 1 Focus Point.';
if (optId === 'long') return 'Long rest: restore HP, spell slots, and long-rest resources (Hit Dice recover half your level); exhaustion 1; death saves reset.';
if (optId === 'short') return 'Short rest: regain pact slots and short-rest resources; spend Hit Dice to heal.';
return `Restore: ${recovers.join(', ')}`;
}
export function ResourcesSection({ c, update }: SectionProps) { export function ResourcesSection({ c, update }: SectionProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const sys = getSystem(c.system); const sys = getSystem(c.system);
@@ -39,7 +53,7 @@ export function ResourcesSection({ c, update }: SectionProps) {
actions={ actions={
<div className="flex gap-2"> <div className="flex gap-2">
{sys.restOptions.map((o) => ( {sys.restOptions.map((o) => (
<Button key={o.id} size="sm" variant="secondary" onClick={() => rest(o.id)} title={`Restore: ${o.recovers.join(', ')}`}> <Button key={o.id} size="sm" variant="secondary" onClick={() => rest(o.id)} title={restTitle(c.system, o.id, o.recovers)}>
{o.label} {o.label}
</Button> </Button>
))} ))}
@@ -61,19 +75,19 @@ export function ResourcesSection({ c, update }: SectionProps) {
{c.resources.map((r) => ( {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"> <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" /> <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="w-12 text-center text-sm">
<span className="font-medium text-ink">{r.current}</span> <span className="font-medium text-ink">{r.current}</span>
<span className="text-muted">/{r.max}</span> <span className="text-muted">/{r.max}</span>
</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" /> <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'] })}> <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) => ( {(['short', 'long', 'daily', 'none'] as const).map((k) => (
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option> <option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
))} ))}
</Select> </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> </li>
))} ))}
</ul> </ul>
@@ -1,8 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { Sparkles, BrainCircuit, X, Minus, Plus } from 'lucide-react';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } 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, availableSlotLevels, dropConcentration } from '@/lib/mechanics';
import { multiclassSlots } from '@/lib/rules/dnd5e/progression';
import { formatModifier } from '@/lib/format'; import { formatModifier } from '@/lib/format';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input'; 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 setSlots = (slots: typeof sc.slots) => update({ spellcasting: { ...sc, slots } });
const patchSlot = (level: number, p: Partial<(typeof sc.slots)[number]>) => const patchSlot = (level: number, p: Partial<(typeof sc.slots)[number]>) =>
setSlots(sc.slots.map((s) => (s.level === level ? { ...s, ...p } : s))); 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 addSlotLevel = () => {
const next = (sc.slots.at(-1)?.level ?? 0) + 1; 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 }]); setSlots([...sc.slots, { level: next, max: 1, current: 1 }]);
}; };
const addSpell = () => { const addSpell = () => {
if (spellName.trim() === '') return; 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] } }); update({ spellcasting: { ...sc, spells: [...sc.spells, entry] } });
setSpellName(''); setSpellName('');
}; };
@@ -50,6 +55,20 @@ export function SpellcastingSection({ c, update }: SectionProps) {
const removeSpell = (id: string) => const removeSpell = (id: string) =>
update({ spellcasting: { ...sc, spells: sc.spells.filter((s) => s.id !== id) } }); 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);
// Chosen slot level per spell (for warlock pact slots / upcasting); defaults to lowest.
const [castLevel, setCastLevel] = useState<Record<string, number>>({});
const cast = (id: string, atLevel?: number) => {
const res = castSpell(c, id, atLevel);
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)); const spellsByLevel = [...sc.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
return ( return (
@@ -68,6 +87,21 @@ export function SpellcastingSection({ c, update }: SectionProps) {
))} ))}
</Select> </Select>
</label> </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 && ( {ability && (
<div className="flex gap-4 text-sm"> <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> <span className="text-muted">Spell DC <span className="font-display text-lg font-semibold text-accent">{dc}</span></span>
@@ -76,10 +110,22 @@ export function SpellcastingSection({ c, update }: SectionProps) {
)} )}
</div> </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 */} {/* Spell slots */}
<div className="mb-4"> <div className="mb-4">
<div className="mb-1 flex items-center gap-2"> <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> <Button size="sm" variant="ghost" onClick={addSlotLevel}>+ slot level</Button>
</div> </div>
{sc.slots.length === 0 ? ( {sc.slots.length === 0 ? (
@@ -90,14 +136,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 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="text-[10px] uppercase text-muted">Lv {s.level}</div>
<div className="flex items-center gap-1"> <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> <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> </div>
))} ))}
</div> </div>
)} )}
{c.system === '5e' && <MulticlassSlots onApply={setSlots} />}
</div> </div>
{/* Spell list */} {/* Spell list */}
@@ -110,7 +157,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
Level Level
<Select className="w-20" value={spellLevel} onChange={(e) => setSpellLevel(Number(e.target.value))}> <Select className="w-20" value={spellLevel} onChange={(e) => setSpellLevel(Number(e.target.value))}>
<option value={0}>Cantrip</option> <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> <option key={l} value={l}>{l}</option>
))} ))}
</Select> </Select>
@@ -124,14 +171,39 @@ export function SpellcastingSection({ c, update }: SectionProps) {
{spellsByLevel.map((s) => ( {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"> <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="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 && ( {s.level > 0 && (
<label className="flex items-center gap-1 text-xs text-muted"> <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 })} /> <input type="checkbox" checked={s.prepared} onChange={(e) => patchSpell(s.id, { prepared: e.target.checked })} />
Prepared Prepared
</label> </label>
)} )}
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}></Button> {s.level === 0 ? (
<Button size="sm" variant="ghost" onClick={() => cast(s.id)} aria-label={`Cast ${s.name}`}>
<Sparkles size={13} aria-hidden /> Cast
</Button>
) : (() => {
const levels = availableSlotLevels(sc, s.level);
if (levels.length === 0) return <span className="text-xs text-muted" title="No available slot">no slots</span>;
const chosen = castLevel[s.id] && levels.includes(castLevel[s.id]!) ? castLevel[s.id]! : levels[0]!;
return (
<div className="flex items-center gap-1">
{levels.length > 1 && (
<Select className="w-auto py-0.5 text-xs" value={chosen} aria-label={`Slot level for ${s.name}`}
onChange={(e) => setCastLevel((p) => ({ ...p, [s.id]: Number(e.target.value) }))}>
{levels.map((l) => <option key={l} value={l}>{`L${l}`}</option>)}
</Select>
)}
<Button size="sm" variant="ghost" onClick={() => cast(s.id, chosen)} aria-label={`Cast ${s.name} at level ${chosen}`}>
<Sparkles size={13} aria-hidden /> Cast{chosen !== s.level ? ` L${chosen}` : ''}
</Button>
</div>
);
})()}
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}><X size={14} aria-hidden /></Button>
</li> </li>
))} ))}
</ul> </ul>
@@ -139,3 +211,23 @@ export function SpellcastingSection({ c, update }: SectionProps) {
</SheetSection> </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,61 @@
import { useEffect, useMemo, useState } from 'react';
import { loadWeapons5e } from '@/lib/compendium';
import type { Weapon5e } from '@/lib/compendium/types';
import { newId } from '@/lib/ids';
import type { Attack } from '@/lib/schemas';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
/** Default attack ability for a weapon: ranged/finesse → DEX, else STR. */
function defaultAbility(w: Weapon5e): Attack['ability'] {
const props = (w.properties ?? []).map((p) => p.toLowerCase());
const ranged = /ranged/i.test(w.category ?? '') || props.some((p) => p.startsWith('ammunition') || p.startsWith('thrown'));
if (ranged || props.some((p) => p.startsWith('finesse'))) return 'dex';
return 'str';
}
function toAttack(w: Weapon5e): Attack {
return {
id: newId(),
name: w.name,
ability: defaultAbility(w),
rank: 'trained',
damageDice: w.damage_dice ?? '1d4',
damageType: w.damage_type ?? '',
itemBonus: 0,
addAbilityToDamage: true,
};
}
/** Pick a 5e weapon from the compendium; creates a ready-to-roll attack with correct dice. */
export function WeaponPickerModal({ onPick, onClose }: { onPick: (a: Attack) => void; onClose: () => void }) {
const [weapons, setWeapons] = useState<Weapon5e[]>([]);
const [q, setQ] = useState('');
useEffect(() => {
let on = true;
void loadWeapons5e().then((w) => on && setWeapons([...w].sort((a, b) => a.name.localeCompare(b.name))));
return () => { on = false; };
}, []);
const results = useMemo(() => {
const s = q.trim().toLowerCase();
return weapons.filter((w) => !s || w.name.toLowerCase().includes(s)).slice(0, 80);
}, [weapons, q]);
return (
<Modal open onClose={onClose} title="Add a weapon" className="max-w-lg"
footer={<Button variant="ghost" onClick={onClose}>Close</Button>}>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search weapons…" className="mb-2" aria-label="Search weapons" data-autofocus />
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
{weapons.length === 0 && <p className="text-sm text-muted">Loading</p>}
{results.map((w) => (
<button key={w.slug} onClick={() => { onPick(toAttack(w)); onClose(); }}
className="flex w-full items-center justify-between gap-2 rounded-md border border-line bg-surface px-2 py-1.5 text-left text-sm hover:border-accent/60">
<span className="truncate text-ink">{w.name}</span>
<span className="shrink-0 text-xs text-muted">{w.damage_dice} {w.damage_type}{(w.properties ?? []).some((p) => /finesse/i.test(p)) ? ' · finesse' : ''}</span>
</button>
))}
</div>
</Modal>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { ProficiencyRank, SystemId } from '@/lib/rules';
const RANK_LABEL_PF2E: Record<ProficiencyRank, string> = {
untrained: 'Untrained', trained: 'Trained', expert: 'Expert', master: 'Master', legendary: 'Legendary',
};
// 5e proficiency is binary; "Expertise" = doubled proficiency (stored as 'expert').
const RANK_LABEL_5E: Record<ProficiencyRank, string> = {
untrained: 'Not proficient', trained: 'Proficient', expert: 'Expertise', master: 'Expertise', legendary: 'Expertise',
};
/** Display label for a proficiency rank in the active system (5e avoids PF2e rank names). */
export function rankLabel(rank: ProficiencyRank, system: SystemId): string {
return system === '5e' ? RANK_LABEL_5E[rank] : RANK_LABEL_PF2E[rank];
}
@@ -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 acceptCloud = 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 acceptCloud()}>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]);
}
+205 -15
View File
@@ -1,7 +1,8 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { import {
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
BrainCircuit,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Dices, Dices,
@@ -22,13 +23,15 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation'; import { rollDice } from '@/lib/dice/notation';
import { getSystem, getConditions, type SystemId } from '@/lib/rules'; import { getSystem, getConditions, type SystemId } from '@/lib/rules';
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget'; import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
import { useCharacters } from '@/features/characters/hooks'; import { deriveState, deriveEffectiveMaxHp, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useConditionGlossary } from './useConditionGlossary'; import { useConditionGlossary } from './useConditionGlossary';
import { import {
addCombatant, addCombatant,
applyDamage, applyDamage,
applyHealing, applyHealing,
applyInitiatives, applyInitiatives,
isMassiveDamageDeath,
currentCombatant, currentCombatant,
endEncounter, endEncounter,
logEvent, logEvent,
@@ -48,6 +51,9 @@ import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) { export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
const characters = useCharacters(campaign.id); 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 glossary = useConditionGlossary(campaign.system);
const undoStack = useRef<Encounter[]>([]); const undoStack = useRef<Encounter[]>([]);
@@ -62,6 +68,59 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
if (prev) void encountersRepo.save(prev); 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;
// 5e only: damage forces a Con save to hold concentration. PF2e has no such save —
// sustained spells simply require the Sustain action, so there is nothing to roll.
if (campaign.system !== '5e') return;
// The GM-set combatant flag works for any creature (monster/NPC/PC); fall back to
// the linked Character's concentration (set when a seated player casts a spell).
const ch = pcCharacter(combatant);
const spellName = combatant.concentrating || ch?.concentration?.spellName;
if (!spellName) return;
const dc = concentrationDC(damage);
void encountersRepo.mutate(encounter.id, (e) =>
logEvent(e, `${combatant.name}: roll a DC ${dc} Constitution save or lose concentration on ${spellName}.`));
};
/** Effective max HP for a combatant — drained / exhaustion 4 reduce it. */
const effMaxOf = (c: Combatant) => deriveEffectiveMaxHp({
system: campaign.system,
baseMaxHp: c.hp.max,
level: c.level ?? 1,
exhaustion: c.conditions.find((x) => x.name.trim().toLowerCase() === 'exhaustion')?.value ?? 0,
conditions: c.conditions,
}).max;
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
const advanceTurn = () => {
mutate((e) => nextTurn(e, campaign.system));
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
const reminder = campaign.system === 'pf2e'
? (() => {
const dying = pcCharacter(upcoming)?.defenses.dying ?? 0;
return dying > 0
? `${upcoming.name} is dying — roll a flat recovery check (DC ${10 + dying}) on their sheet.`
: `${upcoming.name} is down — use “Knock out” on their sheet to start recovery checks.`;
})()
: `${upcoming.name} is down — make a death saving throw.`;
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, reminder));
}
};
const rollAllInitiative = () => { const rollAllInitiative = () => {
const rolls: Record<string, number> = {}; const rolls: Record<string, number> = {};
for (const c of encounter.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus; for (const c of encounter.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus;
@@ -71,7 +130,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
// Difficulty budget from monster combatants vs the campaign's PCs. Once combat // 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 // 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. // 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 partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels;
const monsters = encounter.combatants const monsters = encounter.combatants
.filter((c) => c.kind === 'monster') .filter((c) => c.kind === 'monster')
@@ -84,6 +146,25 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
const current = currentCombatant(encounter); const current = currentCombatant(encounter);
const isActive = encounter.status === 'active'; 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 ( return (
<div> <div>
{/* Control bar */} {/* Control bar */}
@@ -138,10 +219,10 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
)} )}
{isActive && ( {isActive && (
<> <>
<Button variant="secondary" onClick={() => mutate(previousTurn)}> <Button variant="secondary" onClick={() => mutate(previousTurn)} title="Previous turn (p or ←)">
<ChevronLeft size={15} aria-hidden /> Prev <ChevronLeft size={15} aria-hidden /> Prev
</Button> </Button>
<Button variant="primary" onClick={() => mutate(nextTurn)}> <Button variant="primary" onClick={advanceTurn} title="Next turn (n or →)">
Next turn <ChevronRight size={15} aria-hidden /> Next turn <ChevronRight size={15} aria-hidden />
</Button> </Button>
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}> <Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
@@ -159,7 +240,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
<EncounterTipCard campaign={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 */} {/* Combatant list */}
{encounter.combatants.length === 0 ? ( {encounter.combatants.length === 0 ? (
@@ -178,8 +259,46 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
glossary={glossary} glossary={glossary}
isCurrent={isActive && idx === encounter.turnIndex} isCurrent={isActive && idx === encounter.turnIndex}
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))} 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) => {
onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))} 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'})`;
// Surface (don't auto-apply) the death rules for downed PCs — the player
// owns their character's death state, so we remind rather than mutate it.
let reminder: string | null = null;
if (c.kind !== 'monster' && hpLoss > 0) {
if (campaign.system === 'pf2e') {
if (c.hp.current <= 0) reminder = `${c.name} took damage while dying — increase Dying by 1 (2 on a critical hit).`;
else if (after.hp.current <= 0) reminder = `${c.name} drops to 0 HP — use “Knock out” on their sheet (Dying 1 + Wounded; 2 on a crit).`;
} else {
// Massive damage compares against the (possibly reduced) effective max.
if (isMassiveDamageDeath(effMaxOf(c), after.hp.current)) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`;
else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`;
}
}
mutate((e) => {
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note);
if (reminder) next = logEvent(next, reminder);
return next;
});
noteConcentrationCheck(c, hpLoss);
}}
onHeal={(amt) => {
// Cap at the effective max (drained / exhaustion 4), and for pf2e let
// healing above 0 do the wake-up bookkeeping (Dying ends → Wounded +1).
const healed = applyHealing(c, amt, effMaxOf(c));
const woke = campaign.system === 'pf2e' && c.hp.current <= 0 && healed.hp.current > 0;
const conditions = woke
? c.conditions.filter((x) => !['unconscious', 'dying'].includes(x.name.trim().toLowerCase()))
: c.conditions;
mutate((e) => {
let next = logEvent(updateCombatant(e, c.id, { hp: healed.hp, ...(woke ? { conditions } : {}) }), `${c.name} heals ${amt}`);
if (woke) next = logEvent(next, `${c.name} is back up — Dying ends (increase Wounded by 1 on their sheet) and they wake.`);
return next;
});
}}
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))} onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))} onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
/> />
@@ -276,7 +395,7 @@ function AddCombatantBar({
characterId: ch.id, characterId: ch.id,
initiative: rollInitiativeFor(ch), initiative: rollInitiativeFor(ch),
initBonus: sys.initiativeModifier({ level: ch.level, abilities: ch.abilities, perceptionRank: ch.perceptionRank }), 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 }, hp: { ...ch.hp },
conditions: [], conditions: [],
notes: '', notes: '',
@@ -350,12 +469,16 @@ function CombatantRow({
glossary: Map<string, string>; glossary: Map<string, string>;
isCurrent: boolean; isCurrent: boolean;
onChange: (patch: Partial<Combatant>) => void; onChange: (patch: Partial<Combatant>) => void;
onDamage: (amt: number) => void; onDamage: (amt: number, type?: string) => void;
onHeal: (amt: number) => void; onHeal: (amt: number) => void;
onMove: (dir: -1 | 1) => void; onMove: (dir: -1 | 1) => void;
onRemove: () => void; onRemove: () => void;
}) { }) {
const [delta, setDelta] = useState(0); 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 dead = c.hp.current <= 0;
const foe = c.kind === 'monster'; const foe = c.kind === 'monster';
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0; const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
@@ -401,6 +524,20 @@ function CombatantRow({
</Badge> </Badge>
)} )}
<span className="smallcaps" style={{ fontSize: 9 }}>{c.kind}</span> <span className="smallcaps" style={{ fontSize: 9 }}>{c.kind}</span>
<button
type="button"
onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })}
className={cn('rounded p-0.5', c.concentrating ? 'text-accent' : 'text-faint hover:text-muted')}
title={c.concentrating
? `Concentrating on ${c.concentrating} — click to clear`
: system === 'pf2e'
? 'Mark as sustaining a spell (a reminder, in case they stop Sustaining)'
: 'Mark as concentrating (prompts a Con save when damaged)'}
aria-label={c.concentrating ? `${c.name} is concentrating; clear` : `Mark ${c.name} as concentrating`}
aria-pressed={!!c.concentrating}
>
<BrainCircuit size={13} aria-hidden />
</button>
{dead && ( {dead && (
<Badge tone="ember"> <Badge tone="ember">
<Skull size={11} aria-hidden /> DOWN <Skull size={11} aria-hidden /> DOWN
@@ -419,26 +556,78 @@ function CombatantRow({
{c.conditions.length > 0 && ( {c.conditions.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => ( {c.conditions.map((cond, i) => (
<button <span
key={`${cond.name}-${i}`} key={`${cond.name}-${i}`}
className="inline-flex items-center gap-1 rounded-full border border-verdigris/45 px-2 py-0.5 text-xs font-medium text-verdigris transition-colors hover:line-through" className="inline-flex items-center gap-1 rounded-full border border-verdigris/45 px-2 py-0.5 text-xs font-medium text-verdigris"
title={glossary.get(cond.name.toLowerCase()) || 'Click to remove'} title={glossary.get(cond.name.toLowerCase()) || undefined}
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
> >
{cond.name} {cond.name}
{cond.value ? ` ${cond.value}` : ''} {cond.value ? ` ${cond.value}` : ''}
{cond.duration ? ` (${cond.duration}r)` : ''} {cond.duration ? ` (${cond.duration}r)` : ''}
{/* Valued conditions (Frightened 2, Drained 3…) step in place — no delete-and-re-add. */}
{cond.value !== undefined && (
<>
<button
aria-label={`Decrease ${cond.name}`}
className="rounded px-0.5 leading-none hover:bg-verdigris/15"
onClick={() => onChange({
conditions: (cond.value ?? 1) <= 1
? c.conditions.filter((_, j) => j !== i)
: c.conditions.map((x, j) => (j === i ? { ...x, value: (x.value ?? 1) - 1 } : x)),
})}
></button>
<button
aria-label={`Increase ${cond.name}`}
className="rounded px-0.5 leading-none hover:bg-verdigris/15"
onClick={() => onChange({ conditions: c.conditions.map((x, j) => (j === i ? { ...x, value: (x.value ?? 0) + 1 } : x)) })}
>+</button>
</>
)}
<button
aria-label={`Remove ${cond.name}`}
className="transition-opacity hover:opacity-60"
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
>
<X size={11} aria-hidden /> <X size={11} aria-hidden />
</button> </button>
</span>
))} ))}
</div> </div>
)} )}
{/* Mechanical consequences the conditions impose (enforced read-model). */}
{(() => {
const st = deriveState(system, 30, c.conditions, { hpMax: c.hp.max, ...(c.level !== undefined ? { level: c.level } : {}) });
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> </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 */} {/* HP controls */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} /> <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 <Sword size={14} aria-hidden /> Dmg
</Button> </Button>
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal"> <Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
@@ -466,6 +655,7 @@ function CombatantRow({
<X size={15} aria-hidden /> <X size={15} aria-hidden />
</Button> </Button>
</div> </div>
</div>
<div className="mt-2"> <div className="mt-2">
<ConditionPicker <ConditionPicker
+19 -6
View File
@@ -9,7 +9,10 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation'; import { rollDice } from '@/lib/dice/notation';
import { addCombatant } from '@/lib/combat/engine'; import { addCombatant } from '@/lib/combat/engine';
import { charactersRepo, encountersRepo } from '@/lib/db/repositories'; 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 { useUiStore } from '@/stores/uiStore';
import { useActiveCampaign } from '@/features/campaigns/hooks'; import { useActiveCampaign } from '@/features/campaigns/hooks';
import { useCharacters } from '@/features/characters/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 activeEncounterId = useUiStore((s) => s.activeEncounterId);
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
@@ -310,6 +313,7 @@ function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number;
conditions: [], notes: '', conditions: [], notes: '',
...(stats.cr !== undefined ? { cr: stats.cr } : {}), ...(stats.cr !== undefined ? { cr: stats.cr } : {}),
...(stats.level !== undefined ? { level: stats.level } : {}), ...(stats.level !== undefined ? { level: stats.level } : {}),
...(stats.damageDefenses ? { damageDefenses: stats.damageDefenses } : {}),
}), }),
); );
setMsg(`Added to "${enc.name}" (init ${initiative}).`); 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); const c = await charactersRepo.get(characterId);
if (!c) return; if (!c) return;
if (kind === 'spell') { 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 level = system === '5e' ? Number(entry.level_int ?? 0) : Number(entry.level ?? 0);
const spell: SpellEntry = { const spell: SpellEntry = newSpellEntry({
id: newId(), name: entry.name, level: Number.isFinite(level) ? level : 0, id: newId(), name: entry.name,
prepared: false, notes: '', ...(entry.slug ? { compendiumRef: String(entry.slug) } : {}), 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] } }); await charactersRepo.update(c.id, { spellcasting: { ...c.spellcasting, spells: [...c.spellcasting.spells, spell] } });
} else { } else {
const item: InventoryItem = { const item: InventoryItem = {
+40 -3
View File
@@ -18,6 +18,21 @@ function StatLine({ label, children }: { label: string; children?: React.ReactNo
); );
} }
/** Join all movement modes into a statblock Speed line, e.g. "40 ft., climb 40 ft., fly 80 ft." */
function speedLine(speed: Monster['speed']): string {
if (!speed) return '';
const entries = Object.entries(speed).filter(([, v]) => typeof v === 'number') as [string, number][];
const walk = entries.find(([k]) => k === 'walk');
const rest = entries.filter(([k]) => k !== 'walk');
const parts = [...(walk ? [`${walk[1]} ft.`] : []), ...rest.map(([k, v]) => `${k} ${v} ft.`)];
return parts.join(', ');
}
const SAVE_LABELS: [keyof Monster, string][] = [
['strength_save', 'STR'], ['dexterity_save', 'DEX'], ['constitution_save', 'CON'],
['intelligence_save', 'INT'], ['wisdom_save', 'WIS'], ['charisma_save', 'CHA'],
];
export function MonsterDetail({ monster: m }: { monster: Monster }) { export function MonsterDetail({ monster: m }: { monster: Monster }) {
const abilities: [string, number | undefined][] = [ const abilities: [string, number | undefined][] = [
['STR', m.strength], ['STR', m.strength],
@@ -28,6 +43,25 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
['CHA', m.charisma], ['CHA', m.charisma],
]; ];
const subtitle = [m.size, m.type, m.alignment].filter(Boolean).join(', '); const subtitle = [m.size, m.type, m.alignment].filter(Boolean).join(', ');
const saves = SAVE_LABELS
.map(([k, label]) => [label, m[k] as number | null | undefined] as const)
.filter(([, v]) => typeof v === 'number')
.map(([label, v]) => `${label} ${formatModifier(v as number)}`)
.join(', ');
const skills = m.skills
? Object.entries(m.skills).map(([k, v]) => `${k[0]!.toUpperCase()}${k.slice(1)} ${formatModifier(v)}`).join(', ')
: '';
// Passive Perception, computed from the creature's own stats so it can never
// disagree with them (7 SRD monsters ship a wrong passive in their senses string).
const percMod = m.skills?.perception ?? (m.wisdom !== undefined ? abilityModifier(m.wisdom) : undefined);
const passivePerception = percMod !== undefined ? 10 + percMod : undefined;
const senses = m.senses && passivePerception !== undefined
? /passive Perception\s+\d+/i.test(m.senses)
? m.senses.replace(/passive Perception\s+\d+/i, `passive Perception ${passivePerception}`)
: `${m.senses}, passive Perception ${passivePerception}`
: m.senses;
return ( return (
<div className="font-display"> <div className="font-display">
<header> <header>
@@ -40,7 +74,7 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
<div className="space-y-1"> <div className="space-y-1">
<StatLine label="Armor Class">{m.armor_class ?? '—'}{m.armor_desc ? ` (${m.armor_desc})` : ''}</StatLine> <StatLine label="Armor Class">{m.armor_class ?? '—'}{m.armor_desc ? ` (${m.armor_desc})` : ''}</StatLine>
<StatLine label="Hit Points">{m.hit_points ?? '—'}{m.hit_dice ? ` (${m.hit_dice})` : ''}</StatLine> <StatLine label="Hit Points">{m.hit_points ?? '—'}{m.hit_dice ? ` (${m.hit_dice})` : ''}</StatLine>
{m.speed?.walk !== undefined && <StatLine label="Speed">{m.speed.walk} ft.</StatLine>} <StatLine label="Speed">{speedLine(m.speed) || null}</StatLine>
</div> </div>
<hr className="gilt-rule my-4" /> <hr className="gilt-rule my-4" />
@@ -57,14 +91,17 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
))} ))}
</div> </div>
{(m.damage_resistances || m.damage_immunities || m.condition_immunities || m.senses || m.languages) && ( {(saves || skills || m.damage_resistances || m.damage_immunities || m.condition_immunities || senses || m.languages) && (
<> <>
<hr className="gilt-rule my-4" /> <hr className="gilt-rule my-4" />
<div className="space-y-1"> <div className="space-y-1">
<StatLine label="Saving Throws">{saves || null}</StatLine>
<StatLine label="Skills">{skills || null}</StatLine>
<StatLine label="Vulnerabilities">{m.damage_vulnerabilities}</StatLine>
<StatLine label="Resistances">{m.damage_resistances}</StatLine> <StatLine label="Resistances">{m.damage_resistances}</StatLine>
<StatLine label="Immunities">{m.damage_immunities}</StatLine> <StatLine label="Immunities">{m.damage_immunities}</StatLine>
<StatLine label="Condition Immunities">{m.condition_immunities}</StatLine> <StatLine label="Condition Immunities">{m.condition_immunities}</StatLine>
<StatLine label="Senses">{m.senses}</StatLine> <StatLine label="Senses">{senses}</StatLine>
<StatLine label="Languages">{m.languages}</StatLine> <StatLine label="Languages">{m.languages}</StatLine>
</div> </div>
</> </>
+14 -5
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { SystemId } from '@/lib/rules'; import type { SystemId } from '@/lib/rules';
import { abilityModifier } 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 type { CompendiumEntry, Monster, Spell, MagicItem } from '@/lib/compendium/types';
import { import {
loadMonsters, loadMonsters,
@@ -49,7 +51,7 @@ export interface CategoryDef {
/** enables "add to character" for spells/items */ /** enables "add to character" for spells/items */
linkAs?: 'spell' | 'item'; linkAs?: 'spell' | 'item';
/** enables "add to combat" for monsters/creatures */ /** 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 */ /** optional numeric sort axis (e.g. CR, spell level) in addition to name */
numericSort?: { label: string; get: (e: Entry) => number }; 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, name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1,
initBonus: abilityModifier(m.dexterity ?? 10), initBonus: abilityModifier(m.dexterity ?? 10),
...(m.cr !== undefined ? { cr: m.cr } : {}), ...(m.cr !== undefined ? { cr: m.cr } : {}),
damageDefenses: { ...normalizeMonsterDefenses(m), resistFlat: [], weakness: [] },
}; };
}, },
numericSort: { label: 'CR', get: (e) => Number(e.cr) }, numericSort: { label: 'CR', get: (e) => Number(e.cr) },
@@ -182,13 +185,14 @@ export const CATEGORIES: CategoryDef[] = [
{ {
id: '5e-spells', system: '5e', label: 'Spells', linkAs: 'spell', id: '5e-spells', system: '5e', label: 'Spells', linkAs: 'spell',
load: () => loadSpells() as unknown as Promise<Entry[]>, load: () => loadSpells() as unknown as Promise<Entry[]>,
searchKeys: ['name', 'school'], searchKeys: ['name', 'school', 'source'],
meta: (e) => { const l = (e as unknown as Spell).level_int; return l === 0 ? 'Cantrip' : `Lv ${l ?? '?'}`; }, meta: (e) => { const s = e as unknown as Spell; const l = s.level_int; const lvl = l === 0 ? 'Cantrip' : `Lv ${l ?? '?'}`; return s.source && s.source !== 'SRD' ? `${lvl} · ${s.source}` : lvl; },
detail: (e) => <Spell5eDetail entry={e as unknown as Spell} />, detail: (e) => <Spell5eDetail entry={e as unknown as Spell} />,
numericSort: { label: 'Level', get: (e) => Number(e.level_int) }, numericSort: { label: 'Level', get: (e) => Number(e.level_int) },
filters: [ filters: [
{ key: 'level', label: 'Level', kind: 'eq', get: (e) => e.level_int as number, options: (d) => numericOptions(d, (e) => e.level_int, (n) => (n === 0 ? 'Cantrip' : `Level ${n}`)) }, { key: 'level', label: 'Level', kind: 'eq', get: (e) => e.level_int as number, options: (d) => numericOptions(d, (e) => e.level_int, (n) => (n === 0 ? 'Cantrip' : `Level ${n}`)) },
{ key: 'school', label: 'School', kind: 'eq', get: (e) => e.school as string, options: (d) => distinctStrings(d, (e) => e.school) }, { key: 'school', label: 'School', kind: 'eq', get: (e) => e.school as string, options: (d) => distinctStrings(d, (e) => e.school) },
{ key: 'source', label: 'Source', kind: 'eq', get: (e) => e.source as string, options: (d) => distinctStrings(d, (e) => e.source) },
], ],
}, },
{ {
@@ -242,13 +246,18 @@ export const CATEGORIES: CategoryDef[] = [
// ---------- Pathfinder 2e ---------- // ---------- Pathfinder 2e ----------
{ {
...pf2eCategory('creatures', 'Bestiary', 'creatures'), ...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, name: e.name,
ac: Number(e.ac) || 10, ac: Number(e.ac) || 10,
hp: Number(e.hp) || 1, hp: Number(e.hp) || 1,
initBonus: Number(e.perception) || 0, initBonus: Number(e.perception) || 0,
...(e.level !== undefined ? { level: Number(e.level) } : {}), ...(e.level !== undefined ? { level: Number(e.level) } : {}),
}), ...(hasDef ? { damageDefenses: { resist: [], vulnerable: [], ...d } } : {}),
};
},
}, },
classCategory('pf2e'), classCategory('pf2e'),
pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }), pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }),
+17 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useLiveQuery } from 'dexie-react-hooks'; 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 { rollDice, applyRollMode, naturalD20, DiceParseError, type RollResult } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { diceRepo } from '@/lib/db/repositories'; import { diceRepo } from '@/lib/db/repositories';
@@ -11,6 +11,7 @@ import { useRollStore } from '@/stores/rollStore';
import { useSessionStore } from '@/stores/sessionStore'; import { useSessionStore } from '@/stores/sessionStore';
import { Page, PageHeader } from '@/components/ui/Page'; import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useConfirm } from '@/components/ui/useConfirm';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Codex'; import { Badge } from '@/components/ui/Codex';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
@@ -34,11 +35,20 @@ function DieGlyph({ sides, size = 44 }: { sides: number; size?: number }) {
export function DicePage() { export function DicePage() {
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined; const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
const { confirm, confirmElement } = useConfirm();
const [expr, setExpr] = useState('1d20'); const [expr, setExpr] = useState('1d20');
const [last, setLast] = useState<RollResult | null>(null); const [last, setLast] = useState<RollResult | null>(null);
const [rollCount, setRollCount] = useState(0); const [rollCount, setRollCount] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Only ever clear the active campaign's history — never every campaign's rolls.
const clearHistory = async () => {
if (!campaignId) return;
if (await confirm({ title: 'Clear roll history?', message: 'Delete this campaigns dice-roll history? This cant be undone.', confirmLabel: 'Clear' })) {
void diceRepo.clear(campaignId);
}
};
const history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []); const history = useLiveQuery(() => diceRepo.recent(campaignId, 30), [campaignId], []);
const macroKey = campaignId ?? ''; const macroKey = campaignId ?? '';
@@ -75,13 +85,14 @@ export function DicePage() {
return ( return (
<Page> <Page>
{confirmElement}
<PageHeader <PageHeader
eyebrow="Cast the bones" eyebrow="Cast the bones"
title="Dice" title="Dice"
subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20." subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20."
actions={ actions={
history.length > 0 ? ( history.length > 0 && campaignId ? (
<Button variant="ghost" onClick={() => void diceRepo.clear(campaignId)}> <Button variant="ghost" onClick={() => void clearHistory()}>
Clear history Clear history
</Button> </Button>
) : null ) : null
@@ -199,7 +210,7 @@ export function DicePage() {
onClick={toggleSecret} onClick={toggleSecret}
title="When on, your rolls are NOT shared with the players" title="When on, your rolls are NOT shared with the players"
> >
🤫 Secret <EyeOff size={14} aria-hidden /> Secret
</Button> </Button>
)} )}
</div> </div>
@@ -209,7 +220,7 @@ export function DicePage() {
</p> </p>
)} )}
{isGm && secret && ( {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) */} {/* Saved rolls (macros) */}
@@ -240,7 +251,7 @@ export function DicePage() {
onClick={() => removeMacro(macroKey, m.id)} onClick={() => removeMacro(macroKey, m.id)}
aria-label={`Remove ${m.label}`} aria-label={`Remove ${m.label}`}
> >
<X size={13} aria-hidden />
</button> </button>
</span> </span>
))} ))}
+3 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Check, Megaphone, X } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore'; import { useUiStore } from '@/stores/uiStore';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize'; import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -21,9 +22,9 @@ export function HandoutControl() {
return ( return (
<> <>
<Button size="sm" variant={active ? 'primary' : 'ghost'} onClick={openComposer} title="Show a handout (text / image) to players"> <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> </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 && ( {open && (
<Modal open onClose={() => setOpen(false)} title="Handout for players" <Modal open onClose={() => setOpen(false)} title="Handout for players"
footer={<> footer={<>
+5 -2
View File
@@ -1,4 +1,5 @@
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { X } from 'lucide-react';
import { useSessionStore } from '@/stores/sessionStore'; import { useSessionStore } from '@/stores/sessionStore';
import { leaveRoom } from '@/lib/sync/wsSync'; import { leaveRoom } from '@/lib/sync/wsSync';
@@ -11,9 +12,11 @@ export function PlayerSessionBadge() {
const label = status === 'connected' ? 'Live' : status === 'connecting' ? 'Connecting…' : status; const label = status === 'connected' ? 'Live' : status === 'connecting' ? 'Connecting…' : status;
return ( 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"> <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> <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> </div>
); );
} }
+28 -11
View File
@@ -1,13 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks'; 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 { useSessionStore, type SeatRequest } from '@/stores/sessionStore';
import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync'; import { hostSession, stopSession, grantSeat } from '@/lib/sync/wsSync';
import { charactersRepo } from '@/lib/db/repositories'; import { charactersRepo } from '@/lib/db/repositories';
import { cloudUsername } from '@/lib/cloud/client'; import { cloudUsername } from '@/lib/cloud/client';
import { useActiveCampaign } from '@/features/campaigns/hooks'; import { useActiveCampaign } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button'; 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. */ /** Header control: GM hosts a live session, shares the code, and approves seats. */
export function SessionControl() { export function SessionControl() {
@@ -19,6 +21,8 @@ export function SessionControl() {
const seatRequests = useSessionStore((s) => s.seatRequests); const seatRequests = useSessionStore((s) => s.seatRequests);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showSeats, setShowSeats] = 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 if (role === 'player') return null; // players don't host
@@ -28,17 +32,24 @@ export function SessionControl() {
if (!cloudUsername()) { if (!cloudUsername()) {
return ( return (
<Button size="sm" variant="ghost" title="Sign in to host a shared session" onClick={() => void navigate({ to: '/settings' })}> <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> </Button>
); );
} }
return ( return (
<Button size="sm" variant="ghost" title="Host a live session for players" <>
onClick={() => { <Button size="sm" variant="ghost" title="Host a live session for players" onClick={() => setHostOpen(true)}>
const pw = window.prompt('Optional player password (leave blank for none):')?.trim(); <RadioTower size={14} aria-hidden /> Host
hostSession(campaign.id, pw || undefined); </Button>
}} <Modal open={hostOpen} onClose={() => setHostOpen(false)} title="Host a live session" className="max-w-sm"
>📡 Host</Button> 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" className="font-mono font-semibold tracking-wider text-accent-deep"
title="Copy player link" title="Copy player link"
onClick={() => { if (link) void navigator.clipboard?.writeText(link).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }); }} 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 && ( {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" <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 }) { function SeatRequestRow({ req }: { req: SeatRequest }) {
const campaign = useActiveCampaign();
const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]); const character = useLiveQuery(() => charactersRepo.get(req.characterId), [req.characterId]);
const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest); const removeSeatRequest = useSessionStore((s) => s.removeSeatRequest);
const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character'; const name = character?.name ?? req.offlineSnapshot?.name ?? 'Unknown character';
const grant = async (applyOffline: boolean) => { 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; const { id: _id, campaignId: _c, createdAt: _cr, updatedAt: _u, ...rest } = req.offlineSnapshot;
await charactersRepo.update(req.characterId, rest); 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); if (current) grantSeat(req.playerId, current);
else removeSeatRequest(req.playerId); 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 { SessionLogEntry } from '@/lib/schemas';
import type { RosterEntry } from '@/lib/sync/messages'; import type { RosterEntry } from '@/lib/sync/messages';
import { fileToDataUrl, resizeToMax } from '@/lib/img/resize'; import { fileToDataUrl, resizeToMax } from '@/lib/img/resize';
import { Dice5, Mail, MessageSquare, X } from 'lucide-react';
import { Input, Textarea } from '@/components/ui/Input'; import { Input, Textarea } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
@@ -86,7 +87,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
</div> </div>
)} )}
</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> </div>
{!inSession && ( {!inSession && (
@@ -95,21 +96,21 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
{inSession && !isGm && ( {inSession && !isGm && (
<div className="border-b border-line p-3"> <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" /> <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> </div>
)} )}
{inSession && ( {inSession && (
<div className="border-b border-line p-3"> <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"> <ul className="flex flex-wrap gap-1">
{roster.map((p) => ( {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')}> <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 /> <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} {p.name}{p.character ? <span className="text-muted"> · {p.character}</span> : null}
{isGm && p.playerId !== 'gm' && ( {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> </li>
))} ))}
@@ -158,7 +159,7 @@ export function SessionSidebar({ open, onClose }: { open: boolean; onClose: () =
<ul className="space-y-0.5 text-xs"> <ul className="space-y-0.5 text-xs">
{(recap ?? []).map((e) => ( {(recap ?? []).map((e) => (
<li key={e.id} className="flex gap-2"> <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="text-muted">{e.from}:</span>
<span className="min-w-0 flex-1 text-ink">{e.text}</span> <span className="min-w-0 flex-1 text-ink">{e.text}</span>
</li> </li>
+4 -3
View File
@@ -8,7 +8,7 @@ import type { Snapshot } from '@/lib/sync/messages';
import { pushSnapshot, pushImage } from '@/lib/sync/wsSync'; import { pushSnapshot, pushImage } from '@/lib/sync/wsSync';
import { useCharacters } from '@/features/characters/hooks'; import { useCharacters } from '@/features/characters/hooks';
import { useEncounters } from '@/features/combat/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 * 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 encounters = useEncounters(cid);
const maps = useMaps(cid); const maps = useMaps(cid);
const calendar = useCalendar(cid); const calendar = useCalendar(cid);
const quests = useQuests(cid);
const activeEncounterId = useUiStore((s) => s.activeEncounterId); const activeEncounterId = useUiStore((s) => s.activeEncounterId);
const activeMapId = useUiStore((s) => s.activeMapId); const activeMapId = useUiStore((s) => s.activeMapId);
const activeHandout = useUiStore((s) => s.activeHandout); const activeHandout = useUiStore((s) => s.activeHandout);
@@ -31,8 +32,8 @@ export function useSessionBroadcaster(campaign: Campaign | null): void {
useEffect(() => { useEffect(() => {
if (role !== 'gm' || status !== 'connected' || !campaign) return; 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); debouncedPush(snapshot);
for (const [id, dataUrl] of Object.entries(images)) pushImage(id, dataUrl); 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>
);
}
+71 -11
View File
@@ -1,11 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { Sparkles, BrainCircuit, Minus, Plus, X } from 'lucide-react';
import type { Character, Defenses } from '@/lib/schemas'; import type { Character, Defenses } from '@/lib/schemas';
import { castSpell, availableSlotLevels, dropConcentration } from '@/lib/mechanics';
import { ActionGuide } from './ActionGuide';
import { rollDice, applyRollMode } from '@/lib/dice/notation'; import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { useRollStore } from '@/stores/rollStore'; import { useRollStore } from '@/stores/rollStore';
import { sendPlayerRoll } from '@/lib/sync/wsSync'; import { sendPlayerRoll } from '@/lib/sync/wsSync';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
import { Meter } from '@/components/ui/Codex';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
@@ -19,6 +23,12 @@ 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 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 slots = c.spellcasting.slots.filter((s) => s.max > 0);
const [castMsg, setCastMsg] = useState<string | null>(null);
const [castLevel, setCastLevel] = useState<Record<string, number>>({});
const doCast = (id: string, atLevel?: number) => {
const r = castSpell(c, id, atLevel);
if (r.ok) { onPatch(r.patch); setCastMsg(r.log.join(' ')); } else setCastMsg(r.reason);
};
return ( return (
<section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4"> <section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4">
@@ -27,6 +37,14 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
<span className="text-sm text-muted">Lv {c.level} {c.className} · {c.ancestry}</span> <span className="text-sm text-muted">Lv {c.level} {c.className} · {c.ancestry}</span>
</div> </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"> <div className="grid gap-4 md:grid-cols-2">
{/* HP */} {/* HP */}
<div className="rounded-lg border border-line bg-panel p-3"> <div className="rounded-lg border border-line bg-panel p-3">
@@ -34,8 +52,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
<span>Hit points</span> <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> <span className="tabular-nums" data-testid="my-hp">{c.hp.current}/{c.hp.max}{c.hp.temp > 0 ? ` (+${c.hp.temp})` : ''}</span>
</div> </div>
<div className="mb-2 h-3 overflow-hidden rounded-full bg-surface"> <div className="mb-2">
<div className={cn('h-full', hpPct > 50 ? 'bg-success' : hpPct > 0 ? 'bg-warning' : 'bg-danger')} style={{ width: `${hpPct}%` }} /> <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>
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<Button size="sm" variant="danger" onClick={() => setHp(c.hp.current - 5)}>5</Button> <Button size="sm" variant="danger" onClick={() => setHp(c.hp.current - 5)}>5</Button>
@@ -44,8 +62,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
<Button size="sm" variant="primary" onClick={() => setHp(c.hp.current + 5)}>+5</Button> <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="mx-1 h-4 w-px bg-line" />
<span className="text-xs text-muted">Temp</span> <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)}><Minus size={14} aria-hidden /></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)}><Plus size={14} aria-hidden /></Button>
</div> </div>
</div> </div>
@@ -61,8 +79,8 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
</div> </div>
) : ( ) : (
<div className="space-y-2 text-sm"> <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.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 (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"> <div className="flex items-center justify-between">
<Counter label="Exhaustion" value={c.defenses.exhaustion} min={0} max={6} onChange={(exhaustion) => setDef({ exhaustion })} /> <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> <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 +111,47 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
</div> </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>
{s.level === 0 ? (
<Button size="sm" variant="ghost" onClick={() => doCast(s.id)} aria-label={`Cast ${s.name}`}>
<Sparkles size={12} aria-hidden /> Cast
</Button>
) : (() => {
const levels = availableSlotLevels(c.spellcasting, s.level);
if (levels.length === 0) return <span className="text-xs text-muted">no slots</span>;
const chosen = castLevel[s.id] && levels.includes(castLevel[s.id]!) ? castLevel[s.id]! : levels[0]!;
return (
<div className="flex items-center gap-1">
{levels.length > 1 && (
<Select className="w-auto py-0.5 text-xs" value={chosen} aria-label={`Slot level for ${s.name}`}
onChange={(e) => setCastLevel((p) => ({ ...p, [s.id]: Number(e.target.value) }))}>
{levels.map((l) => <option key={l} value={l}>{`L${l}`}</option>)}
</Select>
)}
<Button size="sm" variant="ghost" onClick={() => doCast(s.id, chosen)} aria-label={`Cast ${s.name} at level ${chosen}`}>
<Sparkles size={12} aria-hidden /> Cast{chosen !== s.level ? ` L${chosen}` : ''}
</Button>
</div>
);
})()}
</div>
))}
</div>
{castMsg && <p className="mt-2 text-xs text-muted" aria-live="polite">{castMsg}</p>}
</div>
)}
{/* Resources */} {/* Resources */}
{c.resources.length > 0 && ( {c.resources.length > 0 && (
<div className="rounded-lg border border-line bg-panel p-3"> <div className="rounded-lg border border-line bg-panel p-3">
@@ -114,6 +173,7 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
{/* Dice */} {/* Dice */}
<DiceBox characterId={c.id} /> <DiceBox characterId={c.id} />
</div> </div>
<ActionGuide character={c} />
</section> </section>
); );
} }
@@ -122,9 +182,9 @@ function Counter({ label, value, min, max, onChange, suffix }: { label: string;
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{label && <span className="flex-1 text-xs text-muted">{label}</span>} {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> <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> </div>
); );
} }
@@ -158,8 +218,8 @@ function ConditionsBox({ character: c, onPatch }: { character: Character; onPatc
<div className="mb-2 flex flex-wrap gap-1"> <div className="mb-2 flex flex-wrap gap-1">
{c.conditions.length === 0 && <span className="text-xs text-muted">None.</span>} {c.conditions.length === 0 && <span className="text-xs text-muted">None.</span>}
{c.conditions.map((cond, i) => ( {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"> <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}` : ''} {cond.name}{cond.value ? ` ${cond.value}` : ''} <X size={11} aria-hidden />
</button> </button>
))} ))}
</div> </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 { Snapshot } from '@/lib/sync/messages';
import type { PlayerMap } from '@/lib/map'; import type { PlayerMap } from '@/lib/map';
import { useConditionGlossary } from '@/features/combat/useConditionGlossary';
import { usePlayerSessionStore } from '@/stores/playerSessionStore'; import { usePlayerSessionStore } from '@/stores/playerSessionStore';
import { EmptyState } from '@/components/ui/Page'; import { EmptyState } from '@/components/ui/Page';
import { Meter, Badge } from '@/components/ui/Codex'; 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. */ /** Presentational player view: battle map + party + initiative, from a snapshot. */
export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) { export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot; image?: string; images?: Record<string, string> }) {
const { party, encounter, map, handout } = snapshot; 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 handoutImage = handout?.imageId ? images?.[handout.imageId] : undefined;
const privateHandout = usePlayerSessionStore((s) => s.privateHandout); const privateHandout = usePlayerSessionStore((s) => s.privateHandout);
return ( return (
@@ -17,7 +21,7 @@ export function PlayerBoards({ snapshot, image, images }: { snapshot: Snapshot;
{privateHandout && ( {privateHandout && (
<section className="paper-grain mb-6 rounded-xl border border-info/60 bg-info/5 p-4"> <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"> <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> </div>
<h2 className="font-display text-2xl font-bold text-ink">{privateHandout.title || 'A private note'}</h2> <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>} {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> <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 && ( {c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => ( {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> 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>
)} )}
</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')}> <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="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="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) => ( {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> <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> </section>
</div> </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 { useUiStore } from '@/stores/uiStore';
import { useSessionStore } from '@/stores/sessionStore'; import { useSessionStore } from '@/stores/sessionStore';
import { usePlayerSessionStore } from '@/stores/playerSessionStore'; 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 { useEncounters } from '@/features/combat/hooks';
import { useCalendar, useMaps } from '@/features/world/hooks'; import { useCalendar, useMaps } from '@/features/world/hooks';
import { Maximize2 } from 'lucide-react';
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { PlayerBoards } from './PlayerBoards'; import { PlayerBoards } from './PlayerBoards';
import { SeatClaimScreen } from './SeatClaimScreen'; import { SeatClaimScreen } from './SeatClaimScreen';
import { MyCharacterPanel } from './MyCharacterPanel'; 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> <h1 className="font-display text-3xl font-semibold tracking-tight text-ink">{title}</h1>
<p className="mt-1 text-sm text-muted">{subtitle}</p> <p className="mt-1 text-sm text-muted">{subtitle}</p>
</div> </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> </div>
<hr className="gilt-rule mt-3" /> <hr className="gilt-rule mt-3" />
</div> </div>
@@ -87,19 +90,40 @@ function NetworkedPlayerView() {
const myCharacter = usePlayerSessionStore((s) => s.myCharacter); const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
const seatStatus = usePlayerSessionStore((s) => s.seatStatus); const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
const [needPw, setNeedPw] = useState(false); 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 partyIdsKey = snapshot ? snapshot.party.map((p) => p.id).join(',') : '';
const localList = useLiveQuery(() => charactersRepo.getMany(partyIdsKey ? partyIdsKey.split(',') : []), [partyIdsKey], [] as Character[]); 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(() => { useEffect(() => {
if (error?.toLowerCase().includes('password') && !needPw) { if (error?.toLowerCase().includes('password')) setNeedPw(true);
setNeedPw(true); }, [error]);
const pw = window.prompt('This session needs a password:')?.trim(); const submitPassword = () => {
if (pw) { useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw }); setNeedPw(false); } const pw = pwInput.trim();
} if (!pw) return;
}, [error, needPw, room]); 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 handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
const handlePatch = (diff: Partial<Character>) => { const handlePatch = (diff: Partial<Character>) => {
@@ -113,6 +137,7 @@ function NetworkedPlayerView() {
return ( return (
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}> <Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting</p>} {error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting</p>}
{passwordModal}
</Shell> </Shell>
); );
} }
@@ -122,7 +147,7 @@ function NetworkedPlayerView() {
{myCharacter && seatStatus === 'granted' ? ( {myCharacter && seatStatus === 'granted' ? (
<MyCharacterPanel character={myCharacter} onPatch={handlePatch} /> <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 } : {})} /> <PlayerBoards snapshot={snapshot} images={images} {...(image ? { image } : {})} />
<RollFeed /> <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 * party. If a locally-developed copy exists, its offline edits ride along for
* the GM to review. * the GM to review.
*/ */
export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: { export function SeatClaimScreen({ snapshot, localChars, ownCharacters = [], pending, onClaim }: {
snapshot: Snapshot; snapshot: Snapshot;
localChars: Map<string, Character>; localChars: Map<string, Character>;
/** the player's own local characters — they can push one the GM hasn't added yet */
ownCharacters?: Character[];
pending: boolean; pending: boolean;
onClaim: (characterId: string) => void; onClaim: (characterId: string) => void;
}) { }) {
if (snapshot.party.length === 0) { const partyIds = new Set(snapshot.party.map((p) => p.id));
return <EmptyState title="No characters yet" hint="The GM hasn't added any player characters to this campaign." />; 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 ( return (
<section className="paper-grain mb-6 rounded-xl border border-line bg-panel p-4"> <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> <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> <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" /> <hr className="gilt-rule my-3" />
{snapshot.party.length > 0 && (
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{snapshot.party.map((c) => { {snapshot.party.map((c) => {
const hasOffline = localChars.has(c.id); const hasOffline = localChars.has(c.id);
@@ -41,6 +48,27 @@ export function SeatClaimScreen({ snapshot, localChars, pending, onClaim }: {
); );
})} })}
</div> </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>} {pending && <p className="mt-3 text-sm text-accent">Waiting for the GM to approve</p>}
</section> </section>
); );
+1 -1
View File
@@ -40,7 +40,7 @@ export function AssistantSettings() {
return ( return (
<section className="mb-8"> <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"> <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, 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 level-up routes). The assistant always works without this it falls back to deterministic
+36 -36
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks'; import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db'; import { db } from '@/lib/db/db';
import { cloudUsername, CloudError } from '@/lib/cloud/client'; 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, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns';
import { useCampaigns } from '@/features/campaigns/hooks'; import { useCampaigns } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
@@ -21,13 +22,10 @@ export function CloudCampaigns() {
const [targetCloud, setTargetCloud] = useState(''); const [targetCloud, setTargetCloud] = useState('');
const [viewing, setViewing] = useState<string | null>(null); const [viewing, setViewing] = useState<string | null>(null);
const [party, setParty] = useState<CloudCharInfo[]>([]); const [party, setParty] = useState<CloudCharInfo[]>([]);
const [usage, setUsage] = useState<{ bytes: number; admin: boolean } | null>(null); const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null);
const [admins, setAdmins] = useState<AdminUserRow[]>([]);
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]); useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]); useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]);
const loadAdmin = () => adminListUsers().then((r) => setAdmins(r.users)).catch(() => {});
useEffect(() => { if (usage?.admin) loadAdmin(); }, [usage?.admin]);
const refresh = () => listCloudCampaigns().then(setList).catch(() => {}); const refresh = () => listCloudCampaigns().then(setList).catch(() => {});
const run = (fn: () => Promise<void>) => async () => { const run = (fn: () => Promise<void>) => async () => {
@@ -38,7 +36,7 @@ export function CloudCampaigns() {
if (!signedIn) { if (!signedIn) {
return ( return (
<section className="rounded-lg border border-line bg-panel p-4"> <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> <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> </section>
); );
@@ -48,7 +46,7 @@ export function CloudCampaigns() {
return ( return (
<section className="space-y-4 rounded-lg border border-line bg-panel p-4"> <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 && ( {list.length > 0 && (
<ul className="space-y-2"> <ul className="space-y-2">
@@ -60,12 +58,24 @@ export function CloudCampaigns() {
{c.inviteCode && ( {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> <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>} {c.role === 'owner' && <Button size="sm" variant="ghost" onClick={run(async () => { setViewing(c.id); setParty(await listCloudCharacters(c.id)); })}>View party</Button>}
</div> </div>
{viewing === c.id && ( {viewing === c.id && (
<ul className="mt-1 space-y-0.5 border-t border-line pt-1 text-xs"> <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) => ( {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> </ul>
)} )}
@@ -110,24 +120,26 @@ export function CloudCampaigns() {
</div> </div>
</div> </div>
{usage && <p className="text-xs text-muted">Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span></p>} {usage && (() => {
const pct = usage.quota > 0 ? Math.min(100, Math.round((usage.bytes / usage.quota) * 100)) : 0;
const near = pct >= 90;
return (
<div className="text-xs">
<p className={near ? 'text-warning' : 'text-muted'}>
Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span> of {fmtBytes(usage.quota)} ({pct}%)
{near && ' — almost full; trim old data or ask the admin to raise your quota.'}
</p>
<div className="mt-1 h-1.5 w-full max-w-xs overflow-hidden rounded-full bg-elevated">
<div className={near ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} />
</div>
</div>
);
})()}
{usage?.admin && ( {usage?.admin && (
<div className="rounded-md border border-accent/30 bg-accent/5 p-2"> <div className="rounded-md border border-accent/30 bg-accent/5 p-2 text-sm">
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Admin · users &amp; storage</h3> Youre the instance admin manage accounts, storage, campaigns, and live rooms in the{' '}
<table className="w-full text-xs"> <Link to="/admin" className="text-accent underline">Admin panel</Link>.
<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>
{admins.map((u) => (
<tr key={u.username} className="border-t border-line">
<td className="py-1 text-ink">{u.username}</td>
<td className="py-1 text-muted">{fmtBytes(u.usageBytes)}</td>
<td className="py-1"><QuotaCell user={u} onSaved={loadAdmin} /></td>
</tr>
))}
</tbody>
</table>
<p className="mt-1 text-[11px] text-muted">Quotas are tracked but not yet enforced a safety valve for later.</p>
</div> </div>
)} )}
@@ -142,15 +154,3 @@ function fmtBytes(b: number): string {
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`; if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
return `${Math.max(0, Math.round(b / 1024))} KB`; return `${Math.max(0, Math.round(b / 1024))} KB`;
} }
function QuotaCell({ user, onSaved }: { user: AdminUserRow; onSaved: () => void }) {
const [gb, setGb] = useState(String(user.quotaBytes ? (user.quotaBytes / 1e9).toFixed(0) : '0'));
const [saving, setSaving] = useState(false);
const save = async () => { setSaving(true); try { await adminSetQuota(user.username, (Number(gb) || 0) * 1e9); onSaved(); } catch { /* ignore */ } finally { setSaving(false); } };
return (
<span className="flex items-center gap-1">
<input value={gb} onChange={(e) => setGb(e.target.value.replace(/[^0-9]/g, ''))} className="w-12 rounded border border-line bg-surface px-1 py-0.5 text-xs" aria-label={`Quota GB for ${user.username}`} />
<Button size="sm" variant="ghost" disabled={saving} onClick={() => void save()}>Set</Button>
</span>
);
}
@@ -0,0 +1,49 @@
import { Wand2 } from 'lucide-react';
import { useDirectorStore } from '@/stores/directorStore';
import { Input, Field } from '@/components/ui/Input';
/** Tuning for the AI DM / AI Player. It reuses the assistant's AI key (Assistant
* section above) and never rolls dice or edits a sheet without your approval. */
export function DirectorSettings() {
const narrationStyle = useDirectorStore((s) => s.narrationStyle);
const windowSize = useDirectorStore((s) => s.windowSize);
const setConfig = useDirectorStore((s) => s.setConfig);
return (
<section className="rounded-xl border border-line bg-panel p-4 sm:p-5">
<h2 className="smallcaps text-[11px] text-muted">AI Director</h2>
<hr className="gilt-rule mt-2 mb-1" />
<div className="flex flex-wrap items-center gap-3 py-4">
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-surface-2 text-accent-deep" aria-hidden>
<Wand2 size={18} />
</span>
<div className="min-w-0 flex-1">
<div className="font-display text-[15px] font-semibold text-ink">AI Dungeon Master &amp; Player</div>
<div className="mt-0.5 text-xs text-faint">
Runs scenes and pilots characters, grounded in your campaign data. It uses the Assistant AI key above; without
one it falls back to a deterministic mode. It never auto-rolls dice and never changes a sheet without your
approval.
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Narration style">
<Input
value={narrationStyle}
placeholder="e.g. grim and terse; whimsical; cinematic"
onChange={(e) => setConfig({ narrationStyle: e.target.value })}
/>
</Field>
<Field label="Transcript memory (turns)">
<Input
type="number"
min={4}
max={60}
value={windowSize}
onChange={(e) => setConfig({ windowSize: Math.max(4, Math.min(60, Number(e.target.value) || 16)) })}
/>
</Field>
</div>
</section>
);
}
+27 -3
View File
@@ -18,6 +18,7 @@ import { pickTextFile } from '@/lib/io/file';
import { seedSampleCampaign } from '@/lib/sample'; import { seedSampleCampaign } from '@/lib/sample';
import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client'; import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
import { AssistantSettings } from './AssistantSettings'; import { AssistantSettings } from './AssistantSettings';
import { DirectorSettings } from './DirectorSettings';
import { CloudCampaigns } from './CloudCampaigns'; import { CloudCampaigns } from './CloudCampaigns';
import { Page, PageHeader } from '@/components/ui/Page'; import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -125,6 +126,8 @@ export function SettingsPage() {
<AssistantSettings /> <AssistantSettings />
<DirectorSettings />
<CloudSync /> <CloudSync />
<CloudCampaigns /> <CloudCampaigns />
@@ -187,6 +190,7 @@ function CloudSync() {
const [msg, setMsg] = useState<string | null>(null); const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [confirmPull, setConfirmPull] = useState(false); const [confirmPull, setConfirmPull] = useState(false);
const [syncConflict, setSyncConflict] = useState(false);
const run = async (fn: () => Promise<void>) => { const run = async (fn: () => Promise<void>) => {
setBusy(true); setMsg(null); setBusy(true); setMsg(null);
@@ -196,7 +200,18 @@ function CloudSync() {
const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p)); const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p));
setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.'); 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 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); }); const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
@@ -212,8 +227,8 @@ function CloudSync() {
{user ? ( {user ? (
<div className="space-y-3 pt-4"> <div className="space-y-3 pt-4">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="primary" disabled={busy} onClick={push}> Back up to 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)}> Restore from 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> <Button variant="ghost" disabled={busy} onClick={signOut}>Sign out</Button>
</div> </div>
</div> </div>
@@ -231,6 +246,15 @@ function CloudSync() {
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}> 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> <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>
<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> </SettingsCard>
); );
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { X } from 'lucide-react';
import type { Calendar, Campaign } from '@/lib/schemas'; import type { Calendar, Campaign } from '@/lib/schemas';
import { calendarRepo } from '@/lib/db/repositories'; import { calendarRepo } from '@/lib/db/repositories';
import { newId } from '@/lib/ids'; 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"> <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={'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> <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> </li>
))} ))}
</ul> </ul>
+21 -17
View File
@@ -1,6 +1,9 @@
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks'; 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 type { Campaign, Character, Npc, Quest, DiceRoll } from '@/lib/schemas';
import { diceRepo } from '@/lib/db/repositories'; import { diceRepo } from '@/lib/db/repositories';
import { getSystem } from '@/lib/rules'; import { getSystem } from '@/lib/rules';
@@ -15,20 +18,21 @@ export function DashboardPage() {
return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>; return <RequireCampaign>{(c) => <Dashboard campaign={c} />}</RequireCampaign>;
} }
const LINKS = [ type NavIcon = React.ComponentType<{ size?: number; className?: string; 'aria-hidden'?: boolean | 'true' | 'false' }>;
{ to: '/assistant', label: 'Assistant', icon: '🧠' }, const LINKS: { to: string; label: string; icon: NavIcon }[] = [
{ to: '/characters', label: 'Characters', icon: '🧝' }, { to: '/assistant', label: 'Assistant', icon: Brain },
{ to: '/combat', label: 'Combat', icon: '⚔' }, { to: '/characters', label: 'Characters', icon: Users },
{ to: '/notes', label: 'Notes & Wiki', icon: '📜' }, { to: '/combat', label: 'Combat', icon: Swords },
{ to: '/npcs', label: 'NPCs', icon: '🎭' }, { to: '/notes', label: 'Notes & Wiki', icon: ScrollText },
{ to: '/quests', label: 'Quests', icon: '🗺' }, { to: '/npcs', label: 'NPCs', icon: VenetianMask },
{ to: '/maps', label: 'Maps', icon: '🗺️' }, { to: '/quests', label: 'Quests', icon: Target },
{ to: '/calendar', label: 'Calendar', icon: '📅' }, { to: '/maps', label: 'Maps', icon: Map },
{ to: '/compendium', label: 'Compendium', icon: '📚' }, { to: '/calendar', label: 'Calendar', icon: CalendarDays },
{ to: '/homebrew', label: 'Homebrew', icon: '🛠️' }, { to: '/compendium', label: 'Compendium', icon: Library },
{ to: '/dice', label: 'Dice', icon: '🎲' }, { to: '/homebrew', label: 'Homebrew', icon: Hammer },
{ to: '/play', label: 'Player View', icon: '📺' }, { to: '/dice', label: 'Dice', icon: Dice5 },
] as const; { to: '/play', label: 'Player View', icon: Monitor },
];
function Dashboard({ campaign }: { campaign: Campaign }) { function Dashboard({ campaign }: { campaign: Campaign }) {
const characters = useCharacters(campaign.id); const characters = useCharacters(campaign.id);
@@ -84,7 +88,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
to={l.to} 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" 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> <span className="text-sm text-ink">{l.label}</span>
</Link> </Link>
))} ))}
@@ -159,7 +163,7 @@ function Dashboard({ campaign }: { campaign: Campaign }) {
} }
function PartyRow({ c, sys }: { c: Character; sys: ReturnType<typeof getSystem> }) { 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 ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined; const tone = ratio < 0.35 ? 'var(--app-danger)' : undefined;
return ( return (
+7 -2
View File
@@ -1,4 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { X } from 'lucide-react';
import type { Campaign, Homebrew, HomebrewKind } from '@/lib/schemas'; import type { Campaign, Homebrew, HomebrewKind } from '@/lib/schemas';
import { homebrewSchema } from '@/lib/schemas'; import { homebrewSchema } from '@/lib/schemas';
import { homebrewRepo } from '@/lib/db/repositories'; import { homebrewRepo } from '@/lib/db/repositories';
@@ -8,6 +10,7 @@ import { HOMEBREW_FIELDS, HOMEBREW_KIND_LABEL } from './homebrew';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input'; import { Input, Select } from '@/components/ui/Input';
import { useConfirm } from '@/components/ui/useConfirm';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback'; import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
const KINDS: HomebrewKind[] = ['monster', 'spell', 'item', 'feat', 'condition']; const KINDS: HomebrewKind[] = ['monster', 'spell', 'item', 'feat', 'condition'];
@@ -77,7 +80,7 @@ function HomebrewView({ campaign }: { campaign: Campaign }) {
if (list.length === 0) return null; if (list.length === 0) return null;
return ( return (
<section key={k}> <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"> <div className="grid gap-3 md:grid-cols-2">
{list.map((hb) => <HomebrewCard key={hb.id} hb={hb} />)} {list.map((hb) => <HomebrewCard key={hb.id} hb={hb} />)}
</div> </div>
@@ -92,6 +95,7 @@ function HomebrewView({ campaign }: { campaign: Campaign }) {
function HomebrewCard({ hb }: { hb: Homebrew }) { function HomebrewCard({ hb }: { hb: Homebrew }) {
const [h, setH] = useState(hb); const [h, setH] = useState(hb);
const { confirm, confirmElement } = useConfirm();
// Save the full snapshot (not partial patches) so editing several fields // Save the full snapshot (not partial patches) so editing several fields
// quickly doesn't drop earlier edits under the debounce. // quickly doesn't drop earlier edits under the debounce.
const save = useDebouncedCallback((next: Homebrew) => void homebrewRepo.update(next.id, { name: next.name, fields: next.fields, description: next.description }), 350); const save = useDebouncedCallback((next: Homebrew) => void homebrewRepo.update(next.id, { name: next.name, fields: next.fields, description: next.description }), 350);
@@ -102,8 +106,9 @@ function HomebrewCard({ hb }: { hb: Homebrew }) {
<div className="rounded-lg border border-line bg-panel p-4"> <div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2"> <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" /> <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={async () => { if (await confirm({ title: 'Delete entry?', message: <>Delete <strong className="text-ink">{h.name || 'this entry'}</strong>? This cant be undone.</> })) void homebrewRepo.remove(hb.id); }} aria-label={`Delete ${h.name}`}><X size={14} aria-hidden /></Button>
</div> </div>
{confirmElement}
{HOMEBREW_FIELDS[hb.kind].length > 0 && ( {HOMEBREW_FIELDS[hb.kind].length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2"> <div className="mt-2 grid grid-cols-2 gap-2">
{HOMEBREW_FIELDS[hb.kind].map((f) => ( {HOMEBREW_FIELDS[hb.kind].map((f) => (
+4 -1
View File
@@ -10,6 +10,7 @@ import { useUiStore } from '@/stores/uiStore';
import { useMaps } from './hooks'; import { useMaps } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { useConfirm } from '@/components/ui/useConfirm';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { MapEditor } from './map/MapEditor'; import { MapEditor } from './map/MapEditor';
@@ -22,6 +23,7 @@ export function MapsPage() {
function Maps({ campaign }: { campaign: Campaign }) { function Maps({ campaign }: { campaign: Campaign }) {
const maps = useMaps(campaign.id); const maps = useMaps(campaign.id);
const { confirm, confirmElement } = useConfirm();
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
@@ -92,13 +94,14 @@ function Maps({ campaign }: { campaign: Campaign }) {
<span className="truncate font-display font-medium">{mp.name}</span> <span className="truncate font-display font-medium">{mp.name}</span>
{activeMapId === mp.id && <Cast size={13} className="ml-auto shrink-0 text-accent-deep" aria-hidden />} {activeMapId === mp.id && <Cast size={13} className="ml-auto shrink-0 text-accent-deep" aria-hidden />}
</button> </button>
<button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); }} aria-label={`Delete ${mp.name}`}> <button className="px-2 text-muted opacity-0 hover:text-danger group-hover:opacity-100" onClick={async () => { if (await confirm({ title: 'Delete map?', message: <>Delete <strong className="text-ink">{mp.name || 'this map'}</strong>? This cant be undone.</> })) { await mapsRepo.remove(mp.id); if (selectedId === mp.id) setSelectedId(null); if (activeMapId === mp.id) setActiveMap(null); } }} aria-label={`Delete ${mp.name}`}>
<X size={14} aria-hidden /> <X size={14} aria-hidden />
</button> </button>
</li> </li>
); );
})} })}
</ul> </ul>
{confirmElement}
{selected ? ( {selected ? (
<div> <div>
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2"> <div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2">
+6 -3
View File
@@ -7,6 +7,7 @@ import { useNotes } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { useConfirm } from '@/components/ui/useConfirm';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
export function NotesPage() { export function NotesPage() {
@@ -98,6 +99,7 @@ function NoteEditor({
const [title, setTitle] = useState(note.title); const [title, setTitle] = useState(note.title);
const [body, setBody] = useState(note.body); const [body, setBody] = useState(note.body);
const [tags, setTags] = useState(note.tags.join(', ')); const [tags, setTags] = useState(note.tags.join(', '));
const { confirm, confirmElement } = useConfirm();
// Save the full snapshot so editing title + body quickly doesn't drop an edit. // Save the full snapshot so editing title + body quickly doesn't drop an edit.
const save = useDebouncedCallback((next: { title: string; body: string; tags: string }) => { const save = useDebouncedCallback((next: { title: string; body: string; tags: string }) => {
@@ -127,11 +129,12 @@ function NoteEditor({
<Button <Button
variant="ghost" variant="ghost"
className="text-danger" className="text-danger"
onClick={async () => { await notesRepo.remove(note.id); onSelect(null); }} onClick={async () => { if (await confirm({ title: 'Delete note?', message: <>Delete <strong className="text-ink">{title || 'this note'}</strong>? This cant be undone.</> })) { await notesRepo.remove(note.id); onSelect(null); } }}
> >
Delete Delete
</Button> </Button>
</div> </div>
{confirmElement}
<label className="mb-3 block text-xs text-muted"> <label className="mb-3 block text-xs text-muted">
Tags (comma-separated) Tags (comma-separated)
@@ -151,7 +154,7 @@ function NoteEditor({
{/* Rendered preview with clickable links */} {/* Rendered preview with clickable links */}
<div className="mt-4"> <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"> <p className="whitespace-pre-wrap rounded-md border border-line bg-surface p-3 text-sm text-ink">
{splitWikiLinks(body).map((part, i) => {splitWikiLinks(body).map((part, i) =>
part.link ? ( part.link ? (
@@ -172,7 +175,7 @@ function NoteEditor({
{backlinks.length > 0 && ( {backlinks.length > 0 && (
<div className="mt-4"> <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"> <div className="flex flex-wrap gap-1">
{backlinks.map((n) => ( {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"> <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 { useState } from 'react';
import { Wand2, X } from 'lucide-react';
import type { Campaign, Npc } from '@/lib/schemas'; import type { Campaign, Npc } from '@/lib/schemas';
import { npcsRepo } from '@/lib/db/repositories'; 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 { useDebouncedCallback } from '@/lib/useDebouncedCallback';
import { useNpcs } from './hooks'; import { useNpcs } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; 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" /> <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"> <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> </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 [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, { 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, name: next.name, role: next.role, location: next.location, faction: next.faction, status: next.status, description: next.description,
}), 350); }), 350);
const update = (patch: Partial<Npc>) => setN((p) => { const next = { ...p, ...patch }; save(next); return next; }); 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 ( return (
<div className="rounded-lg border border-line bg-panel p-4"> <div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2"> <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"> <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>)} {STATUS.map((s) => <option key={s} value={s}>{s}</option>)}
</Select> </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>
<div className="mt-2 grid grid-cols-3 gap-2"> <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" /> <Input className="text-xs" value={n.role} onChange={(e) => update({ role: e.target.value })} placeholder="Role" aria-label="Role" />

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