41 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
135 changed files with 11645 additions and 643 deletions
+3
View File
@@ -11,3 +11,6 @@ test-results
.vscode/*
!.vscode/extensions.json
.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
# comma-separated usernames that can see the admin panel (set to your account)
- 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:
- ttrpg-data:/data
security_opt:
- 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:
- "traefik.enable=true"
- "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
+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();
});
+20 -2
View File
@@ -2,8 +2,9 @@
"""Parse MPMB all_WotC_published.js into structured JSON files"""
import re, json, os
INPUT = "/home/nilsb/Downloads/all_WotC_published.js"
OUT_DIR = "/home/nilsb/Documents/Projects/TTRPG_manager/src/assets/srd"
INPUT = os.environ.get("MPMB_INPUT", "/home/nilsb/Downloads/all_WotC_published.js")
# 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):
with open(filepath, 'r', encoding='utf-8') as f:
@@ -87,6 +88,16 @@ def safe_extract_fields(obj_str):
'description': r'description\s*:\s*["\']([^"]+)["\']',
'weight': r'weight\s*:\s*(-?\d+)',
'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():
m = re.search(pattern, obj_str)
@@ -94,6 +105,13 @@ def safe_extract_fields(obj_str):
val = m.group(1)
if field == 'weight':
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':
# Parse [[SourceName, page], [SourceName2, page2]]
sources = re.findall(r'\["([^"]+)",?\s*(\d+)?\]', val)
+52
View File
@@ -54,4 +54,56 @@ describe('AccountStore', () => {
expect(await adminStore.setQuota('ghost', 1)).toBe(false);
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);
});
});
+84 -14
View File
@@ -1,7 +1,12 @@
import crypto from 'node:crypto';
import { promisify } from 'node:util';
import { promises as fs } from 'node:fs';
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
* 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 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 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 {
const ab = Buffer.from(a), bb = Buffer.from(b);
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 {
private users = new Map<string, User>(); // key: lowercased username
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 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 blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
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 }>> {
await this.load();
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> {
await this.load();
const u = this.users.get(username.toLowerCase());
@@ -72,8 +124,15 @@ export class AccountStore {
try {
const raw = await fs.readFile(this.usersFile(), 'utf8');
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); }
} catch { /* no file yet */ }
for (const u of arr) {
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> {
@@ -82,14 +141,19 @@ export class AccountStore {
const tmp = `${this.usersFile()}.tmp`;
await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
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>;
}
private issueToken(u: User): string {
const token = crypto.randomBytes(32).toString('base64url');
u.tokens.push(sha256(token));
if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS);
const h = sha256(token);
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();
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 (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.atCapacity()) return { ok: false, code: 'closed', message: 'Registration is temporarily closed (instance at capacity).' };
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);
this.users.set(username.toLowerCase(), u);
this.byId.set(u.id, u);
@@ -111,7 +176,11 @@ export class AccountStore {
async login(username: string, password: string): Promise<AuthResult | AuthError> {
await this.load();
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);
await this.persist();
return { ok: true, token, username: u.username };
@@ -120,15 +189,16 @@ export class AccountStore {
async userByToken(token: string | undefined): Promise<User | null> {
if (!token) return null;
await this.load();
const h = sha256(token);
for (const u of this.users.values()) if (u.tokens.includes(h)) return u;
return null;
return this.byToken.get(sha256(token)) ?? null;
}
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;
u.tokens = u.tokens.filter((t) => t !== sha256(token));
u.tokens = u.tokens.filter((t) => t !== h);
this.byToken.delete(h);
await this.persist();
}
+38 -1
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { CloudStore } from './campaigns';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
describe('CloudStore', () => {
let dir: string;
@@ -42,6 +42,43 @@ describe('CloudStore', () => {
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 () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
+72 -2
View File
@@ -32,6 +32,9 @@ export interface CloudCharacter {
}
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 {
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[] };
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);
} catch { /* no file yet */ }
} catch (e) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[cloud] load failed', e);
}
}
private persist(): Promise<void> {
@@ -59,7 +64,7 @@ export class CloudStore {
const tmp = `${this.file()}.tmp`;
await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] }));
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>;
}
@@ -140,6 +145,7 @@ export class CloudStore {
async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise<CloudCharacter | null> {
await this.load();
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);
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() };
@@ -163,6 +169,70 @@ export class CloudStore {
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. */
async usageBytes(userId: string): Promise<number> {
await this.load();
+154 -24
View File
@@ -4,17 +4,22 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { promises as fs } from 'node:fs';
import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts';
import { CloudStore } from './campaigns';
import { AccountStore, DEFAULT_QUOTA_BYTES } from './accounts';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
const PORT = Number(process.env.PORT ?? 8787);
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 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 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 h = req.headers.authorization;
@@ -29,30 +34,59 @@ const RATE = { capacity: 80, refillMs: 10_000 };
const HEARTBEAT_MS = 30_000;
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 accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS);
const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS, MAX_USERS);
void accounts.load();
const cloud = new CloudStore(DATA_DIR);
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));
// 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 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) => {
if (!req.url.startsWith('/api/')) return;
const now = Date.now();
const e = ipHits.get(req.ip);
if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 });
else if (++e.n > 60) await reply.code(429).send({ error: 'rate' });
if (!bump(ipHits, req.ip, 60, 60_000)) return reply.code(429).send({ error: 'rate' });
// Strip the querystring before matching the auth path.
const path0 = req.url.split('?')[0]!;
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 ----
app.post('/api/register', async (req, reply) => {
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
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 };
});
app.post('/api/login', async (req, reply) => {
@@ -67,6 +101,11 @@ export function buildServer() {
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
// Quota: the backup blob plus the user's published characters must fit their limit.
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).
@@ -121,8 +160,15 @@ export function buildServer() {
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 } };
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 });
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 };
});
app.get('/api/campaigns/:id/characters', async (req, reply) => {
@@ -141,31 +187,110 @@ export function buildServer() {
app.get('/api/usage', async (req, reply) => {
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));
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) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
const users = await accounts.listUsers();
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const users = await accounts.listUsersDetailed();
const rows = await Promise.all(users.map(async (x) => ({
username: x.username,
admin: x.admin,
createdAt: x.createdAt,
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) };
});
app.post('/api/admin/quota', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number };
const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0);
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 } });
// Live WebSocket connections per client IP — a hard cap on socket-spam.
const wsPerIp = new Map<string, number>();
void app.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket, req) => {
// Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
@@ -174,6 +299,11 @@ export function buildServer() {
socket.close(1008, 'origin');
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 */ } } };
let tokens = RATE.capacity;
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
@@ -195,7 +325,7 @@ export function buildServer() {
const m = parsed.data;
switch (m.t) {
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 'image': hub.image(sender, m.gmSecret, m.id, m.dataUrl); break;
case 'requestImage': hub.requestImage(sender, m.id); break;
@@ -209,11 +339,11 @@ export function buildServer() {
case 'setName': hub.setName(sender, m.name); break;
}
});
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); 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).
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
+78
View File
@@ -92,6 +92,31 @@ describe('RoomHub', () => {
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', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
@@ -128,6 +153,59 @@ describe('RoomHub', () => {
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', () => {
const hub = new RoomHub();
const { gm, player } = hostAndJoin(hub);
+85 -9
View File
@@ -10,8 +10,15 @@ interface Seat {
sender: Sender;
characterId: 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 {
playerId: string;
characterId: string;
@@ -27,6 +34,8 @@ interface Room {
players: Set<Sender>;
snapshot: Snapshot | null;
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 */
seats: Map<string, Seat>;
/** 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 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 {
return crypto.createHash('sha256').update(s).digest('hex');
@@ -55,7 +71,10 @@ export class RoomHub {
private rooms = new Map<string, Room>();
private byCode = new Map<string, 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 {
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 joinCode = this.mintJoinCode();
const gmSecret = crypto.randomBytes(32).toString('base64url');
const room: Room = {
roomId, joinCode, gmSecretHash: sha256(gmSecret),
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(),
};
this.rooms.set(roomId, room);
@@ -98,7 +123,7 @@ export class RoomHub {
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 room = roomId ? this.rooms.get(roomId) : undefined;
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.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 });
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);
}
@@ -125,7 +166,17 @@ export class RoomHub {
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
const room = this.gmRoom(socket, gmSecret);
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.imageBytes += size - prevSize;
room.lastActivity = this.now();
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
}
@@ -158,7 +209,7 @@ export class RoomHub {
let target: Sender | undefined;
for (const [s, c] of this.conns) { if (c.roomId === room.roomId && c.playerId === targetPlayerId) { target = s; break; } }
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 });
this.sendRoster(room);
}
@@ -268,23 +319,48 @@ export class RoomHub {
if (room) {
if (conn.role === 'gm' && room.gm === socket) room.gm = null;
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).
this.sendRoster(room);
}
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 {
const cutoff = this.now() - ROOM_TTL_MS;
const now = this.now();
const cutoff = now - ROOM_TTL_MS;
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; }
/** 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 {
const conn = this.conns.get(socket);
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>
);
}
+9 -2
View File
@@ -3,7 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import {
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, ShieldCheck,
type LucideIcon,
} from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
@@ -24,6 +24,7 @@ import { SignalsBell } from '@/features/assistant/SignalsBell';
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
import { SessionSidebar } from '@/features/play/SessionSidebar';
import { useSessionStore } from '@/stores/sessionStore';
import { useIsAdmin } from '@/features/admin/useIsAdmin';
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' };
const NAV: NavItem[] = [
@@ -31,6 +32,7 @@ const NAV: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, 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: '/director', label: 'AI Director', icon: Wand2, exact: false, group: 'play' },
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
{ to: '/notes', label: 'Notes', icon: ScrollText, exact: false, group: 'world' },
@@ -99,6 +101,11 @@ export function RootLayout() {
useSessionBroadcaster(activeCampaign ?? null);
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.
useEffect(() => {
@@ -147,7 +154,7 @@ export function RootLayout() {
{GROUPS.map((group) => (
<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>}
{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 Ico = item.icon;
return (
+35 -7
View File
@@ -1,6 +1,12 @@
import { useState } from 'react';
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({
value,
onChange,
@@ -16,21 +22,43 @@ export function NumberField({
className?: 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 (
<input
type="number"
inputMode="numeric"
aria-label={ariaLabel}
value={Number.isFinite(value) ? value : 0}
value={display}
min={min}
max={max}
onFocus={(e) => setDraft(e.target.value)}
onChange={(e) => {
const raw = Number(e.target.value);
let n = Number.isFinite(raw) ? Math.trunc(raw) : 0;
if (min !== undefined) n = Math.max(min, n);
if (max !== undefined) n = Math.min(max, n);
onChange(n);
const raw = e.target.value;
setDraft(raw);
// Emit a clamped value live (so previews update) only when the text parses;
// an empty/"-" intermediate leaves the committed value untouched.
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(
'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',
+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;
}
@@ -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;
}
@@ -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>
);
}
+139 -36
View File
@@ -2,9 +2,9 @@ import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, Check, Shield, Gauge, Footprints, Award } from 'lucide-react';
import type { Character } from '@/lib/schemas';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank, SystemId } from '@/lib/rules';
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
import { allArmorMechanics5e, type ArmorMechanics } from '@/lib/mechanics';
import type { AbilityKey, AbilityScores, CharacterRulesInput, ProficiencyRank, SystemId } 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 { encodeClaim } from '@/lib/sync/playerLink';
import { fileToDataUrl, squareThumbnail } from '@/lib/img/resize';
@@ -19,32 +19,32 @@ import { Badge, Meter } from '@/components/ui/Codex';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
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 { AttacksSection } from './sheet/AttacksSection';
import { ResourcesSection } from './sheet/ResourcesSection';
import { SpellcastingSection } from './sheet/SpellcastingSection';
import { DefensesSection } from './sheet/DefensesSection';
import { FeatsSection } from './sheet/FeatsSection';
import { ClassFeaturesSection } from './sheet/ClassFeaturesSection';
import { AbilityGenModal } from './sheet/AbilityGenModal';
import { SheetAbilityTable } from './AbilityScoreTable';
import { LevelUpModal } from './sheet/LevelUpModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
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 }) {
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
const [c, setC] = useState<Character>(character);
const campaigns = useCampaigns();
const [levelUp, setLevelUp] = useState(false);
const [genScores, setGenScores] = useState(false);
const [showAbilityTable, setShowAbilityTable] = 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).
@@ -106,6 +106,21 @@ export function CharacterSheet({ character }: { character: Character }) {
...(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 initiative = sys.initiativeModifier(rulesInput);
const skills = sys.skillModifiers(rulesInput);
@@ -174,15 +189,33 @@ export function CharacterSheet({ character }: { character: Character }) {
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
<Labeled label="Class">
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
</Labeled>
<Labeled label="Level">
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
</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">
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
</Labeled>
<Labeled label="Level">
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
</Labeled>
</>
)}
<Labeled label="Speed">
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
</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>
{/* Vital stats */}
@@ -206,29 +239,59 @@ export function CharacterSheet({ character }: { character: Character }) {
<section className="mb-6">
<div className="mb-3 flex items-center justify-between">
<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">
{/* Compact at-a-glance row */}
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]);
const bd = abilityBuildView[a];
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">
<div className="smallcaps text-[10px]">{ABILITY_ABBR[a]}</div>
<div className="font-mono font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
<NumberField
className="mt-1"
value={c.abilities[a]}
min={1}
max={30}
aria-label={`${ABILITY_ABBR[a]} score`}
onChange={(v) => update({ abilities: { ...c.abilities, [a]: v } })}
/>
</div>
<button
key={a}
type="button"
onClick={() => setShowAbilityTable(true)}
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"
>
<div className="smallcaps text-[10px] text-muted">{ABILITY_ABBR[a]}</div>
<div className="font-display text-2xl font-semibold leading-none text-accent-deep">{formatModifier(mod)}</div>
<div className="font-mono text-sm text-ink">{c.abilities[a]}</div>
{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>
)}
</button>
);
})}
</div>
</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">
@@ -307,11 +370,36 @@ export function CharacterSheet({ character }: { character: Character }) {
<ResourcesSection c={c} update={update} />
{/* Feats & features */}
<ClassFeaturesSection c={c} update={update} />
<FeatsSection c={c} update={update} />
{/* Inventory, currency, encumbrance */}
<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 */}
<section className="mb-6">
<SectionTitle>Notes</SectionTitle>
@@ -324,7 +412,7 @@ export function CharacterSheet({ character }: { character: Character }) {
</section>
{genScores && (
<AbilityGenModal onClose={() => setGenScores(false)} onApply={(abilities) => update({ abilities })} />
<AbilityGenModal onClose={() => setGenScores(false)} onApply={applyGeneratedScores} />
)}
{levelUp && (
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
@@ -353,27 +441,42 @@ export function CharacterSheet({ character }: { character: Character }) {
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
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') => {
if (!Number.isFinite(delta) || delta <= 0) return;
if (mode === 'damage') {
const absorbed = Math.min(c.hp.temp, delta);
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
} 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);
};
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
const ratio = effMax > 0 ? c.hp.current / effMax : 0;
return (
<div className="rounded-xl border border-line bg-panel p-4 text-center">
<div className="smallcaps">Hit Points</div>
<div className="my-1 font-mono font-display text-2xl font-semibold text-ink">
{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>}
</div>
{eff.reduction > 0 && (
<div className="mb-1 text-[11px] text-warning" title={eff.reasons.join('; ')}>{eff.reasons.join(' · ')}</div>
)}
<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 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>
@@ -426,7 +529,7 @@ function RankRow({
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
{ranks.map((r) => (
<option key={r} value={r}>
{RANK_LABEL[r]}
{rankLabel(r, system)}
</option>
))}
</Select>
+12 -6
View File
@@ -7,20 +7,23 @@ import { getSystem } from '@/lib/rules';
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
import { pickTextFile } from '@/lib/io/file';
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 { Avatar, Badge } from '@/components/ui/Codex';
import { Modal } from '@/components/ui/Modal';
import { CreationWizard } from './builder/CreationWizard';
// A campaign is NOT required: PCs are campaign-agnostic and can be created unassigned.
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 }) {
// 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 npcs = useCharacters(campaign?.id ?? '').filter((c) => c.kind === 'npc');
const [creating, setCreating] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
@@ -29,7 +32,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
const text = await pickTextFile();
if (text === null) return;
try {
const character = parseCharacterImport(text, campaign.id);
const character = parseCharacterImport(text, campaign?.id ?? '');
await charactersRepo.insert(character);
} catch (e) {
setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.');
@@ -41,7 +44,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
<PageHeader
eyebrow="The Roster"
title="Characters"
subtitle={campaign.name}
subtitle={campaign?.name ?? 'All player characters'}
actions={
<>
<Button variant="secondary" onClick={importCharacter}>
@@ -78,6 +81,9 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
)}
{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>
);
}
@@ -1,22 +1,27 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { Lightbulb, RotateCcw, Star } from 'lucide-react';
import { type Campaign, type Character, type SpellEntry, newSpellEntry } 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 {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
bumpRank, collectChoices,
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
} 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 { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
import type { RulesetClass } from '@/lib/ruleset/normalize';
import { createRng } from '@/lib/rng';
import { newId } from '@/lib/ids';
import { formatModifier } from '@/lib/format';
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
import { briefOverview, dedupeByName } from './overview';
import { 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 { Wizard5eAbilityTable, WizardPf2eAbilityTable } from '../AbilityScoreTable';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Codex';
import { Field, Input, Select } from '@/components/ui/Input';
@@ -24,9 +29,21 @@ import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn';
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 }
/** A class's "what it plays like" tag, to help newcomers pick. */
@@ -51,11 +68,11 @@ const TEMPLATES: Record<string, { label: string; hint: string; className: string
],
};
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | undefined; onClose: () => void }) {
const navigate = useNavigate();
// Characters are campaign-agnostic: the wizard asks which system to build for,
// defaulting to the campaign's. Changing it resets all dependent selections.
const [system, setSystem] = useState<SystemId>(campaign.system);
const [system, setSystem] = useState<SystemId>(campaign?.system ?? '5e');
const sys = getSystem(system);
const allSkillKeys = sys.skills.map((s) => s.key);
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
@@ -80,7 +97,14 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const caster = def && def.caster !== 'none' && def.spellAbility ? def.spellAbility : rc.caster;
return { ...rc, caster, subclasses };
})
: c;
: 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; };
@@ -88,54 +112,102 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
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 asiMatches = [...r.asi.matchAll(/(\w+) score (?:each )?increases? by (\d+)/g)];
const asiSummary = asiMatches.map(([, ability, n]) => ability && n ? `+${n} ${ability.slice(0, 3).toUpperCase()}` : '').filter(Boolean).join(', ');
const 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(' · ');
return { name: r.name, desc, meta };
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) => ({ name: b.name, desc: b.desc, meta: b.skills }))));
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 {
void loadPf2e('ancestries').then((rs) => {
if (!on) return;
setOrigins(dedupeByName(rs.map((r) => {
const hp = typeof r.hp === 'number' ? r.hp : undefined;
const speed = (r.speed as { land?: number } | undefined)?.land;
const attrArr = Array.isArray(r.attribute) ? (r.attribute as string[]).filter((a) => a !== 'Free') : [];
const attrSummary = attrArr.map((a) => `+${String(a).slice(0, 3)}`).join('/');
const meta = [attrSummary, hp !== undefined ? `${hp} HP` : ''].filter(Boolean).join(' · ');
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,
};
})));
});
void loadPf2e('backgrounds').then((bs) => on && setBackgrounds(dedupeByName(bs.map((b) => ({ name: String(b.name), desc: briefOverview(String((b.description ?? b.text) ?? '')) })))));
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; };
}, [system]);
// sys.skills is a stable singleton keyed by `system`, so `system` covers it.
}, [system, sys.skills]);
// ---- selections ----
const [step, setStep] = useState(0);
const [name, setName] = useState('');
const [kind, setKind] = useState<Character['kind']>('pc');
const [level, setLevel] = useState(1);
const [alignment, setAlignment] = useState('');
const [appearance, setAppearance] = useState('');
const [personality, setPersonality] = useState('');
const [classSlug, setClassSlug] = useState('');
const [subclass, setSubclass] = 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 selectedClass = classes.find((c) => c.slug === classSlug) ?? 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';
// ---- abilities ----
@@ -149,31 +221,170 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
if (m === 'standard') { setPool([...STANDARD_ARRAY]); 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>(() => {
if (system === 'pf2e') return pf2eAbilities ?? { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
const out = {} as AbilityScores;
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;
}, [usesPool, pool, assignment, pb]);
}, [system, pf2eAbilities, usesPool, pool, assignment, pb, selectedOrigin, asiAlloc]);
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
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 ----
const [skills, setSkills] = useState<string[]>([]);
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
? selectedClass.skillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
? baseSkillCount + (system === 'pf2e' ? Math.max(0, intMod) : 0)
: 2;
const skillOptions = useMemo(() => {
if (!selectedClass || selectedClass.skillChoices.length === 0) return allSkillKeys;
// map class skill labels → our skill keys
const wanted = new Set(selectedClass.skillChoices.map((s) => s.toLowerCase()));
if (!selectedClass) return allSkillKeys;
// Curated class skill list (canonical for 5e); empty curated list = choose from all.
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 matched = allSkillKeys.filter((k) => wanted.has(labelOf(k)) || wanted.has(k.toLowerCase()));
return matched.length ? matched : allSkillKeys;
}, [selectedClass, allSkillKeys, sys]);
}, [selectedClass, classDef, allSkillKeys, sys]);
const toggleSkill = (key: string) =>
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 ----
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
const [spellQuery, setSpellQuery] = useState('');
@@ -195,16 +406,17 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
// ---- derived build ----
const built = useMemo(() => buildCharacter(system, {
className: selectedClass?.name ?? '', level, abilities, skillChoices: skills,
...(grantedSkills.length ? { grantedSkills } : {}),
...(selectedOrigin?.hp ? { ancestryHp: selectedOrigin.hp } : {}),
...(selectedClass ? { hitDieOverride: selectedClass.hitDie } : {}),
}), [system, selectedClass, level, abilities, skills, selectedOrigin]);
}), [system, selectedClass, level, abilities, skills, grantedSkills, selectedOrigin]);
// Rough "how many should I pick" suggestion for the Spells step. Not enforced —
// just guidance: a few cantrips plus a handful of starting leveled spells.
const leveledSlots = useMemo(() => built.spellcasting.slots.reduce((n, s) => n + (s.level > 0 ? s.max : 0), 0), [built]);
const suggestedSpells = Math.max(4, leveledSlots + 2);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Details', 'Review'], [isCaster]);
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
const changeSystem = (next: SystemId) => {
@@ -226,7 +438,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const applyTemplate = (t: { label: string; className: string; ability: AbilityScores }) => {
const c = classes.find((x) => x.name === t.className);
if (c) { setClassSlug(c.slug); setSubclass(''); setSkills([]); }
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]));
if (!name) setName(t.label.replace(/^[^A-Za-z]+/, ''));
};
@@ -234,15 +449,18 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
const stepValid: Record<string, boolean> = {
Class: !!selectedClass,
Origin: true,
Abilities: poolValid && pbValid,
Skills: skills.length === Math.min(skillCount, skillOptions.length),
Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0,
Skills: skills.length === Math.min(skillCount, freeSkillOptions.length)
&& skillIncPicks.every(Boolean)
&& expertisePicks.every(Boolean),
Spells: true,
Details: true,
Review: true,
};
const finish = async () => {
if (!selectedClass) return;
const created = await charactersRepo.create(campaign.id, {
const created = await charactersRepo.create(campaign?.id, {
system, name: name.trim() || selectedClass.name, kind,
ancestry: ancestry.trim(), className: selectedClass.name, level,
});
@@ -252,12 +470,22 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
}
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, {
...built,
skillRanks,
abilityBuild: buildAbilityBuild(),
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
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 } : {}),
});
onClose();
@@ -380,6 +608,24 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
</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;
@@ -394,53 +640,118 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
)}
{stepName === 'Abilities' && (
<div>
<div className="mb-3 flex gap-1">
{([['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>
))}
</div>
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}><RotateCcw size={13} aria-hidden /> Reroll</Button>}
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
const text = tip?.abilityTip ?? (selectedClass.keyAbilities.length > 0
? `Prioritise ${selectedClass.keyAbilities.map((k) => ABILITY_ABBR[k as AbilityKey]).join(' / ')} for a ${selectedClass.name}.`
: null);
return text ? (
<p className="mb-3 flex items-start gap-1.5 text-xs text-muted">
<Lightbulb size={12} className="mt-0.5 shrink-0 text-accent" aria-hidden />
{text}
<div className="space-y-4">
{system === 'pf2e' && boostSlots && (
<>
<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+).
Click a cell in a free-boost column to assign it highlighted cells are active.
</p>
) : null;
})()}
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{ABILITIES.map((a, i) => {
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
const isKey = selectedClass?.keyAbilities.includes(a);
return (
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
<div className="flex items-center justify-center gap-0.5 smallcaps">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
{usesPool ? (
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}>
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
</Select>
) : (
<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)))} />
)}
<div className="mt-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</div>
</div>
);
})}
</div>
<WizardPf2eAbilityTable
fixed={boostSlots.fixed}
flaw={boostSlots.flaw}
slots={boostSlots.slots}
picks={boostPicks}
abilities={abilities}
keyAbilities={selectedClass?.keyAbilities}
onPick={(slotId, ability) => {
setBoostPicks((prev) => {
if (prev[slotId] === ability) {
const next = { ...prev };
delete next[slotId];
return next;
}
return { ...prev, [slotId]: ability };
});
}}
/>
</>
)}
{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>
{/* 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>
)}
{stepName === 'Skills' && (
<div>
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<p className="mb-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 ? (
@@ -451,7 +762,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
) : null;
})()}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillOptions.map((key) => {
{freeSkillOptions.map((key) => {
const checked = skills.includes(key);
const disabled = !checked && skills.length >= skillCount;
return (
@@ -462,13 +773,70 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
);
})}
</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>
)}
{stepName === 'Spells' && (
<div>
<p className="mb-1 text-sm text-muted">Pick your cantrips and a few starting spells about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. Search by name; you can always adjust on the sheet later.</p>
<p className="mb-1 text-xs text-muted">These are the spells you <span className="text-ink">{system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells youve learned)'}</span>. You prepare or cast them from slots on the character sheet.</p>
<p className="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>
@@ -489,6 +857,32 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</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 && (
<div className="space-y-3 text-sm">
<div className="rounded-lg border border-line bg-surface p-3">
@@ -500,7 +894,11 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
</div>
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
{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(' ')} />}
{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>
@@ -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;
}
@@ -11,6 +11,8 @@ import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { SheetSection, type SectionProps } from './common';
import { rankLabel } from './labels';
import { WeaponPickerModal } from './WeaponPickerModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
@@ -18,6 +20,7 @@ const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master
export function AttacksSection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const [picking, setPicking] = useState(false);
const sys = getSystem(c.system);
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
@@ -46,8 +49,10 @@ export function AttacksSection({ c, update }: SectionProps) {
New attack
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
</label>
{c.system === '5e' && <Button variant="secondary" onClick={() => setPicking(true)}>From weapon</Button>}
<Button variant="primary" onClick={add}>Add</Button>
</div>
{picking && <WeaponPickerModal onPick={(atk) => update({ attacks: [...c.attacks, atk] })} onClose={() => setPicking(false)} />}
{c.attacks.length === 0 ? (
<p className="text-sm text-muted">No attacks defined.</p>
@@ -65,7 +70,7 @@ export function AttacksSection({ c, update }: SectionProps) {
{ABILITIES.map((ab) => <option key={ab} value={ab}>{ABILITY_ABBR[ab]}</option>)}
</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">
{ranks.map((r) => <option key={r} value={r}>{r}</option>)}
{ranks.map((r) => <option key={r} value={r}>{rankLabel(r, c.system)}</option>)}
</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">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label>
@@ -77,6 +82,11 @@ export function AttacksSection({ c, update }: SectionProps) {
>
{formatModifier(result.toHit)}
</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>
<button
onClick={() => rollAndShow({ expression: result.damage, label: `${a.name} — damage` })}
@@ -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>
);
}
+126 -46
View File
@@ -1,7 +1,11 @@
import { Minus, Plus, Star } from 'lucide-react';
import { useState } from 'react';
import { Minus, Plus, Skull, Star } from 'lucide-react';
import { Button } from '@/components/ui/Button';
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';
function Pips({ count, filled, onToggle, label }: { count: number; filled: number; onToggle: (n: number) => void; label: string }) {
@@ -23,57 +27,133 @@ 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) {
const d = c.defenses;
const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
return (
<SheetSection title="Status & Defenses">
<div className="grid gap-3 sm:grid-cols-2">
{c.system === '5e' ? (
<>
<Field label="Death Saves">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1 text-xs text-success">
Successes
<Pips count={3} filled={d.deathSaves.successes} label="Death save success" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, successes: n } })} />
</span>
<span className="flex items-center gap-1 text-xs text-danger">
Failures
<Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
</span>
</div>
</Field>
<Field label="Inspiration">
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
</Button>
</Field>
<Field label="Exhaustion (06)">
<NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" />
</Field>
</>
) : (
<>
<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"><Minus size={14} aria-hidden /></Button>
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point"><Plus size={14} aria-hidden /></Button>
</div>
</Field>
</>
)}
</div>
{c.system === '5e' ? (
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Death Saves">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1 text-xs text-success">
Successes
<Pips count={3} filled={d.deathSaves.successes} label="Death save success" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, successes: n } })} />
</span>
<span className="flex items-center gap-1 text-xs text-danger">
Failures
<Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
</span>
</div>
</Field>
<Field label="Inspiration">
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
{d.inspiration ? <><Star size={13} aria-hidden /> Inspired</> : 'Grant inspiration'}
</Button>
</Field>
<Field label="Exhaustion (06)">
<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>
</div>
) : (
<Pf2eDefenses c={c} update={update} />
)}
</SheetSection>
);
}
@@ -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>
);
}
@@ -5,10 +5,12 @@ 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;
@@ -27,8 +29,10 @@ export function FeatsSection({ c, update }: SectionProps) {
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>
+304 -19
View File
@@ -1,14 +1,19 @@
import { useMemo, useState } from 'react';
import type { Campaign, Character } from '@/lib/schemas';
import { useEffect, useMemo, useState } from 'react';
import type { Campaign, Character, ClassEntry, Feat, Spellcasting } from '@/lib/schemas';
import { normalizeClassMirror, totalLevel } from '@/lib/schemas';
import {
abilityModifier, getSystem, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
planLevelUp, applyIncreases, bumpRank, getClassDef,
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
planLevelUp, appendLevelIncreases, bumpRank, getClassDef, hitDiceResource,
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
} from '@/lib/rules';
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
import { loadPf2e } from '@/lib/compendium';
import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal';
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';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
@@ -19,12 +24,28 @@ export function LevelUpModal({ character, onApply, onClose }: {
onClose: () => void;
}) {
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(
() => planLevelUp(character.system, character.className, character.level, conMod),
[character.system, character.className, character.level, conMod],
() => planLevelUp(character.system, levelingName, classCurrentLevel, conMod),
[character.system, levelingName, classCurrentLevel, conMod],
);
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 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 [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi');
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.
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
// Skill increase (pf2e): one skill to bump.
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 gain = character.system === 'pf2e'
? plan.hpGainAverage
: 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;
if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e');
if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e');
let abilityBuild = character.abilityBuild;
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> = {
level: plan.nextLevel,
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
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 } : {}) };
}
if (skillInc && skillKey) {
const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained';
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);
onClose();
};
@@ -74,12 +239,12 @@ export function LevelUpModal({ character, onApply, onClose }: {
<Modal
open
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"
footer={
<>
<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,6 +252,24 @@ export function LevelUpModal({ character, onApply, onClose }: {
<p className="text-sm text-warning">Already at level 20.</p>
) : (
<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 */}
<section>
<h3 className="mb-1 smallcaps">Hit points</h3>
@@ -126,10 +309,18 @@ export function LevelUpModal({ character, onApply, onClose }: {
<span className="self-center text-xs text-muted">pick the same twice for +2</span>
</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>
)}
{featBrowse && <FeatPickerModal onPick={(f) => setFeatPick(f)} onClose={() => setFeatBrowse(false)} />}
{/* Boosts (pf2e) */}
{boosts && (
@@ -158,9 +349,63 @@ export function LevelUpModal({ character, onApply, onClose }: {
</section>
)}
{plan.choices.filter((c) => c.kind === 'feat').map((c) => (
<p key={c.label} className="text-sm text-muted"> {c.label}</p>
))}
{/* What you gain at this level (read-only) */}
{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 */}
<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>
);
}
/** 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>
);
}
@@ -17,6 +17,17 @@ const RECOVERY_LABEL: Record<CharacterResource['recovery'], string> = {
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) {
const [name, setName] = useState('');
const sys = getSystem(c.system);
@@ -42,7 +53,7 @@ export function ResourcesSection({ c, update }: SectionProps) {
actions={
<div className="flex gap-2">
{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}
</Button>
))}
@@ -4,7 +4,7 @@ import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import { type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics';
import { castSpell, availableSlotLevels, dropConcentration } from '@/lib/mechanics';
import { multiclassSlots } from '@/lib/rules/dnd5e/progression';
import { formatModifier } from '@/lib/format';
import { Button } from '@/components/ui/Button';
@@ -57,8 +57,10 @@ export function SpellcastingSection({ c, update }: SectionProps) {
// Casting a spell enforces slot consumption + concentration via the kernel.
const [castMsg, setCastMsg] = useState<{ text: string; ok: boolean } | null>(null);
const cast = (id: string) => {
const res = castSpell(c, id);
// 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 });
@@ -179,9 +181,28 @@ export function SpellcastingSection({ c, update }: SectionProps) {
Prepared
</label>
)}
<Button size="sm" variant="ghost" onClick={() => cast(s.id)} aria-label={`Cast ${s.name}`}>
<Sparkles size={13} aria-hidden /> Cast
</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>
))}
@@ -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];
}
+2 -2
View File
@@ -66,7 +66,7 @@ function ConflictResolver() {
const [busy, setBusy] = useState(false);
const setCloudSync = useConnectivityStore((s) => s.setCloudSync);
const useCloud = async () => {
const acceptCloud = async () => {
setBusy(true);
try { if (await pullBackup()) { setCloudSync('saved'); setTimeout(() => location.reload(), 400); } }
finally { setBusy(false); setOpen(false); }
@@ -86,7 +86,7 @@ function ConflictResolver() {
<div className="absolute right-0 top-9 z-50 w-64 rounded-xl border border-line bg-panel p-3 text-sm shadow-xl">
<p className="mb-2 text-muted">The cloud copy was changed on another device since this one last synced.</p>
<div className="flex flex-col gap-1.5">
<Button size="sm" variant="secondary" disabled={busy} onClick={() => void useCloud()}>Use cloud version (reload)</Button>
<Button size="sm" variant="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>
+108 -15
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import {
ArrowDown,
ArrowUp,
BrainCircuit,
ChevronLeft,
ChevronRight,
Dices,
@@ -22,7 +23,7 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
import { deriveState, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
import { deriveState, deriveEffectiveMaxHp, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useConditionGlossary } from './useConditionGlossary';
import {
@@ -30,6 +31,7 @@ import {
applyDamage,
applyHealing,
applyInitiatives,
isMassiveDamageDeath,
currentCombatant,
endEncounter,
logEvent,
@@ -80,19 +82,42 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
*/
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);
if (!ch?.concentration) return;
const spellName = combatant.concentrating || ch?.concentration?.spellName;
if (!spellName) return;
const dc = concentrationDC(damage);
void encountersRepo.mutate(encounter.id, (e) =>
logEvent(e, `${ch.name}: roll a DC ${dc} Constitution save or lose concentration on ${ch.concentration!.spellName}.`));
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(nextTurn);
const upcoming = currentCombatant(nextTurn(encounter));
mutate((e) => nextTurn(e, campaign.system));
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`));
const 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));
}
};
@@ -240,10 +265,40 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
const note = hpLoss === amt
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
mutate((e) => logEvent(updateCombatant(e, c.id, { hp: after.hp }), note));
// 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) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))}
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))}
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
/>
@@ -469,6 +524,20 @@ function CombatantRow({
</Badge>
)}
<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 && (
<Badge tone="ember">
<Skull size={11} aria-hidden /> DOWN
@@ -487,23 +556,47 @@ function CombatantRow({
{c.conditions.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<button
<span
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"
title={glossary.get(cond.name.toLowerCase()) || 'Click to remove'}
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== 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"
title={glossary.get(cond.name.toLowerCase()) || undefined}
>
{cond.name}
{cond.value ? ` ${cond.value}` : ''}
{cond.duration ? ` (${cond.duration}r)` : ''}
<X size={11} aria-hidden />
</button>
{/* 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 />
</button>
</span>
))}
</div>
)}
{/* Mechanical consequences the conditions impose (enforced read-model). */}
{(() => {
const st = deriveState(system, 30, c.conditions);
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) => (
+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 }) {
const abilities: [string, number | undefined][] = [
['STR', m.strength],
@@ -28,6 +43,25 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
['CHA', m.charisma],
];
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 (
<div className="font-display">
<header>
@@ -40,7 +74,7 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
<div className="space-y-1">
<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>
{m.speed?.walk !== undefined && <StatLine label="Speed">{m.speed.walk} ft.</StatLine>}
<StatLine label="Speed">{speedLine(m.speed) || null}</StatLine>
</div>
<hr className="gilt-rule my-4" />
@@ -57,14 +91,17 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
))}
</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" />
<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="Immunities">{m.damage_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>
</div>
</>
+3 -2
View File
@@ -185,13 +185,14 @@ export const CATEGORIES: CategoryDef[] = [
{
id: '5e-spells', system: '5e', label: 'Spells', linkAs: 'spell',
load: () => loadSpells() as unknown as Promise<Entry[]>,
searchKeys: ['name', 'school'],
meta: (e) => { const l = (e as unknown as Spell).level_int; return l === 0 ? 'Cantrip' : `Lv ${l ?? '?'}`; },
searchKeys: ['name', 'school', 'source'],
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} />,
numericSort: { label: 'Level', get: (e) => Number(e.level_int) },
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: '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) },
],
},
{
+13 -2
View File
@@ -11,6 +11,7 @@ import { useRollStore } from '@/stores/rollStore';
import { useSessionStore } from '@/stores/sessionStore';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { useConfirm } from '@/components/ui/useConfirm';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Codex';
import { cn } from '@/lib/cn';
@@ -34,11 +35,20 @@ function DieGlyph({ sides, size = 44 }: { sides: number; size?: number }) {
export function DicePage() {
const campaignId = useUiStore((s) => s.activeCampaignId) ?? undefined;
const { confirm, confirmElement } = useConfirm();
const [expr, setExpr] = useState('1d20');
const [last, setLast] = useState<RollResult | null>(null);
const [rollCount, setRollCount] = useState(0);
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 macroKey = campaignId ?? '';
@@ -75,13 +85,14 @@ export function DicePage() {
return (
<Page>
{confirmElement}
<PageHeader
eyebrow="Cast the bones"
title="Dice"
subtitle="Standard notation: 2d6+3, 4d6kh3 (keep highest 3), 1d20."
actions={
history.length > 0 ? (
<Button variant="ghost" onClick={() => void diceRepo.clear(campaignId)}>
history.length > 0 && campaignId ? (
<Button variant="ghost" onClick={() => void clearHistory()}>
Clear history
</Button>
) : null
+29 -5
View File
@@ -1,14 +1,14 @@
import { useState } from 'react';
import { Sparkles, BrainCircuit, Minus, Plus, X } from 'lucide-react';
import type { Character, Defenses } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics';
import { castSpell, availableSlotLevels, dropConcentration } from '@/lib/mechanics';
import { ActionGuide } from './ActionGuide';
import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { useRollStore } from '@/stores/rollStore';
import { sendPlayerRoll } from '@/lib/sync/wsSync';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Input, Select } from '@/components/ui/Input';
import { Meter } from '@/components/ui/Codex';
import { cn } from '@/lib/cn';
@@ -24,6 +24,11 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
const hpPct = c.hp.max > 0 ? clamp((c.hp.current / c.hp.max) * 100, 0, 100) : 0;
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
const [castMsg, setCastMsg] = useState<string | null>(null);
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 (
<section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4">
@@ -118,9 +123,28 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
{s.name}
{s.concentration && <BrainCircuit size={11} className="ml-1 inline text-accent" aria-label="Concentration" />}
</span>
<Button size="sm" variant="ghost" onClick={() => { const r = castSpell(c, s.id); if (r.ok) { onPatch(r.patch); setCastMsg(r.log.join(' ')); } else setCastMsg(r.reason); }} aria-label={`Cast ${s.name}`}>
<Sparkles size={12} aria-hidden /> Cast
</Button>
{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>
+21 -33
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db';
import { cloudUsername, CloudError } from '@/lib/cloud/client';
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, 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 { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
@@ -21,13 +22,10 @@ export function CloudCampaigns() {
const [targetCloud, setTargetCloud] = useState('');
const [viewing, setViewing] = useState<string | null>(null);
const [party, setParty] = useState<CloudCharInfo[]>([]);
const [usage, setUsage] = useState<{ bytes: number; admin: boolean } | null>(null);
const [admins, setAdmins] = useState<AdminUserRow[]>([]);
const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null);
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).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 run = (fn: () => Promise<void>) => async () => {
@@ -122,24 +120,26 @@ export function CloudCampaigns() {
</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 && (
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
<h3 className="mb-1 smallcaps">Admin · users &amp; storage</h3>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
<tbody>
{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 className="rounded-md border border-accent/30 bg-accent/5 p-2 text-sm">
Youre the instance admin manage accounts, storage, campaigns, and live rooms in the{' '}
<Link to="/admin" className="text-accent underline">Admin panel</Link>.
</div>
)}
@@ -154,15 +154,3 @@ function fmtBytes(b: number): string {
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
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>
);
}
+3
View File
@@ -18,6 +18,7 @@ import { pickTextFile } from '@/lib/io/file';
import { seedSampleCampaign } from '@/lib/sample';
import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client';
import { AssistantSettings } from './AssistantSettings';
import { DirectorSettings } from './DirectorSettings';
import { CloudCampaigns } from './CloudCampaigns';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
@@ -125,6 +126,8 @@ export function SettingsPage() {
<AssistantSettings />
<DirectorSettings />
<CloudSync />
<CloudCampaigns />
+4 -1
View File
@@ -10,6 +10,7 @@ import { HOMEBREW_FIELDS, HOMEBREW_KIND_LABEL } from './homebrew';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { useConfirm } from '@/components/ui/useConfirm';
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
const KINDS: HomebrewKind[] = ['monster', 'spell', 'item', 'feat', 'condition'];
@@ -94,6 +95,7 @@ function HomebrewView({ campaign }: { campaign: Campaign }) {
function HomebrewCard({ hb }: { hb: Homebrew }) {
const [h, setH] = useState(hb);
const { confirm, confirmElement } = useConfirm();
// Save the full snapshot (not partial patches) so editing several fields
// 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);
@@ -104,8 +106,9 @@ function HomebrewCard({ hb }: { hb: Homebrew }) {
<div className="rounded-lg border border-line bg-panel p-4">
<div className="flex items-center gap-2">
<Input className="font-display font-semibold" value={h.name} onChange={(e) => update({ name: e.target.value })} aria-label="Homebrew name" />
<Button size="icon" variant="ghost" className="text-danger" onClick={() => homebrewRepo.remove(hb.id)} aria-label={`Delete ${h.name}`}><X size={14} aria-hidden /></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>
{confirmElement}
{HOMEBREW_FIELDS[hb.kind].length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2">
{HOMEBREW_FIELDS[hb.kind].map((f) => (
+4 -1
View File
@@ -10,6 +10,7 @@ import { useUiStore } from '@/stores/uiStore';
import { useMaps } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { useConfirm } from '@/components/ui/useConfirm';
import { cn } from '@/lib/cn';
import { MapEditor } from './map/MapEditor';
@@ -22,6 +23,7 @@ export function MapsPage() {
function Maps({ campaign }: { campaign: Campaign }) {
const maps = useMaps(campaign.id);
const { confirm, confirmElement } = useConfirm();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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>
{activeMapId === mp.id && <Cast size={13} className="ml-auto shrink-0 text-accent-deep" aria-hidden />}
</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 />
</button>
</li>
);
})}
</ul>
{confirmElement}
{selected ? (
<div>
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-line bg-panel p-2">
+4 -1
View File
@@ -7,6 +7,7 @@ import { useNotes } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useConfirm } from '@/components/ui/useConfirm';
import { cn } from '@/lib/cn';
export function NotesPage() {
@@ -98,6 +99,7 @@ function NoteEditor({
const [title, setTitle] = useState(note.title);
const [body, setBody] = useState(note.body);
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.
const save = useDebouncedCallback((next: { title: string; body: string; tags: string }) => {
@@ -127,11 +129,12 @@ function NoteEditor({
<Button
variant="ghost"
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
</Button>
</div>
{confirmElement}
<label className="mb-3 block text-xs text-muted">
Tags (comma-separated)
+4 -1
View File
@@ -16,6 +16,7 @@ import { useEncounters } from '@/features/combat/hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { useConfirm } from '@/components/ui/useConfirm';
import { cn } from '@/lib/cn';
const STATUSES: Quest['status'][] = ['active', 'on-hold', 'completed', 'failed'];
@@ -89,6 +90,7 @@ function Quests({ campaign }: { campaign: Campaign }) {
function QuestCard({ quest }: { quest: Quest }) {
const [q, setQ] = useState(quest);
const [newObj, setNewObj] = useState('');
const { confirm, confirmElement } = useConfirm();
const save = useDebouncedCallback((next: Quest) => void questsRepo.update(next.id, {
title: next.title, status: next.status, description: next.description, reward: next.reward, objectives: next.objectives,
}), 350);
@@ -110,8 +112,9 @@ function QuestCard({ quest }: { quest: Quest }) {
<Select className={cn('w-auto py-1 text-xs font-medium', STATUS_COLOR[q.status])} value={q.status} onChange={(e) => update({ status: e.target.value as Quest['status'] })} aria-label="Quest status">
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</Select>
<Button size="icon" variant="ghost" className="text-danger" onClick={() => questsRepo.remove(quest.id)} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
<Button size="icon" variant="ghost" className="text-danger" onClick={async () => { if (await confirm({ title: 'Delete quest?', message: <>Delete <strong className="text-ink">{q.title || 'this quest'}</strong>? This cant be undone.</> })) void questsRepo.remove(quest.id); }} aria-label={`Delete ${q.title}`}><X size={14} aria-hidden /></Button>
</div>
{confirmElement}
<textarea
value={q.description}
+131
View File
@@ -0,0 +1,131 @@
import type { Campaign, Character, Encounter, Combatant } from '@/lib/schemas';
import { getSystem, type CharacterRulesInput } from '@/lib/rules';
import { deriveState } from '@/lib/mechanics';
import { currentCombatant } from '@/lib/combat/engine';
import { formatModifier } from '@/lib/format';
import type { DirectorPersona, DirectorScene, SceneActor, SceneEncounter } from './types';
/** Default base speed used for a bare combatant (it carries no speed field). */
const DEFAULT_SPEED = 30;
function buildSystemConstraint(systemLabel: string, system: string): string {
return (
`This is a ${systemLabel} (system id: ${system}) campaign. ` +
`Only use ${systemLabel} rules, creatures, classes, feats, and options. ` +
`Never bring in content from any other game system.`
);
}
function combatantActor(system: Campaign['system'], c: Combatant): SceneActor {
const conditions = c.conditions.map((cn) => (cn.value ? `${cn.name} ${cn.value}` : cn.name));
const badges = deriveState(system, DEFAULT_SPEED, c.conditions).badges;
return {
name: c.name,
kind: c.kind,
...(c.characterId ? { characterId: c.characterId } : {}),
combatantId: c.id,
hp: c.hp,
ac: c.ac,
conditions,
...(badges.length ? { badges } : {}),
};
}
/** Full sheet detail (attacks with derived to-hit/damage, spells, resources, slots)
* for the PC the director pilots so the AI player acts from real numbers. */
function pcDetail(c: Character): Pick<SceneActor, 'attacks' | 'spells' | 'resources' | 'slots'> {
const sys = getSystem(c.system);
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
const attacks = c.attacks.map((a) => {
const r = sys.weaponAttack(rulesInput, {
ability: a.ability, rank: a.rank, itemBonus: a.itemBonus, damageDice: a.damageDice, addAbilityToDamage: a.addAbilityToDamage,
});
return { name: a.name, expression: `1d20${formatModifier(r.toHit)}`, damage: r.damage, damageType: a.damageType };
});
const spells = c.spellcasting.spells.map((s) => ({ name: s.name, level: s.level, concentration: s.concentration }));
const resources = c.resources.map((r) => ({ name: r.name, current: r.current, max: r.max }));
const slots = c.spellcasting.slots.map((s) => ({ level: s.level, current: s.current, max: s.max }));
return {
...(attacks.length ? { attacks } : {}),
...(spells.length ? { spells } : {}),
...(resources.length ? { resources } : {}),
...(slots.length ? { slots } : {}),
};
}
function pcActor(c: Character): SceneActor {
const conditions = c.conditions.map((cn) => (cn.value ? `${cn.name} ${cn.value}` : cn.name));
const badges = deriveState(c.system, c.speed, c.conditions).badges;
return {
name: c.name,
kind: c.kind === 'npc' ? 'npc' : 'pc',
characterId: c.id,
hp: c.hp,
conditions,
...(badges.length ? { badges } : {}),
};
}
/**
* Assemble the grounded scene the director may reference. The roster + addCandidates
* are a CLOSED set: when an encounter is active the roster is its live combatants;
* otherwise it is the party (plus tracked NPCs). Pure and synchronous.
*/
export function buildDirectorScene(input: {
campaign: Campaign;
characters: Character[];
encounter?: Encounter | undefined;
persona: DirectorPersona;
narrationStyle?: string | undefined;
controlledCharacterId?: string | undefined;
transcriptWindow: DirectorScene['transcriptWindow'];
summary?: string | undefined;
addCandidates?: string[] | undefined;
}): DirectorScene {
const { campaign, characters, encounter, persona } = input;
const system = campaign.system;
const systemLabel = getSystem(system).label;
let roster: SceneActor[];
let sceneEncounter: SceneEncounter | undefined;
if (encounter && encounter.status === 'active') {
const cur = currentCombatant(encounter);
roster = encounter.combatants.map((c) => combatantActor(system, c));
sceneEncounter = {
name: encounter.name,
round: encounter.round,
...(cur ? { currentActorName: cur.name } : {}),
combatants: encounter.combatants.map((c) => ({ name: c.name, kind: c.kind, initiative: c.initiative, current: c.id === cur?.id })),
};
} else {
roster = characters.filter((c) => c.kind === 'pc' || c.kind === 'npc').map(pcActor);
}
// Inject full sheet detail for the PC the director pilots (AI seat / player persona).
const detailChar = input.controlledCharacterId
? characters.find((x) => x.id === input.controlledCharacterId)
: undefined;
if (detailChar) {
roster = roster.map((a) => (a.characterId === detailChar.id ? { ...a, ...pcDetail(detailChar) } : a));
}
const controlled = input.controlledCharacterId
? roster.find((a) => a.characterId === input.controlledCharacterId)
: undefined;
return {
system,
systemLabel,
systemConstraint: buildSystemConstraint(systemLabel, system),
persona,
campaignName: campaign.name,
...(input.narrationStyle ? { narrationStyle: input.narrationStyle } : {}),
roster,
...(sceneEncounter ? { encounter: sceneEncounter } : {}),
...(controlled ? { controlledName: controlled.name } : {}),
addCandidates: input.addCandidates ?? [],
transcriptWindow: input.transcriptWindow,
...(input.summary ? { summary: input.summary } : {}),
};
}
+218
View File
@@ -0,0 +1,218 @@
import { describe, it, expect } from 'vitest';
import { directorTurnSchema } from './schema';
import { sanitizeTurn, runDirectorTurn } from './engine';
import { fallbackDirectorTurn } from './fallback';
import { buildDirectorScene } from './context';
import { buildDirectorPrompt, cueFor } from './prompt';
import type { DirectorScene } from './types';
import type { LlmConfig } from '@/lib/llm/types';
import type { Campaign, Character, Encounter } from '@/lib/schemas';
import { characterSchema, encounterSchema, newSpellEntry } from '@/lib/schemas';
// ---- fixtures ----
const campaign5e: Campaign = { id: 'c1', name: 'Test', system: '5e', description: '', createdAt: 't', updatedAt: 't' };
function pc(name: string, hp = 20): Character {
return characterSchema.parse({
id: `pc-${name}`, campaignId: 'c1', system: '5e', kind: 'pc', name,
abilities: { str: 14, dex: 12, con: 13, int: 10, wis: 11, cha: 10 },
hp: { current: hp, max: 20, temp: 0 }, createdAt: 't', updatedAt: 't',
});
}
function scene(overrides: Partial<DirectorScene> = {}): DirectorScene {
return {
system: '5e', systemLabel: 'D&D 5e', systemConstraint: 'x', persona: 'dm',
campaignName: 'Test',
roster: [
{ name: 'Lia', kind: 'pc', conditions: [], hp: { current: 8, max: 20, temp: 0 } },
{ name: 'Goblin', kind: 'monster', conditions: [], hp: { current: 7, max: 7, temp: 0 } },
],
addCandidates: ['Orc'],
transcriptWindow: [],
...overrides,
};
}
describe('directorTurnSchema — DeepSeek tolerance', () => {
it('parses a clean turn', () => {
const t = directorTurnSchema.parse({
narration: 'You enter.', rollRequests: [{ actor: 'Lia', label: 'Stealth', kind: 'check', expression: '1d20+5', dc: 15 }],
actions: [{ kind: 'damage', target: 'Goblin', amount: 7, damageType: 'slashing' }],
suggestions: ['Sneak', 'Charge'],
});
expect(t.narration).toBe('You enter.');
expect(t.rollRequests[0]).toMatchObject({ actor: 'Lia', kind: 'check', dc: 15 });
expect(t.actions[0]).toEqual({ kind: 'damage', target: 'Goblin', amount: 7, damageType: 'slashing' });
expect(t.suggestions).toEqual(['Sneak', 'Charge']);
});
it('recovers a renamed actions key, type→kind, and stringified numbers', () => {
const t = directorTurnSchema.parse({
text: 'The goblin lunges.', // narration alias
effects: [{ type: 'applyDamage', creature: 'Lia', value: '5' }], // renamed key + verb + alias + string number
rolls: [{ dice: '1d20+3', name: 'Scimitar', type: 'attack', who: 'Goblin' }],
});
expect(t.narration).toBe('The goblin lunges.');
expect(t.actions[0]).toEqual({ kind: 'damage', target: 'Lia', amount: 5 });
expect(t.rollRequests[0]).toMatchObject({ expression: '1d20+3', label: 'Scimitar', kind: 'attack', actor: 'Goblin' });
});
it('parses a code-fenced object is handled upstream; here drops one bad action among good ones', () => {
const t = directorTurnSchema.parse({
narration: 'x',
actions: [
{ kind: 'damage', target: 'Goblin', amount: 3 },
{ kind: 'totally-unknown-verb', foo: 1 }, // unmappable → dropped
{ kind: 'advanceTurn' },
],
});
expect(t.actions).toHaveLength(2);
expect(t.actions.map((a) => a.kind)).toEqual(['damage', 'advanceTurn']);
});
it('tolerates a completely empty/garbage object', () => {
expect(directorTurnSchema.parse({}).narration).toBe('');
expect(directorTurnSchema.parse('just a string').narration).toBe('just a string');
const t = directorTurnSchema.parse({ narration: 'x' });
expect(t.rollRequests).toEqual([]);
expect(t.actions).toEqual([]);
});
});
describe('sanitizeTurn — anti-hallucination gate', () => {
it('drops actions targeting entities not in the roster, keeps grounded ones', () => {
const t = directorTurnSchema.parse({
narration: 'x',
actions: [
{ kind: 'damage', target: 'Goblin', amount: 4 }, // in roster → keep
{ kind: 'damage', target: 'Gandalf', amount: 99 }, // not in roster → drop
{ kind: 'condition', target: 'Lia', op: 'add', name: 'prone' }, // in roster → keep
{ kind: 'advanceTurn' }, // always keep
],
});
const out = sanitizeTurn(t, scene());
expect(out.actions.map((a) => (a.kind === 'damage' || a.kind === 'condition' ? a.target : a.kind))).toEqual(['Goblin', 'Lia', 'advanceTurn']);
});
it('drops addCombatant unless the monster is in addCandidates', () => {
const t = directorTurnSchema.parse({ narration: 'x', actions: [
{ kind: 'addCombatant', name: 'Orc' }, // candidate → keep
{ kind: 'addCombatant', name: 'Tarrasque' }, // not a candidate → drop
] });
const out = sanitizeTurn(t, scene());
expect(out.actions).toHaveLength(1);
expect(out.actions[0]).toMatchObject({ kind: 'addCombatant', name: 'Orc' });
});
it('mints roll ids and blanks an off-board roll target', () => {
const t = directorTurnSchema.parse({ narration: 'x', rollRequests: [
{ actor: 'Goblin', label: 'Attack', kind: 'attack', expression: '1d20+4', against: 'Sauron' },
] });
const out = sanitizeTurn(t, scene());
expect(out.rollRequests[0]!.id).toBeTruthy();
expect(out.rollRequests[0]!.against).toBe(''); // off-board target blanked, roll kept
});
});
describe('runDirectorTurn — fallback', () => {
const off: LlmConfig = { enabled: false, provider: 'anthropic', model: 'm', baseUrl: 'b', apiKey: '', rememberKey: false };
it('returns a deterministic turn when the LLM is disabled', async () => {
const r = await runDirectorTurn(off, scene());
expect(r.source).toBe('fallback');
expect(r.turn.narration.length).toBeGreaterThan(0);
});
it("surfaces an attack roll on an enemy's turn (no auto-roll)", async () => {
const s = scene({
encounter: { name: 'Ambush', round: 1, currentActorName: 'Goblin', combatants: [
{ name: 'Lia', kind: 'pc', initiative: 12, current: false },
{ name: 'Goblin', kind: 'monster', initiative: 15, current: true },
] },
});
const r = await runDirectorTurn(off, s);
expect(r.turn.rollRequests.length).toBeGreaterThanOrEqual(1);
expect(r.turn.rollRequests[0]!.kind).toBe('attack');
expect(r.turn.actions).toEqual([]); // proposes nothing destructive before the human rolls
});
it('fallback never references an entity outside the roster', () => {
const t = fallbackDirectorTurn(scene());
const names = new Set(['Lia', 'Goblin']);
for (const a of t.actions) {
if (a.kind === 'damage' || a.kind === 'heal' || a.kind === 'tempHp' || a.kind === 'condition') expect(names.has(a.target)).toBe(true);
}
});
});
describe('buildDirectorScene — grounding', () => {
it('uses the active encounter combatants as the roster', () => {
const enc: Encounter = encounterSchema.parse({
id: 'e1', campaignId: 'c1', name: 'Fight', status: 'active', round: 2, turnIndex: 1,
combatants: [
{ id: 'a', name: 'Lia', kind: 'pc', initiative: 18, ac: 15, hp: { current: 8, max: 20, temp: 0 } },
{ id: 'b', name: 'Goblin', kind: 'monster', initiative: 14, ac: 13, hp: { current: 7, max: 7, temp: 0 } },
],
createdAt: 't', updatedAt: 't',
});
const s = buildDirectorScene({ campaign: campaign5e, characters: [pc('Lia')], encounter: enc, persona: 'dm', transcriptWindow: [] });
expect(s.roster.map((a) => a.name)).toEqual(['Lia', 'Goblin']);
expect(s.encounter?.currentActorName).toBe('Goblin'); // turnIndex 1
expect(s.systemConstraint).toContain('5e');
});
it('falls back to the party PCs out of combat', () => {
const s = buildDirectorScene({ campaign: campaign5e, characters: [pc('Lia'), pc('Borin')], persona: 'dm', transcriptWindow: [] });
expect(s.roster.map((a) => a.name).sort()).toEqual(['Borin', 'Lia']);
});
});
describe('AI Player (player persona)', () => {
function caster(): Character {
return characterSchema.parse({
id: 'pc-Mira', campaignId: 'c1', system: '5e', kind: 'pc', name: 'Mira', className: 'Cleric', level: 5,
abilities: { str: 12, dex: 12, con: 14, int: 10, wis: 16, cha: 10 },
hp: { current: 30, max: 30, temp: 0 },
attacks: [{ id: 'a1', name: 'Mace', ability: 'str', rank: 'trained', damageDice: '1d6', damageType: 'bludgeoning', itemBonus: 0, addAbilityToDamage: true }],
spellcasting: { slots: [{ level: 1, max: 4, current: 4 }], spells: [newSpellEntry({ id: 's1', name: 'Bless', level: 1, concentration: true })] },
resources: [{ id: 'r1', name: 'Channel Divinity', current: 1, max: 1, recovery: 'short' }],
createdAt: 't', updatedAt: 't',
});
}
it('injects the controlled PCs attacks, spells and resources into the scene', () => {
const s = buildDirectorScene({ campaign: campaign5e, characters: [caster()], persona: 'player', controlledCharacterId: 'pc-Mira', transcriptWindow: [] });
expect(s.controlledName).toBe('Mira');
const me = s.roster.find((a) => a.name === 'Mira')!;
expect(me.attacks?.[0]).toMatchObject({ name: 'Mace', damageType: 'bludgeoning' });
expect(me.attacks?.[0]!.expression).toMatch(/^1d20[+-]\d+$/); // derived to-hit
expect(me.spells?.map((sp) => sp.name)).toContain('Bless');
expect(me.resources?.map((r) => r.name)).toContain('Channel Divinity');
});
it('works for a Pathfinder 2e campaign (system carried through, no cross-system leak)', () => {
const campaignPf2e: Campaign = { id: 'c2', name: 'Crypt', system: 'pf2e', description: '', createdAt: 't', updatedAt: 't' };
const pf2ePc = characterSchema.parse({
id: 'pf-Kar', campaignId: 'c2', system: 'pf2e', kind: 'pc', name: 'Kara', className: 'Fighter', level: 3,
abilities: { str: 16, dex: 12, con: 14, int: 10, wis: 11, cha: 10 }, hp: { current: 40, max: 40, temp: 0 },
createdAt: 't', updatedAt: 't',
});
const s = buildDirectorScene({ campaign: campaignPf2e, characters: [pf2ePc], persona: 'dm', transcriptWindow: [] });
expect(s.system).toBe('pf2e');
expect(s.systemConstraint.toLowerCase()).toContain('pathfinder');
const { system } = buildDirectorPrompt(s);
expect(system.toLowerCase()).toContain('pathfinder');
// a deterministic turn is still produced and grounded to the party
const t = fallbackDirectorTurn(s);
expect(t.narration.length).toBeGreaterThan(0);
});
it('prompts in first person as the controlled character and cues its turn', () => {
const s = buildDirectorScene({ campaign: campaign5e, characters: [caster()], persona: 'player', controlledCharacterId: 'pc-Mira', transcriptWindow: [] });
const { system } = buildDirectorPrompt(s);
expect(system).toMatch(/role-playing Mira|playing Mira/i);
expect(system).toMatch(/first person/i);
expect(cueFor(s)).toMatch(/your turn/i);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { newId } from '@/lib/ids';
import { complete } from '@/lib/llm/client';
import type { LlmConfig } from '@/lib/llm/types';
import { directorTurnSchema, type DirectorTurn } from './schema';
import { buildDirectorPrompt, buildDirectorMessages } from './prompt';
import { fallbackDirectorTurn } from './fallback';
import { inRoster } from './resolve';
import type { DirectorScene } from './types';
export interface DirectorTurnResult {
turn: DirectorTurn;
source: 'llm' | 'fallback';
message?: string;
}
/**
* The anti-hallucination gate (the second of three). Mint missing roll ids, blank
* roll targets that aren't on the board, and DROP any state-changing action that
* references an entity outside the closed roster (or an ungrounded monster for
* addCombatant). Roll requests are kept (they never mutate state a human still
* clicks to roll) but cleaned. The third gate is `useDirectorAction`, which
* re-resolves names before touching the kernel.
*/
export function sanitizeTurn(turn: DirectorTurn, scene: DirectorScene): DirectorTurn {
const candidates = new Set(scene.addCandidates.map((c) => c.trim().toLowerCase()));
const rollRequests = turn.rollRequests.map((r) => ({
...r,
id: r.id || newId(),
...(r.against && !inRoster(scene.roster, r.against) ? { against: '' } : {}),
}));
const actions = turn.actions.filter((a) => {
switch (a.kind) {
case 'damage':
case 'heal':
case 'tempHp':
case 'condition':
return inRoster(scene.roster, a.target);
case 'castSpell':
return inRoster(scene.roster, a.caster);
case 'spendResource':
return inRoster(scene.roster, a.actor);
case 'addCombatant':
return candidates.has(a.name.trim().toLowerCase()) || (!!a.monsterRef && candidates.has(a.monsterRef.trim().toLowerCase()));
case 'advanceTurn':
case 'log':
return true;
}
});
return { ...turn, rollRequests, actions };
}
/**
* Run one director turn. Uses the LLM when configured (grounded, multi-turn), and
* always degrades to a deterministic turn otherwise. The returned turn is already
* sanitized against the scene's closed roster.
*/
export async function runDirectorTurn(cfg: LlmConfig, scene: DirectorScene): Promise<DirectorTurnResult> {
if (cfg.enabled && cfg.apiKey.trim()) {
const { system, user } = buildDirectorPrompt(scene);
const messages = buildDirectorMessages(scene);
const res = await complete<DirectorTurn>(cfg, { system, user, messages, schema: directorTurnSchema, maxTokens: 2048 });
if (res.ok && 'data' in res) return { turn: sanitizeTurn(res.data, scene), source: 'llm' };
const message = res.ok
? 'The AI returned an unexpected response. Showing a deterministic turn.'
: `AI unavailable (${res.error}). Showing a deterministic turn.`;
return { turn: fallbackDirectorTurn(scene), source: 'fallback', message };
}
return { turn: fallbackDirectorTurn(scene), source: 'fallback' };
}
+80
View File
@@ -0,0 +1,80 @@
import { newId } from '@/lib/ids';
import type { DirectorScene, SceneActor } from './types';
import type { DirectorTurn, RollRequest } from './schema';
const roll = (r: Omit<RollRequest, 'id'>): RollRequest => ({ id: newId(), ...r });
function lowestHpPc(roster: SceneActor[]): SceneActor | undefined {
return roster
.filter((a) => a.kind === 'pc' && a.hp)
.sort((x, y) => (x.hp!.current - y.hp!.current))[0];
}
/**
* Deterministic director used when the LLM is off/keyless/errored. It still honors
* every constraint: it surfaces rolls as buttons (never rolls), and proposes no
* state changes on its own. Intentionally plain it keeps the loop usable offline.
*/
export function fallbackDirectorTurn(scene: DirectorScene): DirectorTurn {
// Player persona: declare a simple action with the controlled PC's first attack.
if (scene.persona === 'player') {
const me = scene.roster.find((a) => a.name === scene.controlledName);
const atk = me?.attacks?.[0];
if (atk) {
return {
narration: `${me!.name} steps up and strikes with ${atk.name}.`,
speaker: me!.name,
rollRequests: [roll({ actor: me!.name, label: `${atk.name} attack`, kind: 'attack', expression: atk.expression })],
actions: [],
suggestions: ['Roll damage if it hits', 'Take a different action', 'Hold and assess'],
};
}
return {
narration: `${scene.controlledName ?? 'Your character'} sizes up the situation, ready to act.`,
rollRequests: [],
actions: [],
suggestions: ['Attack', 'Cast a spell', 'Move and take cover'],
};
}
// DM persona, in combat, on an enemy's turn: surface an attack roll for the GM.
const cur = scene.encounter?.currentActorName
? scene.roster.find((a) => a.name === scene.encounter!.currentActorName)
: undefined;
if (cur && cur.kind !== 'pc') {
const target = lowestHpPc(scene.roster);
return {
narration: target
? `${cur.name} bears down on ${target.name}, weapon raised.`
: `${cur.name} looks for an opening.`,
speaker: cur.name,
rollRequests: [
roll({
actor: cur.name,
label: `${cur.name} attack`,
kind: 'attack',
expression: '1d20',
...(target ? { against: target.name } : {}),
}),
],
actions: [],
suggestions: ['Roll the attack, then damage on a hit', "Advance to the next turn"],
};
}
// DM persona, a PC's turn or out of combat.
if (scene.encounter) {
return {
narration: cur ? `The moment hangs — it is ${cur.name}'s turn to act.` : 'The fight pauses, tension thick in the air.',
rollRequests: [],
actions: [],
suggestions: ['Describe your action', 'Make an attack', 'Cast a spell', 'Move'],
};
}
return {
narration: 'The scene waits on you. The air is still, and the path ahead is yours to choose.',
rollRequests: [],
actions: [],
suggestions: ['Look around', 'Talk to someone here', 'Search the area', 'Travel onward'],
};
}
+9
View File
@@ -0,0 +1,9 @@
export { directorTurnSchema, rollRequestSchema, directorActionSchema } from './schema';
export type { DirectorTurn, RollRequest, DirectorAction, RollKind } from './schema';
export type { DirectorScene, SceneActor, SceneEncounter, DirectorPersona, DirectorTranscriptEntry } from './types';
export { buildDirectorScene } from './context';
export { buildDirectorPrompt, buildDirectorMessages, cueFor } from './prompt';
export { fallbackDirectorTurn } from './fallback';
export { runDirectorTurn, sanitizeTurn, type DirectorTurnResult } from './engine';
export { resolveCombatant, resolveActor, resolveSpellByName, resolveResourceByName, inRoster } from './resolve';
export { planContext, deterministicSummary, buildSummaryPrompt, SUMMARIZE_BATCH, type ContextPlan } from './summarize';
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
/**
* The hard no-auto-roll rule, enforced structurally: the pure director engine must
* never reach the roll seam. A roll may fire ONLY from a user click in
* RollRequestCard. If any engine module imports `rollAndShow`/`rollDice`/`createRng`
* (directly or transitively via `@/lib/useRoll` / `@/lib/dice/notation`), this fails
* making automatic dice rolls impossible in this layer, not merely avoided.
*/
const sources = import.meta.glob('./*.ts', { query: '?raw', eager: true, import: 'default' }) as Record<string, string>;
const FORBIDDEN = ['useRoll', 'rollAndShow', 'rollDice', 'createRng', '@/lib/dice/notation'];
describe('director engine — no-auto-roll import boundary', () => {
const engineFiles = Object.entries(sources).filter(([path]) => !path.endsWith('.test.ts'));
it('covers the engine modules', () => {
expect(engineFiles.length).toBeGreaterThanOrEqual(6);
});
for (const [path, src] of engineFiles) {
it(`${path} never imports the roll seam`, () => {
for (const token of FORBIDDEN) {
expect(src.includes(token), `${path} must not reference ${token}`).toBe(false);
}
});
}
});
+125
View File
@@ -0,0 +1,125 @@
import type { DirectorScene, SceneActor } from './types';
/** The exact JSON shape we want back, described for the model (DeepSeek-proofing). */
const SHAPE = `Respond with ONE JSON object, no prose, no markdown fences:
{
"narration": string, // vivid prose for the table (2-4 sentences)
"speaker": string, // optional: the NPC/PC name speaking, if any
"rollRequests": [ // rolls a HUMAN must make — you NEVER roll
{ "actor": string, "label": string, "kind": "check"|"save"|"attack"|"damage"|"custom",
"expression": string, // dice notation, e.g. "1d20+5"
"dc": number, // optional target number
"against": string } // optional target name
],
"actions": [ // typed state changes a human will APPROVE — you never apply them
{ "kind": "damage", "target": string, "amount": number, "damageType": string },
{ "kind": "condition", "target": string, "op": "add"|"remove", "name": string, "value": number },
{ "kind": "advanceTurn" },
{ "kind": "log", "text": string }
],
"suggestions": [string] // 2-4 short next-step options for the human
}
Numbers are plain integers (5, not "5"). Omit arrays you don't need (use []).`;
function actorLine(a: SceneActor): string {
const bits: string[] = [`${a.name} (${a.kind})`];
if (a.hp) bits.push(`HP ${a.hp.current}/${a.hp.max}${a.hp.temp ? ` +${a.hp.temp} temp` : ''}`);
if (a.ac !== undefined) bits.push(`AC ${a.ac}`);
if (a.conditions.length) bits.push(`conditions: ${a.conditions.join(', ')}`);
if (a.badges?.length) bits.push(`[${a.badges.join('; ')}]`);
return `- ${bits.join(' · ')}`;
}
function controlledDetail(a: SceneActor): string {
const lines: string[] = [];
if (a.attacks?.length) lines.push(`Attacks: ${a.attacks.map((w) => `${w.name} (${w.expression} to hit, ${w.damage} ${w.damageType})`).join('; ')}`);
if (a.spells?.length) lines.push(`Spells: ${a.spells.map((s) => `${s.name} (lvl ${s.level}${s.concentration ? ', concentration' : ''})`).join('; ')}`);
if (a.slots?.length) lines.push(`Spell slots: ${a.slots.filter((s) => s.max > 0).map((s) => `L${s.level} ${s.current}/${s.max}`).join(', ') || 'none'}`);
if (a.resources?.length) lines.push(`Resources: ${a.resources.map((r) => `${r.name} ${r.current}/${r.max}`).join(', ')}`);
return lines.join('\n');
}
/** Serialize the closed, grounded scene for the system prompt. */
function serializeScene(scene: DirectorScene): string {
const parts: string[] = [];
parts.push(`Campaign: ${scene.campaignName} (${scene.systemLabel}).`);
if (scene.encounter) {
const e = scene.encounter;
parts.push(`Active combat: "${e.name}", round ${e.round}.${e.currentActorName ? ` It is ${e.currentActorName}'s turn.` : ''}`);
parts.push(`Initiative: ${e.combatants.map((c) => `${c.name}${c.current ? ' ←' : ''}`).join(', ')}.`);
}
parts.push('Roster (the ONLY entities you may name — use these exact names):');
parts.push(scene.roster.map(actorLine).join('\n') || '- (no one present)');
if (scene.controlledName) {
const c = scene.roster.find((a) => a.name === scene.controlledName);
if (c) {
const detail = controlledDetail(c);
parts.push(`You control ${c.name}. ${detail ? `\n${detail}` : ''}`);
}
}
if (scene.addCandidates.length) {
parts.push(`Monsters you MAY add to combat (choose only from these, by exact name): ${scene.addCandidates.join(', ')}.`);
}
if (scene.summary) parts.push(`Story so far: ${scene.summary}`);
return parts.join('\n');
}
const RULES = [
'You NEVER roll dice. When a roll is due, emit a rollRequest with the dice expression and DC; a human rolls it and reports the result back to you.',
'You NEVER change game state directly. Propose typed actions; a human approves each one before it takes effect.',
'Only reference creatures, PCs, NPCs, and monsters listed in the roster, by their EXACT names. Do not invent entities, locations as characters, or stats. To add a monster, use an addCombatant action with a name from the candidate list only.',
].join(' ');
function personaRole(scene: DirectorScene): string {
if (scene.persona === 'player') {
return (
`You are role-playing ${scene.controlledName ?? 'a party member'} — a single player character in this ${scene.systemLabel} party. ` +
`Speak and act in the first person AS this character. You control ONLY this character; never act for, narrate, or decide for anyone else. ` +
`Use only this character's listed attacks, spells, and resources.`
);
}
return (
`You are the Dungeon Master for this ${scene.systemLabel} game. Narrate the world vividly, voice the NPCs and monsters, ` +
`set scenes, and run enemies in combat. Adjudicate fairly using ${scene.systemLabel} rules.`
);
}
/** The closing cue (also the last user turn). */
export function cueFor(scene: DirectorScene): string {
if (scene.persona === 'player') return `It is your turn. Decide what ${scene.controlledName ?? 'your character'} does and say it in character.`;
if (scene.encounter?.currentActorName) {
const cur = scene.roster.find((a) => a.name === scene.encounter!.currentActorName);
if (cur && cur.kind !== 'pc') return `It is ${cur.name}'s turn (an enemy you control). Take its turn — describe it and surface any attack rolls.`;
}
return 'Continue the scene, responding to the latest input.';
}
/** Build the system + user (cue) prompt for a director turn. */
export function buildDirectorPrompt(scene: DirectorScene): { system: string; user: string } {
const style = scene.narrationStyle ? `\nNarration style: ${scene.narrationStyle}` : '';
const system = [
scene.systemConstraint,
'',
personaRole(scene),
'',
RULES,
style,
'',
serializeScene(scene),
'',
SHAPE,
].join('\n');
return { system, user: cueFor(scene) };
}
/** Map the transcript window to provider turns, ending with the cue as the final user turn. */
export function buildDirectorMessages(scene: DirectorScene): { role: 'user' | 'assistant'; content: string }[] {
const msgs = scene.transcriptWindow.map((e) => {
if (e.role === 'narration') return { role: 'assistant' as const, content: e.speaker ? `${e.speaker}: ${e.text}` : e.text };
if (e.role === 'roll-result') return { role: 'user' as const, content: `[Roll] ${e.text}` };
if (e.role === 'system') return { role: 'user' as const, content: `[Update] ${e.text}` };
return { role: 'user' as const, content: e.speaker ? `${e.speaker}: ${e.text}` : e.text };
});
msgs.push({ role: 'user', content: cueFor(scene) });
return msgs;
}
+35
View File
@@ -0,0 +1,35 @@
import type { Character, CharacterResource, Combatant, Encounter, SpellEntry } from '@/lib/schemas';
import type { SceneActor } from './types';
const norm = (s: string): string => s.trim().toLowerCase();
/** Exact (case-insensitive) match first, then a unique prefix match either direction. */
function match<T>(items: T[], name: string, nameOf: (t: T) => string): T | undefined {
const n = norm(name);
if (!n) return undefined;
const exact = items.find((t) => norm(nameOf(t)) === n);
if (exact) return exact;
const partial = items.filter((t) => norm(nameOf(t)).startsWith(n) || n.startsWith(norm(nameOf(t))));
return partial.length === 1 ? partial[0] : undefined;
}
export function resolveCombatant(enc: Encounter, name: string): Combatant | undefined {
return match(enc.combatants, name, (c) => c.name);
}
export function resolveActor(roster: SceneActor[], name: string): SceneActor | undefined {
return match(roster, name, (a) => a.name);
}
export function resolveSpellByName(c: Character, spell: string): SpellEntry | undefined {
return match(c.spellcasting.spells, spell, (s) => s.name);
}
export function resolveResourceByName(c: Character, resource: string): CharacterResource | undefined {
return match(c.resources, resource, (r) => r.name);
}
/** True when `name` is in the roster (the closed set the director may reference). */
export function inRoster(roster: SceneActor[], name: string): boolean {
return resolveActor(roster, name) !== undefined;
}
+167
View File
@@ -0,0 +1,167 @@
import { z } from 'zod';
/**
* DeepSeek-tolerant schema for a director "turn". The model is asked for a single
* JSON object, but real-world models (DeepSeek especially) rename keys, stringify
* numbers, and wrap arrays oddly. We follow the same pattern as
* `normalizeBalanceShape` in prompts.ts: `z.preprocess` to repair the shape
* structurally, `z.coerce` on every number, `.catch()` on enums, and per-element
* `safeParse`+drop so one malformed action never discards the whole turn.
*/
const str = z.coerce.string();
const coerceAmount = z.coerce.number().int().min(0).max(9999);
// Bounded coercers reject the nonsensical values the model sometimes invents
// (negative DCs, 12th-rank spells, exhaustion: -3) at the schema boundary.
const coerceDc = z.coerce.number().int().min(0).max(35);
const coerceSpellLevel = z.coerce.number().int().min(0).max(9);
const coercePositive = z.coerce.number().int().min(1);
function asRecord(v: unknown): Record<string, unknown> | undefined {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
}
function looksLikeRoll(v: unknown): boolean {
const o = asRecord(v);
return !!o && ('expression' in o || 'dice' in o || 'roll' in o || 'notation' in o);
}
function looksLikeAction(v: unknown): boolean {
const o = asRecord(v);
// Use 'kind'/'action'/'op' — NOT 'type', which roll requests use for their roll kind.
return !!o && ('kind' in o || 'action' in o || 'op' in o);
}
/** First top-level array under a named key, else the first array whose elements match `pred`. */
function findArrayOf(o: Record<string, unknown>, pred: (v: unknown) => boolean, names: string[]): unknown[] | undefined {
for (const n of names) if (Array.isArray(o[n])) return o[n] as unknown[];
for (const v of Object.values(o)) if (Array.isArray(v) && v.some(pred)) return v;
return undefined;
}
function coerceStringArray(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v
.map((x) => {
if (typeof x === 'string') return x;
const o = asRecord(x);
return o && typeof o.text === 'string' ? o.text : o && typeof o.label === 'string' ? o.label : '';
})
.filter((s) => s.trim() !== '');
}
/** Map free-form action verbs to our discriminant. */
const KIND_SYNONYMS: Record<string, string> = {
damage: 'damage', dealdamage: 'damage', applydamage: 'damage', hurt: 'damage', hit: 'damage',
heal: 'heal', healing: 'heal', restore: 'heal', cure: 'heal',
temphp: 'tempHp', temporaryhp: 'tempHp', temphitpoints: 'tempHp', temphealth: 'tempHp',
condition: 'condition', applycondition: 'condition', addcondition: 'condition', setcondition: 'condition', status: 'condition',
castspell: 'castSpell', cast: 'castSpell', spell: 'castSpell',
spendresource: 'spendResource', useresource: 'spendResource', spend: 'spendResource', use: 'spendResource',
advanceturn: 'advanceTurn', nextturn: 'advanceTurn', endturn: 'advanceTurn', next: 'advanceTurn', advance: 'advanceTurn',
addcombatant: 'addCombatant', spawn: 'addCombatant', summon: 'addCombatant', addmonster: 'addCombatant',
log: 'log', note: 'log', message: 'log',
};
function normalizeRoll(v: unknown): unknown {
const r = asRecord(v);
if (!r) return { expression: typeof v === 'string' ? v : '1d20', label: 'Roll', kind: 'custom', actor: '' };
const out = { ...r };
if (out.expression == null) out.expression = out.dice ?? out.roll ?? out.notation ?? '1d20';
if (out.label == null) out.label = out.name ?? out.description ?? out.check ?? 'Roll';
if (out.kind == null) out.kind = out.type ?? 'custom';
if (out.actor == null) out.actor = out.who ?? out.by ?? out.source ?? out.roller ?? '';
if (out.dc == null && out.difficulty != null) out.dc = out.difficulty;
if (out.against == null && out.vs != null) out.against = out.vs;
return out;
}
function normalizeAction(v: unknown): unknown {
const a = asRecord(v);
if (!a) return null;
const out = { ...a };
let kind = out.kind ?? out.action ?? out.type;
if (typeof kind === 'string') {
const mapped = KIND_SYNONYMS[kind.trim().toLowerCase().replace(/[\s_-]/g, '')];
kind = mapped ?? kind.trim();
}
out.kind = kind;
// Field aliasing (best-effort; the discriminated schema keeps only the right ones).
if (out.target == null) out.target = out.creature ?? out.combatant ?? out.enemy ?? out.recipient ?? out.who;
if (out.amount == null) out.amount = out.value ?? out.hp ?? out.points ?? out.damage;
if (out.caster == null) out.caster = out.by ?? out.who ?? out.actor;
if (out.actor == null) out.actor = out.by ?? out.who;
if (out.resource == null) out.resource = out.resourceName ?? out.pool;
if (out.name == null) out.name = out.condition ?? out.monster ?? out.creature;
if (out.text == null) out.text = out.message ?? out.note;
return out;
}
function normalizeTurn(raw: unknown): unknown {
const o = asRecord(raw);
if (!o) return { narration: typeof raw === 'string' ? raw : '' };
const out = { ...o };
if (out.narration == null) out.narration = out.text ?? out.story ?? out.description ?? out.message ?? out.content ?? '';
if (out.speaker == null && out.character != null) out.speaker = out.character;
const rolls = Array.isArray(out.rollRequests)
? out.rollRequests
: findArrayOf(out, looksLikeRoll, ['rollRequests', 'rolls', 'checks', 'requiredRolls', 'rollsRequired']);
const actions = Array.isArray(out.actions)
? out.actions
: findArrayOf(out, looksLikeAction, ['actions', 'effects', 'mutations', 'updates', 'changes']);
out.rollRequests = (rolls ?? []).map(normalizeRoll);
out.actions = (actions ?? []).map(normalizeAction).filter((x) => x !== null);
out.suggestions = coerceStringArray(out.suggestions ?? out.options ?? out.choices ?? out.nextSteps);
return out;
}
export const rollRequestSchema = z.object({
/** stable within a turn; minted by the engine when the model omits it */
id: str.default(''),
actor: str.default(''),
label: str.default('Roll'),
kind: z.enum(['check', 'save', 'attack', 'damage', 'custom']).catch('custom'),
expression: str.default('1d20'),
dc: coerceDc.optional(),
against: str.optional(),
});
export type RollRequest = z.infer<typeof rollRequestSchema>;
export type RollKind = RollRequest['kind'];
export const directorActionSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('damage'), target: str, amount: coerceAmount, damageType: str.optional() }),
z.object({ kind: z.literal('heal'), target: str, amount: coerceAmount }),
z.object({ kind: z.literal('tempHp'), target: str, amount: coerceAmount }),
z.object({ kind: z.literal('condition'), target: str, op: z.enum(['add', 'remove']).catch('add'), name: str, value: coercePositive.optional() }),
z.object({ kind: z.literal('castSpell'), caster: str, spell: str, atLevel: coerceSpellLevel.optional() }),
z.object({ kind: z.literal('spendResource'), actor: str, resource: str, amount: coerceAmount.default(1) }),
z.object({ kind: z.literal('advanceTurn') }),
z.object({ kind: z.literal('addCombatant'), name: str, monsterRef: str.optional() }),
z.object({ kind: z.literal('log'), text: str }),
]);
export type DirectorAction = z.infer<typeof directorActionSchema>;
export const directorTurnSchema = z.preprocess(
normalizeTurn,
z.object({
narration: str.default(''),
speaker: str.optional(),
rollRequests: z
.array(z.unknown())
.default([])
.transform((xs) => xs.flatMap((x) => {
const p = rollRequestSchema.safeParse(x);
return p.success ? [p.data] : [];
})),
actions: z
.array(z.unknown())
.default([])
.transform((xs) => xs.flatMap((x) => {
const p = directorActionSchema.safeParse(x);
return p.success ? [p.data] : [];
})),
suggestions: z.array(str).default([]),
}),
);
export type DirectorTurn = z.infer<typeof directorTurnSchema>;
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { planContext, deterministicSummary, SUMMARIZE_BATCH } from './summarize';
import type { DirectorTranscriptEntry } from '@/lib/schemas';
const entries = (n: number): DirectorTranscriptEntry[] =>
Array.from({ length: n }, (_, i) => ({ id: `e${i}`, role: 'narration' as const, text: `turn ${i}.`, ts: 't' }));
describe('planContext — token budget windowing', () => {
it('keeps everything in the window until it exceeds windowSize + batch', () => {
const windowSize = 4;
const t = entries(windowSize + SUMMARIZE_BATCH); // exactly at the threshold → no fold
const plan = planContext(t, 0, windowSize);
expect(plan.foldEntries).toHaveLength(0);
expect(plan.window).toHaveLength(windowSize + SUMMARIZE_BATCH);
expect(plan.newSummarizedThrough).toBe(0);
});
it('folds the oldest turns down to the last windowSize once over budget', () => {
const windowSize = 4;
const t = entries(windowSize + SUMMARIZE_BATCH + 1); // one over → fold
const plan = planContext(t, 0, windowSize);
expect(plan.window).toHaveLength(windowSize);
expect(plan.foldEntries).toHaveLength(SUMMARIZE_BATCH + 1);
expect(plan.newSummarizedThrough).toBe(SUMMARIZE_BATCH + 1);
// no gap: summary covers [0, newSummarizedThrough), window is the remainder
expect(plan.window[0]!.id).toBe(`e${plan.newSummarizedThrough}`);
});
it('accounts for already-summarized entries', () => {
const windowSize = 4;
const t = entries(45);
const plan = planContext(t, 30, windowSize); // 15 unsummarized > 4+8 → fold down to last 4
expect(plan.foldEntries).toHaveLength(11);
expect(plan.newSummarizedThrough).toBe(41);
expect(plan.window).toHaveLength(4);
});
it('does not fold when the unsummarized count is within budget', () => {
const plan = planContext(entries(40), 30, 4); // 10 unsummarized ≤ 4+8 → no fold
expect(plan.foldEntries).toHaveLength(0);
expect(plan.window).toHaveLength(10);
});
});
describe('deterministicSummary', () => {
it('merges the prior summary with one compact line per folded entry', () => {
const folded: DirectorTranscriptEntry[] = [
{ id: '1', role: 'player-input', text: 'I search the chest. It is heavy.', ts: 't' },
{ id: '2', role: 'roll-result', text: 'Investigation: 17 — Success', ts: 't' },
];
const out = deterministicSummary('The party entered the crypt.', folded);
expect(out).toContain('The party entered the crypt.');
expect(out).toContain('Player: I search the chest.');
expect(out).toContain('Roll — Investigation: 17');
});
it('caps the summary length', () => {
const big = deterministicSummary('x'.repeat(5000), entries(50));
expect(big.length).toBeLessThanOrEqual(3000);
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { DirectorTranscriptEntry } from '@/lib/schemas';
/** How many turns past the live window must accumulate before we fold them into
* the rolling summary (so we don't fire a summarization call every single turn). */
export const SUMMARIZE_BATCH = 8;
export interface ContextPlan {
/** the live transcript turns to send verbatim this turn */
window: DirectorTranscriptEntry[];
/** older turns to fold into the summary now (empty most turns) */
foldEntries: DirectorTranscriptEntry[];
/** advanced count of entries represented by the summary after folding */
newSummarizedThrough: number;
}
/**
* Decide the token-budgeted context. `summary` covers entries [0, summarizedThrough);
* the live window is everything after that. When the window grows past
* windowSize + SUMMARIZE_BATCH, the oldest entries (down to the last `windowSize`)
* are marked to fold into the summary so there is never a gap between what the
* summary covers and what the window shows.
*/
export function planContext(
transcript: DirectorTranscriptEntry[],
summarizedThrough: number,
windowSize: number,
): ContextPlan {
const unsummarized = transcript.slice(summarizedThrough);
if (unsummarized.length <= windowSize + SUMMARIZE_BATCH) {
return { window: unsummarized, foldEntries: [], newSummarizedThrough: summarizedThrough };
}
const foldCount = unsummarized.length - windowSize; // keep the most recent windowSize
return {
window: unsummarized.slice(foldCount),
foldEntries: unsummarized.slice(0, foldCount),
newSummarizedThrough: summarizedThrough + foldCount,
};
}
const firstSentence = (text: string): string => (text.split(/(?<=[.!?])\s/)[0] ?? text).slice(0, 160);
/** Offline-safe summary: merge the prior summary with one compact line per folded
* entry, capped so it can't grow unbounded. */
export function deterministicSummary(prior: string, entries: DirectorTranscriptEntry[]): string {
const lines = entries.map((e) => {
const who = e.speaker ? `${e.speaker}: ` : e.role === 'roll-result' ? 'Roll — ' : e.role === 'player-input' ? 'Player: ' : '';
return `${who}${firstSentence(e.text)}`;
});
return [prior, ...lines].filter(Boolean).join(' ').slice(-3000);
}
/** Prompt to compress folded turns into durable canon (continuing the prior summary). */
export function buildSummaryPrompt(
systemConstraint: string,
prior: string,
entries: DirectorTranscriptEntry[],
): { system: string; user: string } {
const log = entries.map((e) => `${e.speaker ? `${e.speaker}: ` : ''}${e.text}`).join('\n');
return {
system:
`${systemConstraint}\n` +
'You compress a tabletop RPG session into durable canon. Summarize the events below into at most 180 words of plain prose: who is present, where they are, the current goal, key outcomes, and open threads. Keep names exact and invent nothing. Merge with the prior summary into a single continuous summary.',
user: `Prior summary:\n${prior || '(none)'}\n\nNew events:\n${log}\n\nReturn only the updated summary text — no preamble.`,
};
}
+55
View File
@@ -0,0 +1,55 @@
import type { SystemId } from '@/lib/rules';
import type { DirectorPersona, DirectorTranscriptEntry } from '@/lib/schemas';
export type { DirectorPersona, DirectorTranscriptEntry };
export type { RollRequest, DirectorAction, DirectorTurn, RollKind } from './schema';
/** A single actor the director is allowed to reference. Names are the resolution keys. */
export interface SceneActor {
name: string;
kind: 'pc' | 'npc' | 'monster';
/** links to a Character sheet (full detail for an AI-piloted PC) */
characterId?: string;
/** links to a live Encounter combatant (HP/conditions) */
combatantId?: string;
hp?: { current: number; max: number; temp: number };
ac?: number;
conditions: string[];
/** derived mechanical badges (Speed 0, Disadv. attacks, …) from deriveState */
badges?: string[];
/** PC detail injected for the actor the director pilots (player persona / AI seat) */
attacks?: { name: string; expression: string; damage: string; damageType: string }[];
spells?: { name: string; level: number; concentration: boolean }[];
resources?: { name: string; current: number; max: number }[];
slots?: { level: number; current: number; max: number }[];
}
export interface SceneEncounter {
name: string;
round: number;
currentActorName?: string;
combatants: { name: string; kind: string; initiative: number; current: boolean }[];
}
/** Everything the director needs for one grounded turn. The roster + addCandidates
* form a CLOSED set: the director may reference no entity outside it. */
export interface DirectorScene {
system: SystemId;
systemLabel: string;
/** anti-cross-system anchor — leads the prompt */
systemConstraint: string;
persona: DirectorPersona;
campaignName: string;
/** optional tone hint from settings */
narrationStyle?: string;
roster: SceneActor[];
encounter?: SceneEncounter;
/** the PC the director pilots (player persona, or a DM auto-running an NPC turn) */
controlledName?: string;
/** grounded monster names the director may ADD via addCombatant (DM only) */
addCandidates: string[];
/** recent transcript turns (token-budgeted window) */
transcriptWindow: DirectorTranscriptEntry[];
/** rolling summary of older, evicted turns */
summary?: string;
}
+1 -1
View File
@@ -44,7 +44,7 @@ const HOOK_TEMPLATES = [
{
title: 'Old Debts',
opening: () =>
`A face from the past — an ally, a rival, or someone who simply remembers — reappears with news that changes the situation. They\'re not hostile, but they want something only the party can provide.`,
`A face from the past — an ally, a rival, or someone who simply remembers — reappears with news that changes the situation. They're not hostile, but they want something only the party can provide.`,
tension: 'What they need may directly conflict with the party\'s current goals.',
},
{
+58
View File
@@ -0,0 +1,58 @@
import { req } from './campaigns';
/** Typed client for the admin-panel API (accounts listed in ADMIN_USERS only). */
export interface AdminOverview {
uptimeMs: number;
memory: { rss: number; heapUsed: number };
dataDirBytes: number;
users: { count: number; max: number | null };
defaultQuotaBytes: number;
campaigns: number;
characters: number;
characterBytes: number;
rooms: { count: number; players: number; imageBytes: number };
}
export interface AdminUser {
username: string;
admin: boolean;
createdAt: number;
usageBytes: number;
quotaBytes: number;
effectiveQuota: number;
tokenCount: number;
}
export interface AdminCampaign {
id: string;
name: string;
system: string;
owner: string;
members: number;
characters: number;
bytes: number;
updatedAt: number;
}
export interface AdminRoom {
players: number;
seats: number;
images: number;
imageBytes: number;
idleMs: number;
hasGm: boolean;
}
export const adminOverview = () => req<AdminOverview>('/admin/overview');
export const adminUsers = () => req<{ users: AdminUser[] }>('/admin/users');
export const adminCampaigns = () => req<{ campaigns: AdminCampaign[] }>('/admin/campaigns');
export const adminRooms = () => req<{ rooms: AdminRoom[] }>('/admin/rooms');
export const adminSetQuota = (username: string, quotaBytes: number) =>
req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) });
export const adminRevokeTokens = (username: string) =>
req<{ ok: boolean }>(`/admin/users/${encodeURIComponent(username)}/revoke`, { method: 'POST' });
export const adminDeleteUser = (username: string) =>
req<{ ok: boolean; purged: { campaigns: number; characters: number } }>(`/admin/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
export const adminDeleteCampaign = (id: string) =>
req<{ ok: boolean }>(`/admin/campaigns/${encodeURIComponent(id)}`, { method: 'DELETE' });
+9 -11
View File
@@ -12,10 +12,15 @@ function authHeaders(): Record<string, string> {
export interface CloudCampaignInfo { id: string; name: string; system: string; role: 'owner' | 'member'; inviteCode?: string }
export interface CloudCharInfo { id: string; name: string; ownerUserId: string; mine: boolean; data: string; updatedAt: number }
async function req<T>(path: string, init?: RequestInit): Promise<T> {
/** Authenticated same-origin /api request — shared by the campaign + admin clients. */
export async function req<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(`${base()}${path}`, { ...init, headers: { 'content-type': 'application/json', ...authHeaders(), ...(init?.headers ?? {}) } });
// Only declare a JSON content-type when we actually send a body — Fastify
// rejects an empty body that claims content-type: application/json, which
// broke the body-less DELETE/revoke/rotate calls.
const jsonType = init?.body != null ? { 'content-type': 'application/json' } : {};
res = await fetch(`${base()}${path}`, { ...init, headers: { ...jsonType, ...authHeaders(), ...(init?.headers ?? {}) } });
} catch {
throw new CloudError('Could not reach the server.');
}
@@ -52,13 +57,6 @@ export async function cloudUsageBytes(): Promise<number> {
return (await req<{ bytes: number }>('/usage')).bytes;
}
export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number }
export function getUsage(): Promise<{ bytes: number; admin: boolean }> {
return req<{ bytes: number; admin: boolean }>('/usage');
}
export function adminListUsers(): Promise<{ users: AdminUserRow[] }> {
return req<{ users: AdminUserRow[] }>('/admin/users');
}
export function adminSetQuota(username: string, quotaBytes: number): Promise<{ ok: boolean }> {
return req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) });
export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> {
return req<{ bytes: number; quota: number; admin: boolean }>('/usage');
}
+2
View File
@@ -60,6 +60,8 @@ export async function pushBackup(opts?: { force?: boolean }): Promise<PushResult
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob, baseSavedAt }) });
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
if (res.status === 409) { const d = (await res.json().catch(() => ({}))) as { savedAt?: number }; return { ok: false, conflict: true, savedAt: Number(d.savedAt) || 0 }; }
if (res.status === 413) throw new CloudError('Cloud storage limit reached — trim old data or ask the admin to raise your quota.');
if (res.status === 429) throw new CloudError('Too many requests — please wait a moment and try again.');
if (!res.ok) throw new CloudError('Upload failed.');
const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
+24
View File
@@ -7,6 +7,7 @@ import {
applyInitiatives,
currentCombatant,
endEncounter,
isMassiveDamageDeath,
moveCombatant,
nextTurn,
previousTurn,
@@ -182,6 +183,19 @@ describe('HP transitions', () => {
expect(applyDamage(rs, 8, 'cold').hp.current).toBe(27); // 30 - (8 - 5 'all') = 27
expect(applyDamage(rs, 4, 'cold').hp.current).toBe(30); // 4 - 5 → 0, no change
});
it('pf2e immunity "all" zeroes every damage type', () => {
const im = { ...mk('a', 0, 30), damageDefenses: { resist: [], immune: ['all'], vulnerable: [], conditionImmune: [], notes: [], resistFlat: [], weakness: [] } };
expect(applyDamage(im, 10, 'fire').hp.current).toBe(30); // immune to all → no change
expect(applyDamage(im, 10, 'cold').hp.current).toBe(30);
});
it('healing honors a reduced effective-max cap (drained / exhaustion 4)', () => {
const c = { ...mk('a', 0, 30), hp: { current: 10, max: 30, temp: 0 } };
expect(applyHealing(c, 25, 20).hp.current).toBe(20); // capped at effective max
expect(applyHealing(c, 25).hp.current).toBe(30); // no cap → stored max
expect(applyHealing(c, 5, 40).hp.current).toBe(15); // cap never exceeds stored max
});
});
describe('endEncounter', () => {
@@ -216,3 +230,13 @@ describe('applyInitiatives', () => {
expect(currentCombatant(e)?.id).toBe('b');
});
});
describe('isMassiveDamageDeath', () => {
it('kills when leftover damage past 0 is >= max HP', () => {
// 12-max PC at 4 HP takes 17 → current -13; 13 >= 12 → instant death
expect(isMassiveDamageDeath(12, -13)).toBe(true);
expect(isMassiveDamageDeath(12, -12)).toBe(true); // exactly max → dead
expect(isMassiveDamageDeath(12, -11)).toBe(false); // one short → dying, not dead
expect(isMassiveDamageDeath(12, 0)).toBe(false); // dropped to 0, no overflow
});
});
+42 -9
View File
@@ -1,4 +1,6 @@
import type { Combatant, Encounter } from '@/lib/schemas/encounter';
import type { SystemId } from '@/lib/rules';
import { tickConditionsEndOfTurn } from '@/lib/mechanics/creatureState';
/**
* Pure combat-state transitions. Every function returns a NEW encounter and
@@ -64,14 +66,30 @@ export function endEncounter(enc: Encounter): Encounter {
/**
* Advance to the next combatant; wrapping past the end increments the round.
* Ticks the newly-active combatant's timed conditions and logs expirations.
*
* When `system` is provided, the combatant whose turn is *ending* also has its
* end-of-turn condition decay applied (PF2e Frightened drops by 1).
*/
export function nextTurn(enc: Encounter): Encounter {
export function nextTurn(enc: Encounter, system?: SystemId): Encounter {
if (enc.combatants.length === 0) return { ...enc, turnIndex: 0 };
const atEnd = enc.turnIndex >= enc.combatants.length - 1;
const turnIndex = atEnd ? 0 : enc.turnIndex + 1;
const round = atEnd ? enc.round + 1 : enc.round;
let next: Encounter = { ...enc, turnIndex, round };
// End-of-turn auto-decay for the combatant whose turn is ending (pf2e Frightened).
let from: Encounter = enc;
if (system) {
const outgoing = enc.combatants[enc.turnIndex];
if (outgoing) {
const decayed = tickConditionsEndOfTurn(system, outgoing.conditions);
if (decayed !== outgoing.conditions) {
from = { ...enc, combatants: enc.combatants.map((c, i) => (i === enc.turnIndex ? { ...c, conditions: decayed } : c)) };
}
}
}
const atEnd = from.turnIndex >= from.combatants.length - 1;
const turnIndex = atEnd ? 0 : from.turnIndex + 1;
const round = atEnd ? from.round + 1 : from.round;
let next: Encounter = { ...from, turnIndex, round };
if (atEnd) next = logEvent(next, `— Round ${round}`);
// Tick the combatant whose turn is now beginning.
@@ -219,7 +237,8 @@ export function applyDamage(c: Combatant, amount: number, type?: string): Combat
let dmg = amount;
const def = c.damageDefenses;
if (type && def) {
if (def.immune.includes(type)) return c; // immune — no change
// 'all' (pf2e) immunity applies to every damage type.
if (def.immune.includes(type) || (def.immune as string[]).includes('all')) return c; // immune — no change
// 5e: halve / double
if (def.vulnerable.includes(type)) dmg = amount * 2;
else if (def.resist.includes(type)) dmg = Math.floor(amount / 2);
@@ -239,10 +258,24 @@ export function applyDamage(c: Combatant, amount: number, type?: string): Combat
};
}
/** Heal up to max. Does not touch temporary HP. */
export function applyHealing(c: Combatant, amount: number): Combatant {
/**
* 5e instant death: a creature dropped to 0 HP dies outright if the leftover damage
* equals or exceeds its HP maximum (no death saves). `currentAfter` is hp.current
* after the hit (may be negative); the overflow below 0 is the leftover damage.
*/
export function isMassiveDamageDeath(hpMax: number, currentAfter: number): boolean {
return currentAfter <= 0 && -currentAfter >= hpMax;
}
/**
* Heal up to max. Does not touch temporary HP. `capMax` overrides the cap when the
* effective maximum is reduced by conditions (5e exhaustion 4, pf2e drained) pass
* `deriveEffectiveMaxHp(...).max` so healing can't exceed the reduced maximum.
*/
export function applyHealing(c: Combatant, amount: number, capMax?: number): Combatant {
if (!Number.isFinite(amount) || amount <= 0) return c;
return { ...c, hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + amount) } };
const cap = capMax !== undefined ? Math.min(capMax, c.hp.max) : c.hp.max;
return { ...c, hp: { ...c.hp, current: Math.min(cap, c.hp.current + amount) } };
}
/** Set temporary HP — 5e/PF2e temp HP does not stack; take the higher value. */
+36 -3
View File
@@ -1,5 +1,6 @@
import type { SystemId } from '@/lib/rules';
import type { RulesetClass } from '@/lib/ruleset/normalize';
import { normalizeMpmbSpell, type MpmbSpell } from './mpmb';
import type {
Monster,
Spell,
@@ -30,8 +31,21 @@ export async function loadMonsters(): Promise<Monster[]> {
export async function loadSpells(): Promise<Spell[]> {
if (!spellsCache) {
const mod = await import('@/data/srd/spells-srd.json');
spellsCache = mod.default as unknown as Spell[];
const [srdMod, mpmbMod] = await Promise.all([
import('@/data/srd/spells-srd.json'),
import('@/data/srd/mpmb-spells.json'),
]);
const srd = (srdMod.default as unknown as Spell[]).map((s) => ({ ...s, source: s.source ?? 'SRD' }));
// Merge non-SRD MPMB spells (XGtE/TCE/etc.), deduped by name — SRD wins on overlap.
const have = new Set(srd.map((s) => s.name.trim().toLowerCase()));
const mpmbRaw = mpmbMod.default as Record<string, MpmbSpell>;
const extra: Spell[] = [];
for (const raw of Object.values(mpmbRaw)) {
if (have.has((raw.name ?? '').trim().toLowerCase())) continue;
const s = normalizeMpmbSpell(raw);
if (s) extra.push(s);
}
spellsCache = [...srd, ...extra].sort((a, b) => a.name.localeCompare(b.name));
}
return spellsCache;
}
@@ -118,11 +132,30 @@ export async function loadPf2e(file: string): Promise<CompendiumEntry[]> {
if (!pf2eCache.has(file)) {
const res = await fetch(`${import.meta.env.BASE_URL}data/pf2e/${file}.json`);
if (!res.ok) throw new Error(`Failed to load PF2e ${file} (${res.status})`);
pf2eCache.set(file, (await res.json()) as CompendiumEntry[]);
const raw = (await res.json()) as CompendiumEntry[];
pf2eCache.set(file, dedupePf2e(raw));
}
return pf2eCache.get(file)!;
}
/**
* Archives of Nethys lists each reprinted element once per source (Remaster +
* legacy), so the raw datasets carry thousands of duplicate names. Collapse them
* by name (first occurrence wins) so the compendium shows one row per element.
*/
function dedupePf2e(entries: CompendiumEntry[]): CompendiumEntry[] {
const seen = new Set<string>();
const out: CompendiumEntry[] = [];
for (const e of entries) {
const key = String((e.name ?? e.slug) ?? '').trim().toLowerCase();
if (!key) { out.push(e); continue; }
if (seen.has(key)) continue;
seen.add(key);
out.push(e);
}
return out;
}
/** Build a combat-ready stat block summary from a monster. */
export function monsterToCombatant(m: Monster): {
name: string;
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { normalizeMpmbSpell, sourceLabel, mpmbPrimarySource } from './mpmb';
describe('normalizeMpmbSpell', () => {
it('maps an XGtE cantrip to the Open5e spell shape', () => {
const s = normalizeMpmbSpell({
name: 'Toll the Dead',
source: [{ source: 'X', page: 169 }, { source: 'UA:SS', page: 4 }],
level: 0,
school: 'Necro',
time: '1 a',
range: '60 ft',
components: 'V,S',
duration: 'Instantaneous',
save: 'Wis',
classes: ['cleric', 'warlock', 'wizard'],
description: '1 crea save or 1d12 Necrotic dmg',
})!;
expect(s).toMatchObject({
slug: 'toll-the-dead',
name: 'Toll the Dead',
level_int: 0,
level: 'Cantrip',
school: 'Necromancy',
casting_time: '1 action',
range: '60 ft',
components: 'V,S',
source: 'XGtE', // primary (first) source code mapped
dnd_class: 'Cleric, Warlock, Wizard',
});
expect(s.requires_concentration).toBe(false);
});
it('flags concentration from the duration', () => {
const s = normalizeMpmbSpell({ name: 'Wall of Light', level: 5, school: 'Evoc', duration: 'Conc, 10 min', source: [{ source: 'X', page: 170 }] })!;
expect(s.requires_concentration).toBe(true);
expect(s.concentration).toBe('yes');
expect(s.level).toBe('Level 5');
});
it('passes unknown source codes through; maps known ones', () => {
expect(sourceLabel('T')).toBe('TCE');
expect(sourceLabel('ZZZ')).toBe('ZZZ');
expect(mpmbPrimarySource({ source: [{ source: 'X' }, { source: 'UA' }] })).toBe('X');
});
it('returns null without a name', () => {
expect(normalizeMpmbSpell({ name: '' })).toBeNull();
});
});
+76
View File
@@ -0,0 +1,76 @@
import type { Spell } from './types';
/** A parsed MPMB spell entry (scripts/parse_mpmb.py output). */
export interface MpmbSpell {
name: string;
source?: { source: string; page: number | null }[];
level?: number;
school?: string;
time?: string;
range?: string;
components?: string;
duration?: string;
save?: string;
ritual?: boolean;
classes?: string[];
description?: string;
}
// MPMB sourcebook codes → readable labels. Unknown codes pass through unchanged.
const SOURCE_LABEL: Record<string, string> = {
P: 'PHB', X: 'XGtE', T: 'TCE', V: 'VGtM', M: 'MToF', MM: 'MM',
E: 'Eberron', 'E:RLW': 'Eberron', WGtE: 'Wayfinder', MOT: 'Theros',
G: 'GGtR', S: 'SCAG', SCC: 'Strixhaven', FToD: "Fizban's", BoMT: 'BoMT',
AcqInc: 'Acq. Inc.', RotF: 'Rime', 'S:AiS': 'Spelljammer', 'P:AitM': 'Planescape',
LLoK: 'Lost Lab', W: 'Witchlight', DMG: 'DMG',
};
const SCHOOL: Record<string, string> = {
abjur: 'Abjuration', conj: 'Conjuration', div: 'Divination', ench: 'Enchantment',
evoc: 'Evocation', illus: 'Illusion', necro: 'Necromancy', trans: 'Transmutation',
};
const TIME: Record<string, string> = {
'1 a': '1 action', '1 bns': '1 bonus action', '1 rea': '1 reaction',
'1 min': '1 minute', '10 min': '10 minutes', '1 h': '1 hour', '8 h': '8 hours', '24 h': '24 hours',
};
/** The primary (canonical, first-listed) source code of a parsed MPMB entry. */
export function mpmbPrimarySource(raw: { source?: { source: string }[] }): string {
return raw.source?.[0]?.source ?? '';
}
/** Readable label for an MPMB source code (e.g. 'X' -> 'XGtE'). */
export function sourceLabel(code: string): string {
return SOURCE_LABEL[code] ?? code;
}
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
/** Normalize a parsed MPMB spell into the app's Open5e-shaped Spell. */
export function normalizeMpmbSpell(raw: MpmbSpell): Spell | null {
if (!raw.name) return null;
const level = typeof raw.level === 'number' ? raw.level : 0;
const conc = /conc/i.test(raw.duration ?? '');
const school = raw.school ? SCHOOL[raw.school.toLowerCase()] ?? raw.school : '';
return {
slug: slugify(raw.name),
name: raw.name,
desc: raw.description ?? '',
level_int: level,
level: level === 0 ? 'Cantrip' : `Level ${level}`,
school,
casting_time: raw.time ? TIME[raw.time] ?? raw.time : '',
range: raw.range ?? '',
duration: raw.duration ?? '',
concentration: conc ? 'yes' : 'no',
requires_concentration: conc,
ritual: raw.ritual ? 'yes' : 'no',
can_be_cast_as_ritual: !!raw.ritual,
components: raw.components ?? '',
dnd_class: (raw.classes ?? []).map((c) => c.charAt(0).toUpperCase() + c.slice(1)).join(', '),
source: sourceLabel(mpmbPrimarySource(raw)),
};
}
+10
View File
@@ -29,6 +29,14 @@ export interface Monster {
wisdom?: number;
charisma?: number;
perception?: number;
strength_save?: number | null;
dexterity_save?: number | null;
constitution_save?: number | null;
intelligence_save?: number | null;
wisdom_save?: number | null;
charisma_save?: number | null;
/** skill bonuses keyed by skill name, e.g. { perception: 10, stealth: 6 } */
skills?: Record<string, number>;
senses?: string;
languages?: string;
/** numeric challenge rating (0.25 for "1/4") */
@@ -63,6 +71,8 @@ export interface Spell {
components?: string;
material?: string;
dnd_class?: string;
/** sourcebook label (e.g. 'PHB', 'XGtE', 'TCE'); '' / undefined = SRD. */
source?: string;
}
export interface MagicItem {
+43
View File
@@ -5,6 +5,7 @@ import type { Encounter } from '@/lib/schemas/encounter';
import type { DiceRoll } from '@/lib/schemas/dice';
import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas/world';
import type { SessionLogEntry } from '@/lib/schemas/sessionLog';
import type { AiSession } from '@/lib/schemas/aiSession';
import { characterDefaults } from '@/lib/schemas/character';
/**
@@ -27,6 +28,7 @@ export class TtrpgDatabase extends Dexie {
maps!: EntityTable<BattleMap, 'id'>;
homebrew!: EntityTable<Homebrew, 'id'>;
sessionLog!: EntityTable<SessionLogEntry, 'id'>;
aiSessions!: EntityTable<AiSession, 'id'>;
constructor() {
super('ttrpg-manager');
@@ -151,6 +153,47 @@ export class TtrpgDatabase extends Dexie {
if (c.feats === undefined) c.feats = [];
});
});
// v16 — multiclass: characters gain a `classes[]` array (per-class levels) and
// promote `background` out of the notes string. The legacy className/level become
// mirrors of classes[]. Existing single-class characters become a one-entry list.
this.version(16).stores({}).upgrade(async (tx) => {
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
if (!Array.isArray(c.classes) || c.classes.length === 0) {
const className = typeof c.className === 'string' && c.className ? c.className : '';
const level = typeof c.level === 'number' && c.level > 0 ? c.level : 1;
const notes = typeof c.notes === 'string' ? c.notes : '';
// Pull "Subclass: X" / "Background: Y" out of the notes free-text (whole-line).
const subMatch = /^Subclass:\s*(.+)$/m.exec(notes);
const bgMatch = /^Background:\s*(.+)$/m.exec(notes);
if (c.background === undefined || c.background === '') c.background = bgMatch ? bgMatch[1]!.trim() : (c.background ?? '');
c.classes = className
? [{ className, level, ...(subMatch ? { subclass: subMatch[1]!.trim() } : {}) }]
: [];
// Strip exactly the two parsed lines from notes, keep the rest of the user's text.
c.notes = notes
.split('\n')
.filter((ln) => !/^Subclass:\s*/.test(ln) && !/^Background:\s*/.test(ln))
.join('\n')
.trim();
}
if (c.background === undefined) c.background = '';
if (c.alignment === undefined) c.alignment = '';
if (c.appearance === undefined) c.appearance = '';
if (c.personality === undefined) c.personality = '';
});
});
// v17 — class feature choices (Fighting Style, Pact Boon, Invocations…). Defaulted.
this.version(17).stores({}).upgrade(async (tx) => {
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
if (c.choices === undefined) c.choices = [];
});
});
// v18 — AI DM / AI Player "director" sessions: a structured transcript per
// campaign + persona. New table, so no backfill is needed.
this.version(18).stores({ aiSessions: 'id, campaignId, [campaignId+persona]' });
}
}
+59 -3
View File
@@ -12,6 +12,10 @@ import {
battleMapSchema,
homebrewSchema,
sessionLogEntrySchema,
aiSessionSchema,
type AiSession,
type DirectorPersona,
type DirectorTranscriptEntry,
type SessionLogEntry,
type HomebrewKind,
type Homebrew,
@@ -65,7 +69,7 @@ export const campaignsRepo = {
async remove(id: string): Promise<void> {
await db.transaction(
'rw',
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps, db.homebrew],
[db.campaigns, db.characters, db.encounters, db.diceRolls, db.notes, db.npcs, db.quests, db.calendars, db.maps, db.homebrew, db.sessionLog, db.aiSessions],
async () => {
await db.characters.where('campaignId').equals(id).delete();
await db.encounters.where('campaignId').equals(id).delete();
@@ -75,6 +79,8 @@ export const campaignsRepo = {
await db.quests.where('campaignId').equals(id).delete();
await db.maps.where('campaignId').equals(id).delete();
await db.homebrew.where('campaignId').equals(id).delete();
await db.sessionLog.where('campaignId').equals(id).delete();
await db.aiSessions.where('campaignId').equals(id).delete();
await db.calendars.delete(id);
await db.campaigns.delete(id);
},
@@ -105,7 +111,7 @@ export const charactersRepo = {
},
async create(
campaignId: string,
campaignId: string | undefined,
draft: Pick<Character, 'name' | 'kind' | 'ancestry' | 'className' | 'level'> & {
system: Character['system'];
},
@@ -114,7 +120,7 @@ export const charactersRepo = {
const sys = getSystem(draft.system);
const character = characterSchema.parse({
id: newId(),
campaignId,
campaignId: campaignId ?? '',
system: draft.system,
kind: draft.kind,
name: draft.name,
@@ -239,6 +245,56 @@ export const sessionLogRepo = {
},
};
// ---------------- AI director sessions ----------------
export const aiSessionsRepo = {
listByCampaign(campaignId: string): Promise<AiSession[]> {
return db.aiSessions.where('campaignId').equals(campaignId).toArray();
},
get(id: string): Promise<AiSession | undefined> {
return db.aiSessions.get(id);
},
/** The most-recently-updated session for a campaign + persona, if any. */
async latest(campaignId: string, persona: DirectorPersona): Promise<AiSession | undefined> {
const rows = await db.aiSessions.where('[campaignId+persona]').equals([campaignId, persona]).toArray();
return rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
},
async create(campaignId: string, persona: DirectorPersona, opts?: Partial<Pick<AiSession, 'title' | 'controlledCharacterId' | 'encounterId'>>): Promise<AiSession> {
const ts = now();
const session = aiSessionSchema.parse({
id: newId(), campaignId, persona,
title: opts?.title ?? (persona === 'dm' ? 'AI Dungeon Master' : 'AI Player'),
...(opts?.controlledCharacterId ? { controlledCharacterId: opts.controlledCharacterId } : {}),
...(opts?.encounterId ? { encounterId: opts.encounterId } : {}),
transcript: [], summary: '', summarizedThrough: 0,
createdAt: ts, updatedAt: ts,
});
await db.aiSessions.add(session);
return session;
},
/** Transactional read-modify-write (mirrors encountersRepo.mutate). */
async mutate(id: string, fn: (s: AiSession) => AiSession): Promise<void> {
await db.transaction('rw', db.aiSessions, async () => {
const current = await db.aiSessions.get(id);
if (!current) return;
await db.aiSessions.put(aiSessionSchema.parse({ ...fn(current), updatedAt: now() }));
});
},
/** Append durable transcript entries in one transaction. */
async appendEntries(id: string, entries: DirectorTranscriptEntry[]): Promise<void> {
if (entries.length === 0) return;
await this.mutate(id, (s) => ({ ...s, transcript: [...s.transcript, ...entries] }));
},
async setSummary(id: string, summary: string, summarizedThrough: number): Promise<void> {
await this.mutate(id, (s) => ({ ...s, summary, summarizedThrough }));
},
async update(id: string, patch: Partial<Omit<AiSession, 'id' | 'campaignId' | 'createdAt'>>): Promise<void> {
await db.aiSessions.update(id, { ...patch, updatedAt: now() });
},
async remove(id: string): Promise<void> {
await db.aiSessions.delete(id);
},
};
// ---------------- Notes ----------------
export const notesRepo = {
listByCampaign(campaignId: string): Promise<Note[]> {
+3 -1
View File
@@ -3,12 +3,13 @@ import { db } from '@/lib/db/db';
import {
campaignSchema, characterSchema, encounterSchema, diceRollSchema,
noteSchema, npcSchema, questSchema, calendarSchema, battleMapSchema, homebrewSchema,
sessionLogEntrySchema,
} from '@/lib/schemas';
import { downloadJson } from './file';
const TABLES = [
'campaigns', 'characters', 'encounters', 'diceRolls',
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew',
'notes', 'npcs', 'quests', 'calendars', 'maps', 'homebrew', 'sessionLog',
] as const;
/** Schema per table, so restored rows are validated + backfilled with defaults
@@ -17,6 +18,7 @@ const SCHEMA: Record<(typeof TABLES)[number], ZodTypeAny> = {
campaigns: campaignSchema, characters: characterSchema, encounters: encounterSchema,
diceRolls: diceRollSchema, notes: noteSchema, npcs: npcSchema, quests: questSchema,
calendars: calendarSchema, maps: battleMapSchema, homebrew: homebrewSchema,
sessionLog: sessionLogEntrySchema,
};
const FORMAT = 'ttrpg-manager:backup';
+6 -1
View File
@@ -1,4 +1,5 @@
import type { Character } from '@/lib/schemas';
import { synthManualBuild } from '@/lib/rules/abilityBuild';
/** Convert a Pathbuilder 2e export (build JSON) into our PF2e character fields. */
@@ -43,10 +44,14 @@ export function pathbuilderToCharacterFields(b: PathbuilderBuild): Partial<Chara
ancestry: b.ancestry ?? '',
level,
abilities,
// Imports carry only finals — persist a manual build so the sheet stays consistent.
abilityBuild: synthManualBuild(abilities),
hp: { current: hpMax, max: hpMax, temp: 0 },
saveRanks: saveRanks as Character['saveRanks'],
skillRanks: skillRanks as Character['skillRanks'],
perceptionRank: rank(prof.perception) === 'untrained' ? 'trained' : rank(prof.perception),
...(b.background ? { notes: `Background: ${b.background}${b.heritage ? `\nHeritage: ${b.heritage}` : ''}` } : {}),
// Background and heritage are first-class fields now — no more stuffing them in notes.
...(b.background ? { background: b.background } : {}),
...(b.heritage ? { heritage: b.heritage } : {}),
};
}
+62
View File
@@ -69,6 +69,68 @@ describe('complete — request shapes', () => {
});
});
describe('complete — multi-turn messages', () => {
it('without messages, the Anthropic body is identical to the single-user shape', async () => {
const fn = mockFetch(() => anthropicReply('ok'));
await complete(base, { system: 'sys', user: 'hi' });
const body = JSON.parse((fn.mock.calls[0]![1].body as string));
expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]);
});
it('without messages, the OpenAI body keeps system + single user', async () => {
const fn = mockFetch(() => openaiReply('ok'));
const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' };
await complete(cfg, { system: 'sys', user: 'hi' });
const body = JSON.parse((fn.mock.calls[0]![1].body as string));
expect(body.messages).toEqual([
{ role: 'system', content: 'sys' },
{ role: 'user', content: 'hi' },
]);
});
it('passes a transcript through as alternating turns (Anthropic), superseding user', async () => {
const fn = mockFetch(() => anthropicReply('ok'));
await complete(base, {
system: 'sys', user: 'ignored-when-messages-present',
messages: [
{ role: 'user', content: 'I open the door.' },
{ role: 'assistant', content: 'It creaks open.' },
{ role: 'user', content: 'I step through.' },
],
});
const body = JSON.parse((fn.mock.calls[0]![1].body as string));
expect(body.messages).toEqual([
{ role: 'user', content: 'I open the door.' },
{ role: 'assistant', content: 'It creaks open.' },
{ role: 'user', content: 'I step through.' },
]);
});
it('prepends a synthetic user turn when the transcript starts with assistant', async () => {
const fn = mockFetch(() => anthropicReply('ok'));
await complete(base, {
system: 'sys', user: 'u',
messages: [{ role: 'assistant', content: 'The tavern is loud.' }, { role: 'user', content: 'I sit.' }],
});
const body = JSON.parse((fn.mock.calls[0]![1].body as string));
expect(body.messages[0]).toEqual({ role: 'user', content: '(scene start)' });
expect(body.messages.at(-1)).toEqual({ role: 'user', content: 'I sit.' });
});
it('appends a Continue. user turn when the transcript ends on assistant', async () => {
const fn = mockFetch(() => openaiReply('ok'));
const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' };
await complete(cfg, {
system: 'sys', user: 'u',
messages: [{ role: 'user', content: 'Where am I?' }, { role: 'assistant', content: 'A dark wood.' }],
});
const body = JSON.parse((fn.mock.calls[0]![1].body as string));
// system first, then the two turns, then the synthetic Continue.
expect(body.messages[0].role).toBe('system');
expect(body.messages.at(-1)).toEqual({ role: 'user', content: 'Continue.' });
});
});
describe('complete — structured output', () => {
const schema = z.object({ name: z.string() });
it('parses JSON wrapped in markdown fences', async () => {
+19 -2
View File
@@ -81,6 +81,23 @@ interface Request {
body: unknown;
}
type Turn = { role: 'user' | 'assistant'; content: string };
/**
* Resolve the message list both providers send. Collapses to today's single
* `[{role:'user',content:user}]` when no `messages` are supplied, so existing
* callers are byte-identical. When a transcript is given it enforces the shared
* provider invariants: the first turn must be `user`, and the last turn must NOT
* be an `assistant` prefill (Anthropic Opus 4.6+ 400s on that; OpenAI handles it
* poorly) a trailing assistant turn gets a `"Continue."` user turn appended.
*/
function turns(opts: CompleteOptions): Turn[] {
const src = opts.messages?.length ? opts.messages : [{ role: 'user' as const, content: opts.user }];
const out: Turn[] = src[0]?.role === 'assistant' ? [{ role: 'user', content: '(scene start)' }, ...src] : [...src];
if (out[out.length - 1]?.role === 'assistant') out.push({ role: 'user', content: 'Continue.' });
return out;
}
function buildAnthropic(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean): Request {
return {
url: `${trimSlash(cfg.baseUrl)}/v1/messages`,
@@ -94,7 +111,7 @@ function buildAnthropic(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean
model: cfg.model,
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
system: wantJson ? opts.system + jsonInstruction() : opts.system,
messages: [{ role: 'user', content: opts.user }],
messages: turns(opts),
},
};
}
@@ -111,7 +128,7 @@ function buildOpenai(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean):
max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS,
messages: [
{ role: 'system', content: wantJson ? opts.system + jsonInstruction() : opts.system },
{ role: 'user', content: opts.user },
...turns(opts),
],
...(wantJson ? { response_format: { type: 'json_object' } } : {}),
},
+7 -1
View File
@@ -32,11 +32,17 @@ export type LlmResult<T> =
export interface CompleteOptions<T = unknown> {
/** System prompt — must lead with the grounding/system constraint. */
system: string;
/** User message. */
/** User message. Used when `messages` is absent (and as a fallback). */
user: string;
/** Optional multi-turn conversation history. When non-empty it supersedes `user`.
* The provider request enforces the shared invariants (first turn must be `user`;
* no trailing `assistant` prefill Anthropic Opus 4.6+ rejects it), so callers may
* pass a raw transcript without normalizing alternation themselves. */
messages?: { role: 'user' | 'assistant'; content: string }[];
/** When provided, structured JSON output is requested and validated against it.
* Input is left open (`any`) so schemas using coerce/preprocess/transform whose
* parsed input type differs from T are accepted; T is pinned to the output. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema input type varies (coerce/preprocess); only output T matters here
schema?: ZodType<T, ZodTypeDef, any>;
/** A short name describing the JSON shape (used by providers that require it). */
schemaName?: string;
+14
View File
@@ -20,6 +20,20 @@ function consumeSlot(sc: Spellcasting, level: number): { spellcasting: Spellcast
return null;
}
/**
* The slot levels at which a leveled spell can currently be cast: any regular slot
* level the spell's level that still has a charge, plus the pact-magic level when
* it qualifies. Returns [] for cantrips (no slot needed). Used by the cast UI to
* offer a slot-level picker so warlocks can spend pact slots and any caster can upcast.
*/
export function availableSlotLevels(sc: Spellcasting, spellLevel: number): number[] {
if (spellLevel <= 0) return [];
const levels = new Set<number>();
for (const s of sc.slots) if (s.level >= spellLevel && s.current > 0) levels.add(s.level);
if (sc.pact && sc.pact.level >= spellLevel && sc.pact.current > 0) levels.add(sc.pact.level);
return [...levels].sort((a, b) => a - b);
}
/**
* Cast a spell the character knows, at a chosen slot level ( the spell's own
* level, to allow upcasting). Pure: decrements a slot, sets/replaces
+26 -11
View File
@@ -6,6 +6,13 @@ import type { AbilityKey, AdvState } from './types';
* hardcoded `if`s in combat code. `deriveState` (creatureState.ts) folds these
* into a single query the tracker and dice roller ask.
*/
/**
* Which statistic family a PF2e value-scaled status penalty applies to. Different
* families are independent (they target different rolls) and must never be compared
* against each other clumsy hits Dex-based rolls, enfeebled hits Str-based rolls, etc.
*/
export type PenaltyTarget = 'all' | 'str' | 'dex' | 'con' | 'mental';
export interface ConditionEffect {
/** sets effective speed to 0 */
speedZero?: boolean;
@@ -19,8 +26,11 @@ export interface ConditionEffect {
saves?: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
/** cannot take actions or reactions */
incapacitated?: boolean;
/** pf2e: a numeric status penalty scaling with the condition's value */
statusPenalty?: boolean;
/**
* pf2e: a value-scaled status penalty applied to this statistic family. The
* penalty magnitude is the condition's value; family decides which rolls it hits.
*/
statusPenalty?: PenaltyTarget;
}
const ALL_PHYS: Partial<Record<AbilityKey, 'auto-fail'>> = { str: 'auto-fail', dex: 'auto-fail' };
@@ -48,22 +58,27 @@ export const CONDITION_EFFECTS_5E: Record<string, ConditionEffect> = {
*/
export const CONDITION_EFFECTS_PF2E: Record<string, ConditionEffect> = {
blinded: { attacksAgainst: 'advantage' },
clumsy: { statusPenalty: true },
drained: { statusPenalty: true },
dazzled: { attacksAgainst: 'advantage' },
enfeebled: { statusPenalty: true },
fatigued: { statusPenalty: true },
frightened: { statusPenalty: true },
// Each valued condition penalises a DIFFERENT statistic family (never compared):
clumsy: { statusPenalty: 'dex' }, // AC, Reflex, Dex attacks/skills
drained: { statusPenalty: 'con' }, // Fortitude (also reduces HP — applied separately)
enfeebled: { statusPenalty: 'str' }, // Str attacks/damage, Athletics
stupefied: { statusPenalty: 'mental' }, // spell attacks/DCs, mental checks
frightened: { statusPenalty: 'all' }, // every check and DC; decays 1/turn
sickened: { statusPenalty: 'all' }, // every check and DC
// Dazzled imposes concealment on the dazzled creature's OWN targets (a flat check on
// its attacks) — it does NOT lower its AC. The app can't model the flat-check, so we
// record no advantage to attackers here rather than wrongly making it easier to hit.
dazzled: {},
// fatigued (flat -1 AC/saves) and slowed (action loss) are NOT value-scaled — see deriveState.
fatigued: {},
slowed: {},
grabbed: { speedZero: true, attacksAgainst: 'advantage' },
immobilized: { speedZero: true },
paralyzed: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
petrified: { incapacitated: true, speedZero: true },
prone: { attacksAgainst: 'advantage' },
restrained: { speedZero: true, incapacitated: false, attacksAgainst: 'advantage' },
sickened: { statusPenalty: true },
slowed: { statusPenalty: true },
stunned: { incapacitated: true },
stupefied: { statusPenalty: true },
unconscious: { incapacitated: true, speedZero: true, attacksAgainst: 'advantage' },
'off-guard': { attacksAgainst: 'advantage' },
};
+86 -7
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { deriveState } from './creatureState';
import { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn } from './creatureState';
import { concentrationDC } from './cast';
import type { Condition } from '@/lib/schemas/common';
@@ -44,18 +44,56 @@ describe('deriveState (5e)', () => {
expect(s).toMatchObject({ speed: 30, attackModifier: 'normal', incapacitated: false });
expect(s.badges).toEqual([]);
});
it('exhaustion applies cumulative level effects (3 = ability-check + attack + save disadvantage, speed halved)', () => {
const s = deriveState('5e', 30, [cond('Exhaustion', 3)]);
expect(s.abilityCheckDisadvantage).toBe(true);
expect(s.attackModifier).toBe('disadvantage');
expect(s.saveModifiers.wis).toBe('disadvantage');
expect(s.speed).toBe(15); // halved at level 2+
expect(s.badges).toContain('Exhaustion 3');
});
it('exhaustion 5 zeroes speed', () => {
expect(deriveState('5e', 30, [cond('Exhaustion', 5)]).speed).toBe(0);
});
it('exhaustion 4 halves max HP when context is provided', () => {
const s = deriveState('5e', 30, [cond('Exhaustion', 4)], { hpMax: 45 });
expect(s.hpMaxReduction).toBe(22); // floor(45/2)
expect(s.badges).toContain('Max HP 22');
});
it('exhaustion below 4 leaves max HP untouched', () => {
expect(deriveState('5e', 30, [cond('Exhaustion', 3)], { hpMax: 45 }).hpMaxReduction).toBe(0);
});
it('prone badge is range-qualified (advantage melee, disadvantage ranged)', () => {
const s = deriveState('5e', 30, [cond('Prone')]);
expect(s.badges).toContain('Attacked: adv. melee / disadv. ranged');
});
});
describe('deriveState (pf2e)', () => {
it('frightened contributes a numeric status penalty equal to its value', () => {
it('frightened is an all-checks penalty equal to its value', () => {
const s = deriveState('pf2e', 25, [cond('Frightened', 2)]);
expect(s.statusPenalty).toBe(2);
expect(s.badges).toContain('2 status');
expect(s.statusPenalties.all).toBe(2);
expect(s.badges).toContain('2 all checks');
});
it('status penalties do not stack — worst value wins', () => {
const s = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]);
expect(s.statusPenalty).toBe(3);
it('same-family penalties take the worst; different families stay independent', () => {
const both = deriveState('pf2e', 25, [cond('Frightened', 1), cond('Sickened', 3)]);
expect(both.statusPenalties.all).toBe(3); // both target "all" → worst wins
const mixed = deriveState('pf2e', 25, [cond('Enfeebled', 2), cond('Clumsy', 1)]);
expect(mixed.statusPenalties.str).toBe(2); // Str-based
expect(mixed.statusPenalties.dex).toBe(1); // Dex-based — NOT merged with enfeebled
expect(mixed.badges).toEqual(expect.arrayContaining(['2 Str', '1 Dex/AC']));
});
it('slowed is action loss, not a check penalty', () => {
const s = deriveState('pf2e', 25, [cond('Slowed', 2)]);
expect(s.statusPenalties).toEqual({});
expect(s.badges).toContain('Slowed 2 (2 actions)');
});
it('grabbed zeroes speed and grants advantage to attackers', () => {
@@ -63,6 +101,47 @@ describe('deriveState (pf2e)', () => {
expect(s.speed).toBe(0);
expect(s.attacksAgainstModifier).toBe('advantage');
});
it('dazzled does NOT make the creature easier to hit (concealment is on its own attacks)', () => {
const s = deriveState('pf2e', 25, [cond('Dazzled')]);
expect(s.attacksAgainstModifier).toBe('normal');
});
it('quickened badges the extra action', () => {
const s = deriveState('pf2e', 25, [cond('Quickened')]);
expect(s.badges).toContain('Quickened (+1 action)');
});
it('drained reduces max HP by level × value', () => {
const s = deriveState('pf2e', 25, [cond('Drained', 2)], { level: 5 });
expect(s.hpMaxReduction).toBe(10); // 5 × 2
expect(s.statusPenalties.con).toBe(2); // also a Fortitude penalty
});
});
describe('deriveEffectiveMaxHp', () => {
it('halves 5e max HP at exhaustion 4 (from defenses)', () => {
const r = deriveEffectiveMaxHp({ system: '5e', baseMaxHp: 40, level: 8, exhaustion: 4, conditions: [] });
expect(r.max).toBe(20);
expect(r.reduction).toBe(20);
});
it('subtracts level × drained for pf2e', () => {
const r = deriveEffectiveMaxHp({ system: 'pf2e', baseMaxHp: 50, level: 6, conditions: [cond('Drained', 3)] });
expect(r.max).toBe(32); // 50 - 18
});
it('never drops below 1', () => {
const r = deriveEffectiveMaxHp({ system: 'pf2e', baseMaxHp: 10, level: 20, conditions: [cond('Drained', 4)] });
expect(r.max).toBe(1);
});
it('frightened decays by 1 at end of turn, removed at 0', () => {
expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 2)])).toEqual([{ name: 'Frightened', value: 1 }]);
expect(tickConditionsEndOfTurn('pf2e', [cond('Frightened', 1)])).toEqual([]);
// 5e frightened (unvalued) is untouched
expect(tickConditionsEndOfTurn('5e', [cond('Frightened')])).toEqual([{ name: 'Frightened' }]);
});
});
describe('concentrationDC', () => {
+157 -15
View File
@@ -1,10 +1,10 @@
import type { SystemId } from '@/lib/rules';
import { ABILITY_KEYS, type SystemId } from '@/lib/rules';
import type { Condition } from '@/lib/schemas/common';
import { conditionEffects } from './conditionEffects';
import { conditionEffects, type PenaltyTarget } from './conditionEffects';
import type { AbilityKey, AdvState } from './types';
export interface MechanicalState {
/** effective speed after conditions (0 if any condition zeroes it) */
/** effective speed after conditions (0 if zeroed, halved by exhaustion 2-4, etc.) */
speed: number;
/** advantage state on THIS creature's attack rolls (5e) */
attackModifier: AdvState;
@@ -16,12 +16,30 @@ export interface MechanicalState {
saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>>;
/** disadvantage on ability checks (5e) */
abilityCheckDisadvantage: boolean;
/** pf2e: aggregated numeric status penalty (worst single value, penalties don't stack) */
statusPenalty: number;
/** short human-readable badges for the UI, e.g. "Speed 0", "Disadv. attacks" */
/**
* pf2e: value-scaled status penalties, by statistic family. Penalties within a
* family don't stack (worst value wins); different families are independent and
* apply to different rolls, so they are tracked separately never summed.
*/
statusPenalties: Partial<Record<PenaltyTarget, number>>;
/**
* Reduction to maximum HP from conditions: 5e exhaustion 4 halves the max; pf2e
* drained N subtracts level × N. 0 when no reduction applies (or when the caller
* passes no hpMax/level context).
*/
hpMaxReduction: number;
/** short human-readable badges for the UI, e.g. "Speed 0", "2 Str" */
badges: string[];
}
/** Optional numeric context so deriveState can compute HP-max reductions. */
export interface DeriveContext {
/** the creature's *base* maximum HP (before reduction), for 5e exhaustion 4. */
hpMax?: number;
/** the creature's level, for pf2e drained (level × value). */
level?: number;
}
/** Combine two advantage states by the 5e rule: opposing sources cancel to normal. */
function combineAdv(a: AdvState, b: AdvState): AdvState {
if (a === b) return a;
@@ -30,6 +48,14 @@ function combineAdv(a: AdvState, b: AdvState): AdvState {
return 'normal'; // one advantage + one disadvantage cancel
}
const PENALTY_LABEL: Record<PenaltyTarget, string> = {
all: 'all checks',
str: 'Str',
dex: 'Dex/AC',
con: 'Fort',
mental: 'spell/mental',
};
/**
* Fold a creature's conditions into a single mechanical read-model. Pure and
* synchronous combat, the dice roller, and the player view all call this
@@ -39,50 +65,166 @@ function combineAdv(a: AdvState, b: AdvState): AdvState {
* Unknown / homebrew condition names contribute nothing (they remain visible
* labels), exactly as before.
*/
export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[]): MechanicalState {
export function deriveState(system: SystemId, baseSpeed: number, conditions: Condition[], ctx?: DeriveContext): MechanicalState {
const table = conditionEffects(system);
let speed = baseSpeed;
let speedZero = false;
let attackModifier: AdvState = 'normal';
let attacksAgainstModifier: AdvState = 'normal';
let incapacitated = false;
let abilityCheckDisadvantage = false;
let statusPenalty = 0;
let allSavesDisadvantage = false;
let hpMaxReduction = 0;
let prone5e = false;
const statusPenalties: Partial<Record<PenaltyTarget, number>> = {};
const saveModifiers: Partial<Record<AbilityKey, 'disadvantage' | 'auto-fail'>> = {};
const extraBadges: string[] = [];
for (const cond of conditions) {
const eff = table[cond.name.trim().toLowerCase()];
const name = cond.name.trim().toLowerCase();
// --- 5e Exhaustion: cumulative level-based effects (PHB). ---
if (system === '5e' && name === 'exhaustion' && cond.value) {
const lvl = cond.value;
if (lvl >= 1) abilityCheckDisadvantage = true;
if (lvl >= 2) speed = Math.min(speed, Math.floor(baseSpeed / 2)); // speed halved
if (lvl >= 3) { attackModifier = combineAdv(attackModifier, 'disadvantage'); allSavesDisadvantage = true; }
if (lvl >= 4 && ctx?.hpMax) hpMaxReduction = Math.floor(ctx.hpMax / 2); // HP maximum halved
if (lvl >= 5) speedZero = true; // speed 0
extraBadges.push(lvl >= 6 ? 'Exhaustion 6 (dead)' : `Exhaustion ${lvl}`);
continue;
}
// --- pf2e Slowed: action loss only, no roll penalty. ---
if (system === 'pf2e' && name === 'slowed') {
extraBadges.push(cond.value ? `Slowed ${cond.value} (${cond.value} actions)` : 'Slowed');
continue;
}
// --- pf2e Fatigued: flat 1 to AC and saves (not value-scaled). ---
if (system === 'pf2e' && name === 'fatigued') {
extraBadges.push('Fatigued (1 AC & saves)');
continue;
}
// --- pf2e Quickened: grants one extra action; no roll penalty. ---
if (system === 'pf2e' && name === 'quickened') {
extraBadges.push('Quickened (+1 action)');
continue;
}
// --- pf2e Persistent Damage: the human rolls it; we surface the procedure. ---
if (system === 'pf2e' && name === 'persistent damage') {
extraBadges.push(cond.value
? `Persistent damage ${cond.value} (apply at end of turn, then DC 15 flat check to end)`
: 'Persistent damage (apply at end of turn, then DC 15 flat check to end)');
continue;
}
const eff = table[name];
if (!eff) continue;
if (eff.speedZero) speedZero = true;
if (eff.incapacitated) incapacitated = true;
if (eff.abilityChecks) abilityCheckDisadvantage = true;
if (eff.attackRolls) attackModifier = combineAdv(attackModifier, eff.attackRolls);
if (eff.attacksAgainst) attacksAgainstModifier = combineAdv(attacksAgainstModifier, eff.attacksAgainst);
if (system === '5e' && name === 'prone') prone5e = true;
if (eff.saves) {
for (const [ability, level] of Object.entries(eff.saves) as [AbilityKey, 'disadvantage' | 'auto-fail'][]) {
// auto-fail dominates disadvantage
if (level === 'auto-fail' || saveModifiers[ability] === undefined) saveModifiers[ability] = level;
}
}
// pf2e: status penalties don't stack — take the worst single value.
if (eff.statusPenalty && cond.value !== undefined) statusPenalty = Math.max(statusPenalty, cond.value);
// pf2e: value-scaled penalty per statistic family — worst value within a family.
if (eff.statusPenalty && cond.value !== undefined) {
const fam = eff.statusPenalty;
statusPenalties[fam] = Math.max(statusPenalties[fam] ?? 0, cond.value);
}
// pf2e Drained N also reduces maximum HP by level × N.
if (system === 'pf2e' && name === 'drained' && cond.value && ctx?.level) {
hpMaxReduction += ctx.level * cond.value;
}
}
// 5e exhaustion 3+ imposes disadvantage on all saves (unless already auto-fail).
if (allSavesDisadvantage) {
for (const a of ABILITY_KEYS) if (saveModifiers[a] !== 'auto-fail') saveModifiers[a] = 'disadvantage';
}
const badges: string[] = [];
if (speedZero) badges.push('Speed 0');
else if (speed !== baseSpeed) badges.push(`Speed ${speed}`);
if (incapacitated) badges.push('Incapacitated');
if (attackModifier === 'disadvantage') badges.push('Disadv. attacks');
if (attackModifier === 'advantage') badges.push('Adv. attacks');
if (attacksAgainstModifier === 'advantage') badges.push('Attacked w/ adv.');
if (statusPenalty > 0) badges.push(`${statusPenalty} status`);
if (attacksAgainstModifier === 'advantage') {
// Prone is range-dependent: melee attackers have advantage, ranged have disadvantage.
badges.push(prone5e ? 'Attacked: adv. melee / disadv. ranged' : 'Attacked w/ adv.');
}
for (const [fam, val] of Object.entries(statusPenalties) as [PenaltyTarget, number][]) {
if (val > 0) badges.push(`${val} ${PENALTY_LABEL[fam]}`);
}
if (hpMaxReduction > 0) badges.push(`Max HP ${hpMaxReduction}`);
badges.push(...extraBadges);
return {
speed: speedZero ? 0 : baseSpeed,
speed: speedZero ? 0 : speed,
attackModifier,
attacksAgainstModifier,
incapacitated,
saveModifiers,
abilityCheckDisadvantage,
statusPenalty,
statusPenalties,
hpMaxReduction,
badges,
};
}
/**
* Effective maximum HP after condition-based reductions: 5e exhaustion 4 halves the
* maximum; pf2e Drained N subtracts level × N. Pure the sheet and tracker show this
* reduced max but never mutate the stored `hp.max`, matching the surface-don't-apply
* rule. Returns the reduced max plus human-readable reasons for the UI to explain it.
*/
export function deriveEffectiveMaxHp(input: {
system: SystemId;
baseMaxHp: number;
level: number;
/** 5e exhaustion level (from defenses), if tracked separately from conditions. */
exhaustion?: number;
conditions: Condition[];
}): { max: number; reduction: number; reasons: string[] } {
let reduction = 0;
const reasons: string[] = [];
if (input.system === '5e' && (input.exhaustion ?? 0) >= 4) {
reduction += Math.floor(input.baseMaxHp / 2);
reasons.push('Exhaustion 4 — maximum HP halved');
}
if (input.system === 'pf2e') {
const drained = input.conditions.find((c) => c.name.trim().toLowerCase() === 'drained')?.value ?? 0;
if (drained > 0) {
const drop = input.level * drained;
reduction += drop;
reasons.push(`Drained ${drained}${drop} maximum HP`);
}
}
return { max: Math.max(1, input.baseMaxHp - reduction), reduction, reasons };
}
/**
* End-of-turn auto-decay for valued conditions. In PF2e, Frightened decreases by 1
* at the end of each of the affected creature's turns (removed at 0). Returns a new
* conditions array; callers persist it when a combatant's turn ends.
*/
export function tickConditionsEndOfTurn(system: SystemId, conditions: Condition[]): Condition[] {
if (system !== 'pf2e') return conditions;
let changed = false;
const next: Condition[] = [];
for (const c of conditions) {
if (c.name.trim().toLowerCase() === 'frightened' && c.value !== undefined) {
changed = true;
const value = c.value - 1;
if (value > 0) next.push({ ...c, value });
// value 0 → condition ends, drop it
} else {
next.push(c);
}
}
return changed ? next : conditions;
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue, pf2eRestDecay } from './dying';
import type { Condition } from '@/lib/schemas/common';
const cond = (name: string, value?: number): Condition => (value !== undefined ? { name, value } : { name });
describe('maxDying / isDead', () => {
it('doomed lowers the death threshold (floor 1)', () => {
expect(maxDying(0)).toBe(4);
expect(maxDying(1)).toBe(3);
expect(maxDying(3)).toBe(1);
expect(maxDying(9)).toBe(1);
});
it('dead once dying reaches the doomed-reduced maximum', () => {
expect(isDead(4, 0)).toBe(true);
expect(isDead(3, 0)).toBe(false);
expect(isDead(3, 1)).toBe(true); // max 3
expect(isDead(1, 3)).toBe(true); // max 1
});
});
describe('recoveryDc', () => {
it('is 10 + dying value', () => {
expect(recoveryDc(1)).toBe(11);
expect(recoveryDc(3)).toBe(13);
});
});
describe('knockOut', () => {
it('enters dying 1 + wounded (2 + wounded on a crit)', () => {
expect(knockOut({ wounded: 0 })).toEqual({ dying: 1 });
expect(knockOut({ wounded: 2 })).toEqual({ dying: 3 });
expect(knockOut({ wounded: 1 }, true)).toEqual({ dying: 3 });
});
});
describe('applyRecovery', () => {
it('adjusts dying by degree of success', () => {
expect(applyRecovery({ dying: 3, wounded: 0 }, 'critical-success')).toEqual({ dying: 1, wounded: 0 });
expect(applyRecovery({ dying: 2, wounded: 0 }, 'success')).toEqual({ dying: 1, wounded: 0 });
expect(applyRecovery({ dying: 2, wounded: 0 }, 'failure')).toEqual({ dying: 3, wounded: 0 });
expect(applyRecovery({ dying: 1, wounded: 0 }, 'critical-failure')).toEqual({ dying: 3, wounded: 0 });
});
it('recovering out of dying increases wounded', () => {
expect(applyRecovery({ dying: 1, wounded: 0 }, 'success')).toEqual({ dying: 0, wounded: 1 });
expect(applyRecovery({ dying: 2, wounded: 1 }, 'critical-success')).toEqual({ dying: 0, wounded: 2 });
});
it('does not increase wounded if not already dying', () => {
expect(applyRecovery({ dying: 0, wounded: 2 }, 'success')).toEqual({ dying: 0, wounded: 2 });
});
});
describe('heroPointRescue', () => {
it('spends all hero points to drop to dying 0 and increases wounded', () => {
expect(heroPointRescue({ dying: 3, heroPoints: 2, wounded: 1 })).toEqual({ dying: 0, heroPoints: 0, wounded: 2 });
});
it('returns null when not dying or no points to spend', () => {
expect(heroPointRescue({ dying: 0, heroPoints: 2, wounded: 0 })).toBeNull();
expect(heroPointRescue({ dying: 2, heroPoints: 0, wounded: 0 })).toBeNull();
});
});
describe('pf2eRestDecay', () => {
it('decays doomed, clears wounded, decrements/removes drained, ends fatigued', () => {
const r = pf2eRestDecay({ doomed: 2, wounded: 3 }, [cond('Drained', 2), cond('Fatigued'), cond('Frightened', 1)]);
expect(r.defenses).toEqual({ doomed: 1, wounded: 0 });
expect(r.conditions).toEqual([cond('Drained', 1), cond('Frightened', 1)]);
});
it('removes drained when it would drop to 0', () => {
const r = pf2eRestDecay({ doomed: 0, wounded: 0 }, [cond('Drained', 1)]);
expect(r.conditions).toEqual([]);
});
});
+80
View File
@@ -0,0 +1,80 @@
import type { Defenses } from '@/lib/schemas/character';
import type { Condition } from '@/lib/schemas/common';
import type { Degree } from '@/lib/dice/check';
/**
* Pathfinder 2e dying / wounded / doomed mechanics the PF2e analog of 5e death
* saves (`deathSaves.ts`). Pure helpers; the sheet drives them with human-rolled
* recovery checks (the app never rolls). Dying/wounded/doomed live on
* `character.defenses`; Drained/Fatigued live in the conditions array.
*/
/** Maximum dying value before death — normally 4, reduced by doomed (floor 1). */
export function maxDying(doomed: number): number {
return Math.max(1, 4 - Math.max(0, doomed));
}
/** Dead once the dying value reaches the (doomed-reduced) maximum. */
export function isDead(dying: number, doomed: number): boolean {
return dying >= maxDying(doomed);
}
/** The flat recovery-check DC while dying: 10 + current dying value. */
export function recoveryDc(dying: number): number {
return 10 + Math.max(0, dying);
}
/**
* Knocked out (reduced to 0 HP): gain dying 1, plus your wounded value; 2 instead of
* 1 if the blow was a critical hit or you critically failed the triggering save.
* Wounded itself is unchanged here it only grows when you RECOVER from dying.
*/
export function knockOut(d: Pick<Defenses, 'wounded'>, fromCrit = false): { dying: number } {
return { dying: (fromCrit ? 2 : 1) + Math.max(0, d.wounded) };
}
/**
* Apply a recovery-check degree to the dying value:
* critical success 2 · success 1 · failure +1 · critical failure +2.
* Reaching dying 0 ends the dying condition and leaves you wounded (or more wounded).
*/
export function applyRecovery(d: Pick<Defenses, 'dying' | 'wounded'>, degree: Degree): { dying: number; wounded: number } {
const delta = degree === 'critical-success' ? -2 : degree === 'success' ? -1 : degree === 'failure' ? 1 : 2;
const dying = Math.max(0, d.dying + delta);
const wounded = dying === 0 && d.dying > 0 ? d.wounded + 1 : d.wounded;
return { dying, wounded };
}
/**
* Heroic Recovery: spend ALL remaining Hero Points (minimum 1) to avoid death
* dying drops to 0 and you stabilize (you stay unconscious at 0 HP until healed).
* Losing the dying condition always increases wounded by 1.
*/
export function heroPointRescue(d: Pick<Defenses, 'dying' | 'heroPoints' | 'wounded'>): { dying: number; heroPoints: number; wounded: number } | null {
if (d.heroPoints < 1 || d.dying <= 0) return null;
return { dying: 0, heroPoints: 0, wounded: d.wounded + 1 };
}
/**
* Condition decay from a full night's rest (Rest for the Night): doomed 1, wounded
* cleared (you wake at full HP), Drained 1, and Fatigued removed. Dying is assumed
* already 0. Returns both the defenses patch and the updated conditions array.
*/
export function pf2eRestDecay(
d: Pick<Defenses, 'doomed' | 'wounded'>,
conditions: Condition[],
): { defenses: { doomed: number; wounded: number }; conditions: Condition[] } {
const defenses = { doomed: Math.max(0, d.doomed - 1), wounded: 0 };
const next: Condition[] = [];
for (const c of conditions) {
const name = c.name.trim().toLowerCase();
if (name === 'fatigued') continue; // fatigued ends on a full rest
if (name === 'drained' && c.value !== undefined) {
const value = c.value - 1;
if (value > 0) next.push({ ...c, value }); // else drops
continue;
}
next.push(c);
}
return { defenses, conditions: next };
}
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { characterDefaults, newSpellEntry, type Character } from '@/lib/schemas';
import { castSpell, dropConcentration } from './cast';
import { castSpell, availableSlotLevels, dropConcentration } from './cast';
import { spendResource, regainResource } from './resources';
import { rollDeathSave } from './deathSaves';
@@ -77,6 +77,15 @@ describe('castSpell', () => {
expect(r.patch.spellcasting!.pact!.current).toBe(1);
});
it('availableSlotLevels offers the pact level for a warlock and upcast levels for a caster', () => {
// Warlock: a level-1 spell can be cast with the level-3 pact slot (the only option).
expect(availableSlotLevels({ slots: [], pact: { level: 3, max: 2, current: 2 }, spells: [] }, 1)).toEqual([3]);
// Wizard: a level-1 spell can go in the level-1 or level-3 slot (upcast), but not empties.
expect(availableSlotLevels({ slots: [{ level: 1, max: 4, current: 4 }, { level: 2, max: 2, current: 0 }, { level: 3, max: 3, current: 2 }], spells: [] }, 1)).toEqual([1, 3]);
// Cantrips need no slot.
expect(availableSlotLevels({ slots: [{ level: 1, max: 4, current: 4 }], spells: [] }, 0)).toEqual([]);
});
it('dropConcentration clears the slot', () => {
const concentrating = mk({ concentration: { spellId: 's2', spellName: 'Haste' } });
expect(dropConcentration(concentrating)).toEqual({ concentration: null });
+4 -3
View File
@@ -15,11 +15,12 @@ export type { EnforceResult } from './result';
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
export { normalizeArmor5e } from './normalize/armor';
export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefenses } from './normalize/monsterDefenses';
export { castSpell, dropConcentration, concentrationDC } from './cast';
export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast';
export { spendResource, regainResource } from './resources';
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect } from './conditionEffects';
export { deriveState, type MechanicalState } from './creatureState';
export { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue, pf2eRestDecay } from './dying';
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects';
export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState';
let spellMechCache: Map<string, SpellMechanics> | null = null;
let armorMechCache: Map<string, ArmorMechanics> | null = null;
+7 -1
View File
@@ -24,7 +24,13 @@ export function normalizeArmor5e(raw: Armor5e): ArmorMechanics | null {
: 'unarmored';
// Shields don't cap Dex (they're additive); honor the source dexCap otherwise.
const dexCap = isShield ? null : (raw.dexCap ?? null);
// When the source omits dexCap, fall back by category: heavy ignores Dex (0),
// medium caps at +2, light/unarmored is uncapped (null).
const dexCap = isShield
? null
: raw.dexCap !== undefined && raw.dexCap !== null
? raw.dexCap
: category === 'heavy' ? 0 : category === 'medium' ? 2 : null;
return {
name: raw.name,
@@ -65,6 +65,9 @@ export function normalizeMonsterDefensesPf2e(raw: {
const immune: DamageType[] = [];
const conditionImmune: string[] = [];
for (const i of Array.isArray(raw.immunity) ? (raw.immunity as string[]) : []) {
// "all" is a damage immunity (applies to every type), like resistance/weakness —
// not a condition. Special-case it before the damage-type lookup.
if (String(i).trim().toLowerCase() === 'all') { immune.push('all' as DamageType); continue; }
const t = pf2eType(String(i));
if (t) immune.push(t);
else conditionImmune.push(String(i).trim().toLowerCase());

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