Initial commit

This commit is contained in:
2026-09-22 16:27:53 +02:00
commit 9b29dc8253
70 changed files with 14726 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
.env.local
dist/
build/
*.log
.DS_Store
.idea/
+197
View File
@@ -0,0 +1,197 @@
# Build Prompt: D&D Campaign Manager Web App
Hand this whole folder to Claude Code (or paste this file as the initial prompt
with the rest of the folder alongside it) to build the app. Read this file
fully before writing any code, then read `prisma/schema.prisma`,
`docs/GAME_RULES_REFERENCE.md`, and `docs/ROLL_YOUR_CHARACTER.md` — those three
are the authoritative data model and game-logic spec, not just background.
## What we're building
A web app for a Dungeons & Dragons 5e Dungeon Master and their players to run a
campaign together. Users log in and see the campaigns/stories they're active in.
Opening a campaign shows a player their character card (stats, spells,
inventory). A DM can configure campaign basics, add characters, and build full
character sheets through a guided dice-roll flow. Players can also build and
manage their own characters, take notes, and pull up a contextual "quick stats"
panel mid-combat (attack/damage bonus, HP, special abilities). The DM can create
and assign weapons, create monsters/enemies, run encounters, and track damage
live.
## Tech stack (fixed — do not substitute)
- **Backend:** Node.js + Express (or Fastify if you strongly prefer, but default
to Express), JavaScript (not TypeScript, to match the stack the user works in
day to day), Prisma ORM, PostgreSQL.
- **Frontend:** React + Vite + TypeScript.
- **Real-time:** Socket.io over WebSockets, self-hosted on the Express server —
no paid real-time SaaS (no Pusher, Ably, Supabase Realtime, etc.).
- **Auth:** Email + password. Hash passwords with bcrypt. Issue a JWT on login,
store it client-side, verify it as Express middleware. A DM invites players to
a campaign via a short invite code or a shareable `/join/:inviteCode` link
(see `Campaign.inviteCode` in the schema).
- **Repo layout:** monorepo, two top-level folders:
```
/frontend — React + Vite + TypeScript app
/backend — Express + Prisma API, Socket.io server
/prisma — (already provided) schema.prisma — move into /backend/prisma
/seed-data — (already provided) JSON reference data — move into /backend/prisma or /backend/src/data, wire into a seed script
/docs — (already provided) keep as top-level /docs for reference during and after the build
```
Move `prisma/` and `seed-data/` into `/backend` as part of scaffolding (or keep
top-level and point `backend/prisma/schema.prisma`'s location via
`package.json`'s `prisma.schema` field — either is fine, just be consistent).
## First command to run
```
cd backend && npx prisma validate
```
This schema was authored and structurally reviewed (brace/paren balance, no
duplicate models/enums, relation names cross-checked by hand) but could not be
run through `prisma validate` in the environment that generated it — its
sandbox blocked outbound access to Prisma's engine-binary CDN. Run it for real
as your first step before trusting the schema further.
## Database & seeding
1. `prisma migrate dev` from the schema as provided (adjust only if `prisma
validate` surfaces a real error).
2. Write `backend/prisma/seed.js` (or `.ts`) that reads the four JSON files in
`seed-data/` and:
- Does **not** need to load species/classes/skills into the database as rows
— those are fixed game-rule tables, not user data. Instead, port them into
a small constants module the calculation engine imports directly (e.g.
`backend/src/game/data/species.js` etc., generated from the JSON so the
numbers stay traceable to `docs/GAME_RULES_REFERENCE.md`).
- **Does** seed `weapons.json` into the `Weapon` table, but only as part of
a demo/dev campaign seed (weapons are per-campaign in the schema, since a
DM should be able to add homebrew weapons) — don't seed them globally.
3. Build the calculation engine described in `docs/GAME_RULES_REFERENCE.md` as
a single module, fully unit tested (ability modifier, proficiency bonus, HP
by level — including the level-1-uses-max-die rule, AC, save/skill bonuses,
spell DC/attack bonus). Every place in the API that returns a character's
derived stats must call this module — never duplicate the formulas.
## Backend API — suggested route groups
- `POST /auth/register`, `POST /auth/login`, `GET /auth/me`
- `GET /campaigns` (mine, as DM or player), `POST /campaigns` (create, DM
becomes owner), `GET /campaigns/:id`, `PATCH /campaigns/:id` (DM-only,
updates `startingItemCount`/`startingSpellCount`/`startingGold`/etc.)
- `POST /campaigns/:id/invite` (DM regenerates/fetches invite code),
`POST /campaigns/join/:inviteCode` (player joins)
- `GET /campaigns/:id/characters`, `POST /campaigns/:id/characters` (start a
build — DM for anyone, player for themselves)
- `GET /characters/:id`, `PATCH /characters/:id` (stats/notes updates),
`DELETE /characters/:id` (DM only)
- `POST /characters/:id/build/roll` (server-rolls the requested die for the
current build step — never trust a client-submitted roll — see
`docs/ROLL_YOUR_CHARACTER.md`), `POST /characters/:id/build/choose` (submit a
choice for the current step), `POST /characters/:id/build/finalize`
- `GET /characters/:id/quick-stats` (assembles the combat-context panel per
`docs/GAME_RULES_REFERENCE.md`)
- `POST /characters/:id/weapons`, `PATCH /characters/:id/weapons/:weaponId`
(equip/unequip/quantity), weapon CRUD under `/campaigns/:id/weapons` (DM)
- `POST /characters/:id/inventory`, `PATCH
/characters/:id/inventory/:itemId`
- Monster CRUD under `/campaigns/:id/monsters` (DM only)
- `POST /campaigns/:id/encounters`, `GET /encounters/:id`, `POST
/encounters/:id/participants` (add character/monster), `PATCH
/encounters/:id/participants/:participantId` (HP/conditions/initiative
updates — this is the hot path during combat), `POST
/encounters/:id/advance-round`
- `POST /notes`, `GET /campaigns/:id/notes`, `GET /characters/:id/notes`
Authorization rule of thumb: DM (campaign owner / `CampaignMember.role ===
'DM'`) can read/write everything in their campaign; a player can read their own
character fully, can read other players' character cards at whatever visibility
the DM allows (default: full read for party members — keep it simple for v1),
and can only write to their own character and their own notes.
## Real-time events (Socket.io)
Join a Socket.io room per campaign (`campaign:<id>`) on connect. Emit on:
- Character HP/condition changes (`character:updated`)
- Encounter participant HP/condition/initiative changes
(`encounter:participant-updated`)
- Encounter round advances (`encounter:round-advanced`)
- New character added / build finalized (`character:created`)
This is what makes HP changes and combat tracking show up live for everyone
looking at the same campaign/encounter — keep the payloads small (just the
changed record) and let the frontend re-fetch details if needed rather than
pushing full derived-stat blobs over the socket.
## Frontend — key screens
1. **Login / Register**
2. **Campaign list** (post-login home) — campaigns the user is a DM or player
in; a "Join campaign" flow that takes an invite code/link
3. **Campaign home** — DM sees campaign settings + character list + "start
encounter"; player sees their character card front and center
4. **Character card / sheet** — stats, AC, HP (editable inline for
authorized users), skills, saves, spells, inventory, weapons, notes
5. **Character builder** — walks `docs/ROLL_YOUR_CHARACTER.md` step by step:
big "Roll d_N_" buttons, reveal choices, assign ability scores, pick
skills, pick starting equipment, finalize
6. **Quick Stats panel** — a compact overlay/drawer callable from anywhere the
character is on screen, showing attack/damage bonus (per equipped
weapon), HP, AC, and special abilities — this is what a player pulls up
mid-attack, so it must be fast to open (no full page nav) and legible at a
glance
7. **DM encounter/combat tracker** — add characters and monsters to an
encounter, initiative order, per-combatant HP/condition tracking, round
counter, all live via Socket.io
Use React Query (or an equivalent fetch/cache layer) for API state and a
lightweight Socket.io client hook that invalidates/updates the relevant query
cache on each event above, rather than hand-rolling a parallel state store.
## Build phases (do these in order; each should be independently runnable/testable)
1. Monorepo scaffold: `/frontend` (Vite React-TS template) + `/backend`
(Express, `npm init`, Prisma installed, schema in place), root README with
dev-run instructions, `.env.example` in both.
2. Prisma migrate + seed script + the calculation engine module + its unit
tests (this is the foundation everything else depends on — get it fully
correct and tested before building UI on top of it).
3. Auth (register/login/JWT middleware) + campaign CRUD + invite/join flow.
4. Character builder end-to-end (dice-roll flow → finalized character),
backed by the calculation engine.
5. Character sheet view (read) + stat/note editing (write) on the frontend.
6. Quick Stats panel.
7. Weapons (campaign-level CRUD + assign to character) and inventory.
8. Monsters + encounters + combat/damage tracking.
9. Socket.io wiring for all the real-time events above.
10. Polish pass: loading/error states, mobile-usable layout (a DM running combat
from a phone or tablet at the table is a realistic use case), basic
automated tests for the calculation engine and the auth/campaign/character
API routes.
## Non-functional requirements
- Validate all request bodies server-side (e.g. `zod`) — don't trust the client
for anything that affects game state, especially dice rolls.
- Never let a non-DM, non-owning-player mutate a character or campaign they
don't have rights to — enforce this in middleware, not just by hiding UI.
- Keep the calculation engine pure and dependency-free (no DB calls inside it)
so it stays unit-testable in isolation.
- Match the naming and structure in `prisma/schema.prisma` and the JSON files
in `seed-data/` exactly — they're the result of a full review pass and are
meant to be used as-is, not redesigned.
## Reference material in this folder
- `prisma/schema.prisma` — full data model, described above
- `docs/GAME_RULES_REFERENCE.md` — every D&D formula the app needs, verified
against a hand-checked Excel calculator built earlier for this same campaign
- `docs/ROLL_YOUR_CHARACTER.md` — the dice-roll character build flow, step by
step, for the builder UI
- `seed-data/species.json`, `classes.json`, `skills.json`, `weapons.json` —
structured versions of the tables in `GAME_RULES_REFERENCE.md`, ready to
import into code or a seed script
+80
View File
@@ -0,0 +1,80 @@
# The Sunken Reach — D&D 5e Campaign Manager
A web app for a D&D 5e Dungeon Master and their players to run a campaign
together: campaign management, a guided dice-roll character builder, live
character sheets, a mid-combat "quick stats" panel, weapons/inventory/spells,
monsters, and a real-time encounter/combat tracker.
See `CLAUDE_BUILD_PROMPT.md` and `/docs` for the full spec this was built
from (`GAME_RULES_REFERENCE.md` is the authoritative rules/math reference;
`ROLL_YOUR_CHARACTER.md` is the character-builder flow spec).
## Stack
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
- **Frontend:** React + Vite + TypeScript, React Router, TanStack Query, socket.io-client.
## Prerequisites
- Node.js 18+ (tested on Node 22)
- A running PostgreSQL instance (local install, Docker, or a hosted service like Neon/Supabase)
## First-time setup
```bash
npm install # installs both workspaces (backend + frontend)
# Backend: point at your Postgres instance and run migrations
cd backend
cp .env.example .env # edit DATABASE_URL and JWT_SECRET
npx prisma migrate dev --name init
npx prisma db seed # creates a demo campaign + demo users (see output for login/invite code)
# Frontend
cd ../frontend
cp .env.example .env # defaults to http://localhost:4000, adjust if needed
```
## Running in dev
In two terminals, from the repo root:
```bash
npm run dev:backend # Express + Socket.io on http://localhost:4000
npm run dev:frontend # Vite dev server, prints its own port (5173 by default)
```
Open the frontend URL, register an account (or log in with the seeded demo
users — see the seed script's console output for credentials and invite
code), and start playing.
## Tests
```bash
cd backend
npm test
```
The calculation engine (`src/game/calculations.js`) is fully unit tested and
runs with no external dependencies. The route-level integration tests in
`src/routes/api.integration.test.js` need a real, migrated database reachable
via `DATABASE_URL` — they skip themselves gracefully (with a console warning)
if the DB isn't reachable, so `npm test` still passes in an environment with
no database configured.
## Project layout
```
/backend — Express + Prisma API, Socket.io server
/prisma — schema.prisma, seed.js
/seed-data — reference JSON (weapons seeded per-campaign; species/classes/skills
are ported into src/game/data/*.js and used directly by the
calculation engine, not stored as DB rows)
/src
/game — calculations.js (the ONLY place D&D formulas live) + buildFlow.js
/middleware — auth + campaign/character authorization
/routes — auth, campaigns, characters, encounters, notes
/sockets — Socket.io room wiring
/frontend — React + Vite + TypeScript app
/docs — game rules & character-builder specs (reference material)
```
+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dnd_manager?schema=public"
JWT_SECRET="change-me-to-a-long-random-string"
JWT_EXPIRES_IN="7d"
PORT=4000
CORS_ORIGIN="http://localhost:5173"
+34
View File
@@ -0,0 +1,34 @@
{
"name": "backend",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
"prisma": {
"seed": "node prisma/seed.js"
},
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:seed": "prisma db seed",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"socket.io": "^4.8.0",
"zod": "^3.23.8"
},
"devDependencies": {
"jest": "^29.7.0",
"prisma": "^5.20.0",
"supertest": "^7.0.0"
}
}
@@ -0,0 +1,334 @@
-- CreateEnum
CREATE TYPE "CampaignRole" AS ENUM ('DM', 'PLAYER');
-- CreateEnum
CREATE TYPE "Species" AS ENUM ('HUMAN', 'HALF_ELF', 'ELF', 'GNOME', 'DWARF', 'HALFLING', 'DRAGONBORN', 'TIEFLING', 'HALF_ORC', 'ORC');
-- CreateEnum
CREATE TYPE "CharacterClass" AS ENUM ('FIGHTER', 'BARBARIAN', 'ROGUE', 'RANGER', 'WIZARD', 'SORCERER', 'CLERIC', 'PALADIN', 'BARD', 'WARLOCK', 'DRUID', 'MONK');
-- CreateEnum
CREATE TYPE "Ability" AS ENUM ('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
-- CreateEnum
CREATE TYPE "Skill" AS ENUM ('ACROBATICS', 'ANIMAL_HANDLING', 'ARCANA', 'ATHLETICS', 'DECEPTION', 'HISTORY', 'INSIGHT', 'INTIMIDATION', 'INVESTIGATION', 'MEDICINE', 'NATURE', 'PERCEPTION', 'PERFORMANCE', 'PERSUASION', 'RELIGION', 'SLEIGHT_OF_HAND', 'STEALTH', 'SURVIVAL');
-- CreateEnum
CREATE TYPE "BuildStage" AS ENUM ('NOT_STARTED', 'ROLLING_SPECIES', 'ROLLING_CLASS', 'ROLLING_MOTIVATION', 'ASSIGNING_SCORES', 'ASSIGNING_PROFICIENCIES', 'CHOOSING_EQUIPMENT', 'COMPLETE');
-- CreateEnum
CREATE TYPE "EncounterStatus" AS ENUM ('PLANNING', 'ACTIVE', 'COMPLETE');
-- CreateEnum
CREATE TYPE "NoteVisibility" AS ENUM ('DM_ONLY', 'SHARED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "campaigns" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"inviteCode" TEXT NOT NULL,
"startingGold" INTEGER NOT NULL DEFAULT 0,
"startingItemCount" INTEGER NOT NULL DEFAULT 0,
"startingSpellCount" INTEGER NOT NULL DEFAULT 0,
"skipMotivationRoll" BOOLEAN NOT NULL DEFAULT false,
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "campaigns_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "campaign_members" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "CampaignRole" NOT NULL DEFAULT 'PLAYER',
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "campaign_members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "characters" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"userId" TEXT,
"createdById" TEXT NOT NULL,
"name" TEXT NOT NULL,
"species" "Species",
"characterClass" "CharacterClass",
"level" INTEGER NOT NULL DEFAULT 1,
"xp" INTEGER NOT NULL DEFAULT 0,
"motivation" TEXT,
"strScore" INTEGER NOT NULL DEFAULT 10,
"dexScore" INTEGER NOT NULL DEFAULT 10,
"conScore" INTEGER NOT NULL DEFAULT 10,
"intScore" INTEGER NOT NULL DEFAULT 10,
"wisScore" INTEGER NOT NULL DEFAULT 10,
"chaScore" INTEGER NOT NULL DEFAULT 10,
"halfElfBonusAbility1" "Ability",
"halfElfBonusAbility2" "Ability",
"currentHp" INTEGER NOT NULL DEFAULT 0,
"tempHp" INTEGER NOT NULL DEFAULT 0,
"armorBonus" INTEGER NOT NULL DEFAULT 0,
"speedOverride" INTEGER,
"specialAbilities" TEXT,
"gold" INTEGER NOT NULL DEFAULT 0,
"buildStage" "BuildStage" NOT NULL DEFAULT 'NOT_STARTED',
"buildState" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "characters_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "character_skill_proficiencies" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"skill" "Skill" NOT NULL,
"proficient" BOOLEAN NOT NULL DEFAULT true,
"expertise" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "character_skill_proficiencies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "character_save_proficiencies" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"ability" "Ability" NOT NULL,
"proficient" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "character_save_proficiencies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "weapons" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"damageDice" TEXT NOT NULL,
"damageType" TEXT NOT NULL,
"attackAbility" TEXT NOT NULL,
"properties" TEXT[],
"rangeNormal" INTEGER,
"rangeLong" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "weapons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "character_weapons" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"weaponId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1,
"equipped" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "character_weapons_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "inventory_items" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"quantity" INTEGER NOT NULL DEFAULT 1,
"equipped" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "inventory_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "spells" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"level" INTEGER NOT NULL,
"school" TEXT,
"castingTime" TEXT,
"range" TEXT,
"duration" TEXT,
"description" TEXT,
CONSTRAINT "spells_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "character_spells" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"spellId" TEXT NOT NULL,
"prepared" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "character_spells_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "monsters" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"ac" INTEGER NOT NULL,
"maxHp" INTEGER NOT NULL,
"speed" INTEGER NOT NULL DEFAULT 30,
"strScore" INTEGER NOT NULL DEFAULT 10,
"dexScore" INTEGER NOT NULL DEFAULT 10,
"conScore" INTEGER NOT NULL DEFAULT 10,
"intScore" INTEGER NOT NULL DEFAULT 10,
"wisScore" INTEGER NOT NULL DEFAULT 10,
"chaScore" INTEGER NOT NULL DEFAULT 10,
"attackBonus" INTEGER NOT NULL DEFAULT 0,
"damageDice" TEXT,
"challengeRating" TEXT,
"specialTrait" TEXT,
"notes" TEXT,
CONSTRAINT "monsters_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "encounters" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"status" "EncounterStatus" NOT NULL DEFAULT 'PLANNING',
"currentRound" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "encounters_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "encounter_participants" (
"id" TEXT NOT NULL,
"encounterId" TEXT NOT NULL,
"characterId" TEXT,
"monsterId" TEXT,
"displayName" TEXT NOT NULL,
"initiative" INTEGER NOT NULL DEFAULT 0,
"currentHp" INTEGER NOT NULL,
"maxHp" INTEGER NOT NULL,
"conditions" TEXT[] DEFAULT ARRAY[]::TEXT[],
"isMonster" BOOLEAN NOT NULL,
CONSTRAINT "encounter_participants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notes" (
"id" TEXT NOT NULL,
"campaignId" TEXT,
"characterId" TEXT,
"authorId" TEXT NOT NULL,
"body" TEXT NOT NULL,
"visibility" "NoteVisibility" NOT NULL DEFAULT 'SHARED',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "campaigns_inviteCode_key" ON "campaigns"("inviteCode");
-- CreateIndex
CREATE UNIQUE INDEX "campaign_members_campaignId_userId_key" ON "campaign_members"("campaignId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "character_skill_proficiencies_characterId_skill_key" ON "character_skill_proficiencies"("characterId", "skill");
-- CreateIndex
CREATE UNIQUE INDEX "character_save_proficiencies_characterId_ability_key" ON "character_save_proficiencies"("characterId", "ability");
-- CreateIndex
CREATE UNIQUE INDEX "character_weapons_characterId_weaponId_key" ON "character_weapons"("characterId", "weaponId");
-- CreateIndex
CREATE UNIQUE INDEX "character_spells_characterId_spellId_key" ON "character_spells"("characterId", "spellId");
-- AddForeignKey
ALTER TABLE "campaign_members" ADD CONSTRAINT "campaign_members_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "campaign_members" ADD CONSTRAINT "campaign_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "characters" ADD CONSTRAINT "characters_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "characters" ADD CONSTRAINT "characters_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "characters" ADD CONSTRAINT "characters_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "character_skill_proficiencies" ADD CONSTRAINT "character_skill_proficiencies_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "character_save_proficiencies" ADD CONSTRAINT "character_save_proficiencies_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "weapons" ADD CONSTRAINT "weapons_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "character_weapons" ADD CONSTRAINT "character_weapons_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "character_weapons" ADD CONSTRAINT "character_weapons_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventory_items" ADD CONSTRAINT "inventory_items_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "spells" ADD CONSTRAINT "spells_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "character_spells" ADD CONSTRAINT "character_spells_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "character_spells" ADD CONSTRAINT "character_spells_spellId_fkey" FOREIGN KEY ("spellId") REFERENCES "spells"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "monsters" ADD CONSTRAINT "monsters_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_participants" ADD CONSTRAINT "encounter_participants_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_participants" ADD CONSTRAINT "encounter_participants_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_participants" ADD CONSTRAINT "encounter_participants_monsterId_fkey" FOREIGN KEY ("monsterId") REFERENCES "monsters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notes" ADD CONSTRAINT "notes_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notes" ADD CONSTRAINT "notes_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notes" ADD CONSTRAINT "notes_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+412
View File
@@ -0,0 +1,412 @@
// D&D Campaign Manager — Prisma schema
// Postgres. Run `npx prisma migrate dev --name init` after `DATABASE_URL` is set,
// then `npx prisma db seed` using the seed script built from /seed-data/*.json
// (see CLAUDE_BUILD_PROMPT.md, "Database" section).
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
enum CampaignRole {
DM
PLAYER
}
enum Species {
HUMAN
HALF_ELF
ELF
GNOME
DWARF
HALFLING
DRAGONBORN
TIEFLING
HALF_ORC
ORC
}
enum CharacterClass {
FIGHTER
BARBARIAN
ROGUE
RANGER
WIZARD
SORCERER
CLERIC
PALADIN
BARD
WARLOCK
DRUID
MONK
}
enum Ability {
STR
DEX
CON
INT
WIS
CHA
}
enum Skill {
ACROBATICS
ANIMAL_HANDLING
ARCANA
ATHLETICS
DECEPTION
HISTORY
INSIGHT
INTIMIDATION
INVESTIGATION
MEDICINE
NATURE
PERCEPTION
PERFORMANCE
PERSUASION
RELIGION
SLEIGHT_OF_HAND
STEALTH
SURVIVAL
}
enum BuildStage {
NOT_STARTED
ROLLING_SPECIES
ROLLING_CLASS
ROLLING_MOTIVATION
ASSIGNING_SCORES
ASSIGNING_PROFICIENCIES
CHOOSING_EQUIPMENT
COMPLETE
}
enum EncounterStatus {
PLANNING
ACTIVE
COMPLETE
}
enum NoteVisibility {
DM_ONLY
SHARED
}
// ---------------------------------------------------------------------------
// Identity & campaigns
// ---------------------------------------------------------------------------
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
displayName String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships CampaignMember[]
charactersOwned Character[] @relation("PlayerCharacters")
charactersCreated Character[] @relation("CreatedBy")
notesAuthored Note[]
@@map("users")
}
model Campaign {
id String @id @default(cuid())
name String
description String?
inviteCode String @unique
startingGold Int @default(0)
startingItemCount Int @default(0)
startingSpellCount Int @default(0)
skipMotivationRoll Boolean @default(false)
ownerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members CampaignMember[]
characters Character[]
weapons Weapon[]
spells Spell[]
monsters Monster[]
encounters Encounter[]
notes Note[]
@@map("campaigns")
}
model CampaignMember {
id String @id @default(cuid())
campaignId String
userId String
role CampaignRole @default(PLAYER)
joinedAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([campaignId, userId])
@@map("campaign_members")
}
// ---------------------------------------------------------------------------
// Characters
// ---------------------------------------------------------------------------
model Character {
id String @id @default(cuid())
campaignId String
userId String? // owning player; null for a DM-controlled NPC/ally
createdById String
name String
species Species?
characterClass CharacterClass?
level Int @default(1)
xp Int @default(0)
motivation String?
// Rolled ability scores (before species bonus)
strScore Int @default(10)
dexScore Int @default(10)
conScore Int @default(10)
intScore Int @default(10)
wisScore Int @default(10)
chaScore Int @default(10)
// Half-Elf's flexible +1/+1 choice; null unless species = HALF_ELF
halfElfBonusAbility1 Ability?
halfElfBonusAbility2 Ability?
currentHp Int @default(0)
tempHp Int @default(0)
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
speedOverride Int?
specialAbilities String? // free-text list shown in the quick-stats panel
gold Int @default(0)
buildStage BuildStage @default(NOT_STARTED)
buildState Json? // in-progress dice rolls / pending choices while building
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User? @relation("PlayerCharacters", fields: [userId], references: [id])
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
skillProficiencies CharacterSkillProficiency[]
saveProficiencies CharacterSaveProficiency[]
inventoryItems InventoryItem[]
weapons CharacterWeapon[]
spells CharacterSpell[]
notes Note[]
encounterEntries EncounterParticipant[]
@@map("characters")
}
model CharacterSkillProficiency {
id String @id @default(cuid())
characterId String
skill Skill
proficient Boolean @default(true)
expertise Boolean @default(false) // double proficiency bonus, e.g. Rogue
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([characterId, skill])
@@map("character_skill_proficiencies")
}
model CharacterSaveProficiency {
id String @id @default(cuid())
characterId String
ability Ability
proficient Boolean @default(true)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([characterId, ability])
@@map("character_save_proficiencies")
}
// ---------------------------------------------------------------------------
// Weapons & inventory
// ---------------------------------------------------------------------------
model Weapon {
id String @id @default(cuid())
campaignId String
name String
damageDice String // e.g. "1d8"
damageType String // e.g. "slashing"
attackAbility String // "STR" | "DEX" | "FINESSE" (finesse = better of STR/DEX)
properties String[] // e.g. ["finesse", "light", "thrown"]
rangeNormal Int?
rangeLong Int?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
assigned CharacterWeapon[]
@@map("weapons")
}
model CharacterWeapon {
id String @id @default(cuid())
characterId String
weaponId String
quantity Int @default(1)
equipped Boolean @default(false)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
weapon Weapon @relation(fields: [weaponId], references: [id], onDelete: Cascade)
@@unique([characterId, weaponId])
@@map("character_weapons")
}
model InventoryItem {
id String @id @default(cuid())
characterId String
name String
description String?
quantity Int @default(1)
equipped Boolean @default(false)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@map("inventory_items")
}
// ---------------------------------------------------------------------------
// Spells
// ---------------------------------------------------------------------------
model Spell {
id String @id @default(cuid())
campaignId String
name String
level Int // 0 = cantrip
school String?
castingTime String?
range String?
duration String?
description String?
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
known CharacterSpell[]
@@map("spells")
}
model CharacterSpell {
id String @id @default(cuid())
characterId String
spellId String
prepared Boolean @default(false)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
spell Spell @relation(fields: [spellId], references: [id], onDelete: Cascade)
@@unique([characterId, spellId])
@@map("character_spells")
}
// ---------------------------------------------------------------------------
// Monsters & encounters
// ---------------------------------------------------------------------------
model Monster {
id String @id @default(cuid())
campaignId String
name String
ac Int
maxHp Int
speed Int @default(30)
strScore Int @default(10)
dexScore Int @default(10)
conScore Int @default(10)
intScore Int @default(10)
wisScore Int @default(10)
chaScore Int @default(10)
attackBonus Int @default(0)
damageDice String?
challengeRating String?
specialTrait String?
notes String?
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
encounterAppearances EncounterParticipant[]
@@map("monsters")
}
model Encounter {
id String @id @default(cuid())
campaignId String
name String
status EncounterStatus @default(PLANNING)
currentRound Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
participants EncounterParticipant[]
@@map("encounters")
}
// A row per combatant in an encounter — either a character or a monster.
// currentHp/maxHp are snapshotted here (not just read off Character/Monster)
// so damage tracking during a fight doesn't need to mutate the character's or
// monster's permanent record until the DM chooses to apply it back.
model EncounterParticipant {
id String @id @default(cuid())
encounterId String
characterId String?
monsterId String?
displayName String // denormalized so a monster can be renamed per-instance, e.g. "Goblin #2"
initiative Int @default(0)
currentHp Int
maxHp Int
conditions String[] @default([]) // e.g. ["poisoned", "prone"]
isMonster Boolean
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
monster Monster? @relation(fields: [monsterId], references: [id], onDelete: SetNull)
@@map("encounter_participants")
}
// ---------------------------------------------------------------------------
// Notes
// ---------------------------------------------------------------------------
model Note {
id String @id @default(cuid())
campaignId String?
characterId String?
authorId String
body String
visibility NoteVisibility @default(SHARED)
createdAt DateTime @default(now())
campaign Campaign? @relation(fields: [campaignId], references: [id], onDelete: Cascade)
character Character? @relation(fields: [characterId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
@@map("notes")
}
+70
View File
@@ -0,0 +1,70 @@
// Seeds a demo campaign for local dev. Species/classes/skills are NOT seeded
// as rows — they're fixed game-rule tables ported into src/game/data/*.js and
// used directly by the calculation engine. Weapons ARE seeded, but only into
// this demo campaign (weapons are per-campaign in the schema, since a DM can
// add homebrew weapons).
const path = require('path');
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { generateInviteCode } = require('../src/utils/inviteCode');
const prisma = new PrismaClient();
const weapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
async function main() {
const passwordHash = await bcrypt.hash('password123', 12);
const dm = await prisma.user.upsert({
where: { email: 'dm@example.com' },
update: {},
create: { email: 'dm@example.com', passwordHash, displayName: 'Dungeon Master' },
});
const player = await prisma.user.upsert({
where: { email: 'player@example.com' },
update: {},
create: { email: 'player@example.com', passwordHash, displayName: 'Test Player' },
});
let campaign = await prisma.campaign.findFirst({ where: { name: 'The Sunken Reach (Demo)' } });
if (!campaign) {
campaign = await prisma.campaign.create({
data: {
name: 'The Sunken Reach (Demo)',
description: 'A demo campaign seeded for local development.',
inviteCode: generateInviteCode(),
startingGold: 15,
startingItemCount: 2,
startingSpellCount: 2,
ownerId: dm.id,
members: {
create: [
{ userId: dm.id, role: 'DM' },
{ userId: player.id, role: 'PLAYER' },
],
},
},
});
}
for (const weapon of weapons) {
const existing = await prisma.weapon.findFirst({ where: { campaignId: campaign.id, name: weapon.name } });
if (!existing) {
await prisma.weapon.create({ data: { ...weapon, campaignId: campaign.id } });
}
}
console.log('Seed complete.');
console.log(` DM login: dm@example.com / password123`);
console.log(` Player login: player@example.com / password123`);
console.log(` Campaign invite code: ${campaign.inviteCode}`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+14
View File
@@ -0,0 +1,14 @@
[
{ "key": "FIGHTER", "name": "Fighter", "hitDie": 10, "primaryAbility": "Str/Dex", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null },
{ "key": "BARBARIAN", "name": "Barbarian", "hitDie": 12, "primaryAbility": "Str", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null },
{ "key": "ROGUE", "name": "Rogue", "hitDie": 8, "primaryAbility": "Dex", "saveProficiencies": ["DEX", "INT"], "spellcastingAbility": null },
{ "key": "RANGER", "name": "Ranger", "hitDie": 10, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": "WIS" },
{ "key": "WIZARD", "name": "Wizard", "hitDie": 6, "primaryAbility": "Int", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "INT" },
{ "key": "SORCERER", "name": "Sorcerer", "hitDie": 6, "primaryAbility": "Cha", "saveProficiencies": ["CON", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "CLERIC", "name": "Cleric", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "WIS" },
{ "key": "PALADIN", "name": "Paladin", "hitDie": 10, "primaryAbility": "Str/Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "BARD", "name": "Bard", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["DEX", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "WARLOCK", "name": "Warlock", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "DRUID", "name": "Druid", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "WIS" },
{ "key": "MONK", "name": "Monk", "hitDie": 8, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": null }
]
+20
View File
@@ -0,0 +1,20 @@
[
{ "key": "ACROBATICS", "name": "Acrobatics", "ability": "DEX" },
{ "key": "ANIMAL_HANDLING", "name": "Animal Handling", "ability": "WIS" },
{ "key": "ARCANA", "name": "Arcana", "ability": "INT" },
{ "key": "ATHLETICS", "name": "Athletics", "ability": "STR" },
{ "key": "DECEPTION", "name": "Deception", "ability": "CHA" },
{ "key": "HISTORY", "name": "History", "ability": "INT" },
{ "key": "INSIGHT", "name": "Insight", "ability": "WIS" },
{ "key": "INTIMIDATION", "name": "Intimidation", "ability": "CHA" },
{ "key": "INVESTIGATION", "name": "Investigation", "ability": "INT" },
{ "key": "MEDICINE", "name": "Medicine", "ability": "WIS" },
{ "key": "NATURE", "name": "Nature", "ability": "INT" },
{ "key": "PERCEPTION", "name": "Perception", "ability": "WIS" },
{ "key": "PERFORMANCE", "name": "Performance", "ability": "CHA" },
{ "key": "PERSUASION", "name": "Persuasion", "ability": "CHA" },
{ "key": "RELIGION", "name": "Religion", "ability": "INT" },
{ "key": "SLEIGHT_OF_HAND", "name": "Sleight of Hand", "ability": "DEX" },
{ "key": "STEALTH", "name": "Stealth", "ability": "DEX" },
{ "key": "SURVIVAL", "name": "Survival", "ability": "WIS" }
]
+12
View File
@@ -0,0 +1,12 @@
[
{ "key": "HUMAN", "name": "Human", "abilityBonuses": { "STR": 1, "DEX": 1, "CON": 1, "INT": 1, "WIS": 1, "CHA": 1 }, "speed": 30, "notes": null },
{ "key": "HALF_ELF", "name": "Half-Elf", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 2 }, "speed": 30, "notes": "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default." },
{ "key": "ELF", "name": "Elf", "abilityBonuses": { "STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null },
{ "key": "GNOME", "name": "Gnome", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 2, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
{ "key": "DWARF", "name": "Dwarf", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 2, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
{ "key": "HALFLING", "name": "Halfling", "abilityBonuses": { "STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
{ "key": "DRAGONBORN", "name": "Dragonborn", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 1 }, "speed": 30, "notes": null },
{ "key": "TIEFLING", "name": "Tiefling", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 1, "WIS": 0, "CHA": 2 }, "speed": 30, "notes": null },
{ "key": "HALF_ORC", "name": "Half-Orc", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null },
{ "key": "ORC", "name": "Orc", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null }
]
+18
View File
@@ -0,0 +1,18 @@
[
{ "name": "Dagger", "damageDice": "1d4", "damageType": "piercing", "attackAbility": "FINESSE", "properties": ["finesse", "light", "thrown"], "rangeNormal": 20, "rangeLong": 60 },
{ "name": "Shortsword", "damageDice": "1d6", "damageType": "piercing", "attackAbility": "FINESSE", "properties": ["finesse", "light"], "rangeNormal": null, "rangeLong": null },
{ "name": "Rapier", "damageDice": "1d8", "damageType": "piercing", "attackAbility": "FINESSE", "properties": ["finesse"], "rangeNormal": null, "rangeLong": null },
{ "name": "Longsword", "damageDice": "1d8", "damageType": "slashing", "attackAbility": "STR", "properties": ["versatile (1d10)"], "rangeNormal": null, "rangeLong": null },
{ "name": "Greatsword", "damageDice": "2d6", "damageType": "slashing", "attackAbility": "STR", "properties": ["heavy", "two-handed"], "rangeNormal": null, "rangeLong": null },
{ "name": "Battleaxe", "damageDice": "1d8", "damageType": "slashing", "attackAbility": "STR", "properties": ["versatile (1d10)"], "rangeNormal": null, "rangeLong": null },
{ "name": "Handaxe", "damageDice": "1d6", "damageType": "slashing", "attackAbility": "STR", "properties": ["light", "thrown"], "rangeNormal": 20, "rangeLong": 60 },
{ "name": "Warhammer", "damageDice": "1d8", "damageType": "bludgeoning", "attackAbility": "STR", "properties": ["versatile (1d10)"], "rangeNormal": null, "rangeLong": null },
{ "name": "Mace", "damageDice": "1d6", "damageType": "bludgeoning", "attackAbility": "STR", "properties": [], "rangeNormal": null, "rangeLong": null },
{ "name": "Quarterstaff", "damageDice": "1d6", "damageType": "bludgeoning", "attackAbility": "STR", "properties": ["versatile (1d8)"], "rangeNormal": null, "rangeLong": null },
{ "name": "Spear", "damageDice": "1d6", "damageType": "piercing", "attackAbility": "STR", "properties": ["thrown", "versatile (1d8)"], "rangeNormal": 20, "rangeLong": 60 },
{ "name": "Shortbow", "damageDice": "1d6", "damageType": "piercing", "attackAbility": "DEX", "properties": ["ammunition", "two-handed"], "rangeNormal": 80, "rangeLong": 320 },
{ "name": "Longbow", "damageDice": "1d8", "damageType": "piercing", "attackAbility": "DEX", "properties": ["ammunition", "heavy", "two-handed"], "rangeNormal": 150, "rangeLong": 600 },
{ "name": "Light Crossbow", "damageDice": "1d8", "damageType": "piercing", "attackAbility": "DEX", "properties": ["ammunition", "loading", "two-handed"], "rangeNormal": 80, "rangeLong": 320 },
{ "name": "Dart", "damageDice": "1d4", "damageType": "piercing", "attackAbility": "FINESSE", "properties": ["finesse", "thrown"], "rangeNormal": 20, "rangeLong": 60 },
{ "name": "Unarmed Strike", "damageDice": "1", "damageType": "bludgeoning", "attackAbility": "STR", "properties": [], "rangeNormal": null, "rangeLong": null }
]
+39
View File
@@ -0,0 +1,39 @@
const express = require('express');
const cors = require('cors');
const authRoutes = require('./routes/auth.routes');
const campaignsRoutes = require('./routes/campaigns.routes');
const charactersRoutes = require('./routes/characters.routes');
const encountersRoutes = require('./routes/encounters.routes');
const notesRoutes = require('./routes/notes.routes');
function createApp() {
const app = express();
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173' }));
app.use(express.json());
app.get('/health', (req, res) => res.json({ ok: true }));
app.use('/auth', authRoutes);
app.use('/campaigns', campaignsRoutes);
app.use('/characters', charactersRoutes);
app.use('/encounters', encountersRoutes);
app.use('/notes', notesRoutes);
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
console.error(err);
if (err.code === 'P2025') return res.status(404).json({ error: 'Record not found' });
if (err.code === 'P2002') return res.status(409).json({ error: 'A record with that value already exists' });
res.status(500).json({ error: 'Internal server error' });
});
return app;
}
module.exports = { createApp };
+5
View File
@@ -0,0 +1,5 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
module.exports = prisma;
+76
View File
@@ -0,0 +1,76 @@
// Roll-Your-Character build flow tables — see /docs/ROLL_YOUR_CHARACTER.md.
// Dice are always rolled server-side (crypto.randomInt), never trusted from the client.
const crypto = require('crypto');
const { SPECIES } = require('./data/species');
const { CLASSES } = require('./data/classes');
const ALL_SPECIES = Object.keys(SPECIES);
const ALL_CLASSES = Object.keys(CLASSES);
const SPECIES_ROLL_TABLE = {
1: ['HUMAN', 'HALF_ELF'],
2: ['ELF', 'GNOME'],
3: ['DWARF', 'HALFLING'],
4: ['DRAGONBORN', 'TIEFLING'],
5: ['HALF_ORC', 'ORC'],
6: 'WILDCARD',
};
const CLASS_ROLL_TABLE = {
1: ['FIGHTER', 'BARBARIAN'],
2: ['ROGUE', 'RANGER'],
3: ['WIZARD', 'SORCERER'],
4: ['CLERIC', 'PALADIN'],
5: ['BARD', 'WARLOCK'],
6: ['DRUID', 'MONK'],
7: 'REROLL',
8: 'WILDCARD',
};
const MOTIVATION_TABLE = {
1: 'Revenge for a past wrong',
2: 'Seeking fortune or wealth',
3: 'Protecting someone they love',
4: 'Redemption for a past mistake',
5: 'Driven by curiosity or knowledge',
6: 'Duty or oath to a cause or order',
};
function rollDie(sides) {
return crypto.randomInt(1, sides + 1);
}
// Standard method: 4d6, drop the lowest, sum the rest.
function rollAbilityScore() {
const dice = Array.from({ length: 4 }, () => rollDie(6));
const sorted = [...dice].sort((a, b) => a - b);
const dropped = sorted[0];
const total = sorted.slice(1).reduce((a, b) => a + b, 0);
return { dice, dropped, total };
}
function speciesChoicesForRoll(roll) {
const entry = SPECIES_ROLL_TABLE[roll];
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_SPECIES };
return { type: 'choice', options: entry };
}
function classChoicesForRoll(roll) {
const entry = CLASS_ROLL_TABLE[roll];
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_CLASSES };
if (entry === 'REROLL') return { type: 'reroll', options: [] };
return { type: 'choice', options: entry };
}
module.exports = {
SPECIES_ROLL_TABLE,
CLASS_ROLL_TABLE,
MOTIVATION_TABLE,
ALL_SPECIES,
ALL_CLASSES,
rollDie,
rollAbilityScore,
speciesChoicesForRoll,
classChoicesForRoll,
};
+250
View File
@@ -0,0 +1,250 @@
// D&D 5e rules calculation engine — the ONLY place these formulas may live.
// See /docs/GAME_RULES_REFERENCE.md for the spec this implements.
// Pure and dependency-free on purpose: no DB calls, no framework imports.
const { SPECIES } = require('./data/species');
const { CLASSES } = require('./data/classes');
const { SKILLS } = require('./data/skills');
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
function abilityModifier(score) {
return Math.floor((score - 10) / 2);
}
function proficiencyBonus(level) {
return 2 + Math.floor((level - 1) / 4);
}
// Half-Elf's flexible +1/+1 lands on whichever two abilities the player picked
// (halfElfBonusAbilities), on top of the fixed +2 CHA in the species table.
function speciesAbilityBonus(ability, species, halfElfBonusAbilities = []) {
const speciesData = species ? SPECIES[species] : null;
if (!speciesData) return 0;
let bonus = speciesData.abilityBonuses[ability] || 0;
if (species === 'HALF_ELF' && halfElfBonusAbilities.includes(ability)) {
bonus += 1;
}
return bonus;
}
function finalAbilityScore(rolledScore, ability, species, halfElfBonusAbilities = []) {
return rolledScore + speciesAbilityBonus(ability, species, halfElfBonusAbilities);
}
function speciesSpeed(species) {
return species && SPECIES[species] ? SPECIES[species].speed : 30;
}
function effectiveSpeed(species, speedOverride) {
return speedOverride != null ? speedOverride : speciesSpeed(species);
}
function saveBonus(abilityMod, proficient, profBonus) {
return abilityMod + (proficient ? profBonus : 0);
}
// expertise (e.g. Rogue) doubles the proficiency portion of the bonus.
function skillBonus(abilityMod, proficient, profBonus, expertise = false) {
if (!proficient) return abilityMod;
return abilityMod + profBonus * (expertise ? 2 : 1);
}
function passivePerception(perceptionSkillBonus) {
return 10 + perceptionSkillBonus;
}
function armorClass(dexMod, armorBonus = 0) {
return 10 + dexMod + armorBonus;
}
function initiative(dexMod) {
return dexMod;
}
// Level 1 always uses the maximum possible Hit Die roll; every level after
// that uses the fixed "average" value (floor(hitDieSize / 2) + 1). Do not
// regress this to a flat average-for-all-levels formula — that was a bug
// caught while building the reference Excel calculator.
function hpMax(level, hitDieSize, conMod) {
const base = hitDieSize + conMod;
if (level <= 1) return base;
const perLevelAverage = Math.floor(hitDieSize / 2) + 1;
return base + (level - 1) * (perLevelAverage + conMod);
}
function spellSaveDC(profBonus, spellAbilityMod) {
return 8 + profBonus + spellAbilityMod;
}
function spellAttackBonus(profBonus, spellAbilityMod) {
return profBonus + spellAbilityMod;
}
// attackAbility is "STR" | "DEX" | "FINESSE" (finesse = better of STR/DEX).
function weaponAbilityModifier(attackAbility, strMod, dexMod) {
if (attackAbility === 'STR') return strMod;
if (attackAbility === 'DEX') return dexMod;
if (attackAbility === 'FINESSE') return Math.max(strMod, dexMod);
throw new Error(`Unknown attackAbility: ${attackAbility}`);
}
function weaponAttackBonus({ attackAbility, strMod, dexMod, proficient, profBonus }) {
const abilityMod = weaponAbilityModifier(attackAbility, strMod, dexMod);
return abilityMod + (proficient ? profBonus : 0);
}
function weaponDamageBonus({ attackAbility, strMod, dexMod }) {
return weaponAbilityModifier(attackAbility, strMod, dexMod);
}
function getGoverningAbilityForSkill(skill) {
const data = SKILLS[skill];
if (!data) throw new Error(`Unknown skill: ${skill}`);
return data.ability;
}
// Saving throw proficiencies are fixed per class, not a player choice —
// use this at character-build finalize time to seed CharacterSaveProficiency.
function getClassSaveProficiencies(characterClass) {
const data = characterClass ? CLASSES[characterClass] : null;
return data ? data.saveProficiencies : [];
}
function getClassHitDieSize(characterClass) {
const data = characterClass ? CLASSES[characterClass] : null;
return data ? data.hitDie : 8;
}
function getClassSpellcastingAbility(characterClass) {
const data = characterClass ? CLASSES[characterClass] : null;
return data ? data.spellcastingAbility : null;
}
/**
* Assembles the full derived character sheet from raw stored data. This is
* the single function the API and quick-stats view should call rather than
* re-deriving any of the above by hand.
*
* @param {object} character - rolled scores, species/class, level, armorBonus, etc.
* @param {Array<{skill: string, proficient: boolean, expertise: boolean}>} skillProficiencies
* @param {Array<{ability: string, proficient: boolean}>} saveProficiencies
*/
function computeCharacterSheet(character, skillProficiencies = [], saveProficiencies = []) {
const {
level = 1,
species = null,
characterClass = null,
strScore = 10,
dexScore = 10,
conScore = 10,
intScore = 10,
wisScore = 10,
chaScore = 10,
halfElfBonusAbility1 = null,
halfElfBonusAbility2 = null,
armorBonus = 0,
speedOverride = null,
} = character;
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
const abilities = {};
for (const ability of ABILITIES) {
const final = finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities);
abilities[ability] = {
rolled: rolled[ability],
final,
modifier: abilityModifier(final),
};
}
const profBonus = proficiencyBonus(level);
const saves = {};
for (const ability of ABILITIES) {
const entry = saveProficiencies.find((s) => s.ability === ability);
const proficient = entry ? entry.proficient : false;
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) };
}
const skills = {};
for (const skillKey of Object.keys(SKILLS)) {
const ability = getGoverningAbilityForSkill(skillKey);
const entry = skillProficiencies.find((s) => s.skill === skillKey);
const proficient = entry ? entry.proficient : false;
const expertise = entry ? Boolean(entry.expertise) : false;
skills[skillKey] = {
ability,
proficient,
expertise,
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise),
};
}
const hitDieSize = getClassHitDieSize(characterClass);
const spellcastingAbility = getClassSpellcastingAbility(characterClass);
return {
abilities,
proficiencyBonus: profBonus,
armorClass: armorClass(abilities.DEX.modifier, armorBonus),
initiative: initiative(abilities.DEX.modifier),
speed: effectiveSpeed(species, speedOverride),
hp: { max: hpMax(level, hitDieSize, abilities.CON.modifier) },
hitDice: { size: hitDieSize, count: level },
saves,
skills,
passivePerception: passivePerception(skills.PERCEPTION.bonus),
spellcasting: spellcastingAbility
? {
ability: spellcastingAbility,
saveDC: spellSaveDC(profBonus, abilities[spellcastingAbility].modifier),
attackBonus: spellAttackBonus(profBonus, abilities[spellcastingAbility].modifier),
}
: null,
};
}
/**
* Attack/damage bonus for a specific weapon against a character's abilities —
* used by the quick-stats panel and weapon list.
*/
function computeWeaponStats(weapon, abilities, profBonus, proficient = true) {
const strMod = abilities.STR.modifier;
const dexMod = abilities.DEX.modifier;
return {
attackBonus: weaponAttackBonus({ attackAbility: weapon.attackAbility, strMod, dexMod, proficient, profBonus }),
damageBonus: weaponDamageBonus({ attackAbility: weapon.attackAbility, strMod, dexMod }),
damageDice: weapon.damageDice,
damageType: weapon.damageType,
};
}
module.exports = {
ABILITIES,
abilityModifier,
proficiencyBonus,
speciesAbilityBonus,
finalAbilityScore,
speciesSpeed,
effectiveSpeed,
saveBonus,
skillBonus,
passivePerception,
armorClass,
initiative,
hpMax,
spellSaveDC,
spellAttackBonus,
weaponAbilityModifier,
weaponAttackBonus,
weaponDamageBonus,
getGoverningAbilityForSkill,
getClassSaveProficiencies,
getClassHitDieSize,
getClassSpellcastingAbility,
computeCharacterSheet,
computeWeaponStats,
};
+214
View File
@@ -0,0 +1,214 @@
const {
abilityModifier,
proficiencyBonus,
finalAbilityScore,
saveBonus,
skillBonus,
passivePerception,
armorClass,
initiative,
hpMax,
spellSaveDC,
spellAttackBonus,
weaponAttackBonus,
weaponDamageBonus,
computeCharacterSheet,
computeWeaponStats,
} = require('./calculations');
describe('abilityModifier', () => {
test('floors toward negative infinity, not toward zero', () => {
expect(abilityModifier(7)).toBe(-2); // (7-10)/2 = -1.5 -> floor = -2
expect(abilityModifier(10)).toBe(0);
expect(abilityModifier(11)).toBe(0);
expect(abilityModifier(12)).toBe(1);
expect(abilityModifier(20)).toBe(5);
expect(abilityModifier(1)).toBe(-5);
});
});
describe('proficiencyBonus', () => {
test('matches the 5e level bands', () => {
expect(proficiencyBonus(1)).toBe(2);
expect(proficiencyBonus(4)).toBe(2);
expect(proficiencyBonus(5)).toBe(3);
expect(proficiencyBonus(8)).toBe(3);
expect(proficiencyBonus(9)).toBe(4);
expect(proficiencyBonus(12)).toBe(4);
expect(proficiencyBonus(13)).toBe(5);
expect(proficiencyBonus(16)).toBe(5);
expect(proficiencyBonus(17)).toBe(6);
expect(proficiencyBonus(20)).toBe(6);
});
});
describe('finalAbilityScore / species bonuses', () => {
test('applies a flat species bonus', () => {
expect(finalAbilityScore(14, 'DEX', 'ELF')).toBe(16);
expect(finalAbilityScore(14, 'STR', 'ELF')).toBe(14);
});
test('human gets +1 to every ability', () => {
expect(finalAbilityScore(10, 'STR', 'HUMAN')).toBe(11);
expect(finalAbilityScore(10, 'CHA', 'HUMAN')).toBe(11);
});
test('half-elf gets fixed +2 CHA plus chosen +1/+1', () => {
expect(finalAbilityScore(10, 'CHA', 'HALF_ELF', ['STR', 'INT'])).toBe(12);
expect(finalAbilityScore(10, 'STR', 'HALF_ELF', ['STR', 'INT'])).toBe(11);
expect(finalAbilityScore(10, 'INT', 'HALF_ELF', ['STR', 'INT'])).toBe(11);
expect(finalAbilityScore(10, 'WIS', 'HALF_ELF', ['STR', 'INT'])).toBe(10);
});
test('no species leaves score untouched', () => {
expect(finalAbilityScore(10, 'STR', null)).toBe(10);
});
});
describe('saveBonus / skillBonus', () => {
test('save bonus adds proficiency only when proficient', () => {
expect(saveBonus(3, true, 2)).toBe(5);
expect(saveBonus(3, false, 2)).toBe(3);
});
test('skill bonus with expertise doubles the proficiency portion', () => {
expect(skillBonus(2, true, 2, false)).toBe(4);
expect(skillBonus(2, true, 2, true)).toBe(6);
expect(skillBonus(2, false, 2, true)).toBe(2);
});
});
describe('passivePerception / armorClass / initiative', () => {
test('passive perception is 10 + perception bonus', () => {
expect(passivePerception(3)).toBe(13);
});
test('armor class is 10 + dex mod + armor bonus', () => {
expect(armorClass(2, 3)).toBe(15);
expect(armorClass(-1, 0)).toBe(9);
});
test('initiative equals dex modifier', () => {
expect(initiative(3)).toBe(3);
});
});
describe('hpMax — the level-1-max-die rule', () => {
test('level 1 uses the max die roll, not the average', () => {
expect(hpMax(1, 10, 2)).toBe(12); // 10 (max d10) + 2 CON
expect(hpMax(1, 6, 0)).toBe(6);
});
test('level 2+ adds the fixed average per additional level', () => {
// d10 average = floor(10/2)+1 = 6
expect(hpMax(2, 10, 2)).toBe(12 + (6 + 2)); // 20
expect(hpMax(5, 10, 2)).toBe(12 + 4 * (6 + 2)); // 44
});
test('d6 and d12 averages', () => {
expect(Math.floor(6 / 2) + 1).toBe(4);
expect(Math.floor(12 / 2) + 1).toBe(7);
expect(hpMax(2, 6, 0)).toBe(6 + 4);
expect(hpMax(2, 12, 0)).toBe(12 + 7);
});
});
describe('spellcasting', () => {
test('spell save DC and attack bonus', () => {
expect(spellSaveDC(3, 4)).toBe(15);
expect(spellAttackBonus(3, 4)).toBe(7);
});
});
describe('weapon bonuses', () => {
test('STR weapon uses STR modifier', () => {
expect(weaponAttackBonus({ attackAbility: 'STR', strMod: 3, dexMod: 1, proficient: true, profBonus: 2 })).toBe(5);
expect(weaponDamageBonus({ attackAbility: 'STR', strMod: 3, dexMod: 1 })).toBe(3);
});
test('DEX weapon uses DEX modifier', () => {
expect(weaponAttackBonus({ attackAbility: 'DEX', strMod: 3, dexMod: 1, proficient: false, profBonus: 2 })).toBe(1);
});
test('FINESSE weapon uses the better of STR/DEX', () => {
expect(weaponDamageBonus({ attackAbility: 'FINESSE', strMod: 1, dexMod: 4 })).toBe(4);
expect(weaponDamageBonus({ attackAbility: 'FINESSE', strMod: 5, dexMod: 2 })).toBe(5);
});
test('unknown attackAbility throws rather than silently defaulting', () => {
expect(() => weaponDamageBonus({ attackAbility: 'BOGUS', strMod: 1, dexMod: 1 })).toThrow();
});
});
describe('computeCharacterSheet — full assembly', () => {
test('level 1 human fighter', () => {
const sheet = computeCharacterSheet(
{
level: 1,
species: 'HUMAN',
characterClass: 'FIGHTER',
strScore: 15,
dexScore: 13,
conScore: 14,
intScore: 10,
wisScore: 12,
chaScore: 8,
armorBonus: 4,
},
[{ skill: 'ATHLETICS', proficient: true, expertise: false }],
[
{ ability: 'STR', proficient: true },
{ ability: 'CON', proficient: true },
]
);
// Human: +1 to every score -> STR 16 (+3), DEX 14 (+2), CON 15 (+2)
expect(sheet.abilities.STR.final).toBe(16);
expect(sheet.abilities.STR.modifier).toBe(3);
expect(sheet.abilities.CON.modifier).toBe(2);
expect(sheet.proficiencyBonus).toBe(2);
expect(sheet.armorClass).toBe(10 + sheet.abilities.DEX.modifier + 4);
expect(sheet.hp.max).toBe(10 + sheet.abilities.CON.modifier); // d10 fighter, level 1 max die
expect(sheet.saves.STR.proficient).toBe(true);
expect(sheet.saves.STR.bonus).toBe(sheet.abilities.STR.modifier + 2);
expect(sheet.saves.DEX.proficient).toBe(false);
expect(sheet.skills.ATHLETICS.bonus).toBe(sheet.abilities.STR.modifier + 2);
expect(sheet.spellcasting).toBeNull(); // Fighter has no spellcasting ability
});
test('spellcasting classes expose saveDC/attackBonus', () => {
const sheet = computeCharacterSheet({
level: 5,
species: 'ELF',
characterClass: 'WIZARD',
intScore: 16,
});
expect(sheet.spellcasting).not.toBeNull();
expect(sheet.spellcasting.ability).toBe('INT');
expect(sheet.spellcasting.saveDC).toBe(8 + sheet.proficiencyBonus + sheet.abilities.INT.modifier);
});
test('half-elf bonus abilities flow through the full sheet', () => {
const sheet = computeCharacterSheet({
level: 1,
species: 'HALF_ELF',
characterClass: 'BARD',
strScore: 10,
chaScore: 10,
halfElfBonusAbility1: 'STR',
halfElfBonusAbility2: 'INT',
});
expect(sheet.abilities.CHA.final).toBe(12); // fixed +2
expect(sheet.abilities.STR.final).toBe(11); // chosen +1
});
});
describe('computeWeaponStats', () => {
test('assembles attack/damage bonus for a weapon from a computed sheet', () => {
const sheet = computeCharacterSheet({ level: 1, strScore: 16, dexScore: 12 });
const stats = computeWeaponStats({ attackAbility: 'STR', damageDice: '1d8', damageType: 'slashing' }, sheet.abilities, sheet.proficiencyBonus, true);
expect(stats.attackBonus).toBe(sheet.abilities.STR.modifier + sheet.proficiencyBonus);
expect(stats.damageBonus).toBe(sheet.abilities.STR.modifier);
expect(stats.damageDice).toBe('1d8');
});
});
+19
View File
@@ -0,0 +1,19 @@
// Generated from /seed-data/classes.json — keep in sync by hand if that file changes.
// See docs/GAME_RULES_REFERENCE.md "Class Quick Facts".
const CLASSES = {
FIGHTER: { key: 'FIGHTER', name: 'Fighter', hitDie: 10, primaryAbility: 'Str/Dex', saveProficiencies: ['STR', 'CON'], spellcastingAbility: null },
BARBARIAN: { key: 'BARBARIAN', name: 'Barbarian', hitDie: 12, primaryAbility: 'Str', saveProficiencies: ['STR', 'CON'], spellcastingAbility: null },
ROGUE: { key: 'ROGUE', name: 'Rogue', hitDie: 8, primaryAbility: 'Dex', saveProficiencies: ['DEX', 'INT'], spellcastingAbility: null },
RANGER: { key: 'RANGER', name: 'Ranger', hitDie: 10, primaryAbility: 'Dex/Wis', saveProficiencies: ['STR', 'DEX'], spellcastingAbility: 'WIS' },
WIZARD: { key: 'WIZARD', name: 'Wizard', hitDie: 6, primaryAbility: 'Int', saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'INT' },
SORCERER: { key: 'SORCERER', name: 'Sorcerer', hitDie: 6, primaryAbility: 'Cha', saveProficiencies: ['CON', 'CHA'], spellcastingAbility: 'CHA' },
CLERIC: { key: 'CLERIC', name: 'Cleric', hitDie: 8, primaryAbility: 'Wis', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'WIS' },
PALADIN: { key: 'PALADIN', name: 'Paladin', hitDie: 10, primaryAbility: 'Str/Cha', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA' },
BARD: { key: 'BARD', name: 'Bard', hitDie: 8, primaryAbility: 'Cha', saveProficiencies: ['DEX', 'CHA'], spellcastingAbility: 'CHA' },
WARLOCK: { key: 'WARLOCK', name: 'Warlock', hitDie: 8, primaryAbility: 'Cha', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA' },
DRUID: { key: 'DRUID', name: 'Druid', hitDie: 8, primaryAbility: 'Wis', saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'WIS' },
MONK: { key: 'MONK', name: 'Monk', hitDie: 8, primaryAbility: 'Dex/Wis', saveProficiencies: ['STR', 'DEX'], spellcastingAbility: null },
};
module.exports = { CLASSES };
+25
View File
@@ -0,0 +1,25 @@
// Generated from /seed-data/skills.json — keep in sync by hand if that file changes.
// See docs/GAME_RULES_REFERENCE.md "Skill -> Governing Ability".
const SKILLS = {
ACROBATICS: { key: 'ACROBATICS', name: 'Acrobatics', ability: 'DEX' },
ANIMAL_HANDLING: { key: 'ANIMAL_HANDLING', name: 'Animal Handling', ability: 'WIS' },
ARCANA: { key: 'ARCANA', name: 'Arcana', ability: 'INT' },
ATHLETICS: { key: 'ATHLETICS', name: 'Athletics', ability: 'STR' },
DECEPTION: { key: 'DECEPTION', name: 'Deception', ability: 'CHA' },
HISTORY: { key: 'HISTORY', name: 'History', ability: 'INT' },
INSIGHT: { key: 'INSIGHT', name: 'Insight', ability: 'WIS' },
INTIMIDATION: { key: 'INTIMIDATION', name: 'Intimidation', ability: 'CHA' },
INVESTIGATION: { key: 'INVESTIGATION', name: 'Investigation', ability: 'INT' },
MEDICINE: { key: 'MEDICINE', name: 'Medicine', ability: 'WIS' },
NATURE: { key: 'NATURE', name: 'Nature', ability: 'INT' },
PERCEPTION: { key: 'PERCEPTION', name: 'Perception', ability: 'WIS' },
PERFORMANCE: { key: 'PERFORMANCE', name: 'Performance', ability: 'CHA' },
PERSUASION: { key: 'PERSUASION', name: 'Persuasion', ability: 'CHA' },
RELIGION: { key: 'RELIGION', name: 'Religion', ability: 'INT' },
SLEIGHT_OF_HAND: { key: 'SLEIGHT_OF_HAND', name: 'Sleight of Hand', ability: 'DEX' },
STEALTH: { key: 'STEALTH', name: 'Stealth', ability: 'DEX' },
SURVIVAL: { key: 'SURVIVAL', name: 'Survival', ability: 'WIS' },
};
module.exports = { SKILLS };
+23
View File
@@ -0,0 +1,23 @@
// Generated from /seed-data/species.json — keep in sync by hand if that file changes.
// See docs/GAME_RULES_REFERENCE.md "Species Ability Score Increases & Speed".
const SPECIES = {
HUMAN: { key: 'HUMAN', name: 'Human', abilityBonuses: { STR: 1, DEX: 1, CON: 1, INT: 1, WIS: 1, CHA: 1 }, speed: 30, notes: null },
HALF_ELF: {
key: 'HALF_ELF',
name: 'Half-Elf',
abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 2 },
speed: 30,
notes: "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default.",
},
ELF: { key: 'ELF', name: 'Elf', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
GNOME: { key: 'GNOME', name: 'Gnome', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 2, WIS: 0, CHA: 0 }, speed: 25, notes: null },
DWARF: { key: 'DWARF', name: 'Dwarf', abilityBonuses: { STR: 0, DEX: 0, CON: 2, INT: 0, WIS: 0, CHA: 0 }, speed: 25, notes: null },
HALFLING: { key: 'HALFLING', name: 'Halfling', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 25, notes: null },
DRAGONBORN: { key: 'DRAGONBORN', name: 'Dragonborn', abilityBonuses: { STR: 2, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 1 }, speed: 30, notes: null },
TIEFLING: { key: 'TIEFLING', name: 'Tiefling', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 1, WIS: 0, CHA: 2 }, speed: 30, notes: null },
HALF_ORC: { key: 'HALF_ORC', name: 'Half-Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
ORC: { key: 'ORC', name: 'Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
};
module.exports = { SPECIES };
+20
View File
@@ -0,0 +1,20 @@
require('dotenv').config();
const http = require('http');
const { Server } = require('socket.io');
const { createApp } = require('./app');
const { attachSockets } = require('./sockets');
const PORT = process.env.PORT || 4000;
const app = createApp();
const server = http.createServer(app);
const io = new Server(server, {
cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:5173' },
});
attachSockets(io);
app.set('io', io);
server.listen(PORT, () => {
console.log(`D&D Campaign Manager API listening on http://localhost:${PORT}`);
});
+18
View File
@@ -0,0 +1,18 @@
const jwt = require('jsonwebtoken');
function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
const token = header.slice('Bearer '.length);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = { id: payload.sub, email: payload.email, displayName: payload.displayName };
return next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
module.exports = { requireAuth };
+40
View File
@@ -0,0 +1,40 @@
const prisma = require('../db');
async function loadCampaignMembership(userId, campaignId) {
return prisma.campaignMember.findUnique({
where: { campaignId_userId: { campaignId, userId } },
});
}
// Loads req.campaign + req.membership for a route keyed by :id or :campaignId.
// 404s if the campaign doesn't exist, 403s if the user isn't a member.
function requireCampaignMember() {
return async (req, res, next) => {
const campaignId = req.params.campaignId || req.params.id;
try {
const campaign = await prisma.campaign.findUnique({ where: { id: campaignId } });
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
const membership = await loadCampaignMembership(req.user.id, campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
req.campaign = campaign;
req.membership = membership;
return next();
} catch (err) {
return next(err);
}
};
}
// Must run after requireCampaignMember.
function requireDM() {
return (req, res, next) => {
if (!req.membership || req.membership.role !== 'DM') {
return res.status(403).json({ error: 'DM only' });
}
return next();
};
}
module.exports = { loadCampaignMembership, requireCampaignMember, requireDM };
+38
View File
@@ -0,0 +1,38 @@
const prisma = require('../db');
const { loadCampaignMembership } = require('./campaignAccess');
// Loads req.character (+ req.campaign, req.membership, req.isDM, req.isOwner)
// for a route keyed by :id (the character id). A DM can read/write any
// character in their campaign; a player can only read/write their own.
function requireCharacterAccess({ write = false } = {}) {
return async (req, res, next) => {
try {
const character = await prisma.character.findUnique({
where: { id: req.params.id },
include: { campaign: true },
});
if (!character) return res.status(404).json({ error: 'Character not found' });
const membership = await loadCampaignMembership(req.user.id, character.campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
const isDM = membership.role === 'DM';
const isOwner = character.userId === req.user.id;
if (write && !isDM && !isOwner) {
return res.status(403).json({ error: 'You can only edit your own character' });
}
req.character = character;
req.campaign = character.campaign;
req.membership = membership;
req.isDM = isDM;
req.isOwner = isOwner;
return next();
} catch (err) {
return next(err);
}
};
}
module.exports = { requireCharacterAccess };
+199
View File
@@ -0,0 +1,199 @@
// Integration tests exercising the real Express app + a real Postgres
// database through Prisma. These need DATABASE_URL pointed at a reachable,
// migrated database (see /README.md) — they are skipped automatically if the
// DB isn't reachable so `npm test` still passes in an environment with no DB.
const request = require('supertest');
const crypto = require('crypto');
const prisma = require('../db');
const { createApp } = require('../app');
const app = createApp();
app.set('io', { to: () => ({ emit: () => {} }) }); // stub Socket.io for tests
let dbAvailable = true;
const cleanupCampaignIds = [];
const cleanupUserIds = [];
beforeAll(async () => {
try {
await prisma.$connect();
} catch {
dbAvailable = false;
}
});
afterAll(async () => {
if (dbAvailable) {
for (const id of cleanupCampaignIds) {
await prisma.campaign.delete({ where: { id } }).catch(() => {});
}
for (const id of cleanupUserIds) {
await prisma.user.delete({ where: { id } }).catch(() => {});
}
}
await prisma.$disconnect();
});
function uniqueEmail(prefix) {
return `${prefix}-${crypto.randomUUID()}@test.local`;
}
function itIfDb(name, fn) {
test(name, async () => {
if (!dbAvailable) {
console.warn(`Skipping "${name}" — no reachable database (set DATABASE_URL and run migrations).`);
return;
}
await fn();
});
}
describe('auth', () => {
itIfDb('registers, logs in, and reads the current user', async () => {
const email = uniqueEmail('dm');
const registerRes = await request(app)
.post('/auth/register')
.send({ email, password: 'password123', displayName: 'Test DM' });
expect(registerRes.status).toBe(201);
expect(registerRes.body.token).toBeTruthy();
cleanupUserIds.push(registerRes.body.user.id);
const loginRes = await request(app).post('/auth/login').send({ email, password: 'password123' });
expect(loginRes.status).toBe(200);
const meRes = await request(app).get('/auth/me').set('Authorization', `Bearer ${loginRes.body.token}`);
expect(meRes.status).toBe(200);
expect(meRes.body.user.email).toBe(email);
});
itIfDb('rejects a bad password', async () => {
const email = uniqueEmail('baduser');
const registerRes = await request(app)
.post('/auth/register')
.send({ email, password: 'password123', displayName: 'Bad Login' });
cleanupUserIds.push(registerRes.body.user.id);
const loginRes = await request(app).post('/auth/login').send({ email, password: 'wrong-password' });
expect(loginRes.status).toBe(401);
});
});
describe('campaigns + characters + build flow', () => {
itIfDb('DM creates a campaign, player joins, DM builds a character end-to-end', async () => {
const dmEmail = uniqueEmail('dm2');
const playerEmail = uniqueEmail('player2');
const dmRegister = await request(app)
.post('/auth/register')
.send({ email: dmEmail, password: 'password123', displayName: 'DM Two' });
const dmToken = dmRegister.body.token;
cleanupUserIds.push(dmRegister.body.user.id);
const playerRegister = await request(app)
.post('/auth/register')
.send({ email: playerEmail, password: 'password123', displayName: 'Player Two' });
cleanupUserIds.push(playerRegister.body.user.id);
const campaignRes = await request(app)
.post('/campaigns')
.set('Authorization', `Bearer ${dmToken}`)
.send({ name: 'Integration Test Campaign', startingItemCount: 1, startingSpellCount: 0, skipMotivationRoll: true });
expect(campaignRes.status).toBe(201);
const campaign = campaignRes.body.campaign;
cleanupCampaignIds.push(campaign.id);
const joinRes = await request(app)
.post(`/campaigns/join/${campaign.inviteCode}`)
.set('Authorization', `Bearer ${playerRegister.body.token}`);
expect(joinRes.status).toBe(201);
// DM adds a homebrew weapon to the pool so the equipment step has something to pick.
const weaponRes = await request(app)
.post(`/campaigns/${campaign.id}/weapons`)
.set('Authorization', `Bearer ${dmToken}`)
.send({ name: 'Test Dagger', damageDice: '1d4', damageType: 'piercing', attackAbility: 'FINESSE', properties: [] });
expect(weaponRes.status).toBe(201);
const weaponId = weaponRes.body.weapon.id;
const characterRes = await request(app)
.post(`/campaigns/${campaign.id}/characters`)
.set('Authorization', `Bearer ${dmToken}`)
.send({ name: 'Integration Hero' });
expect(characterRes.status).toBe(201);
const characterId = characterRes.body.character.id;
const auth = { Authorization: `Bearer ${dmToken}` };
// Step 1: species
let rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
expect(rollRes.status).toBe(200);
let speciesChoices = rollRes.body.buildState.speciesChoices;
const species = speciesChoices.options[0];
let chooseRes = await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(auth)
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
expect(chooseRes.status).toBe(200);
expect(chooseRes.body.buildStage).toBe('ROLLING_CLASS');
// Step 2: class (skip any reroll results deterministically)
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
while (rollRes.body.buildState.classChoices.type === 'reroll') {
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
}
const characterClass = rollRes.body.buildState.classChoices.options[0];
chooseRes = await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ characterClass });
expect(chooseRes.status).toBe(200);
// skipMotivationRoll was true, so motivation is skipped entirely.
expect(chooseRes.body.buildStage).toBe('ASSIGNING_SCORES');
// Step 4: ability scores
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
expect(rollRes.body.buildState.abilityRolls).toHaveLength(6);
chooseRes = await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(auth)
.send({ assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
expect(chooseRes.status).toBe(200);
expect(chooseRes.body.buildStage).toBe('ASSIGNING_PROFICIENCIES');
// Step 5: skills
chooseRes = await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(auth)
.send({ skillProficiencies: [{ skill: 'ATHLETICS', proficient: true, expertise: false }] });
expect(chooseRes.status).toBe(200);
expect(chooseRes.body.buildStage).toBe('CHOOSING_EQUIPMENT');
// Step 6: equipment
chooseRes = await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(auth)
.send({ weaponIds: [weaponId], spellIds: [] });
expect(chooseRes.status).toBe(200);
expect(chooseRes.body.buildStage).toBe('CHOOSING_EQUIPMENT'); // stays here until finalize
// Step 7: finalize
const finalizeRes = await request(app).post(`/characters/${characterId}/build/finalize`).set(auth);
expect(finalizeRes.status).toBe(200);
const character = finalizeRes.body.character;
expect(character.buildStage).toBe('COMPLETE');
expect(character.sheet).not.toBeNull();
expect(character.currentHp).toBe(character.sheet.hp.max);
expect(character.weapons).toHaveLength(1);
// Quick stats should now work.
const quickStatsRes = await request(app).get(`/characters/${characterId}/quick-stats`).set(auth);
expect(quickStatsRes.status).toBe(200);
expect(quickStatsRes.body.quickStats.weapons).toHaveLength(1);
// A non-member cannot read the character.
const outsiderRegister = await request(app)
.post('/auth/register')
.send({ email: uniqueEmail('outsider'), password: 'password123', displayName: 'Outsider' });
cleanupUserIds.push(outsiderRegister.body.user.id);
const forbiddenRes = await request(app)
.get(`/characters/${characterId}`)
.set('Authorization', `Bearer ${outsiderRegister.body.token}`);
expect(forbiddenRes.status).toBe(403);
});
});
+79
View File
@@ -0,0 +1,79 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const router = express.Router();
const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters'),
displayName: z.string().min(1).max(60),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
function issueToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, displayName: user.displayName },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
}
function publicUser(user) {
return { id: user.id, email: user.email, displayName: user.displayName };
}
router.post('/register', async (req, res, next) => {
try {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { email, password, displayName } = parsed.data;
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) return res.status(409).json({ error: 'An account with that email already exists' });
const passwordHash = await bcrypt.hash(password, 12);
const user = await prisma.user.create({ data: { email, passwordHash, displayName } });
return res.status(201).json({ token: issueToken(user), user: publicUser(user) });
} catch (err) {
return next(err);
}
});
router.post('/login', async (req, res, next) => {
try {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
if (!user) return res.status(401).json({ error: 'Invalid email or password' });
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) return res.status(401).json({ error: 'Invalid email or password' });
return res.json({ token: issueToken(user), user: publicUser(user) });
} catch (err) {
return next(err);
}
});
router.get('/me', requireAuth, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) return res.status(404).json({ error: 'User not found' });
return res.json({ user: publicUser(user) });
} catch (err) {
return next(err);
}
});
module.exports = router;
+429
View File
@@ -0,0 +1,429 @@
const express = require('express');
const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { requireCampaignMember, requireDM } = require('../middleware/campaignAccess');
const { generateInviteCode } = require('../utils/inviteCode');
const { computeCharacterSheet } = require('../game/calculations');
const router = express.Router();
router.use(requireAuth);
function campaignSummary(campaign, role) {
return {
id: campaign.id,
name: campaign.name,
description: campaign.description,
inviteCode: campaign.inviteCode,
startingGold: campaign.startingGold,
startingItemCount: campaign.startingItemCount,
startingSpellCount: campaign.startingSpellCount,
skipMotivationRoll: campaign.skipMotivationRoll,
ownerId: campaign.ownerId,
role,
createdAt: campaign.createdAt,
};
}
// GET /campaigns — campaigns I'm a DM or player in
router.get('/', async (req, res, next) => {
try {
const memberships = await prisma.campaignMember.findMany({
where: { userId: req.user.id },
include: { campaign: true },
orderBy: { joinedAt: 'desc' },
});
res.json({ campaigns: memberships.map((m) => campaignSummary(m.campaign, m.role)) });
} catch (err) {
next(err);
}
});
const createCampaignSchema = z.object({
name: z.string().min(1).max(120),
description: z.string().max(2000).optional().nullable(),
startingGold: z.number().int().min(0).optional(),
startingItemCount: z.number().int().min(0).optional(),
startingSpellCount: z.number().int().min(0).optional(),
skipMotivationRoll: z.boolean().optional(),
});
// POST /campaigns — create, creator becomes DM/owner
router.post('/', async (req, res, next) => {
try {
const parsed = createCampaignSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
let inviteCode = generateInviteCode();
// Extremely unlikely to collide, but guard anyway.
for (let i = 0; i < 5; i++) {
const clash = await prisma.campaign.findUnique({ where: { inviteCode } });
if (!clash) break;
inviteCode = generateInviteCode();
}
const campaign = await prisma.campaign.create({
data: {
...parsed.data,
inviteCode,
ownerId: req.user.id,
members: { create: { userId: req.user.id, role: 'DM' } },
},
});
res.status(201).json({ campaign: campaignSummary(campaign, 'DM') });
} catch (err) {
next(err);
}
});
// POST /campaigns/join/:inviteCode — must be before requireCampaignMember-gated routes below
router.post('/join/:inviteCode', async (req, res, next) => {
try {
const campaign = await prisma.campaign.findUnique({ where: { inviteCode: req.params.inviteCode } });
if (!campaign) return res.status(404).json({ error: 'Invalid invite code' });
const existing = await prisma.campaignMember.findUnique({
where: { campaignId_userId: { campaignId: campaign.id, userId: req.user.id } },
});
if (existing) return res.json({ campaign: campaignSummary(campaign, existing.role) });
await prisma.campaignMember.create({ data: { campaignId: campaign.id, userId: req.user.id, role: 'PLAYER' } });
res.status(201).json({ campaign: campaignSummary(campaign, 'PLAYER') });
} catch (err) {
next(err);
}
});
router.get('/:id', requireCampaignMember(), async (req, res, next) => {
try {
const members = await prisma.campaignMember.findMany({
where: { campaignId: req.campaign.id },
include: { user: { select: { id: true, displayName: true, email: true } } },
});
res.json({
campaign: campaignSummary(req.campaign, req.membership.role),
members: members.map((m) => ({ userId: m.userId, displayName: m.user.displayName, role: m.role })),
});
} catch (err) {
next(err);
}
});
const updateCampaignSchema = createCampaignSchema.partial();
router.patch('/:id', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = updateCampaignSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data: parsed.data });
res.json({ campaign: campaignSummary(campaign, 'DM') });
} catch (err) {
next(err);
}
});
router.post('/:id/invite', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const regenerate = req.body && req.body.regenerate === true;
if (!regenerate) return res.json({ inviteCode: req.campaign.inviteCode });
let inviteCode = generateInviteCode();
for (let i = 0; i < 5; i++) {
const clash = await prisma.campaign.findUnique({ where: { inviteCode } });
if (!clash) break;
inviteCode = generateInviteCode();
}
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data: { inviteCode } });
res.json({ inviteCode: campaign.inviteCode });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Characters within a campaign
// ---------------------------------------------------------------------------
function characterSummary(character) {
const sheet = computeCharacterSheet(
character,
character.skillProficiencies || [],
character.saveProficiencies || []
);
return {
id: character.id,
campaignId: character.campaignId,
userId: character.userId,
createdById: character.createdById,
name: character.name,
species: character.species,
characterClass: character.characterClass,
level: character.level,
xp: character.xp,
buildStage: character.buildStage,
currentHp: character.currentHp,
tempHp: character.tempHp,
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
};
}
router.get('/:id/characters', requireCampaignMember(), async (req, res, next) => {
try {
const characters = await prisma.character.findMany({
where: { campaignId: req.campaign.id },
include: { skillProficiencies: true, saveProficiencies: true },
orderBy: { createdAt: 'asc' },
});
res.json({ characters: characters.map(characterSummary) });
} catch (err) {
next(err);
}
});
const createCharacterSchema = z.object({
name: z.string().min(1).max(80),
userId: z.string().optional().nullable(), // DM building for a player; omitted => for self
});
router.post('/:id/characters', requireCampaignMember(), async (req, res, next) => {
try {
const parsed = createCharacterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const isDM = req.membership.role === 'DM';
let targetUserId = req.user.id;
if (parsed.data.userId) {
if (!isDM && parsed.data.userId !== req.user.id) {
return res.status(403).json({ error: 'Only the DM can build a character for another player' });
}
const targetMembership = await prisma.campaignMember.findUnique({
where: { campaignId_userId: { campaignId: req.campaign.id, userId: parsed.data.userId } },
});
if (!targetMembership) return res.status(400).json({ error: 'That user is not a member of this campaign' });
targetUserId = parsed.data.userId;
}
const character = await prisma.character.create({
data: {
campaignId: req.campaign.id,
userId: targetUserId,
createdById: req.user.id,
name: parsed.data.name,
buildStage: 'ROLLING_SPECIES',
gold: req.campaign.startingGold,
},
});
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { character: characterSummary(character) });
res.status(201).json({ character: characterSummary(character) });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Weapons (campaign-level pool; DM manages, anyone in campaign can read)
// ---------------------------------------------------------------------------
router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
try {
const weapons = await prisma.weapon.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
res.json({ weapons });
} catch (err) {
next(err);
}
});
const weaponSchema = z.object({
name: z.string().min(1).max(80),
damageDice: z.string().min(1).max(20),
damageType: z.string().min(1).max(40),
attackAbility: z.enum(['STR', 'DEX', 'FINESSE']),
properties: z.array(z.string()).default([]),
rangeNormal: z.number().int().positive().optional().nullable(),
rangeLong: z.number().int().positive().optional().nullable(),
});
router.post('/:id/weapons', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = weaponSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const weapon = await prisma.weapon.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
res.status(201).json({ weapon });
} catch (err) {
next(err);
}
});
router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = weaponSchema.partial().safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const weapon = await prisma.weapon.update({
where: { id: req.params.weaponId },
data: parsed.data,
});
res.json({ weapon });
} catch (err) {
next(err);
}
});
router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
await prisma.weapon.delete({ where: { id: req.params.weaponId } });
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Spells (campaign-level list; DM manages, used as the starting-spell pool)
// ---------------------------------------------------------------------------
router.get('/:id/spells', requireCampaignMember(), async (req, res, next) => {
try {
const spells = await prisma.spell.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
res.json({ spells });
} catch (err) {
next(err);
}
});
const spellSchema = z.object({
name: z.string().min(1).max(80),
level: z.number().int().min(0).max(9),
school: z.string().max(40).optional().nullable(),
castingTime: z.string().max(60).optional().nullable(),
range: z.string().max(60).optional().nullable(),
duration: z.string().max(60).optional().nullable(),
description: z.string().max(4000).optional().nullable(),
});
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = spellSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const spell = await prisma.spell.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
res.status(201).json({ spell });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Monsters (DM only, campaign-scoped)
// ---------------------------------------------------------------------------
router.get('/:id/monsters', requireCampaignMember(), async (req, res, next) => {
try {
const monsters = await prisma.monster.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
res.json({ monsters });
} catch (err) {
next(err);
}
});
const monsterSchema = z.object({
name: z.string().min(1).max(80),
ac: z.number().int().min(0),
maxHp: z.number().int().min(1),
speed: z.number().int().min(0).optional(),
strScore: z.number().int().min(1).max(30).optional(),
dexScore: z.number().int().min(1).max(30).optional(),
conScore: z.number().int().min(1).max(30).optional(),
intScore: z.number().int().min(1).max(30).optional(),
wisScore: z.number().int().min(1).max(30).optional(),
chaScore: z.number().int().min(1).max(30).optional(),
attackBonus: z.number().int().optional(),
damageDice: z.string().max(20).optional().nullable(),
challengeRating: z.string().max(10).optional().nullable(),
specialTrait: z.string().max(500).optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
});
router.post('/:id/monsters', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = monsterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const monster = await prisma.monster.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
res.status(201).json({ monster });
} catch (err) {
next(err);
}
});
router.patch('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = monsterSchema.partial().safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const monster = await prisma.monster.update({ where: { id: req.params.monsterId }, data: parsed.data });
res.json({ monster });
} catch (err) {
next(err);
}
});
router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
await prisma.monster.delete({ where: { id: req.params.monsterId } });
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Encounters
// ---------------------------------------------------------------------------
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
try {
const encounters = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id },
orderBy: { createdAt: 'desc' },
});
res.json({ encounters });
} catch (err) {
next(err);
}
});
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ name: z.string().min(1).max(120) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const encounter = await prisma.encounter.create({ data: { campaignId: req.campaign.id, name: parsed.data.name } });
res.status(201).json({ encounter });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Notes
// ---------------------------------------------------------------------------
router.get('/:id/notes', requireCampaignMember(), async (req, res, next) => {
try {
const isDM = req.membership.role === 'DM';
const notes = await prisma.note.findMany({
where: {
campaignId: req.campaign.id,
...(isDM ? {} : { OR: [{ visibility: 'SHARED' }, { authorId: req.user.id }] }),
},
include: { author: { select: { displayName: true } } },
orderBy: { createdAt: 'desc' },
});
res.json({ notes });
} catch (err) {
next(err);
}
});
module.exports = router;
+582
View File
@@ -0,0 +1,582 @@
const express = require('express');
const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { requireCharacterAccess } = require('../middleware/characterAccess');
const {
computeCharacterSheet,
computeWeaponStats,
getClassSaveProficiencies,
getClassSpellcastingAbility,
ABILITIES,
} = require('../game/calculations');
const {
rollDie,
rollAbilityScore,
speciesChoicesForRoll,
classChoicesForRoll,
MOTIVATION_TABLE,
} = require('../game/buildFlow');
const { SKILLS } = require('../game/data/skills');
const router = express.Router();
router.use(requireAuth);
async function loadFullCharacter(id) {
return prisma.character.findUnique({
where: { id },
include: {
skillProficiencies: true,
saveProficiencies: true,
inventoryItems: true,
weapons: { include: { weapon: true } },
spells: { include: { spell: true } },
},
});
}
function serializeCharacter(character) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
return {
id: character.id,
campaignId: character.campaignId,
userId: character.userId,
createdById: character.createdById,
name: character.name,
species: character.species,
characterClass: character.characterClass,
level: character.level,
xp: character.xp,
motivation: character.motivation,
halfElfBonusAbility1: character.halfElfBonusAbility1,
halfElfBonusAbility2: character.halfElfBonusAbility2,
rolledScores: {
STR: character.strScore,
DEX: character.dexScore,
CON: character.conScore,
INT: character.intScore,
WIS: character.wisScore,
CHA: character.chaScore,
},
currentHp: character.currentHp,
tempHp: character.tempHp,
armorBonus: character.armorBonus,
specialAbilities: character.specialAbilities,
gold: character.gold,
buildStage: character.buildStage,
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
skillProficiencies: character.skillProficiencies,
saveProficiencies: character.saveProficiencies,
inventoryItems: character.inventoryItems,
weapons: character.weapons,
spells: character.spells,
sheet: character.buildStage === 'COMPLETE' ? sheet : null,
};
}
router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
try {
const character = await loadFullCharacter(req.character.id);
res.json({ character: serializeCharacter(character) });
} catch (err) {
next(err);
}
});
const patchCharacterSchema = z.object({
name: z.string().min(1).max(80).optional(),
currentHp: z.number().int().optional(),
tempHp: z.number().int().min(0).optional(),
armorBonus: z.number().int().min(0).optional(),
speedOverride: z.number().int().min(0).nullable().optional(),
specialAbilities: z.string().max(4000).nullable().optional(),
gold: z.number().int().min(0).optional(),
level: z.number().int().min(1).max(20).optional(),
xp: z.number().int().min(0).optional(),
});
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = patchCharacterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const updated = await prisma.character.update({ where: { id: req.character.id }, data: parsed.data });
const full = await loadFullCharacter(updated.id);
const payload = serializeCharacter(full);
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { character: payload });
res.json({ character: payload });
} catch (err) {
next(err);
}
});
router.delete('/:id', requireCharacterAccess(), async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
await prisma.character.delete({ where: { id: req.character.id } });
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Roll-Your-Character build flow — docs/ROLL_YOUR_CHARACTER.md
// ---------------------------------------------------------------------------
router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const character = req.character;
const stage = character.buildStage;
const buildState = { ...(character.buildState || {}) };
if (stage === 'ROLLING_SPECIES') {
const roll = rollDie(6);
buildState.speciesRoll = roll;
buildState.speciesChoices = speciesChoicesForRoll(roll);
delete buildState.species;
delete buildState.halfElfBonusAbilities;
} else if (stage === 'ROLLING_CLASS') {
const alreadyRolled = buildState.classChoices && buildState.classChoices.type !== 'reroll';
if (alreadyRolled) {
return res.status(400).json({ error: 'Class die already rolled for this character — submit a choice, or reroll if the current roll allows it.' });
}
const roll = rollDie(8);
buildState.classRoll = roll;
buildState.classChoices = classChoicesForRoll(roll);
delete buildState.characterClass;
} else if (stage === 'ROLLING_MOTIVATION') {
const roll = rollDie(6);
buildState.motivationRoll = roll;
buildState.motivationText = MOTIVATION_TABLE[roll];
} else if (stage === 'ASSIGNING_SCORES') {
if (buildState.abilityRolls) {
return res.status(400).json({ error: 'Ability scores were already rolled for this character.' });
}
buildState.abilityRolls = Array.from({ length: 6 }, () => rollAbilityScore());
} else {
return res.status(400).json({ error: `No die roll is required at the "${stage}" step.` });
}
const updated = await prisma.character.update({ where: { id: character.id }, data: { buildState } });
res.json({ buildStage: updated.buildStage, buildState: updated.buildState });
} catch (err) {
next(err);
}
});
const HALF_ELF_ELIGIBLE_ABILITIES = ABILITIES.filter((a) => a !== 'CHA');
const chooseSchemas = {
ROLLING_SPECIES: z.object({
species: z.string(),
halfElfBonusAbilities: z.array(z.enum(HALF_ELF_ELIGIBLE_ABILITIES)).length(2).optional(),
}),
ROLLING_CLASS: z.object({ characterClass: z.string() }),
ROLLING_MOTIVATION: z.object({ motivation: z.string().max(500).optional() }),
ASSIGNING_SCORES: z.object({
assignments: z.object({
STR: z.number().int().min(0).max(5),
DEX: z.number().int().min(0).max(5),
CON: z.number().int().min(0).max(5),
INT: z.number().int().min(0).max(5),
WIS: z.number().int().min(0).max(5),
CHA: z.number().int().min(0).max(5),
}),
}),
ASSIGNING_PROFICIENCIES: z.object({
skillProficiencies: z.array(
z.object({
skill: z.enum(Object.keys(SKILLS)),
proficient: z.boolean().default(true),
expertise: z.boolean().default(false),
})
),
}),
CHOOSING_EQUIPMENT: z.object({
weaponIds: z.array(z.string()).default([]),
spellIds: z.array(z.string()).default([]),
}),
};
const STAGE_ORDER = [
'ROLLING_SPECIES',
'ROLLING_CLASS',
'ROLLING_MOTIVATION',
'ASSIGNING_SCORES',
'ASSIGNING_PROFICIENCIES',
'CHOOSING_EQUIPMENT',
'COMPLETE',
];
function nextStage(current) {
const idx = STAGE_ORDER.indexOf(current);
return STAGE_ORDER[idx + 1];
}
router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const character = req.character;
const stage = character.buildStage;
const schema = chooseSchemas[stage];
if (!schema) return res.status(400).json({ error: `No choice is expected at the "${stage}" step.` });
const parsed = schema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const buildState = { ...(character.buildState || {}) };
let advanceTo = nextStage(stage);
if (stage === 'ROLLING_SPECIES') {
const choices = buildState.speciesChoices;
if (!choices) return res.status(400).json({ error: 'Roll for species before choosing one.' });
const { species, halfElfBonusAbilities } = parsed.data;
if (choices.type !== 'wildcard' && !choices.options.includes(species)) {
return res.status(400).json({ error: `${species} was not offered by this roll.` });
}
if (species === 'HALF_ELF') {
if (!halfElfBonusAbilities) {
return res.status(400).json({ error: 'Half-Elf requires choosing two abilities (other than CHA) for the +1/+1 bonus.' });
}
if (new Set(halfElfBonusAbilities).size !== 2) {
return res.status(400).json({ error: 'The two Half-Elf bonus abilities must be different.' });
}
buildState.halfElfBonusAbilities = halfElfBonusAbilities;
} else {
delete buildState.halfElfBonusAbilities;
}
buildState.species = species;
} else if (stage === 'ROLLING_CLASS') {
const choices = buildState.classChoices;
if (!choices || choices.type === 'reroll') {
return res.status(400).json({ error: 'Roll for class before choosing one.' });
}
const { characterClass } = parsed.data;
if (choices.type !== 'wildcard' && !choices.options.includes(characterClass)) {
return res.status(400).json({ error: `${characterClass} was not offered by this roll.` });
}
buildState.characterClass = characterClass;
if (req.campaign.skipMotivationRoll) {
advanceTo = 'ASSIGNING_SCORES';
buildState.motivationText = null;
}
} else if (stage === 'ROLLING_MOTIVATION') {
if (!buildState.motivationRoll) return res.status(400).json({ error: 'Roll for motivation first.' });
if (parsed.data.motivation) buildState.motivationText = parsed.data.motivation;
} else if (stage === 'ASSIGNING_SCORES') {
if (!buildState.abilityRolls) return res.status(400).json({ error: 'Roll ability scores first.' });
const { assignments } = parsed.data;
const usedIndexes = Object.values(assignments);
const uniqueIndexes = new Set(usedIndexes);
if (uniqueIndexes.size !== 6 || Math.max(...usedIndexes) > 5) {
return res.status(400).json({ error: 'Each of the 6 rolled scores must be assigned to exactly one ability.' });
}
buildState.abilityAssignments = assignments;
} else if (stage === 'ASSIGNING_PROFICIENCIES') {
buildState.skillProficiencies = parsed.data.skillProficiencies;
} else if (stage === 'CHOOSING_EQUIPMENT') {
const { weaponIds, spellIds } = parsed.data;
const maxItems = req.campaign.startingItemCount;
const maxSpells = req.campaign.startingSpellCount;
if (weaponIds.length > maxItems) {
return res.status(400).json({ error: `This campaign allows at most ${maxItems} starting item(s).` });
}
const hasSpellcasting = Boolean(getClassSpellcastingAbility(buildState.characterClass));
if (spellIds.length > 0 && !hasSpellcasting) {
return res.status(400).json({ error: 'This class has no spellcasting ability — it cannot pick starting spells.' });
}
if (spellIds.length > maxSpells) {
return res.status(400).json({ error: `This campaign allows at most ${maxSpells} starting spell(s).` });
}
buildState.equipment = { weaponIds, spellIds };
advanceTo = 'CHOOSING_EQUIPMENT'; // stay here; POST /build/finalize completes the character
}
const updated = await prisma.character.update({
where: { id: character.id },
data: { buildState, buildStage: advanceTo },
});
res.json({ buildStage: updated.buildStage, buildState: updated.buildState });
} catch (err) {
next(err);
}
});
router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const character = req.character;
if (character.buildStage !== 'CHOOSING_EQUIPMENT') {
return res.status(400).json({ error: `Character isn't ready to finalize (currently at "${character.buildStage}").` });
}
const bs = character.buildState || {};
if (!bs.species || !bs.characterClass || !bs.abilityAssignments || !bs.equipment) {
return res.status(400).json({ error: 'Build is incomplete — species, class, ability assignments, and equipment are all required.' });
}
const rolledScores = {};
for (const ability of ABILITIES) {
const rollIndex = bs.abilityAssignments[ability];
rolledScores[ability] = bs.abilityRolls[rollIndex].total;
}
const [halfElfBonusAbility1, halfElfBonusAbility2] = bs.halfElfBonusAbilities || [null, null];
const saveProfs = getClassSaveProficiencies(bs.characterClass);
const result = await prisma.$transaction(async (tx) => {
await tx.characterSkillProficiency.deleteMany({ where: { characterId: character.id } });
await tx.characterSaveProficiency.deleteMany({ where: { characterId: character.id } });
const updated = await tx.character.update({
where: { id: character.id },
data: {
species: bs.species,
characterClass: bs.characterClass,
motivation: bs.motivationText || null,
strScore: rolledScores.STR,
dexScore: rolledScores.DEX,
conScore: rolledScores.CON,
intScore: rolledScores.INT,
wisScore: rolledScores.WIS,
chaScore: rolledScores.CHA,
halfElfBonusAbility1,
halfElfBonusAbility2,
buildStage: 'COMPLETE',
buildState: null,
skillProficiencies: {
create: (bs.skillProficiencies || []).map((sp) => ({
skill: sp.skill,
proficient: sp.proficient !== false,
expertise: Boolean(sp.expertise),
})),
},
saveProficiencies: {
create: ABILITIES.map((ability) => ({ ability, proficient: saveProfs.includes(ability) })),
},
weapons: {
create: (bs.equipment.weaponIds || []).map((weaponId) => ({ weaponId, quantity: 1, equipped: false })),
},
spells: {
create: (bs.equipment.spellIds || []).map((spellId) => ({ spellId, prepared: false })),
},
},
include: { skillProficiencies: true, saveProficiencies: true },
});
const sheet = computeCharacterSheet(updated, updated.skillProficiencies, updated.saveProficiencies);
const finalized = await tx.character.update({
where: { id: character.id },
data: { currentHp: sheet.hp.max },
});
return finalized;
});
const full = await loadFullCharacter(result.id);
const payload = serializeCharacter(full);
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { character: payload });
res.json({ character: payload });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Quick Stats — combat-context panel (docs/GAME_RULES_REFERENCE.md)
// ---------------------------------------------------------------------------
router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next) => {
try {
const character = await loadFullCharacter(req.character.id);
if (character.buildStage !== 'COMPLETE') {
return res.status(400).json({ error: 'Character build is not finished yet.' });
}
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
const weapons = character.weapons.map((cw) => ({
characterWeaponId: cw.id,
weaponId: cw.weaponId,
name: cw.weapon.name,
equipped: cw.equipped,
quantity: cw.quantity,
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true),
}));
res.json({
quickStats: {
name: character.name,
currentHp: character.currentHp,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
armorClass: sheet.armorClass,
initiative: sheet.initiative,
speed: sheet.speed,
proficiencyBonus: sheet.proficiencyBonus,
spellcasting: sheet.spellcasting,
specialAbilities: character.specialAbilities,
weapons,
},
});
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Weapons assigned to this character
// ---------------------------------------------------------------------------
router.post('/:id/weapons', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z.object({ weaponId: z.string(), quantity: z.number().int().min(1).default(1) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const weapon = await prisma.weapon.findUnique({ where: { id: parsed.data.weaponId } });
if (!weapon || weapon.campaignId !== req.character.campaignId) {
return res.status(400).json({ error: 'That weapon is not part of this campaign.' });
}
const characterWeapon = await prisma.characterWeapon.upsert({
where: { characterId_weaponId: { characterId: req.character.id, weaponId: parsed.data.weaponId } },
create: { characterId: req.character.id, weaponId: parsed.data.weaponId, quantity: parsed.data.quantity },
update: { quantity: { increment: parsed.data.quantity } },
include: { weapon: true },
});
res.status(201).json({ characterWeapon });
} catch (err) {
next(err);
}
});
router.patch('/:id/weapons/:weaponId', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
.object({ quantity: z.number().int().min(0).optional(), equipped: z.boolean().optional() })
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const characterWeapon = await prisma.characterWeapon.update({
where: { characterId_weaponId: { characterId: req.character.id, weaponId: req.params.weaponId } },
data: parsed.data,
include: { weapon: true },
});
res.json({ characterWeapon });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Inventory
// ---------------------------------------------------------------------------
router.post('/:id/inventory', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
.object({
name: z.string().min(1).max(120),
description: z.string().max(2000).optional().nullable(),
quantity: z.number().int().min(1).default(1),
equipped: z.boolean().default(false),
})
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const item = await prisma.inventoryItem.create({ data: { ...parsed.data, characterId: req.character.id } });
res.status(201).json({ item });
} catch (err) {
next(err);
}
});
router.patch('/:id/inventory/:itemId', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
.object({
name: z.string().min(1).max(120).optional(),
description: z.string().max(2000).nullable().optional(),
quantity: z.number().int().min(0).optional(),
equipped: z.boolean().optional(),
})
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const item = await prisma.inventoryItem.update({ where: { id: req.params.itemId }, data: parsed.data });
res.json({ item });
} catch (err) {
next(err);
}
});
router.delete('/:id/inventory/:itemId', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
await prisma.inventoryItem.delete({ where: { id: req.params.itemId } });
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Spells known by this character
// ---------------------------------------------------------------------------
router.post('/:id/spells', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z.object({ spellId: z.string(), prepared: z.boolean().default(false) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const spell = await prisma.spell.findUnique({ where: { id: parsed.data.spellId } });
if (!spell || spell.campaignId !== req.character.campaignId) {
return res.status(400).json({ error: 'That spell is not part of this campaign.' });
}
const characterSpell = await prisma.characterSpell.upsert({
where: { characterId_spellId: { characterId: req.character.id, spellId: parsed.data.spellId } },
create: { characterId: req.character.id, spellId: parsed.data.spellId, prepared: parsed.data.prepared },
update: { prepared: parsed.data.prepared },
include: { spell: true },
});
res.status(201).json({ characterSpell });
} catch (err) {
next(err);
}
});
router.patch('/:id/spells/:spellId', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z.object({ prepared: z.boolean() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const characterSpell = await prisma.characterSpell.update({
where: { characterId_spellId: { characterId: req.character.id, spellId: req.params.spellId } },
data: parsed.data,
include: { spell: true },
});
res.json({ characterSpell });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Notes about this character
// ---------------------------------------------------------------------------
router.get('/:id/notes', requireCharacterAccess(), async (req, res, next) => {
try {
const notes = await prisma.note.findMany({
where: {
characterId: req.character.id,
...(req.isDM ? {} : { OR: [{ visibility: 'SHARED' }, { authorId: req.user.id }] }),
},
include: { author: { select: { displayName: true } } },
orderBy: { createdAt: 'desc' },
});
res.json({ notes });
} catch (err) {
next(err);
}
});
module.exports = router;
+187
View File
@@ -0,0 +1,187 @@
const express = require('express');
const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet } = require('../game/calculations');
const router = express.Router();
router.use(requireAuth);
async function requireEncounterAccess(req, res, next) {
try {
const encounter = await prisma.encounter.findUnique({ where: { id: req.params.id } });
if (!encounter) return res.status(404).json({ error: 'Encounter not found' });
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
req.encounter = encounter;
req.membership = membership;
req.isDM = membership.role === 'DM';
return next();
} catch (err) {
return next(err);
}
}
async function serializeEncounter(encounterId) {
return prisma.encounter.findUnique({
where: { id: encounterId },
include: {
participants: {
include: {
character: { select: { id: true, name: true } },
monster: { select: { id: true, name: true } },
},
orderBy: { initiative: 'desc' },
},
},
});
}
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
try {
const encounter = await serializeEncounter(req.encounter.id);
res.json({ encounter });
} catch (err) {
next(err);
}
});
const participantSchema = z
.object({
characterId: z.string().optional(),
monsterId: z.string().optional(),
displayName: z.string().min(1).max(80).optional(),
initiative: z.number().int().default(0),
})
.refine((data) => Boolean(data.characterId) !== Boolean(data.monsterId), {
message: 'Provide exactly one of characterId or monsterId',
});
router.post('/:id/participants', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const parsed = participantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, monsterId, displayName, initiative } = parsed.data;
let currentHp, maxHp, resolvedName, isMonster;
if (characterId) {
const character = await prisma.character.findUnique({ where: { id: characterId } });
if (!character || character.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That character is not part of this campaign.' });
}
const full = await prisma.character.findUnique({
where: { id: characterId },
include: { skillProficiencies: true, saveProficiencies: true },
});
const sheet = computeCharacterSheet(full, full.skillProficiencies, full.saveProficiencies);
currentHp = character.currentHp;
maxHp = sheet.hp.max;
resolvedName = displayName || character.name;
isMonster = false;
} else {
const monster = await prisma.monster.findUnique({ where: { id: monsterId } });
if (!monster || monster.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That monster is not part of this campaign.' });
}
currentHp = monster.maxHp;
maxHp = monster.maxHp;
resolvedName = displayName || monster.name;
isMonster = true;
}
const participant = await prisma.encounterParticipant.create({
data: {
encounterId: req.encounter.id,
characterId: characterId || null,
monsterId: monsterId || null,
displayName: resolvedName,
initiative,
currentHp,
maxHp,
isMonster,
},
});
const encounter = await serializeEncounter(req.encounter.id);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
res.status(201).json({ participant, encounter });
} catch (err) {
next(err);
}
});
const updateParticipantSchema = z.object({
currentHp: z.number().int().optional(),
maxHp: z.number().int().min(1).optional(),
initiative: z.number().int().optional(),
conditions: z.array(z.string()).optional(),
displayName: z.string().min(1).max(80).optional(),
});
router.patch('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const parsed = updateParticipantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const participant = await prisma.encounterParticipant.update({
where: { id: req.params.participantId },
data: parsed.data,
});
const encounter = await serializeEncounter(req.encounter.id);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
res.json({ participant, encounter });
} catch (err) {
next(err);
}
});
router.delete('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
const encounter = await serializeEncounter(req.encounter.id);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
res.json({ encounter });
} catch (err) {
next(err);
}
});
router.post('/:id/advance-round', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const updated = await prisma.encounter.update({
where: { id: req.encounter.id },
data: { currentRound: { increment: 1 }, status: 'ACTIVE' },
});
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
encounterId: updated.id,
currentRound: updated.currentRound,
});
res.json({ encounter: updated });
} catch (err) {
next(err);
}
});
router.patch('/:id', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const parsed = z.object({ status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(), name: z.string().min(1).max(120).optional() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const updated = await prisma.encounter.update({ where: { id: req.encounter.id }, data: parsed.data });
res.json({ encounter: updated });
} catch (err) {
next(err);
}
});
module.exports = router;
+63
View File
@@ -0,0 +1,63 @@
const express = require('express');
const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const router = express.Router();
router.use(requireAuth);
const createNoteSchema = z
.object({
campaignId: z.string().optional(),
characterId: z.string().optional(),
body: z.string().min(1).max(10000),
visibility: z.enum(['DM_ONLY', 'SHARED']).default('SHARED'),
})
.refine((data) => Boolean(data.campaignId) || Boolean(data.characterId), {
message: 'Provide campaignId and/or characterId',
});
router.post('/', async (req, res, next) => {
try {
const parsed = createNoteSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { campaignId, characterId, body, visibility } = parsed.data;
let resolvedCampaignId = campaignId || null;
if (characterId) {
const character = await prisma.character.findUnique({ where: { id: characterId } });
if (!character) return res.status(404).json({ error: 'Character not found' });
resolvedCampaignId = resolvedCampaignId || character.campaignId;
}
if (resolvedCampaignId) {
const membership = await loadCampaignMembership(req.user.id, resolvedCampaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
}
const note = await prisma.note.create({
data: { campaignId, characterId, authorId: req.user.id, body, visibility },
include: { author: { select: { displayName: true } } },
});
res.status(201).json({ note });
} catch (err) {
next(err);
}
});
router.delete('/:id', async (req, res, next) => {
try {
const note = await prisma.note.findUnique({ where: { id: req.params.id } });
if (!note) return res.status(404).json({ error: 'Note not found' });
if (note.authorId !== req.user.id) {
return res.status(403).json({ error: 'You can only delete your own notes' });
}
await prisma.note.delete({ where: { id: req.params.id } });
res.status(204).end();
} catch (err) {
next(err);
}
});
module.exports = router;
+29
View File
@@ -0,0 +1,29 @@
const jwt = require('jsonwebtoken');
// Clients join a room per campaign (`campaign:<id>`) after authenticating with
// the same JWT issued by /auth/login. Server-side route handlers emit into
// these rooms (see character:updated, encounter:*-updated, character:created).
function attachSockets(io) {
io.use((socket, next) => {
const token = socket.handshake.auth && socket.handshake.auth.token;
if (!token) return next(new Error('Missing auth token'));
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
socket.user = { id: payload.sub, email: payload.email };
return next();
} catch (err) {
return next(new Error('Invalid or expired token'));
}
});
io.on('connection', (socket) => {
socket.on('campaign:join', (campaignId) => {
if (typeof campaignId === 'string') socket.join(`campaign:${campaignId}`);
});
socket.on('campaign:leave', (campaignId) => {
if (typeof campaignId === 'string') socket.leave(`campaign:${campaignId}`);
});
});
}
module.exports = { attachSockets };
+13
View File
@@ -0,0 +1,13 @@
const crypto = require('crypto');
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no O/0/I/1 to avoid ambiguity
function generateInviteCode(length = 6) {
let code = '';
for (let i = 0; i < length; i++) {
code += ALPHABET[crypto.randomInt(0, ALPHABET.length)];
}
return code;
}
module.exports = { generateInviteCode };
+150
View File
@@ -0,0 +1,150 @@
# D&D 5e Rules Reference — Implementation Spec
This is the canonical math the backend must implement. Build it as a single shared
calculation module (e.g. `backend/src/game/calculations.ts`) that is the **only**
place these formulas live — every API route and the character builder call into it
rather than re-deriving numbers. This mirrors the logic already built and verified
in the "DnD Character Sheet Calculator.xlsx" workbook delivered earlier in this
project; treat that workbook as the reference implementation if anything below is
ambiguous.
All rules are 5e (2014 core rules).
## Ability Modifier
```
modifier = floor((score - 10) / 2)
```
Use `Math.floor`, not integer truncation — they differ for negative results
(e.g. score 7 → (7-10)/2 = -1.5 → floor = -2, not -1).
## Proficiency Bonus
```
proficiencyBonus = 2 + floor((level - 1) / 4)
```
Level 1–4 → +2, 5–8 → +3, 9–12 → +4, 13–16 → +5, 17–20 → +6.
## Final Ability Score
```
finalScore = rolledScore + speciesBonus(ability, species)
```
`speciesBonus` comes from the Species Ability Score Increase table below. See
`seed-data/species.json`.
## Saving Throws
```
saveBonus = abilityModifier + (proficient ? proficiencyBonus : 0)
```
Proficiency per ability comes from the Class Quick Facts table (`seed-data/classes.json`).
## Skills
```
skillBonus = governingAbilityModifier + (proficient ? proficiencyBonus : 0)
```
Governing ability per skill is fixed — see `seed-data/skills.json`. Proficiency is
a per-character, per-skill boolean the player/DM sets during character building
(`CharacterSkillProficiency` in the Prisma schema).
Passive Perception:
```
passivePerception = 10 + perceptionSkillBonus
```
## Armor Class
```
armorClass = 10 + dexModifier + armorBonus
```
`armorBonus` is a flat number the player enters representing whatever armor/shield
they have equipped (this matches the workbook's "Armor Bonus (from armor worn)"
input cell). Do not try to model armor types as a lookup table unless you want to
extend scope later — a plain integer field on `Character` is enough for v1.
## Initiative
```
initiative = dexModifier
```
## Speed
Comes from the species table (30 ft standard; 25 ft for Gnome, Dwarf, Halfling).
Store as `speed` on the species seed data; characters may override it
(`speedOverride` on `Character`) for future feature support (e.g. barbarian speed
boosts) without a schema change.
## Hit Points
Hit Die size comes from class (`seed-data/classes.json`).
```
hpMax(level) =
if level === 1:
hitDieMax + conModifier
else:
hitDieMax + conModifier
+ (level - 1) * (floor(hitDieSize / 2) + 1 + conModifier)
```
Important: **level 1 always uses the maximum possible roll of the Hit Die**, not
the average. Every level after 1 uses the fixed "average" value
`floor(hitDieSize / 2) + 1` (e.g. d10 → 6, d6 → 4, d12 → 7) plus the CON modifier
again. This was a bug caught and fixed while building the Excel calculator —
do not regress it.
`hitDice` available to spend on a short rest = character level (one die of the
class's Hit Die size per level).
## Spellcasting (only for classes with a Spellcasting Ability ≠ "None")
```
spellSaveDC = 8 + proficiencyBonus + spellcastingAbilityModifier
spellAttackBonus = proficiencyBonus + spellcastingAbilityModifier
```
Spellcasting ability per class is in `seed-data/classes.json` (Intelligence,
Wisdom, or Charisma). Classes with `"None"` have no spellcasting stats — the UI
should hide those fields entirely rather than show them as zero/blank.
## Species Ability Score Increases & Speed
See `seed-data/species.json` for the full table (10 species). Note: Half-Elf gets
+2 Charisma fixed, plus +1 to **two other** abilities of the player's choice — the
seed data defaults that flexible pair to STR/INT, but the character builder UI
must let the player pick which two abilities get the +1 during Half-Elf
character creation (don't hardcode the default in the UI).
## Class Quick Facts
See `seed-data/classes.json` for the full table (12 classes): Hit Die, Primary
Ability, the two Saving Throw proficiencies, and Spellcasting Ability.
## Skill → Governing Ability
See `seed-data/skills.json` (18 skills).
## "Quick Stats" View (combat-context panel)
When the frontend shows the contextual quick-stats panel during an attack action,
surface exactly these derived values (all computed via the module above, never
stored redundantly):
- Attack bonus for the equipped/selected weapon: `proficiencyBonus (if proficient) + relevantAbilityModifier` (STR normally, DEX for finesse/ranged weapons — see `seed-data/weapons.json`'s `attackAbility` field, which is `"STR"`, `"DEX"`, or `"FINESSE"` meaning "use whichever of STR/DEX is higher")
- Damage: weapon's damage dice + relevant ability modifier
- Current HP / Max HP / Temp HP
- AC
- Any special abilities/features flagged on the character (free-text list is fine for v1 — a `specialAbilities` text/JSON field on `Character` or a simple related table)
This is a read view assembled from existing data — it does not need its own
schema table.
+107
View File
@@ -0,0 +1,107 @@
# Roll-Your-Character Build Flow — Implementation Spec
This is the guided, dice-driven character creation flow referenced in the build
prompt. It should work for both a DM building a character for a player and a
player building their own, and the UI must tell the user exactly which die to
roll at each step (this is the whole point of the feature — it should feel like
rolling at a physical table, not filling out a form).
Model this as a state machine on `Character.buildStage` (see Prisma schema) with
the in-progress rolls/choices held in `Character.buildState` (JSON) until the
build is finalized, at which point the real columns (`species`, `characterClass`,
ability scores, proficiencies) get set and `buildStage` becomes `COMPLETE`.
## Step 1 — Roll Species (d6)
Tell the player: **"Roll a d6."** Then offer the choice(s) that result maps to.
Most rolls offer 2 species to pick between; keep the flavor text short.
| d6 Roll | Choose between |
|---|---|
| 1 | Human or Half-Elf |
| 2 | Elf or Gnome |
| 3 | Dwarf or Halfling |
| 4 | Dragonborn or Tiefling |
| 5 | Half-Orc or Orc |
| 6 | Wildcard — pick any species |
If the player rolls Half-Elf, immediately prompt for which two abilities (other
than Charisma) get the flexible +1 — this choice has to happen here, not be
silently defaulted (see GAME_RULES_REFERENCE.md).
## Step 2 — Roll Class (d8)
Tell the player: **"Roll a d8."**
| d8 Roll | Choose between |
|---|---|
| 1 | Fighter or Barbarian |
| 2 | Rogue or Ranger |
| 3 | Wizard or Sorcerer |
| 4 | Cleric or Paladin |
| 5 | Bard or Warlock |
| 6 | Druid or Monk |
| 7 | Reroll once, keep either result |
| 8 | Wildcard — pick any class |
## Step 3 — Roll Motivation (d6, optional flavor)
Tell the player: **"Roll a d6"** (optional — DM can skip this step for the whole
campaign via a campaign setting).
| d6 Roll | Motivation |
|---|---|
| 1 | Revenge for a past wrong |
| 2 | Seeking fortune or wealth |
| 3 | Protecting someone they love |
| 4 | Redemption for a past mistake |
| 5 | Driven by curiosity or knowledge |
| 6 | Duty or oath to a cause or order |
Store this as free text on the character (a `motivation` field, or fold it into
`notes`) — it's flavor, not a mechanical input.
## Step 4 — Roll Ability Scores
Standard method: **roll 4d6, drop the lowest die, sum the remaining 3** — six
times, once per ability (STR, DEX, CON, INT, WIS, CHA). Let the UI roll these
(server-side, so results can't be tampered with client-side) and then let the
player **assign** each of the six results to an ability score of their choice.
Show the running math live (base roll → + species bonus → final score →
modifier) as they assign, using the calculation module from
GAME_RULES_REFERENCE.md — don't let this drift out of sync with the sheet view.
## Step 5 — Skill & Save Proficiencies
Present the 18 skills with their governing ability (`seed-data/skills.json`) and
let the player check proficiencies — respect each class's normal "choose N
skills from list" limits if you want to be strict, or keep v1 permissive (DM can
always correct it). Saving throw proficiencies are **not** a choice — they're
fixed per class (`seed-data/classes.json`) and should just be set automatically.
## Step 6 — Starting Equipment
Pull `startingItemCount` and `startingSpellCount` from the campaign's settings
(DM configures these when creating/configuring the campaign — see
`Campaign.startingItemCount` / `Campaign.startingSpellCount` in the schema).
Let the player pick that many starting items from the campaign's weapon/item
pool and, if their class has spellcasting, that many starting spells from the
campaign's spell list.
## Step 7 — Finalize
Compute and freeze HP max (`currentHp = hpMax` on finalize), AC, initiative, and
all derived stats via the calculation module. Set `buildStage = COMPLETE`. From
this point the character sheet is editable directly (level up, stat changes,
inventory, notes) rather than walking the dice flow again.
## UI Notes
- Every step should show a big, obvious "Roll d_N_" button that triggers a
server-side roll (never trust a client-submitted roll result) and animates/
displays the die result before revealing the choice(s) it maps to.
- The DM should be able to build a character for a player using this exact same
flow (same component, different "who is this for" context) — don't build two
separate builders.
- Players building their own characters need the same flow, gated to campaigns
they're a member of.
+1
View File
@@ -0,0 +1 @@
VITE_API_URL=http://localhost:4000
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#14100c" />
<title>The Sunken Reach — Campaign Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700;900&family=Crimson+Pro:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.4",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<polygon points="50,4 93,28 93,72 50,96 7,72 7,28" fill="#1a1410" stroke="#c9a227" stroke-width="4"/>
<polygon points="50,4 93,28 50,50 7,28" fill="#2a2116" stroke="#c9a227" stroke-width="2.5"/>
<polygon points="7,28 50,50 50,96 7,72" fill="#171310" stroke="#c9a227" stroke-width="2.5"/>
<polygon points="93,28 50,50 50,96 93,72" fill="#231b12" stroke="#c9a227" stroke-width="2.5"/>
<text x="50" y="62" font-family="Georgia, serif" font-size="30" fill="#e0b83a" text-anchor="middle" font-weight="bold">20</text>
</svg>

After

Width:  |  Height:  |  Size: 592 B

+32
View File
@@ -0,0 +1,32 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { Layout } from './components/Layout';
import { ProtectedRoute } from './components/ProtectedRoute';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { CampaignListPage } from './pages/CampaignListPage';
import { CampaignHomePage } from './pages/CampaignHomePage';
import { CharacterSheetPage } from './pages/CharacterSheetPage';
import { CharacterBuilderPage } from './pages/CharacterBuilderPage';
import { EncounterPage } from './pages/EncounterPage';
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/campaigns" replace />} />
<Route path="/campaigns" element={<CampaignListPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignHomePage />} />
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default App;
+19
View File
@@ -0,0 +1,19 @@
import axios from 'axios';
export const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
export const apiClient = axios.create({ baseURL: API_URL });
export function setAuthToken(token: string | null) {
if (token) {
apiClient.defaults.headers.common.Authorization = `Bearer ${token}`;
localStorage.setItem('dnd_token', token);
} else {
delete apiClient.defaults.headers.common.Authorization;
localStorage.removeItem('dnd_token');
}
}
export function getStoredToken(): string | null {
return localStorage.getItem('dnd_token');
}
+429
View File
@@ -0,0 +1,429 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from './client';
import type {
Campaign,
CampaignMemberSummary,
CharacterDetail,
CharacterSummary,
Encounter,
EncounterParticipant,
InventoryItem,
Monster,
Note,
QuickStats,
Spell,
Weapon,
} from './types';
// --------------------------------------------------------------------------
// Campaigns
// --------------------------------------------------------------------------
export function useCampaigns() {
return useQuery({
queryKey: ['campaigns'],
queryFn: async () => (await apiClient.get<{ campaigns: Campaign[] }>('/campaigns')).data.campaigns,
});
}
export function useCampaign(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId],
enabled: Boolean(campaignId),
queryFn: async () =>
(
await apiClient.get<{ campaign: Campaign; members: CampaignMemberSummary[] }>(`/campaigns/${campaignId}`)
).data,
});
}
export function useCreateCampaign() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: {
name: string;
description?: string;
startingGold?: number;
startingItemCount?: number;
startingSpellCount?: number;
skipMotivationRoll?: boolean;
}) => (await apiClient.post<{ campaign: Campaign }>('/campaigns', data)).data.campaign,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
});
}
export function useUpdateCampaign(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<Campaign>) =>
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, data)).data.campaign,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
qc.invalidateQueries({ queryKey: ['campaigns'] });
},
});
}
export function useJoinCampaign() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (inviteCode: string) =>
(await apiClient.post<{ campaign: Campaign }>(`/campaigns/join/${inviteCode}`)).data.campaign,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
});
}
export function useRegenerateInvite(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () =>
(await apiClient.post<{ inviteCode: string }>(`/campaigns/${campaignId}/invite`, { regenerate: true })).data
.inviteCode,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId] }),
});
}
// --------------------------------------------------------------------------
// Characters
// --------------------------------------------------------------------------
export function useCampaignCharacters(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'characters'],
enabled: Boolean(campaignId),
queryFn: async () =>
(await apiClient.get<{ characters: CharacterSummary[] }>(`/campaigns/${campaignId}/characters`)).data
.characters,
});
}
export function useCreateCharacter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; userId?: string }) =>
(await apiClient.post<{ character: CharacterSummary }>(`/campaigns/${campaignId}/characters`, data)).data
.character,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] }),
});
}
export function useCharacter(characterId: string | undefined) {
return useQuery({
queryKey: ['character', characterId],
enabled: Boolean(characterId),
refetchInterval: false,
queryFn: async () => (await apiClient.get<{ character: CharacterDetail }>(`/characters/${characterId}`)).data
.character,
});
}
export function usePatchCharacter(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<CharacterDetail>) =>
(await apiClient.patch<{ character: CharacterDetail }>(`/characters/${characterId}`, data)).data.character,
onSuccess: (character) => {
qc.setQueryData(['character', characterId], character);
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
export function useDeleteCharacter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (characterId: string) => apiClient.delete(`/characters/${characterId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] }),
});
}
export function useQuickStats(characterId: string | undefined, enabled: boolean) {
return useQuery({
queryKey: ['character', characterId, 'quick-stats'],
enabled: Boolean(characterId) && enabled,
queryFn: async () =>
(await apiClient.get<{ quickStats: QuickStats }>(`/characters/${characterId}/quick-stats`)).data.quickStats,
});
}
// --------------------------------------------------------------------------
// Character build flow
// --------------------------------------------------------------------------
function invalidateCharacter(qc: ReturnType<typeof useQueryClient>, characterId: string) {
qc.invalidateQueries({ queryKey: ['character', characterId] });
}
export function useBuildRoll(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () =>
(await apiClient.post<{ buildStage: string; buildState: Record<string, unknown> }>(
`/characters/${characterId}/build/roll`
)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useBuildChoose(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: Record<string, unknown>) =>
(await apiClient.post<{ buildStage: string; buildState: Record<string, unknown> }>(
`/characters/${characterId}/build/choose`,
body
)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useBuildFinalize(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/build/finalize`)).data
.character,
onSuccess: (character) => {
qc.setQueryData(['character', characterId], character);
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
// --------------------------------------------------------------------------
// Weapons / inventory / spells on a character
// --------------------------------------------------------------------------
export function useAssignWeapon(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { weaponId: string; quantity?: number }) =>
(await apiClient.post(`/characters/${characterId}/weapons`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function usePatchCharacterWeapon(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ weaponId, ...data }: { weaponId: string; equipped?: boolean; quantity?: number }) =>
(await apiClient.patch(`/characters/${characterId}/weapons/${weaponId}`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useAddInventoryItem(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; description?: string; quantity?: number }) =>
(await apiClient.post<{ item: InventoryItem }>(`/characters/${characterId}/inventory`, data)).data.item,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function usePatchInventoryItem(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ itemId, ...data }: { itemId: string; quantity?: number; equipped?: boolean }) =>
(await apiClient.patch(`/characters/${characterId}/inventory/${itemId}`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useDeleteInventoryItem(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (itemId: string) => apiClient.delete(`/characters/${characterId}/inventory/${itemId}`),
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function useAssignSpell(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { spellId: string; prepared?: boolean }) =>
(await apiClient.post(`/characters/${characterId}/spells`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
export function usePatchCharacterSpell(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ spellId, prepared }: { spellId: string; prepared: boolean }) =>
(await apiClient.patch(`/characters/${characterId}/spells/${spellId}`, { prepared })).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
// --------------------------------------------------------------------------
// Campaign weapons / spells / monsters pools
// --------------------------------------------------------------------------
export function useCampaignWeapons(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'weapons'],
enabled: Boolean(campaignId),
queryFn: async () => (await apiClient.get<{ weapons: Weapon[] }>(`/campaigns/${campaignId}/weapons`)).data.weapons,
});
}
export function useCreateWeapon(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Omit<Weapon, 'id' | 'campaignId'>) =>
(await apiClient.post<{ weapon: Weapon }>(`/campaigns/${campaignId}/weapons`, data)).data.weapon,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'weapons'] }),
});
}
export function useCampaignSpells(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'spells'],
enabled: Boolean(campaignId),
queryFn: async () => (await apiClient.get<{ spells: Spell[] }>(`/campaigns/${campaignId}/spells`)).data.spells,
});
}
export function useCreateSpell(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Omit<Spell, 'id' | 'campaignId'>) =>
(await apiClient.post<{ spell: Spell }>(`/campaigns/${campaignId}/spells`, data)).data.spell,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'spells'] }),
});
}
export function useCampaignMonsters(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'monsters'],
enabled: Boolean(campaignId),
queryFn: async () =>
(await apiClient.get<{ monsters: Monster[] }>(`/campaigns/${campaignId}/monsters`)).data.monsters,
});
}
export function useCreateMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Omit<Monster, 'id' | 'campaignId'>) =>
(await apiClient.post<{ monster: Monster }>(`/campaigns/${campaignId}/monsters`, data)).data.monster,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] }),
});
}
export function useDeleteMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (monsterId: string) => apiClient.delete(`/campaigns/${campaignId}/monsters/${monsterId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] }),
});
}
// --------------------------------------------------------------------------
// Encounters
// --------------------------------------------------------------------------
export function useCampaignEncounters(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'encounters'],
enabled: Boolean(campaignId),
queryFn: async () =>
(await apiClient.get<{ encounters: Encounter[] }>(`/campaigns/${campaignId}/encounters`)).data.encounters,
});
}
export function useCreateEncounter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (name: string) =>
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, { name })).data
.encounter,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
});
}
export function useEncounter(encounterId: string | undefined) {
return useQuery({
queryKey: ['encounter', encounterId],
enabled: Boolean(encounterId),
queryFn: async () => (await apiClient.get<{ encounter: Encounter }>(`/encounters/${encounterId}`)).data.encounter,
});
}
export function useAddParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterId?: string; monsterId?: string; displayName?: string; initiative?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function usePatchParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ participantId, ...data }: Partial<EncounterParticipant> & { participantId: string }) =>
(await apiClient.patch(`/encounters/${encounterId}/participants/${participantId}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function useRemoveParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantId: string) =>
apiClient.delete(`/encounters/${encounterId}/participants/${participantId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function useAdvanceRound(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/advance-round`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function usePatchEncounter(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { status?: EncounterStatusInput; name?: string }) =>
(await apiClient.patch(`/encounters/${encounterId}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
// --------------------------------------------------------------------------
// Notes
// --------------------------------------------------------------------------
export function useCampaignNotes(campaignId: string | undefined) {
return useQuery({
queryKey: ['campaign', campaignId, 'notes'],
enabled: Boolean(campaignId),
queryFn: async () => (await apiClient.get<{ notes: Note[] }>(`/campaigns/${campaignId}/notes`)).data.notes,
});
}
export function useCharacterNotes(characterId: string | undefined) {
return useQuery({
queryKey: ['character', characterId, 'notes'],
enabled: Boolean(characterId),
queryFn: async () => (await apiClient.get<{ notes: Note[] }>(`/characters/${characterId}/notes`)).data.notes,
});
}
export function useCreateNote() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { campaignId?: string; characterId?: string; body: string; visibility?: 'DM_ONLY' | 'SHARED' }) =>
(await apiClient.post<{ note: Note }>('/notes', data)).data.note,
onSuccess: (note) => {
if (note.campaignId) qc.invalidateQueries({ queryKey: ['campaign', note.campaignId, 'notes'] });
if (note.characterId) qc.invalidateQueries({ queryKey: ['character', note.characterId, 'notes'] });
},
});
}
+274
View File
@@ -0,0 +1,274 @@
export type Ability = 'STR' | 'DEX' | 'CON' | 'INT' | 'WIS' | 'CHA';
export type Species =
| 'HUMAN'
| 'HALF_ELF'
| 'ELF'
| 'GNOME'
| 'DWARF'
| 'HALFLING'
| 'DRAGONBORN'
| 'TIEFLING'
| 'HALF_ORC'
| 'ORC';
export type CharacterClass =
| 'FIGHTER'
| 'BARBARIAN'
| 'ROGUE'
| 'RANGER'
| 'WIZARD'
| 'SORCERER'
| 'CLERIC'
| 'PALADIN'
| 'BARD'
| 'WARLOCK'
| 'DRUID'
| 'MONK';
export type BuildStage =
| 'NOT_STARTED'
| 'ROLLING_SPECIES'
| 'ROLLING_CLASS'
| 'ROLLING_MOTIVATION'
| 'ASSIGNING_SCORES'
| 'ASSIGNING_PROFICIENCIES'
| 'CHOOSING_EQUIPMENT'
| 'COMPLETE';
export interface User {
id: string;
email: string;
displayName: string;
}
export interface Campaign {
id: string;
name: string;
description: string | null;
inviteCode: string;
startingGold: number;
startingItemCount: number;
startingSpellCount: number;
skipMotivationRoll: boolean;
ownerId: string;
role: 'DM' | 'PLAYER';
createdAt: string;
}
export interface CampaignMemberSummary {
userId: string;
displayName: string;
role: 'DM' | 'PLAYER';
}
export interface CharacterSummary {
id: string;
campaignId: string;
userId: string | null;
createdById: string;
name: string;
species: Species | null;
characterClass: CharacterClass | null;
level: number;
xp: number;
buildStage: BuildStage;
currentHp: number;
tempHp: number;
maxHp: number | null;
armorClass: number | null;
}
export interface AbilityDetail {
rolled: number;
final: number;
modifier: number;
}
export interface CharacterSheet {
abilities: Record<Ability, AbilityDetail>;
proficiencyBonus: number;
armorClass: number;
initiative: number;
speed: number;
hp: { max: number };
hitDice: { size: number; count: number };
saves: Record<Ability, { proficient: boolean; bonus: number }>;
skills: Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
passivePerception: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
}
export interface Weapon {
id: string;
campaignId: string;
name: string;
damageDice: string;
damageType: string;
attackAbility: 'STR' | 'DEX' | 'FINESSE';
properties: string[];
rangeNormal: number | null;
rangeLong: number | null;
}
export interface CharacterWeapon {
id: string;
characterId: string;
weaponId: string;
quantity: number;
equipped: boolean;
weapon: Weapon;
}
export interface Spell {
id: string;
campaignId: string;
name: string;
level: number;
school: string | null;
castingTime: string | null;
range: string | null;
duration: string | null;
description: string | null;
}
export interface CharacterSpell {
id: string;
characterId: string;
spellId: string;
prepared: boolean;
spell: Spell;
}
export interface InventoryItem {
id: string;
characterId: string;
name: string;
description: string | null;
quantity: number;
equipped: boolean;
}
export interface SkillProficiency {
skill: string;
proficient: boolean;
expertise: boolean;
}
export interface SaveProficiency {
ability: Ability;
proficient: boolean;
}
export interface CharacterDetail {
id: string;
campaignId: string;
userId: string | null;
createdById: string;
name: string;
species: Species | null;
characterClass: CharacterClass | null;
level: number;
xp: number;
motivation: string | null;
halfElfBonusAbility1: Ability | null;
halfElfBonusAbility2: Ability | null;
rolledScores: Record<Ability, number>;
currentHp: number;
tempHp: number;
armorBonus: number;
specialAbilities: string | null;
gold: number;
buildStage: BuildStage;
buildState: Record<string, unknown> | null;
skillProficiencies: SkillProficiency[];
saveProficiencies: SaveProficiency[];
inventoryItems: InventoryItem[];
weapons: CharacterWeapon[];
spells: CharacterSpell[];
sheet: CharacterSheet | null;
}
export interface Monster {
id: string;
campaignId: string;
name: string;
ac: number;
maxHp: number;
speed: number;
strScore: number;
dexScore: number;
conScore: number;
intScore: number;
wisScore: number;
chaScore: number;
attackBonus: number;
damageDice: string | null;
challengeRating: string | null;
specialTrait: string | null;
notes: string | null;
}
export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
export interface EncounterParticipant {
id: string;
encounterId: string;
characterId: string | null;
monsterId: string | null;
displayName: string;
initiative: number;
currentHp: number;
maxHp: number;
conditions: string[];
isMonster: boolean;
character?: { id: string; name: string } | null;
monster?: { id: string; name: string } | null;
}
export interface Encounter {
id: string;
campaignId: string;
name: string;
status: EncounterStatus;
currentRound: number;
createdAt: string;
participants?: EncounterParticipant[];
}
export interface Note {
id: string;
campaignId: string | null;
characterId: string | null;
authorId: string;
body: string;
visibility: 'DM_ONLY' | 'SHARED';
createdAt: string;
author?: { displayName: string };
}
export interface QuickStatsWeapon {
characterWeaponId: string;
weaponId: string;
name: string;
equipped: boolean;
quantity: number;
attackBonus: number;
damageBonus: number;
damageDice: string;
damageType: string;
}
export interface QuickStats {
name: string;
currentHp: number;
maxHp: number;
tempHp: number;
armorClass: number;
initiative: number;
speed: number;
proficiencyBonus: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
specialAbilities: string | null;
weapons: QuickStatsWeapon[];
}
+61
View File
@@ -0,0 +1,61 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { apiClient, getStoredToken, setAuthToken } from '../api/client';
import type { User } from '../api/types';
interface AuthContextValue {
user: User | null;
token: string | null;
loading: boolean;
login: (token: string, user: User) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(getStoredToken());
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const stored = getStoredToken();
if (!stored) {
setLoading(false);
return;
}
setAuthToken(stored);
apiClient
.get<{ user: User }>('/auth/me')
.then((res) => {
setUser(res.data.user);
setToken(stored);
})
.catch(() => {
setAuthToken(null);
setToken(null);
})
.finally(() => setLoading(false));
}, []);
const login = (newToken: string, newUser: User) => {
setAuthToken(newToken);
setToken(newToken);
setUser(newUser);
};
const logout = () => {
setAuthToken(null);
setToken(null);
setUser(null);
};
const value = useMemo(() => ({ user, token, loading, login, logout }), [user, token, loading]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+32
View File
@@ -0,0 +1,32 @@
import { Link, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
export function Layout() {
const { user, logout } = useAuth();
return (
<div className="app-shell">
<header className="topbar">
<Link to="/campaigns" className="topbar-brand">
<span className="die" aria-hidden>
⚄
</span>
The Sunken Reach
</Link>
<nav className="topbar-nav">
{user && (
<div className="topbar-user">
<span>{user.displayName}</span>
<button className="btn btn-ghost" onClick={logout}>
Log out
</button>
</div>
)}
</nav>
</header>
<main className="page">
<Outlet />
</main>
</div>
);
}
@@ -0,0 +1,10 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { Spinner } from './Spinner';
export function ProtectedRoute() {
const { user, loading } = useAuth();
if (loading) return <Spinner label="Checking your credentials…" />;
if (!user) return <Navigate to="/login" replace />;
return <Outlet />;
}
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { useQuickStats } from '../api/queries';
import { Spinner, ErrorBanner } from './Spinner';
import { fmtMod } from '../lib/format';
export function QuickStatsFab({ characterId }: { characterId: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button className="fab-quickstats" onClick={() => setOpen(true)} title="Quick Stats" aria-label="Open quick stats">
⚡
</button>
{open && <QuickStatsDrawer characterId={characterId} onClose={() => setOpen(false)} />}
</>
);
}
export function QuickStatsDrawer({ characterId, onClose }: { characterId: string; onClose: () => void }) {
const { data, isLoading, error } = useQuickStats(characterId, true);
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<div className="drawer">
<div className="flex-between">
<h2 style={{ margin: 0 }}>Quick Stats</h2>
<button className="btn btn-ghost" onClick={onClose}>
✕
</button>
</div>
{isLoading && <Spinner label="Reading the sheet…" />}
{error && <ErrorBanner message="Could not load quick stats." />}
{data && (
<div className="mt-md">
<h3 style={{ margin: 0 }}>{data.name}</h3>
<div className="grid grid-cols-3 mt-sm">
<div className="stat-block">
<span className="stat-label">HP</span>
<span className="stat-value">
{data.currentHp}/{data.maxHp}
</span>
{data.tempHp > 0 && <span className="stat-sub">+{data.tempHp} temp</span>}
</div>
<div className="stat-block">
<span className="stat-label">AC</span>
<span className="stat-value">{data.armorClass}</span>
</div>
<div className="stat-block">
<span className="stat-label">Initiative</span>
<span className="stat-value">{fmtMod(data.initiative)}</span>
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className="hp-bar-fill" style={{ width: `${Math.max(0, Math.min(100, (data.currentHp / data.maxHp) * 100))}%` }} />
</div>
{data.spellcasting && (
<div className="card mt-md">
<div className="card-title">Spellcasting</div>
<p style={{ margin: 0 }}>
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
</p>
</div>
)}
<div className="card mt-md">
<div className="card-title">Weapons</div>
{data.weapons.length === 0 && <p style={{ color: 'var(--text-muted)', margin: 0 }}>No weapons equipped.</p>}
{data.weapons.map((w) => (
<div key={w.characterWeaponId} className="flex-between mt-sm">
<div>
<strong>{w.name}</strong>
{w.equipped && <span className="badge" style={{ marginLeft: 6 }}>equipped</span>}
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-muted)' }}>
{w.damageDice} {w.damageType} {fmtMod(w.damageBonus)}
</p>
</div>
<span className="stat-value" style={{ fontSize: '1.1rem' }}>
{fmtMod(w.attackBonus)}
</span>
</div>
))}
</div>
{data.specialAbilities && (
<div className="card mt-md">
<div className="card-title">Special Abilities</div>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
</div>
)}
</div>
)}
</div>
</>
);
}
+18
View File
@@ -0,0 +1,18 @@
export function Spinner({ label = 'Loading…' }: { label?: string }) {
return (
<div className="flex-row" style={{ padding: '2rem', color: 'var(--text-muted)' }}>
<span className="spinner" aria-hidden />
<span>{label}</span>
</div>
);
}
export function ErrorBanner({ message }: { message: string }) {
return (
<div className="card" style={{ borderColor: 'var(--danger)' }}>
<p className="error-text" style={{ margin: 0 }}>
{message}
</p>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { io, type Socket } from 'socket.io-client';
import { API_URL } from '../api/client';
import { useAuth } from '../auth/AuthContext';
let sharedSocket: Socket | null = null;
function getSocket(token: string): Socket {
if (!sharedSocket) {
sharedSocket = io(API_URL, { auth: { token }, transports: ['websocket', 'polling'] });
}
return sharedSocket;
}
// Joins the Socket.io room for a campaign and keeps React Query's cache in
// sync with server-pushed events (HP/condition changes, encounter rounds,
// new characters) so every open tab in the campaign updates live.
export function useCampaignSocket(campaignId: string | undefined) {
const { token } = useAuth();
const qc = useQueryClient();
useEffect(() => {
if (!campaignId || !token) return;
const socket = getSocket(token);
socket.emit('campaign:join', campaignId);
const onCharacterUpdated = () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
qc.invalidateQueries({ queryKey: ['character'] });
};
const onCharacterCreated = () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
};
const onParticipantUpdated = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
};
const onRoundAdvanced = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
};
socket.on('character:updated', onCharacterUpdated);
socket.on('character:created', onCharacterCreated);
socket.on('encounter:participant-updated', onParticipantUpdated);
socket.on('encounter:round-advanced', onRoundAdvanced);
return () => {
socket.emit('campaign:leave', campaignId);
socket.off('character:updated', onCharacterUpdated);
socket.off('character:created', onCharacterCreated);
socket.off('encounter:participant-updated', onParticipantUpdated);
socket.off('encounter:round-advanced', onRoundAdvanced);
};
}, [campaignId, token, qc]);
}
+585
View File
@@ -0,0 +1,585 @@
:root {
--bg: #131009;
--bg-elevated: #1b160e;
--bg-card: #221b12;
--bg-card-hover: #2a2116;
--bg-inset: #0e0b07;
--border: #4a3a24;
--border-soft: #362a1a;
--text: #ece1c8;
--text-muted: #b2a284;
--text-dim: #7c7159;
--accent: #c9a227;
--accent-strong: #e6c34a;
--accent-soft: rgba(201, 162, 39, 0.15);
--danger: #b23a3a;
--danger-strong: #d85454;
--danger-soft: rgba(178, 58, 58, 0.18);
--success: #5f8a4f;
--success-strong: #7ab266;
--arcane: #5c7fb0;
--arcane-strong: #7fa3d6;
--shadow-deep: 0 10px 30px rgba(0, 0, 0, 0.55);
--radius: 8px;
--font-display: 'Cinzel', Georgia, serif;
--font-body: 'Crimson Pro', Georgia, 'Times New Roman', serif;
}
@media (prefers-color-scheme: light) {
:root {
/* This app is intentionally dark-themed only (D&D tavern-at-night aesthetic). */
}
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: radial-gradient(ellipse at top, #1a1510 0%, var(--bg) 55%, #0d0a06 100%);
color: var(--text);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.5;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
font-weight: 600;
letter-spacing: 0.02em;
color: var(--accent-strong);
margin: 0 0 0.5em 0;
}
a {
color: var(--arcane-strong);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
p {
margin: 0 0 0.75em 0;
}
button,
input,
select,
textarea {
font-family: var(--font-body);
font-size: 1rem;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg-inset);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 5px;
}
/* ---------------------------------------------------------------------- */
/* Layout primitives */
/* ---------------------------------------------------------------------- */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1.25rem;
background: linear-gradient(180deg, #1e1810 0%, #171209 100%);
border-bottom: 1px solid var(--border);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
position: sticky;
top: 0;
z-index: 30;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-family: var(--font-display);
color: var(--accent-strong);
font-size: 1.15rem;
white-space: nowrap;
}
.topbar-brand .die {
font-size: 1.4rem;
filter: drop-shadow(0 0 6px rgba(201, 162, 39, 0.5));
}
.topbar-nav {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.topbar-user {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--text-muted);
font-size: 0.9rem;
}
.page {
flex: 1;
width: 100%;
max-width: 1080px;
margin: 0 auto;
padding: 1.5rem 1.25rem 4rem;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin-bottom: 0.15em;
}
.subtitle {
color: var(--text-muted);
font-size: 0.95rem;
}
/* ---------------------------------------------------------------------- */
/* Cards & ornamentation */
/* ---------------------------------------------------------------------- */
.card {
background: linear-gradient(160deg, var(--bg-card) 0%, var(--bg-elevated) 100%);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow-deep);
position: relative;
}
.card::before,
.card::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
border: 2px solid var(--accent);
opacity: 0.55;
}
.card::before {
top: -1px;
left: -1px;
border-right: none;
border-bottom: none;
}
.card::after {
bottom: -1px;
right: -1px;
border-left: none;
border-top: none;
}
.card + .card {
margin-top: 1rem;
}
.card-title {
font-family: var(--font-display);
color: var(--accent);
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.grid {
display: grid;
gap: 1rem;
}
.grid-cols-2 {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.grid-cols-3 {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
/* ---------------------------------------------------------------------- */
/* Buttons */
/* ---------------------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
font-family: var(--font-display);
font-size: 0.85rem;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 0.6rem 1.1rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent-strong);
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: linear-gradient(160deg, var(--accent) 0%, #a8811c 100%);
border-color: var(--accent-strong);
color: #1a1410;
font-weight: 700;
}
.btn-primary:hover:not(:disabled) {
color: #1a1410;
box-shadow: 0 0 16px rgba(201, 162, 39, 0.45);
}
.btn-danger {
background: linear-gradient(160deg, var(--danger) 0%, #7a2626 100%);
border-color: var(--danger-strong);
color: #fbeaea;
}
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-muted);
}
.btn-ghost:hover:not(:disabled) {
border-color: var(--border);
}
.btn-block {
width: 100%;
}
.btn-lg {
padding: 0.9rem 1.5rem;
font-size: 1rem;
}
/* Big dice-roll button for the character builder */
.btn-roll {
font-family: var(--font-display);
font-size: 1.3rem;
padding: 1.3rem 2rem;
border-radius: 12px;
background: radial-gradient(circle at 30% 20%, #3a2e18, #1c160c 70%);
border: 2px solid var(--accent);
color: var(--accent-strong);
box-shadow: 0 0 24px rgba(201, 162, 39, 0.25), inset 0 0 20px rgba(0, 0, 0, 0.5);
cursor: pointer;
transition: all 0.2s ease;
}
.btn-roll:hover:not(:disabled) {
box-shadow: 0 0 32px rgba(201, 162, 39, 0.45), inset 0 0 20px rgba(0, 0, 0, 0.5);
transform: translateY(-2px);
}
.btn-roll:disabled {
opacity: 0.6;
cursor: wait;
}
/* ---------------------------------------------------------------------- */
/* Forms */
/* ---------------------------------------------------------------------- */
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
}
.field label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
input[type='text'],
input[type='email'],
input[type='password'],
input[type='number'],
select,
textarea {
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.6rem 0.75rem;
color: var(--text);
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
/* ---------------------------------------------------------------------- */
/* Badges / stat blocks */
/* ---------------------------------------------------------------------- */
.badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid var(--border);
color: var(--text-muted);
}
.badge-dm {
border-color: var(--accent);
color: var(--accent-strong);
}
.badge-player {
border-color: var(--arcane);
color: var(--arcane-strong);
}
.stat-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
background: var(--bg-inset);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 0.6rem 0.4rem;
min-width: 64px;
}
.stat-block .stat-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.stat-block .stat-value {
font-family: var(--font-display);
font-size: 1.4rem;
color: var(--accent-strong);
}
.stat-block .stat-sub {
font-size: 0.75rem;
color: var(--text-muted);
}
.hp-bar-track {
width: 100%;
height: 10px;
background: var(--bg-inset);
border: 1px solid var(--border-soft);
border-radius: 999px;
overflow: hidden;
}
.hp-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--danger) 0%, var(--success) 100%);
transition: width 0.3s ease;
}
.divider {
border: none;
border-top: 1px solid var(--border-soft);
margin: 1rem 0;
}
.error-text {
color: var(--danger-strong);
font-size: 0.9rem;
}
.empty-state {
text-align: center;
padding: 2.5rem 1rem;
color: var(--text-muted);
}
.spinner {
display: inline-block;
width: 1.4rem;
height: 1.4rem;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.roll-result {
font-family: var(--font-display);
font-size: 3rem;
color: var(--accent-strong);
text-shadow: 0 0 20px rgba(201, 162, 39, 0.5);
}
@keyframes rollPop {
0% {
transform: scale(0.4) rotate(-15deg);
opacity: 0;
}
60% {
transform: scale(1.1) rotate(5deg);
opacity: 1;
}
100% {
transform: scale(1) rotate(0deg);
}
}
.roll-result.animate {
animation: rollPop 0.4s ease-out;
}
/* Quick stats drawer */
.drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 90;
backdrop-filter: blur(2px);
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(420px, 100vw);
background: linear-gradient(180deg, #1c160d 0%, #120e08 100%);
border-left: 1px solid var(--border);
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.6);
z-index: 91;
overflow-y: auto;
padding: 1.25rem;
}
.fab-quickstats {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
z-index: 80;
border-radius: 999px;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 35% 30%, #3a2e18, #1c160c 70%);
border: 2px solid var(--accent);
color: var(--accent-strong);
font-size: 1.6rem;
cursor: pointer;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 18px rgba(201, 162, 39, 0.3);
}
.mono {
font-family: 'Courier New', monospace;
}
.text-center {
text-align: center;
}
.flex-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.flex-wrap {
flex-wrap: wrap;
}
.gap-sm {
gap: 0.4rem;
}
.mt-sm {
margin-top: 0.5rem;
}
.mt-md {
margin-top: 1rem;
}
.mt-lg {
margin-top: 2rem;
}
@media (max-width: 640px) {
.page {
padding: 1rem 0.85rem 5rem;
}
.topbar {
padding: 0.6rem 0.85rem;
}
.roll-result {
font-size: 2.2rem;
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { Ability, CharacterClass } from '../api/types';
// Mirrors backend/src/game/data/classes.js spellcastingAbility column — used
// only to decide whether the builder's equipment step offers spell picks.
export const CLASS_SPELLCASTING_ABILITY: Record<CharacterClass, Ability | null> = {
FIGHTER: null,
BARBARIAN: null,
ROGUE: null,
RANGER: 'WIS',
WIZARD: 'INT',
SORCERER: 'CHA',
CLERIC: 'WIS',
PALADIN: 'CHA',
BARD: 'CHA',
WARLOCK: 'CHA',
DRUID: 'WIS',
MONK: null,
};
+78
View File
@@ -0,0 +1,78 @@
export const SPECIES_LABELS: Record<string, string> = {
HUMAN: 'Human',
HALF_ELF: 'Half-Elf',
ELF: 'Elf',
GNOME: 'Gnome',
DWARF: 'Dwarf',
HALFLING: 'Halfling',
DRAGONBORN: 'Dragonborn',
TIEFLING: 'Tiefling',
HALF_ORC: 'Half-Orc',
ORC: 'Orc',
};
export const CLASS_LABELS: Record<string, string> = {
FIGHTER: 'Fighter',
BARBARIAN: 'Barbarian',
ROGUE: 'Rogue',
RANGER: 'Ranger',
WIZARD: 'Wizard',
SORCERER: 'Sorcerer',
CLERIC: 'Cleric',
PALADIN: 'Paladin',
BARD: 'Bard',
WARLOCK: 'Warlock',
DRUID: 'Druid',
MONK: 'Monk',
};
export const SKILL_LABELS: Record<string, string> = {
ACROBATICS: 'Acrobatics',
ANIMAL_HANDLING: 'Animal Handling',
ARCANA: 'Arcana',
ATHLETICS: 'Athletics',
DECEPTION: 'Deception',
HISTORY: 'History',
INSIGHT: 'Insight',
INTIMIDATION: 'Intimidation',
INVESTIGATION: 'Investigation',
MEDICINE: 'Medicine',
NATURE: 'Nature',
PERCEPTION: 'Perception',
PERFORMANCE: 'Performance',
PERSUASION: 'Persuasion',
RELIGION: 'Religion',
SLEIGHT_OF_HAND: 'Sleight of Hand',
STEALTH: 'Stealth',
SURVIVAL: 'Survival',
};
export const ABILITY_LABELS: Record<string, string> = {
STR: 'Strength',
DEX: 'Dexterity',
CON: 'Constitution',
INT: 'Intelligence',
WIS: 'Wisdom',
CHA: 'Charisma',
};
export function fmtMod(mod: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`;
}
export function extractErrorMessage(err: unknown): string {
const anyErr = err as { response?: { data?: { error?: unknown } } };
const apiError = anyErr?.response?.data?.error;
if (typeof apiError === 'string') return apiError;
if (apiError && typeof apiError === 'object') {
const flat = apiError as { formErrors?: string[]; fieldErrors?: Record<string, string[]> };
const messages = [
...(flat.formErrors || []),
...Object.values(flat.fieldErrors || {}).flat(),
];
if (messages.length) return messages.join(', ');
return JSON.stringify(apiError);
}
if (err instanceof Error) return err.message;
return 'Something went wrong.';
}
+28
View File
@@ -0,0 +1,28 @@
import type { Ability, Species } from '../api/types';
// Mirrors backend/src/game/data/species.js — used only to preview the running
// math live during the builder. The server recomputes authoritatively at
// finalize, so this duplication is display-only and never gameplay-deciding.
export const SPECIES_ABILITY_BONUSES: Record<Species, Partial<Record<Ability, number>>> = {
HUMAN: { STR: 1, DEX: 1, CON: 1, INT: 1, WIS: 1, CHA: 1 },
HALF_ELF: { CHA: 2 },
ELF: { DEX: 2 },
GNOME: { INT: 2 },
DWARF: { CON: 2 },
HALFLING: { DEX: 2 },
DRAGONBORN: { STR: 2, CHA: 1 },
TIEFLING: { INT: 1, CHA: 2 },
HALF_ORC: { STR: 2, CON: 1 },
ORC: { STR: 2, CON: 1 },
};
export function speciesBonusFor(species: Species | null, ability: Ability, halfElfBonusAbilities: Ability[] = []): number {
if (!species) return 0;
let bonus = SPECIES_ABILITY_BONUSES[species][ability] || 0;
if (species === 'HALF_ELF' && halfElfBonusAbilities.includes(ability)) bonus += 1;
return bonus;
}
export function abilityModifier(score: number): number {
return Math.floor((score - 10) / 2);
}
+28
View File
@@ -0,0 +1,28 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import App from './App.tsx';
import { AuthProvider } from './auth/AuthContext';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 15_000,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
);
+436
View File
@@ -0,0 +1,436 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
useCampaign,
useCampaignCharacters,
useCreateCharacter,
useDeleteCharacter,
useCampaignEncounters,
useCreateEncounter,
useCampaignMonsters,
useCreateMonster,
useDeleteMonster,
useCampaignWeapons,
useCreateWeapon,
useCampaignSpells,
useCreateSpell,
useCampaignNotes,
useCreateNote,
useUpdateCampaign,
useRegenerateInvite,
} from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { extractErrorMessage } from '../lib/format';
import type { CharacterSummary } from '../api/types';
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
const inProgress = character.buildStage !== 'COMPLETE';
return (
<div className="card flex-between flex-wrap">
<div>
<div className="flex-row">
<Link to={`/characters/${character.id}`} style={{ fontFamily: 'var(--font-display)', color: 'var(--accent-strong)', fontSize: '1.05rem' }}>
{character.name}
</Link>
{inProgress && <span className="badge">Building…</span>}
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
{character.species ? `${character.species} ` : ''}
{character.characterClass || (inProgress ? 'Not yet built' : '')} · Level {character.level}
</p>
</div>
<div className="flex-row">
{!inProgress && (
<div style={{ textAlign: 'right', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
</div>
)}
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
{inProgress ? 'Continue Build' : 'Open Sheet'}
</Link>
{isDM && (
<button className="btn btn-ghost" onClick={onDelete}>
✕
</button>
)}
</div>
</div>
);
}
export function CampaignHomePage() {
const { campaignId = '' } = useParams();
const { user } = useAuth();
const navigate = useNavigate();
useCampaignSocket(campaignId);
const { data, isLoading, error } = useCampaign(campaignId);
const { data: characters } = useCampaignCharacters(campaignId);
const { data: encounters } = useCampaignEncounters(campaignId);
const { data: monsters } = useCampaignMonsters(campaignId);
const { data: weapons } = useCampaignWeapons(campaignId);
const { data: spells } = useCampaignSpells(campaignId);
const { data: notes } = useCampaignNotes(campaignId);
const createCharacter = useCreateCharacter(campaignId);
const deleteCharacter = useDeleteCharacter(campaignId);
const createEncounter = useCreateEncounter(campaignId);
const createMonster = useCreateMonster(campaignId);
const deleteMonster = useDeleteMonster(campaignId);
const createWeapon = useCreateWeapon(campaignId);
const createSpell = useCreateSpell(campaignId);
const createNote = useCreateNote();
const updateCampaign = useUpdateCampaign(campaignId);
const regenerateInvite = useRegenerateInvite(campaignId);
const [newCharName, setNewCharName] = useState('');
const [newEncounterName, setNewEncounterName] = useState('');
const [noteBody, setNoteBody] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [showMonsterForm, setShowMonsterForm] = useState(false);
const [showWeaponForm, setShowWeaponForm] = useState(false);
const [showSpellForm, setShowSpellForm] = useState(false);
if (isLoading) return <Spinner label="Unrolling the campaign map…" />;
if (error || !data) return <ErrorBanner message="Could not load this campaign." />;
const { campaign, members } = data;
const isDM = campaign.role === 'DM';
const myCharacters = characters?.filter((c) => c.userId === user?.id) || [];
const partyCharacters = characters?.filter((c) => !isDM || c.userId !== user?.id) || [];
async function handleCreateCharacter(e: FormEvent) {
e.preventDefault();
setFormError(null);
try {
const character = await createCharacter.mutateAsync({ name: newCharName });
setNewCharName('');
navigate(`/characters/${character.id}/build`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleCreateEncounter(e: FormEvent) {
e.preventDefault();
try {
const encounter = await createEncounter.mutateAsync(newEncounterName || 'New Encounter');
navigate(`/encounters/${encounter.id}`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleCreateNote(e: FormEvent) {
e.preventDefault();
if (!noteBody.trim()) return;
await createNote.mutateAsync({ campaignId, body: noteBody, visibility: 'SHARED' });
setNoteBody('');
}
return (
<div>
<div className="page-header">
<div>
<h1>{campaign.name}</h1>
<p className="subtitle">{campaign.description}</p>
</div>
<span className={`badge ${isDM ? 'badge-dm' : 'badge-player'}`}>{isDM ? 'Dungeon Master' : 'Player'}</span>
</div>
{isDM && (
<div className="card">
<div className="card-title">
<span>Campaign Settings</span>
<button className="btn btn-ghost" onClick={() => setShowSettings((s) => !s)}>
{showSettings ? 'Hide' : 'Edit'}
</button>
</div>
<div className="flex-row flex-wrap">
<span className="badge">Invite code: <strong className="mono">{campaign.inviteCode}</strong></span>
<button className="btn btn-ghost" onClick={() => regenerateInvite.mutate()}>
Regenerate code
</button>
</div>
{showSettings && (
<form
className="mt-md"
onSubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
updateCampaign.mutate({
startingGold: Number(fd.get('startingGold')),
startingItemCount: Number(fd.get('startingItemCount')),
startingSpellCount: Number(fd.get('startingSpellCount')),
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
});
}}
>
<div className="grid grid-cols-3">
<div className="field">
<label>Starting gold</label>
<input name="startingGold" type="number" min={0} defaultValue={campaign.startingGold} />
</div>
<div className="field">
<label>Starting items</label>
<input name="startingItemCount" type="number" min={0} defaultValue={campaign.startingItemCount} />
</div>
<div className="field">
<label>Starting spells</label>
<input name="startingSpellCount" type="number" min={0} defaultValue={campaign.startingSpellCount} />
</div>
</div>
<label className="checkbox-row">
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
Skip the motivation roll for this campaign
</label>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings
</button>
</form>
)}
<div className="mt-md">
<div className="card-title" style={{ marginBottom: '0.4rem' }}>Party</div>
<div className="flex-row flex-wrap">
{members.map((m) => (
<span key={m.userId} className={`badge ${m.role === 'DM' ? 'badge-dm' : 'badge-player'}`}>
{m.displayName}
</span>
))}
</div>
</div>
</div>
)}
{formError && <div className="mt-md"><ErrorBanner message={formError} /></div>}
{!isDM && myCharacters.length > 0 && (
<div className="mt-lg">
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
{myCharacters.map((c) => (
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
))}
</div>
)}
<div className="mt-lg">
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
</div>
<form onSubmit={handleCreateCharacter} className="flex-row flex-wrap mt-sm" style={{ marginBottom: '0.75rem' }}>
<input
placeholder="New character name"
value={newCharName}
onChange={(e) => setNewCharName(e.target.value)}
style={{ flex: 1, minWidth: 160 }}
/>
<button type="submit" className="btn btn-primary" disabled={!newCharName || createCharacter.isPending}>
Roll a New Character
</button>
</form>
{(isDM ? characters : partyCharacters)?.length === 0 && (
<div className="empty-state">No characters yet. Roll one up above.</div>
)}
{(isDM ? characters : partyCharacters)?.map((c) => (
<CharacterRow
key={c.id}
character={c}
isDM={isDM}
onDelete={() => window.confirm(`Delete ${c.name}?`) && deleteCharacter.mutate(c.id)}
/>
))}
</div>
<div className="mt-lg">
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
</div>
{isDM && (
<form onSubmit={handleCreateEncounter} className="flex-row flex-wrap" style={{ marginBottom: '0.75rem' }}>
<input
placeholder="Encounter name"
value={newEncounterName}
onChange={(e) => setNewEncounterName(e.target.value)}
style={{ flex: 1, minWidth: 160 }}
/>
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
⚔ Start Encounter
</button>
</form>
)}
{encounters?.length === 0 && <div className="empty-state">No encounters yet.</div>}
{encounters?.map((enc) => (
<Link key={enc.id} to={`/encounters/${enc.id}`} className="card flex-between" style={{ display: 'flex' }}>
<span>{enc.name}</span>
<span className="badge">{enc.status} · Round {enc.currentRound}</span>
</Link>
))}
</div>
{isDM && (
<>
<div className="mt-lg card">
<div className="card-title">
<span>Weapons Pool</span>
<button className="btn btn-ghost" onClick={() => setShowWeaponForm((s) => !s)}>
{showWeaponForm ? 'Close' : '+ Add Weapon'}
</button>
</div>
{showWeaponForm && (
<form
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
await createWeapon.mutateAsync({
name: String(fd.get('name')),
damageDice: String(fd.get('damageDice')),
damageType: String(fd.get('damageType')),
attackAbility: fd.get('attackAbility') as 'STR' | 'DEX' | 'FINESSE',
properties: [],
rangeNormal: null,
rangeLong: null,
});
(e.target as HTMLFormElement).reset();
}}
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="damageDice" placeholder="Damage dice (1d8)" required />
<input name="damageType" placeholder="Damage type" required />
<select name="attackAbility" defaultValue="STR">
<option value="STR">STR</option>
<option value="DEX">DEX</option>
<option value="FINESSE">Finesse</option>
</select>
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
<div className="mt-sm flex-row flex-wrap">
{weapons?.map((w) => (
<span key={w.id} className="badge">{w.name} ({w.damageDice})</span>
))}
</div>
</div>
<div className="mt-md card">
<div className="card-title">
<span>Spell List</span>
<button className="btn btn-ghost" onClick={() => setShowSpellForm((s) => !s)}>
{showSpellForm ? 'Close' : '+ Add Spell'}
</button>
</div>
{showSpellForm && (
<form
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
await createSpell.mutateAsync({
name: String(fd.get('name')),
level: Number(fd.get('level')),
school: String(fd.get('school') || '') || null,
castingTime: null,
range: null,
duration: null,
description: null,
});
(e.target as HTMLFormElement).reset();
}}
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="level" type="number" min={0} max={9} placeholder="Level (0=cantrip)" required />
<input name="school" placeholder="School" />
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
<div className="mt-sm flex-row flex-wrap">
{spells?.map((s) => (
<span key={s.id} className="badge">{s.name} (Lv{s.level})</span>
))}
</div>
</div>
<div className="mt-md card">
<div className="card-title">
<span>Monsters</span>
<button className="btn btn-ghost" onClick={() => setShowMonsterForm((s) => !s)}>
{showMonsterForm ? 'Close' : '+ Add Monster'}
</button>
</div>
{showMonsterForm && (
<form
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
await createMonster.mutateAsync({
name: String(fd.get('name')),
ac: Number(fd.get('ac')),
maxHp: Number(fd.get('maxHp')),
speed: 30,
strScore: 10,
dexScore: 10,
conScore: 10,
intScore: 10,
wisScore: 10,
chaScore: 10,
attackBonus: Number(fd.get('attackBonus') || 0),
damageDice: String(fd.get('damageDice') || '') || null,
challengeRating: null,
specialTrait: null,
notes: null,
});
(e.target as HTMLFormElement).reset();
}}
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="ac" type="number" placeholder="AC" required />
<input name="maxHp" type="number" placeholder="Max HP" required />
<input name="attackBonus" type="number" placeholder="Attack bonus" />
<input name="damageDice" placeholder="Damage dice" />
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
<div className="mt-sm">
{monsters?.map((m) => (
<div key={m.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<span>{m.name} — AC {m.ac}, HP {m.maxHp}</span>
<button className="btn btn-ghost" onClick={() => deleteMonster.mutate(m.id)}>✕</button>
</div>
))}
</div>
</div>
</>
)}
<div className="mt-lg card">
<div className="card-title">Campaign Notes</div>
<form onSubmit={handleCreateNote} className="flex-row flex-wrap">
<textarea
placeholder="Add a shared note for the party…"
rows={2}
value={noteBody}
onChange={(e) => setNoteBody(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn btn-primary">Add Note</button>
</form>
<div className="mt-sm">
{notes?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No notes yet.</p>}
{notes?.map((n) => (
<div key={n.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<p style={{ margin: 0 }}>{n.body}</p>
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
— {n.author?.displayName} · {new Date(n.createdAt).toLocaleString()}
</span>
</div>
))}
</div>
</div>
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useCampaigns, useCreateCampaign, useJoinCampaign } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { extractErrorMessage } from '../lib/format';
export function CampaignListPage() {
const { data: campaigns, isLoading, error } = useCampaigns();
const createCampaign = useCreateCampaign();
const joinCampaign = useJoinCampaign();
const navigate = useNavigate();
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [formError, setFormError] = useState<string | null>(null);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setFormError(null);
try {
const campaign = await createCampaign.mutateAsync({ name, description: description || undefined });
navigate(`/campaigns/${campaign.id}`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleJoin(e: FormEvent) {
e.preventDefault();
setFormError(null);
try {
const campaign = await joinCampaign.mutateAsync(inviteCode.trim().toUpperCase());
navigate(`/campaigns/${campaign.id}`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
return (
<div>
<div className="page-header">
<div>
<h1>Your Campaigns</h1>
<p className="subtitle">The tales you're telling, and the ones you're living.</p>
</div>
<button className="btn btn-primary" onClick={() => setShowCreate((s) => !s)}>
{showCreate ? 'Cancel' : '+ New Campaign'}
</button>
</div>
{showCreate && (
<div className="card mt-md">
<div className="card-title">Found a New Campaign</div>
<form onSubmit={handleCreate}>
<div className="field">
<label htmlFor="name">Campaign name</label>
<input id="name" required value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="field">
<label htmlFor="description">Description (optional)</label>
<textarea id="description" rows={3} value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
{formError && <ErrorBanner message={formError} />}
<button type="submit" className="btn btn-primary" disabled={createCampaign.isPending}>
{createCampaign.isPending ? 'Founding…' : 'Found Campaign'}
</button>
</form>
</div>
)}
<div className="card mt-md">
<div className="card-title">Join With an Invite Code</div>
<form onSubmit={handleJoin} className="flex-row flex-wrap">
<input
placeholder="e.g. AB3XQ9"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
style={{ flex: 1, minWidth: 160, textTransform: 'uppercase' }}
/>
<button type="submit" className="btn" disabled={joinCampaign.isPending || !inviteCode}>
{joinCampaign.isPending ? 'Joining…' : 'Join'}
</button>
</form>
{formError && <div className="mt-sm"><ErrorBanner message={formError} /></div>}
</div>
<div className="mt-lg">
{isLoading && <Spinner label="Consulting the tavern board…" />}
{error && <ErrorBanner message="Could not load your campaigns." />}
{campaigns && campaigns.length === 0 && (
<div className="empty-state">No campaigns yet — found one, or join with an invite code above.</div>
)}
<div className="grid grid-cols-2">
{campaigns?.map((c) => (
<Link key={c.id} to={`/campaigns/${c.id}`} className="card" style={{ display: 'block' }}>
<div className="card-title">
<span>{c.name}</span>
<span className={`badge ${c.role === 'DM' ? 'badge-dm' : 'badge-player'}`}>{c.role}</span>
</div>
<p style={{ color: 'var(--text-muted)', margin: 0 }}>{c.description || 'No description yet.'}</p>
</Link>
))}
</div>
</div>
</div>
);
}
+499
View File
@@ -0,0 +1,499 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useBuildChoose, useBuildFinalize, useBuildRoll, useCampaign, useCampaignSpells, useCampaignWeapons, useCharacter } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, extractErrorMessage } from '../lib/format';
import { abilityModifier, speciesBonusFor } from '../lib/speciesData';
import { CLASS_SPELLCASTING_ABILITY } from '../lib/classData';
import type { Ability, CharacterClass, Species } from '../api/types';
interface SpeciesChoices {
type: 'choice' | 'wildcard';
options: string[];
}
interface ClassChoices {
type: 'choice' | 'wildcard' | 'reroll';
options: string[];
}
interface AbilityRoll {
dice: number[];
dropped: number;
total: number;
}
interface BuildState {
speciesRoll?: number;
speciesChoices?: SpeciesChoices;
species?: Species;
halfElfBonusAbilities?: Ability[];
classRoll?: number;
classChoices?: ClassChoices;
characterClass?: CharacterClass;
motivationRoll?: number;
motivationText?: string | null;
abilityRolls?: AbilityRoll[];
abilityAssignments?: Record<Ability, number>;
skillProficiencies?: { skill: string; proficient: boolean; expertise: boolean }[];
equipment?: { weaponIds: string[]; spellIds: string[] };
}
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
const HALF_ELF_ELIGIBLE: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS'];
const ALL_SKILLS = Object.keys(SKILL_LABELS);
export function CharacterBuilderPage() {
const { characterId = '' } = useParams();
const navigate = useNavigate();
const { data: character, isLoading, error } = useCharacter(characterId);
const roll = useBuildRoll(characterId);
const choose = useBuildChoose(characterId);
const finalize = useBuildFinalize(characterId);
const [formError, setFormError] = useState<string | null>(null);
const [halfElfPick, setHalfElfPick] = useState<Ability[]>([]);
const [pendingSpecies, setPendingSpecies] = useState<Species | null>(null);
const [assignments, setAssignments] = useState<Partial<Record<Ability, number>>>({});
const [skillPicks, setSkillPicks] = useState<Record<string, boolean>>({});
const [expertisePicks, setExpertisePicks] = useState<Record<string, boolean>>({});
const [equipWeapons, setEquipWeapons] = useState<string[]>([]);
const [equipSpells, setEquipSpells] = useState<string[]>([]);
const campaignId = character?.campaignId;
const { data: campaignData } = useCampaign(campaignId);
const { data: weapons } = useCampaignWeapons(campaignId);
const { data: spells } = useCampaignSpells(campaignId);
if (isLoading) return <Spinner label="Preparing the dice…" />;
if (error || !character) return <ErrorBanner message="Could not load this character." />;
if (character.buildStage === 'COMPLETE') {
navigate(`/characters/${characterId}`, { replace: true });
return null;
}
const bs = (character.buildState || {}) as BuildState;
const stage = character.buildStage;
async function safeRoll() {
setFormError(null);
try {
await roll.mutateAsync();
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function safeChoose(body: Record<string, unknown>) {
setFormError(null);
try {
await choose.mutateAsync(body);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
return (
<div>
<div className="page-header">
<div>
<h1>Rolling Up {character.name}</h1>
<p className="subtitle">Roll the dice at the table — every result is locked in server-side.</p>
</div>
</div>
<BuildProgress stage={stage} />
{formError && <ErrorBanner message={formError} />}
{stage === 'ROLLING_SPECIES' && (
<div className="card mt-md text-center">
<div className="card-title">Step 1 — Species</div>
{!bs.speciesRoll ? (
<>
<p>Roll a d6 to see which species you can choose between.</p>
<button className="btn-roll" onClick={safeRoll} disabled={roll.isPending}>
🎲 Roll d6
</button>
</>
) : (
<>
<div className="roll-result animate">{bs.speciesRoll}</div>
<p className="mt-sm">
{bs.speciesChoices?.type === 'wildcard' ? 'Wildcard! Pick any species:' : 'Choose your species:'}
</p>
<div className="flex-row flex-wrap" style={{ justifyContent: 'center' }}>
{bs.speciesChoices?.options.map((s) => (
<button
key={s}
className={`btn ${pendingSpecies === s ? 'btn-primary' : ''}`}
onClick={() => {
setPendingSpecies(s as Species);
setHalfElfPick([]);
if (s !== 'HALF_ELF') safeChoose({ species: s });
}}
>
{SPECIES_LABELS[s]}
</button>
))}
</div>
{pendingSpecies === 'HALF_ELF' && (
<div className="card mt-md" style={{ textAlign: 'left' }}>
<p>Half-Elf gets a fixed +2 CHA, plus +1/+1 to two other abilities of your choice:</p>
<div className="flex-row flex-wrap">
{HALF_ELF_ELIGIBLE.map((a) => (
<label key={a} className="checkbox-row">
<input
type="checkbox"
checked={halfElfPick.includes(a)}
disabled={!halfElfPick.includes(a) && halfElfPick.length >= 2}
onChange={(e) =>
setHalfElfPick((prev) =>
e.target.checked ? [...prev, a] : prev.filter((x) => x !== a)
)
}
/>
{ABILITY_LABELS[a]}
</label>
))}
</div>
<button
className="btn btn-primary mt-sm"
disabled={halfElfPick.length !== 2}
onClick={() => safeChoose({ species: 'HALF_ELF', halfElfBonusAbilities: halfElfPick })}
>
Confirm Half-Elf
</button>
</div>
)}
</>
)}
</div>
)}
{stage === 'ROLLING_CLASS' && (
<div className="card mt-md text-center">
<div className="card-title">Step 2 — Class</div>
{!bs.classRoll || bs.classChoices?.type === 'reroll' ? (
<>
{bs.classChoices?.type === 'reroll' && (
<div className="roll-result animate mb-2">{bs.classRoll}</div>
)}
<p>{bs.classChoices?.type === 'reroll' ? 'You rolled a 7 — reroll once!' : 'Roll a d8 to see which classes you can choose between.'}</p>
<button className="btn-roll" onClick={safeRoll} disabled={roll.isPending}>
🎲 Roll d8
</button>
</>
) : (
<>
<div className="roll-result animate">{bs.classRoll}</div>
<p className="mt-sm">
{bs.classChoices?.type === 'wildcard' ? 'Wildcard! Pick any class:' : 'Choose your class:'}
</p>
<div className="flex-row flex-wrap" style={{ justifyContent: 'center' }}>
{bs.classChoices?.options.map((c) => (
<button key={c} className="btn" onClick={() => safeChoose({ characterClass: c })}>
{CLASS_LABELS[c]}
</button>
))}
</div>
</>
)}
</div>
)}
{stage === 'ROLLING_MOTIVATION' && (
<div className="card mt-md text-center">
<div className="card-title">Step 3 — Motivation (Flavor)</div>
{!bs.motivationRoll ? (
<>
<p>Roll a d6 for your character's driving motivation.</p>
<button className="btn-roll" onClick={safeRoll} disabled={roll.isPending}>
🎲 Roll d6
</button>
</>
) : (
<>
<div className="roll-result animate">{bs.motivationRoll}</div>
<p className="mt-sm" style={{ fontStyle: 'italic' }}>
"{bs.motivationText}"
</p>
<button className="btn btn-primary mt-sm" onClick={() => safeChoose({})}>
Confirm & Continue
</button>
</>
)}
</div>
)}
{stage === 'ASSIGNING_SCORES' && (
<AssignScoresStep
bs={bs}
assignments={assignments}
setAssignments={setAssignments}
onRoll={safeRoll}
onSubmit={() => safeChoose({ assignments })}
rolling={roll.isPending}
/>
)}
{stage === 'ASSIGNING_PROFICIENCIES' && (
<div className="card mt-md">
<div className="card-title">Step 5 — Skill Proficiencies</div>
<p className="subtitle">Saving throw proficiencies are fixed by class and were set automatically.</p>
<div className="grid grid-cols-3">
{ALL_SKILLS.map((skill) => (
<label key={skill} className="checkbox-row" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}>
<span className="flex-row">
<input
type="checkbox"
checked={Boolean(skillPicks[skill])}
onChange={(e) => setSkillPicks((p) => ({ ...p, [skill]: e.target.checked }))}
/>
{SKILL_LABELS[skill]}
</span>
{skillPicks[skill] && (
<span className="flex-row" style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginLeft: '1.5rem' }}>
<input
type="checkbox"
checked={Boolean(expertisePicks[skill])}
onChange={(e) => setExpertisePicks((p) => ({ ...p, [skill]: e.target.checked }))}
/>
expertise
</span>
)}
</label>
))}
</div>
<button
className="btn btn-primary mt-md"
onClick={() =>
safeChoose({
skillProficiencies: ALL_SKILLS.filter((s) => skillPicks[s]).map((s) => ({
skill: s,
proficient: true,
expertise: Boolean(expertisePicks[s]),
})),
})
}
>
Confirm Skills
</button>
</div>
)}
{stage === 'CHOOSING_EQUIPMENT' && (
<EquipmentStep
bs={bs}
campaignData={campaignData}
weapons={weapons}
spells={spells}
equipWeapons={equipWeapons}
setEquipWeapons={setEquipWeapons}
equipSpells={equipSpells}
setEquipSpells={setEquipSpells}
onSubmit={() => safeChoose({ weaponIds: equipWeapons, spellIds: equipSpells })}
onFinalize={async () => {
try {
const finalized = await finalize.mutateAsync();
navigate(`/characters/${finalized.id}`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}}
finalizing={finalize.isPending}
/>
)}
</div>
);
}
function BuildProgress({ stage }: { stage: string }) {
const steps = ['ROLLING_SPECIES', 'ROLLING_CLASS', 'ROLLING_MOTIVATION', 'ASSIGNING_SCORES', 'ASSIGNING_PROFICIENCIES', 'CHOOSING_EQUIPMENT'];
const idx = steps.indexOf(stage);
return (
<div className="flex-row flex-wrap gap-sm">
{steps.map((s, i) => (
<span key={s} className="badge" style={i <= idx ? { borderColor: 'var(--accent)', color: 'var(--accent-strong)' } : undefined}>
{i + 1}
</span>
))}
</div>
);
}
function AssignScoresStep({
bs,
assignments,
setAssignments,
onRoll,
onSubmit,
rolling,
}: {
bs: BuildState;
assignments: Partial<Record<Ability, number>>;
setAssignments: (a: Partial<Record<Ability, number>>) => void;
onRoll: () => void;
onSubmit: () => void;
rolling: boolean;
}) {
if (!bs.abilityRolls) {
return (
<div className="card mt-md text-center">
<div className="card-title">Step 4 — Ability Scores</div>
<p>Roll 4d6 (drop the lowest) six times — one set per ability.</p>
<button className="btn-roll" onClick={onRoll} disabled={rolling}>
🎲 Roll Ability Scores
</button>
</div>
);
}
const usedIndexes = new Set(Object.values(assignments));
const species = bs.species ?? null;
const halfElfBonusAbilities = bs.halfElfBonusAbilities || [];
const complete = ABILITIES.every((a) => assignments[a] !== undefined) && usedIndexes.size === 6;
return (
<div className="card mt-md">
<div className="card-title">Step 4 — Assign Ability Scores</div>
<div className="flex-row flex-wrap gap-sm mt-sm">
{bs.abilityRolls.map((r, i) => (
<span key={i} className="badge" style={usedIndexes.has(i) ? { opacity: 0.4 } : { borderColor: 'var(--accent)' }}>
{r.dice.join('+')} (drop {r.dropped}) = {r.total}
</span>
))}
</div>
<div className="grid grid-cols-3 mt-md">
{ABILITIES.map((ability) => {
const assignedIndex = assignments[ability];
const rolledTotal = assignedIndex !== undefined ? bs.abilityRolls![assignedIndex].total : null;
const bonus = speciesBonusFor(species, ability, halfElfBonusAbilities);
const final = rolledTotal !== null ? rolledTotal + bonus : null;
return (
<div className="stat-block" key={ability}>
<span className="stat-label">{ABILITY_LABELS[ability]}</span>
<select
value={assignedIndex ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? undefined : Number(e.target.value);
const next = { ...assignments };
if (val === undefined) delete next[ability];
else next[ability] = val;
setAssignments(next);
}}
>
<option value="">—</option>
{bs.abilityRolls!.map((r, i) => (
<option key={i} value={i} disabled={usedIndexes.has(i) && assignedIndex !== i}>
{r.total}
</option>
))}
</select>
{final !== null && (
<span className="stat-sub">
{rolledTotal} {bonus ? `+${bonus}` : ''} = {final} ({abilityModifier(final) >= 0 ? '+' : ''}
{abilityModifier(final)})
</span>
)}
</div>
);
})}
</div>
<button className="btn btn-primary mt-md" disabled={!complete} onClick={onSubmit}>
Confirm Ability Scores
</button>
</div>
);
}
function EquipmentStep({
bs,
campaignData,
weapons,
spells,
equipWeapons,
setEquipWeapons,
equipSpells,
setEquipSpells,
onSubmit,
onFinalize,
finalizing,
}: {
bs: BuildState;
campaignData: { campaign: { startingItemCount: number; startingSpellCount: number } } | undefined;
weapons: { id: string; name: string; damageDice: string }[] | undefined;
spells: { id: string; name: string; level: number }[] | undefined;
equipWeapons: string[];
setEquipWeapons: (ids: string[]) => void;
equipSpells: string[];
setEquipSpells: (ids: string[]) => void;
onSubmit: () => void;
onFinalize: () => void;
finalizing: boolean;
}) {
const maxItems = campaignData?.campaign.startingItemCount ?? 0;
const maxSpells = campaignData?.campaign.startingSpellCount ?? 0;
const hasSpellcasting = bs.characterClass ? Boolean(CLASS_SPELLCASTING_ABILITY[bs.characterClass]) : false;
const chosen = Boolean(bs.equipment);
function toggle(list: string[], set: (ids: string[]) => void, id: string, max: number) {
if (list.includes(id)) set(list.filter((x) => x !== id));
else if (list.length < max) set([...list, id]);
}
if (chosen) {
return (
<div className="card mt-md text-center">
<div className="card-title">Step 7 — Finalize</div>
<p>Equipment chosen. Ready to compute HP, AC, and lock in your character.</p>
<button className="btn btn-primary btn-lg" onClick={onFinalize} disabled={finalizing}>
{finalizing ? 'Finalizing…' : '✦ Finalize Character'}
</button>
</div>
);
}
return (
<div className="card mt-md">
<div className="card-title">Step 6 — Starting Equipment</div>
<p className="subtitle">
Pick up to {maxItems} item{maxItems === 1 ? '' : 's'}
{hasSpellcasting ? ` and up to ${maxSpells} spell${maxSpells === 1 ? '' : 's'}` : ''}.
</p>
<div className="flex-row flex-wrap">
{weapons?.map((w) => (
<label key={w.id} className="checkbox-row">
<input
type="checkbox"
checked={equipWeapons.includes(w.id)}
disabled={!equipWeapons.includes(w.id) && equipWeapons.length >= maxItems}
onChange={() => toggle(equipWeapons, setEquipWeapons, w.id, maxItems)}
/>
{w.name} ({w.damageDice})
</label>
))}
</div>
{hasSpellcasting && (
<>
<div className="divider" />
<div className="flex-row flex-wrap">
{spells?.map((s) => (
<label key={s.id} className="checkbox-row">
<input
type="checkbox"
checked={equipSpells.includes(s.id)}
disabled={!equipSpells.includes(s.id) && equipSpells.length >= maxSpells}
onChange={() => toggle(equipSpells, setEquipSpells, s.id, maxSpells)}
/>
{s.name} (Lv {s.level})
</label>
))}
{(!spells || spells.length === 0) && <p style={{ color: 'var(--text-muted)' }}>No spells in this campaign's list yet — the DM can add some.</p>}
</div>
</>
)}
<button className="btn btn-primary mt-md" onClick={onSubmit}>
Confirm Equipment
</button>
</div>
);
}
+427
View File
@@ -0,0 +1,427 @@
import { useState, type FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
useCharacter,
usePatchCharacter,
useCampaignWeapons,
useAssignWeapon,
usePatchCharacterWeapon,
useAddInventoryItem,
usePatchInventoryItem,
useDeleteInventoryItem,
useCampaignSpells,
useAssignSpell,
usePatchCharacterSpell,
useCharacterNotes,
useCreateNote,
useCampaign,
} from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, extractErrorMessage } from '../lib/format';
import type { Ability } from '../api/types';
export function CharacterSheetPage() {
const { characterId = '' } = useParams();
const { user } = useAuth();
const { data: character, isLoading, error } = useCharacter(characterId);
const patchCharacter = usePatchCharacter(characterId);
useCampaignSocket(character?.campaignId);
const { data: campaignData } = useCampaign(character?.campaignId);
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
const { data: notes } = useCharacterNotes(characterId);
const assignWeapon = useAssignWeapon(characterId);
const patchWeapon = usePatchCharacterWeapon(characterId);
const addItem = useAddInventoryItem(characterId);
const patchItem = usePatchInventoryItem(characterId);
const deleteItem = useDeleteInventoryItem(characterId);
const assignSpell = useAssignSpell(characterId);
const patchSpell = usePatchCharacterSpell(characterId);
const createNote = useCreateNote();
const [hpDelta, setHpDelta] = useState('');
const [newItemName, setNewItemName] = useState('');
const [noteBody, setNoteBody] = useState('');
const [pickedWeaponId, setPickedWeaponId] = useState('');
const [pickedSpellId, setPickedSpellId] = useState('');
const [formError, setFormError] = useState<string | null>(null);
if (isLoading) return <Spinner label="Unfurling the character sheet…" />;
if (error || !character) return <ErrorBanner message="Could not load this character." />;
const isDM = campaignData?.campaign.role === 'DM';
const isOwner = character.userId === user?.id;
const canEdit = isDM || isOwner;
if (character.buildStage !== 'COMPLETE') {
return (
<div className="empty-state card">
<p>{character.name} hasn't finished being built yet.</p>
<Link to={`/characters/${characterId}/build`} className="btn btn-primary">
Continue Build
</Link>
</div>
);
}
const sheet = character.sheet!;
function applyHpDelta(sign: 1 | -1) {
const amount = Number(hpDelta);
if (!amount) return;
const next = character!.currentHp + sign * amount;
patchCharacter.mutate({ currentHp: next });
setHpDelta('');
}
async function handleAddItem(e: FormEvent) {
e.preventDefault();
if (!newItemName.trim()) return;
await addItem.mutateAsync({ name: newItemName });
setNewItemName('');
}
async function handleAddNote(e: FormEvent) {
e.preventDefault();
if (!noteBody.trim()) return;
await createNote.mutateAsync({ characterId, body: noteBody, visibility: 'SHARED' });
setNoteBody('');
}
async function handleAssignWeapon() {
if (!pickedWeaponId) return;
try {
await assignWeapon.mutateAsync({ weaponId: pickedWeaponId });
setPickedWeaponId('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleAssignSpell() {
if (!pickedSpellId) return;
try {
await assignSpell.mutateAsync({ spellId: pickedSpellId });
setPickedSpellId('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
const assignedWeaponIds = new Set(character.weapons.map((w) => w.weaponId));
const assignedSpellIds = new Set(character.spells.map((s) => s.spellId));
return (
<div>
<div className="page-header">
<div>
<h1>{character.name}</h1>
<p className="subtitle">
{character.species && SPECIES_LABELS[character.species]} {character.characterClass && CLASS_LABELS[character.characterClass]} · Level{' '}
{character.level}
{character.motivation && ` · "${character.motivation}"`}
</p>
</div>
<Link to={`/campaigns/${character.campaignId}`} className="btn btn-ghost">
← Back to Campaign
</Link>
</div>
{formError && <ErrorBanner message={formError} />}
<div className="grid grid-cols-3">
<div className="stat-block">
<span className="stat-label">HP</span>
<span className="stat-value">
{character.currentHp}/{sheet.hp.max}
</span>
{character.tempHp > 0 && <span className="stat-sub">+{character.tempHp} temp</span>}
</div>
<div className="stat-block">
<span className="stat-label">AC</span>
<span className="stat-value">{sheet.armorClass}</span>
</div>
<div className="stat-block">
<span className="stat-label">Initiative</span>
<span className="stat-value">{fmtMod(sheet.initiative)}</span>
</div>
<div className="stat-block">
<span className="stat-label">Speed</span>
<span className="stat-value">{sheet.speed}</span>
</div>
<div className="stat-block">
<span className="stat-label">Prof. Bonus</span>
<span className="stat-value">{fmtMod(sheet.proficiencyBonus)}</span>
</div>
<div className="stat-block">
<span className="stat-label">Passive Perception</span>
<span className="stat-value">{sheet.passivePerception}</span>
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className="hp-bar-fill" style={{ width: `${Math.max(0, Math.min(100, (character.currentHp / sheet.hp.max) * 100))}%` }} />
</div>
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<input
type="number"
placeholder="Amount"
value={hpDelta}
onChange={(e) => setHpDelta(e.target.value)}
style={{ width: 100 }}
/>
<button className="btn btn-danger" onClick={() => applyHpDelta(-1)}>
− Damage
</button>
<button className="btn" onClick={() => applyHpDelta(1)}>
+ Heal
</button>
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
Temp HP
<input
type="number"
min={0}
defaultValue={character.tempHp}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
/>
</label>
<label className="flex-row gap-sm">
Armor Bonus
<input
type="number"
min={0}
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
/>
</label>
</div>
)}
<div className="card mt-md">
<div className="card-title">Ability Scores</div>
<div className="grid grid-cols-3">
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
<div className="stat-block" key={ability}>
<span className="stat-label">{ABILITY_LABELS[ability]}</span>
<span className="stat-value">{sheet.abilities[ability].final}</span>
<span className="stat-sub">{fmtMod(sheet.abilities[ability].modifier)}</span>
</div>
))}
</div>
</div>
<div className="grid grid-cols-2 mt-md">
<div className="card">
<div className="card-title">Saving Throws</div>
{(Object.keys(sheet.saves) as Ability[]).map((ability) => (
<div key={ability} className="flex-between" style={{ padding: '0.3rem 0' }}>
<span>
{ABILITY_LABELS[ability]} {sheet.saves[ability].proficient && <span className="badge">prof</span>}
</span>
<span className="mono">{fmtMod(sheet.saves[ability].bonus)}</span>
</div>
))}
</div>
<div className="card">
<div className="card-title">Skills</div>
<div style={{ maxHeight: 320, overflowY: 'auto' }}>
{Object.entries(sheet.skills).map(([skill, detail]) => (
<div key={skill} className="flex-between" style={{ padding: '0.25rem 0' }}>
<span>
{SKILL_LABELS[skill]}{' '}
{detail.expertise ? (
<span className="badge badge-dm">expertise</span>
) : detail.proficient ? (
<span className="badge">prof</span>
) : null}
</span>
<span className="mono">{fmtMod(detail.bonus)}</span>
</div>
))}
</div>
</div>
</div>
{sheet.spellcasting && (
<div className="card mt-md">
<div className="card-title">Spellcasting ({ABILITY_LABELS[sheet.spellcasting.ability]})</div>
<p style={{ margin: 0 }}>
Save DC <strong>{sheet.spellcasting.saveDC}</strong> · Attack Bonus{' '}
<strong>{fmtMod(sheet.spellcasting.attackBonus)}</strong>
</p>
</div>
)}
<div className="card mt-md">
<div className="card-title">Weapons</div>
{character.weapons.map((cw) => (
<div key={cw.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{cw.weapon.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
{cw.weapon.damageDice} {cw.weapon.damageType} · x{cw.quantity}
</span>
</div>
{canEdit && (
<label className="flex-row gap-sm">
Equipped
<input
type="checkbox"
checked={cw.equipped}
onChange={(e) => patchWeapon.mutate({ weaponId: cw.weaponId, equipped: e.target.checked })}
/>
</label>
)}
</div>
))}
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedWeaponId} onChange={(e) => setPickedWeaponId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Add a weapon from the campaign pool…</option>
{campaignWeapons
?.filter((w) => !assignedWeaponIds.has(w.id))
.map((w) => (
<option key={w.id} value={w.id}>
{w.name}
</option>
))}
</select>
<button className="btn" onClick={handleAssignWeapon} disabled={!pickedWeaponId}>
Add
</button>
</div>
)}
</div>
{campaignSpells && campaignSpells.length > 0 && (
<div className="card mt-md">
<div className="card-title">Spells Known</div>
{character.spells.map((cs) => (
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{cs.spell.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>Lv {cs.spell.level}</span>
</div>
{canEdit && (
<label className="flex-row gap-sm">
Prepared
<input
type="checkbox"
checked={cs.prepared}
onChange={(e) => patchSpell.mutate({ spellId: cs.spellId, prepared: e.target.checked })}
/>
</label>
)}
</div>
))}
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedSpellId} onChange={(e) => setPickedSpellId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Add a spell from the campaign list…</option>
{campaignSpells
.filter((s) => !assignedSpellIds.has(s.id))
.map((s) => (
<option key={s.id} value={s.id}>
{s.name} (Lv {s.level})
</option>
))}
</select>
<button className="btn" onClick={handleAssignSpell} disabled={!pickedSpellId}>
Add
</button>
</div>
)}
</div>
)}
<div className="card mt-md">
<div className="card-title">Inventory</div>
{character.inventoryItems.map((item) => (
<div key={item.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{item.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>x{item.quantity}</span>
</div>
{canEdit && (
<div className="flex-row gap-sm">
<label className="flex-row gap-sm">
Equipped
<input
type="checkbox"
checked={item.equipped}
onChange={(e) => patchItem.mutate({ itemId: item.id, equipped: e.target.checked })}
/>
</label>
<button className="btn btn-ghost" onClick={() => deleteItem.mutate(item.id)}>
✕
</button>
</div>
)}
</div>
))}
{canEdit && (
<form onSubmit={handleAddItem} className="flex-row mt-sm flex-wrap">
<input
placeholder="New item name"
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
style={{ flex: 1, minWidth: 160 }}
/>
<button type="submit" className="btn">
Add Item
</button>
</form>
)}
</div>
{canEdit && (
<div className="card mt-md">
<div className="card-title">Special Abilities</div>
<textarea
rows={3}
defaultValue={character.specialAbilities || ''}
placeholder="Darkvision, Second Wind, Sneak Attack, etc."
onBlur={(e) => patchCharacter.mutate({ specialAbilities: e.target.value })}
/>
</div>
)}
<div className="card mt-md">
<div className="card-title">Notes</div>
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
<textarea
placeholder="Add a note…"
rows={2}
value={noteBody}
onChange={(e) => setNoteBody(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<button type="submit" className="btn">
Add
</button>
</form>
<div className="mt-sm">
{notes?.map((n) => (
<div key={n.id} style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<p style={{ margin: 0 }}>{n.body}</p>
<span style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
— {n.author?.displayName} · {new Date(n.createdAt).toLocaleString()}
</span>
</div>
))}
</div>
</div>
<QuickStatsFab characterId={characterId} />
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
useEncounter,
useCampaign,
useCampaignCharacters,
useCampaignMonsters,
useAddParticipant,
usePatchParticipant,
useRemoveParticipant,
useAdvanceRound,
usePatchEncounter,
} from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { extractErrorMessage } from '../lib/format';
import type { EncounterParticipant } from '../api/types';
function ParticipantRow({
participant,
isDM,
onPatch,
onRemove,
}: {
participant: EncounterParticipant;
isDM: boolean;
onPatch: (data: Partial<EncounterParticipant>) => void;
onRemove: () => void;
}) {
const [hpDelta, setHpDelta] = useState('');
const pct = Math.max(0, Math.min(100, (participant.currentHp / participant.maxHp) * 100));
function applyDelta(sign: 1 | -1) {
const amount = Number(hpDelta);
if (!amount) return;
onPatch({ currentHp: participant.currentHp + sign * amount });
setHpDelta('');
}
return (
<div className="card">
<div className="flex-between flex-wrap">
<div className="flex-row">
<span className={`badge ${participant.isMonster ? 'badge-dm' : 'badge-player'}`}>
{participant.isMonster ? 'Monster' : 'PC'}
</span>
<strong>{participant.displayName}</strong>
</div>
{isDM && (
<label className="flex-row gap-sm">
Init
<input
type="number"
defaultValue={participant.initiative}
style={{ width: 60 }}
onBlur={(e) => onPatch({ initiative: Number(e.target.value) })}
/>
</label>
)}
</div>
<div className="hp-bar-track mt-sm">
<div className="hp-bar-fill" style={{ width: `${pct}%` }} />
</div>
<div className="flex-between mt-sm flex-wrap">
<span>
HP {participant.currentHp}/{participant.maxHp}
</span>
{isDM && (
<div className="flex-row gap-sm">
<input type="number" placeholder="Amt" value={hpDelta} onChange={(e) => setHpDelta(e.target.value)} style={{ width: 70 }} />
<button className="btn btn-danger" onClick={() => applyDelta(-1)}>
−
</button>
<button className="btn" onClick={() => applyDelta(1)}>
+
</button>
</div>
)}
</div>
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<input
placeholder="Conditions, comma separated"
defaultValue={participant.conditions.join(', ')}
style={{ flex: 1, minWidth: 160 }}
onBlur={(e) =>
onPatch({
conditions: e.target.value
.split(',')
.map((c) => c.trim())
.filter(Boolean),
})
}
/>
<button className="btn btn-ghost" onClick={onRemove}>
Remove
</button>
</div>
)}
{!isDM && participant.conditions.length > 0 && (
<div className="flex-row flex-wrap mt-sm">
{participant.conditions.map((c) => (
<span key={c} className="badge">
{c}
</span>
))}
</div>
)}
</div>
);
}
export function EncounterPage() {
const { encounterId = '' } = useParams();
const { data: encounter, isLoading, error } = useEncounter(encounterId);
useCampaignSocket(encounter?.campaignId);
const { data: campaignData } = useCampaign(encounter?.campaignId);
const { data: characters } = useCampaignCharacters(encounter?.campaignId);
const { data: monsters } = useCampaignMonsters(encounter?.campaignId);
const addParticipant = useAddParticipant(encounterId);
const patchParticipant = usePatchParticipant(encounterId);
const removeParticipant = useRemoveParticipant(encounterId);
const advanceRound = useAdvanceRound(encounterId);
const patchEncounter = usePatchEncounter(encounterId);
const [pickType, setPickType] = useState<'character' | 'monster'>('character');
const [pickId, setPickId] = useState('');
const [initiative, setInitiative] = useState('0');
const [formError, setFormError] = useState<string | null>(null);
if (isLoading) return <Spinner label="Setting the battlefield…" />;
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
const isDM = campaignData?.campaign.role === 'DM';
const sorted = [...(encounter.participants || [])].sort((a, b) => b.initiative - a.initiative);
const availableCharacters = characters?.filter((c) => !encounter.participants?.some((p) => p.characterId === c.id) && c.buildStage === 'COMPLETE');
const availableMonsters = monsters;
async function handleAdd() {
if (!pickId) return;
setFormError(null);
try {
await addParticipant.mutateAsync({
[pickType === 'character' ? 'characterId' : 'monsterId']: pickId,
initiative: Number(initiative) || 0,
});
setPickId('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
return (
<div>
<div className="page-header">
<div>
<h1>⚔ {encounter.name}</h1>
<p className="subtitle">
{encounter.status} · Round {encounter.currentRound}
</p>
</div>
<div className="flex-row">
<Link to={`/campaigns/${encounter.campaignId}`} className="btn btn-ghost">
← Campaign
</Link>
{isDM && (
<>
{encounter.status === 'PLANNING' && (
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' })}>
Begin Combat
</button>
)}
{encounter.status === 'ACTIVE' && (
<>
<button className="btn btn-primary" onClick={() => advanceRound.mutate()}>
Advance Round
</button>
<button className="btn btn-danger" onClick={() => patchEncounter.mutate({ status: 'COMPLETE' })}>
End Encounter
</button>
</>
)}
</>
)}
</div>
</div>
{formError && <ErrorBanner message={formError} />}
{isDM && (
<div className="card mt-md">
<div className="card-title">Add Combatant</div>
<div className="flex-row flex-wrap">
<select value={pickType} onChange={(e) => setPickType(e.target.value as 'character' | 'monster')}>
<option value="character">Character</option>
<option value="monster">Monster</option>
</select>
<select value={pickId} onChange={(e) => setPickId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Select…</option>
{(pickType === 'character' ? availableCharacters : availableMonsters)?.map((entity) => (
<option key={entity.id} value={entity.id}>
{entity.name}
</option>
))}
</select>
<input
type="number"
placeholder="Initiative"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
style={{ width: 100 }}
/>
<button className="btn btn-primary" onClick={handleAdd} disabled={!pickId}>
Add
</button>
</div>
</div>
)}
<div className="mt-lg">
{sorted.length === 0 && <div className="empty-state">No combatants yet.</div>}
{sorted.map((p) => (
<ParticipantRow
key={p.id}
participant={p}
isDM={Boolean(isDM)}
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data })}
onRemove={() => removeParticipant.mutate(p.id)}
/>
))}
</div>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import type { User } from '../api/types';
import { extractErrorMessage } from '../lib/format';
import { ErrorBanner } from '../components/Spinner';
export function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await apiClient.post<{ token: string; user: User }>('/auth/login', { email, password });
login(res.data.token, res.data.user);
navigate('/campaigns');
} catch (err) {
setError(extractErrorMessage(err));
} finally {
setSubmitting(false);
}
}
return (
<div className="text-center" style={{ maxWidth: 420, margin: '3rem auto' }}>
<h1>⚔ The Sunken Reach</h1>
<p className="subtitle">Gather at the tavern table. Log in to continue your campaign.</p>
<div className="card" style={{ textAlign: 'left', marginTop: '1.5rem' }}>
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="email">Email</label>
<input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <ErrorBanner message={error} />}
<button type="submit" className="btn btn-primary btn-block btn-lg mt-md" disabled={submitting}>
{submitting ? 'Entering…' : 'Enter the Tavern'}
</button>
</form>
</div>
<p className="subtitle mt-md">
New adventurer? <Link to="/register">Create an account</Link>
</p>
<p className="subtitle" style={{ fontSize: '0.8rem' }}>
Demo: dm@example.com / player@example.com — password123
</p>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import type { User } from '../api/types';
import { extractErrorMessage } from '../lib/format';
import { ErrorBanner } from '../components/Spinner';
export function RegisterPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [displayName, setDisplayName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await apiClient.post<{ token: string; user: User }>('/auth/register', {
displayName,
email,
password,
});
login(res.data.token, res.data.user);
navigate('/campaigns');
} catch (err) {
setError(extractErrorMessage(err));
} finally {
setSubmitting(false);
}
}
return (
<div className="text-center" style={{ maxWidth: 420, margin: '3rem auto' }}>
<h1>Create Your Legend</h1>
<p className="subtitle">Every hero needs a name before the dice are rolled.</p>
<div className="card" style={{ textAlign: 'left', marginTop: '1.5rem' }}>
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="displayName">Display name</label>
<input
id="displayName"
type="text"
required
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="email">Email</label>
<input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <ErrorBanner message={error} />}
<button type="submit" className="btn btn-primary btn-block btn-lg mt-md" disabled={submitting}>
{submitting ? 'Forging…' : 'Begin Your Legend'}
</button>
</form>
</div>
<p className="subtitle mt-md">
Already have an account? <Link to="/login">Log in</Link>
</p>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
+7131
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
{
"name": "dnd-campaign-manager",
"version": "1.0.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [
"backend",
"frontend"
],
"scripts": {
"dev:backend": "npm run dev --workspace backend",
"dev:frontend": "npm run dev --workspace frontend",
"test:backend": "npm run test --workspace backend"
}
}