commit 9b29dc82537a8b32b460c60adb47cdc9c204f4a4 Author: Joshua Oosthuizen Date: Tue Sep 22 16:27:53 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e9c0c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.env +.env.local +dist/ +build/ +*.log +.DS_Store +.idea/ diff --git a/CLAUDE_BUILD_PROMPT.md b/CLAUDE_BUILD_PROMPT.md new file mode 100644 index 0000000..f0a14c9 --- /dev/null +++ b/CLAUDE_BUILD_PROMPT.md @@ -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:`) 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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4124f1e --- /dev/null +++ b/README.md @@ -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) +``` diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..52c379a --- /dev/null +++ b/backend/.env.example @@ -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" diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..216ef83 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/prisma/migrations/20260922140433_init/migration.sql b/backend/prisma/migrations/20260922140433_init/migration.sql new file mode 100644 index 0000000..b85c07d --- /dev/null +++ b/backend/prisma/migrations/20260922140433_init/migration.sql @@ -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; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..2c154ec --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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") +} diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js new file mode 100644 index 0000000..fd41500 --- /dev/null +++ b/backend/prisma/seed.js @@ -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(); + }); diff --git a/backend/seed-data/classes.json b/backend/seed-data/classes.json new file mode 100644 index 0000000..65e2cf2 --- /dev/null +++ b/backend/seed-data/classes.json @@ -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 } +] diff --git a/backend/seed-data/skills.json b/backend/seed-data/skills.json new file mode 100644 index 0000000..f020ea0 --- /dev/null +++ b/backend/seed-data/skills.json @@ -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" } +] diff --git a/backend/seed-data/species.json b/backend/seed-data/species.json new file mode 100644 index 0000000..67e3271 --- /dev/null +++ b/backend/seed-data/species.json @@ -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 } +] diff --git a/backend/seed-data/weapons.json b/backend/seed-data/weapons.json new file mode 100644 index 0000000..aea33bc --- /dev/null +++ b/backend/seed-data/weapons.json @@ -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 } +] diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..99e213b --- /dev/null +++ b/backend/src/app.js @@ -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 }; diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..0dbc802 --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,5 @@ +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); + +module.exports = prisma; diff --git a/backend/src/game/buildFlow.js b/backend/src/game/buildFlow.js new file mode 100644 index 0000000..9d63b7e --- /dev/null +++ b/backend/src/game/buildFlow.js @@ -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, +}; diff --git a/backend/src/game/calculations.js b/backend/src/game/calculations.js new file mode 100644 index 0000000..3e4f744 --- /dev/null +++ b/backend/src/game/calculations.js @@ -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, +}; diff --git a/backend/src/game/calculations.test.js b/backend/src/game/calculations.test.js new file mode 100644 index 0000000..0c2f5c7 --- /dev/null +++ b/backend/src/game/calculations.test.js @@ -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'); + }); +}); diff --git a/backend/src/game/data/classes.js b/backend/src/game/data/classes.js new file mode 100644 index 0000000..f7f73a0 --- /dev/null +++ b/backend/src/game/data/classes.js @@ -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 }; diff --git a/backend/src/game/data/skills.js b/backend/src/game/data/skills.js new file mode 100644 index 0000000..f81ba65 --- /dev/null +++ b/backend/src/game/data/skills.js @@ -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 }; diff --git a/backend/src/game/data/species.js b/backend/src/game/data/species.js new file mode 100644 index 0000000..92535da --- /dev/null +++ b/backend/src/game/data/species.js @@ -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 }; diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..7b00758 --- /dev/null +++ b/backend/src/index.js @@ -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}`); +}); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..d71c759 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -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 }; diff --git a/backend/src/middleware/campaignAccess.js b/backend/src/middleware/campaignAccess.js new file mode 100644 index 0000000..61d8e21 --- /dev/null +++ b/backend/src/middleware/campaignAccess.js @@ -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 }; diff --git a/backend/src/middleware/characterAccess.js b/backend/src/middleware/characterAccess.js new file mode 100644 index 0000000..65bfe42 --- /dev/null +++ b/backend/src/middleware/characterAccess.js @@ -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 }; diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js new file mode 100644 index 0000000..a11848f --- /dev/null +++ b/backend/src/routes/api.integration.test.js @@ -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); + }); +}); diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js new file mode 100644 index 0000000..026bcb3 --- /dev/null +++ b/backend/src/routes/auth.routes.js @@ -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; diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js new file mode 100644 index 0000000..89b67b9 --- /dev/null +++ b/backend/src/routes/campaigns.routes.js @@ -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; diff --git a/backend/src/routes/characters.routes.js b/backend/src/routes/characters.routes.js new file mode 100644 index 0000000..144ea2b --- /dev/null +++ b/backend/src/routes/characters.routes.js @@ -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; diff --git a/backend/src/routes/encounters.routes.js b/backend/src/routes/encounters.routes.js new file mode 100644 index 0000000..568f057 --- /dev/null +++ b/backend/src/routes/encounters.routes.js @@ -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; diff --git a/backend/src/routes/notes.routes.js b/backend/src/routes/notes.routes.js new file mode 100644 index 0000000..e03fb14 --- /dev/null +++ b/backend/src/routes/notes.routes.js @@ -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; diff --git a/backend/src/sockets/index.js b/backend/src/sockets/index.js new file mode 100644 index 0000000..0293b01 --- /dev/null +++ b/backend/src/sockets/index.js @@ -0,0 +1,29 @@ +const jwt = require('jsonwebtoken'); + +// Clients join a room per campaign (`campaign:`) 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 }; diff --git a/backend/src/utils/inviteCode.js b/backend/src/utils/inviteCode.js new file mode 100644 index 0000000..e7b1f20 --- /dev/null +++ b/backend/src/utils/inviteCode.js @@ -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 }; diff --git a/docs/GAME_RULES_REFERENCE.md b/docs/GAME_RULES_REFERENCE.md new file mode 100644 index 0000000..de781c2 --- /dev/null +++ b/docs/GAME_RULES_REFERENCE.md @@ -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. diff --git a/docs/ROLL_YOUR_CHARACTER.md b/docs/ROLL_YOUR_CHARACTER.md new file mode 100644 index 0000000..49b6cf8 --- /dev/null +++ b/docs/ROLL_YOUR_CHARACTER.md @@ -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. diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..1fad084 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1 @@ +VITE_API_URL=http://localhost:4000 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3570118 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,20 @@ + + + + + + + + The Sunken Reach — Campaign Manager + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..b5cb748 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..4362b3a --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + 20 + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..204db47 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + } /> + } /> + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + ); +} + +export default App; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..41deb1c --- /dev/null +++ b/frontend/src/api/client.ts @@ -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'); +} diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts new file mode 100644 index 0000000..10763f1 --- /dev/null +++ b/frontend/src/api/queries.ts @@ -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) => + (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) => + (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, 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 }>( + `/characters/${characterId}/build/roll` + )).data, + onSuccess: () => invalidateCharacter(qc, characterId), + }); +} + +export function useBuildChoose(characterId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body: Record) => + (await apiClient.post<{ buildStage: string; buildState: Record }>( + `/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) => + (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) => + (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) => + (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 & { 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'] }); + }, + }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..19af189 --- /dev/null +++ b/frontend/src/api/types.ts @@ -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; + proficiencyBonus: number; + armorClass: number; + initiative: number; + speed: number; + hp: { max: number }; + hitDice: { size: number; count: number }; + saves: Record; + skills: Record; + 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; + currentHp: number; + tempHp: number; + armorBonus: number; + specialAbilities: string | null; + gold: number; + buildStage: BuildStage; + buildState: Record | 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[]; +} diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..6ae5520 --- /dev/null +++ b/frontend/src/auth/AuthContext.tsx @@ -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(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState(getStoredToken()); + const [user, setUser] = useState(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 {children}; +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..7920d71 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,32 @@ +import { Link, Outlet } from 'react-router-dom'; +import { useAuth } from '../auth/AuthContext'; + +export function Layout() { + const { user, logout } = useAuth(); + + return ( +
+
+ + + ⚄ + + The Sunken Reach + + +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..c555314 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -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 ; + if (!user) return ; + return ; +} diff --git a/frontend/src/components/QuickStatsDrawer.tsx b/frontend/src/components/QuickStatsDrawer.tsx new file mode 100644 index 0000000..5ffd43c --- /dev/null +++ b/frontend/src/components/QuickStatsDrawer.tsx @@ -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 ( + <> + + {open && setOpen(false)} />} + + ); +} + +export function QuickStatsDrawer({ characterId, onClose }: { characterId: string; onClose: () => void }) { + const { data, isLoading, error } = useQuickStats(characterId, true); + + return ( + <> +
+
+
+

Quick Stats

+ +
+ {isLoading && } + {error && } + {data && ( +
+

{data.name}

+
+
+ HP + + {data.currentHp}/{data.maxHp} + + {data.tempHp > 0 && +{data.tempHp} temp} +
+
+ AC + {data.armorClass} +
+
+ Initiative + {fmtMod(data.initiative)} +
+
+
+
+
+ + {data.spellcasting && ( +
+
Spellcasting
+

+ Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)} +

+
+ )} + +
+
Weapons
+ {data.weapons.length === 0 &&

No weapons equipped.

} + {data.weapons.map((w) => ( +
+
+ {w.name} + {w.equipped && equipped} +

+ {w.damageDice} {w.damageType} {fmtMod(w.damageBonus)} +

+
+ + {fmtMod(w.attackBonus)} + +
+ ))} +
+ + {data.specialAbilities && ( +
+
Special Abilities
+

{data.specialAbilities}

+
+ )} +
+ )} +
+ + ); +} diff --git a/frontend/src/components/Spinner.tsx b/frontend/src/components/Spinner.tsx new file mode 100644 index 0000000..b10065e --- /dev/null +++ b/frontend/src/components/Spinner.tsx @@ -0,0 +1,18 @@ +export function Spinner({ label = 'Loading…' }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} + +export function ErrorBanner({ message }: { message: string }) { + return ( +
+

+ {message} +

+
+ ); +} diff --git a/frontend/src/hooks/useCampaignSocket.ts b/frontend/src/hooks/useCampaignSocket.ts new file mode 100644 index 0000000..84d36de --- /dev/null +++ b/frontend/src/hooks/useCampaignSocket.ts @@ -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]); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..1dc8084 --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/lib/classData.ts b/frontend/src/lib/classData.ts new file mode 100644 index 0000000..a7e90cf --- /dev/null +++ b/frontend/src/lib/classData.ts @@ -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 = { + FIGHTER: null, + BARBARIAN: null, + ROGUE: null, + RANGER: 'WIS', + WIZARD: 'INT', + SORCERER: 'CHA', + CLERIC: 'WIS', + PALADIN: 'CHA', + BARD: 'CHA', + WARLOCK: 'CHA', + DRUID: 'WIS', + MONK: null, +}; diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts new file mode 100644 index 0000000..5a5f422 --- /dev/null +++ b/frontend/src/lib/format.ts @@ -0,0 +1,78 @@ +export const SPECIES_LABELS: Record = { + 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 = { + 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 = { + 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 = { + 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 }; + 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.'; +} diff --git a/frontend/src/lib/speciesData.ts b/frontend/src/lib/speciesData.ts new file mode 100644 index 0000000..15b5ed9 --- /dev/null +++ b/frontend/src/lib/speciesData.ts @@ -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>> = { + 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); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..4948567 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + + + + + +); diff --git a/frontend/src/pages/CampaignHomePage.tsx b/frontend/src/pages/CampaignHomePage.tsx new file mode 100644 index 0000000..dab3b8f --- /dev/null +++ b/frontend/src/pages/CampaignHomePage.tsx @@ -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 ( +
+
+
+ + {character.name} + + {inProgress && Building…} +
+

+ {character.species ? `${character.species} ` : ''} + {character.characterClass || (inProgress ? 'Not yet built' : '')} · Level {character.level} +

+
+
+ {!inProgress && ( +
+ HP {character.currentHp}/{character.maxHp} · AC {character.armorClass} +
+ )} + + {inProgress ? 'Continue Build' : 'Open Sheet'} + + {isDM && ( + + )} +
+
+ ); +} + +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(null); + const [showSettings, setShowSettings] = useState(false); + const [showMonsterForm, setShowMonsterForm] = useState(false); + const [showWeaponForm, setShowWeaponForm] = useState(false); + const [showSpellForm, setShowSpellForm] = useState(false); + + if (isLoading) return ; + if (error || !data) return ; + + 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 ( +
+
+
+

{campaign.name}

+

{campaign.description}

+
+ {isDM ? 'Dungeon Master' : 'Player'} +
+ + {isDM && ( +
+
+ Campaign Settings + +
+
+ Invite code: {campaign.inviteCode} + +
+ {showSettings && ( +
{ + 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', + }); + }} + > +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ )} +
+
Party
+
+ {members.map((m) => ( + + {m.displayName} + + ))} +
+
+
+ )} + + {formError &&
} + + {!isDM && myCharacters.length > 0 && ( +
+

Your Character{myCharacters.length > 1 ? 's' : ''}

+ {myCharacters.map((c) => ( + {}} /> + ))} +
+ )} + +
+
+

{isDM ? 'Characters' : 'Rest of the Party'}

+
+
+ setNewCharName(e.target.value)} + style={{ flex: 1, minWidth: 160 }} + /> + +
+ {(isDM ? characters : partyCharacters)?.length === 0 && ( +
No characters yet. Roll one up above.
+ )} + {(isDM ? characters : partyCharacters)?.map((c) => ( + window.confirm(`Delete ${c.name}?`) && deleteCharacter.mutate(c.id)} + /> + ))} +
+ +
+
+

Encounters

+
+ {isDM && ( +
+ setNewEncounterName(e.target.value)} + style={{ flex: 1, minWidth: 160 }} + /> + +
+ )} + {encounters?.length === 0 &&
No encounters yet.
} + {encounters?.map((enc) => ( + + {enc.name} + {enc.status} · Round {enc.currentRound} + + ))} +
+ + {isDM && ( + <> +
+
+ Weapons Pool + +
+ {showWeaponForm && ( +
{ + 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" + > + + + + + +
+ )} +
+ {weapons?.map((w) => ( + {w.name} ({w.damageDice}) + ))} +
+
+ +
+
+ Spell List + +
+ {showSpellForm && ( +
{ + 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" + > + + + + +
+ )} +
+ {spells?.map((s) => ( + {s.name} (Lv{s.level}) + ))} +
+
+ +
+
+ Monsters + +
+ {showMonsterForm && ( +
{ + 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" + > + + + + + + +
+ )} +
+ {monsters?.map((m) => ( +
+ {m.name} — AC {m.ac}, HP {m.maxHp} + +
+ ))} +
+
+ + )} + +
+
Campaign Notes
+
+