Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
module.exports = prisma;
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:4000
|
||||||
@@ -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?
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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;
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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.';
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Generated
+7131
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user