Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dnd_manager?schema=public"
|
||||
JWT_SECRET="change-me-to-a-long-random-string"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
PORT=4000
|
||||
CORS_ORIGIN="http://localhost:5173"
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:seed": "prisma db seed",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"socket.io": "^4.8.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.20.0",
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CampaignRole" AS ENUM ('DM', 'PLAYER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Species" AS ENUM ('HUMAN', 'HALF_ELF', 'ELF', 'GNOME', 'DWARF', 'HALFLING', 'DRAGONBORN', 'TIEFLING', 'HALF_ORC', 'ORC');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CharacterClass" AS ENUM ('FIGHTER', 'BARBARIAN', 'ROGUE', 'RANGER', 'WIZARD', 'SORCERER', 'CLERIC', 'PALADIN', 'BARD', 'WARLOCK', 'DRUID', 'MONK');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Ability" AS ENUM ('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Skill" AS ENUM ('ACROBATICS', 'ANIMAL_HANDLING', 'ARCANA', 'ATHLETICS', 'DECEPTION', 'HISTORY', 'INSIGHT', 'INTIMIDATION', 'INVESTIGATION', 'MEDICINE', 'NATURE', 'PERCEPTION', 'PERFORMANCE', 'PERSUASION', 'RELIGION', 'SLEIGHT_OF_HAND', 'STEALTH', 'SURVIVAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BuildStage" AS ENUM ('NOT_STARTED', 'ROLLING_SPECIES', 'ROLLING_CLASS', 'ROLLING_MOTIVATION', 'ASSIGNING_SCORES', 'ASSIGNING_PROFICIENCIES', 'CHOOSING_EQUIPMENT', 'COMPLETE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EncounterStatus" AS ENUM ('PLANNING', 'ACTIVE', 'COMPLETE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NoteVisibility" AS ENUM ('DM_ONLY', 'SHARED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "campaigns" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"inviteCode" TEXT NOT NULL,
|
||||
"startingGold" INTEGER NOT NULL DEFAULT 0,
|
||||
"startingItemCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"startingSpellCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"skipMotivationRoll" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "campaigns_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "campaign_members" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" "CampaignRole" NOT NULL DEFAULT 'PLAYER',
|
||||
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "campaign_members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "characters" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"createdById" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"species" "Species",
|
||||
"characterClass" "CharacterClass",
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"xp" INTEGER NOT NULL DEFAULT 0,
|
||||
"motivation" TEXT,
|
||||
"strScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"dexScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"conScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"intScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"wisScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"chaScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"halfElfBonusAbility1" "Ability",
|
||||
"halfElfBonusAbility2" "Ability",
|
||||
"currentHp" INTEGER NOT NULL DEFAULT 0,
|
||||
"tempHp" INTEGER NOT NULL DEFAULT 0,
|
||||
"armorBonus" INTEGER NOT NULL DEFAULT 0,
|
||||
"speedOverride" INTEGER,
|
||||
"specialAbilities" TEXT,
|
||||
"gold" INTEGER NOT NULL DEFAULT 0,
|
||||
"buildStage" "BuildStage" NOT NULL DEFAULT 'NOT_STARTED',
|
||||
"buildState" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "characters_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "character_skill_proficiencies" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"skill" "Skill" NOT NULL,
|
||||
"proficient" BOOLEAN NOT NULL DEFAULT true,
|
||||
"expertise" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "character_skill_proficiencies_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "character_save_proficiencies" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"ability" "Ability" NOT NULL,
|
||||
"proficient" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "character_save_proficiencies_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "weapons" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"damageDice" TEXT NOT NULL,
|
||||
"damageType" TEXT NOT NULL,
|
||||
"attackAbility" TEXT NOT NULL,
|
||||
"properties" TEXT[],
|
||||
"rangeNormal" INTEGER,
|
||||
"rangeLong" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "weapons_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "character_weapons" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"weaponId" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"equipped" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "character_weapons_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "inventory_items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"equipped" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "inventory_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "spells" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"level" INTEGER NOT NULL,
|
||||
"school" TEXT,
|
||||
"castingTime" TEXT,
|
||||
"range" TEXT,
|
||||
"duration" TEXT,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "spells_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "character_spells" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"spellId" TEXT NOT NULL,
|
||||
"prepared" BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT "character_spells_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "monsters" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"ac" INTEGER NOT NULL,
|
||||
"maxHp" INTEGER NOT NULL,
|
||||
"speed" INTEGER NOT NULL DEFAULT 30,
|
||||
"strScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"dexScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"conScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"intScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"wisScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"chaScore" INTEGER NOT NULL DEFAULT 10,
|
||||
"attackBonus" INTEGER NOT NULL DEFAULT 0,
|
||||
"damageDice" TEXT,
|
||||
"challengeRating" TEXT,
|
||||
"specialTrait" TEXT,
|
||||
"notes" TEXT,
|
||||
|
||||
CONSTRAINT "monsters_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounters" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"status" "EncounterStatus" NOT NULL DEFAULT 'PLANNING',
|
||||
"currentRound" INTEGER NOT NULL DEFAULT 1,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "encounters_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_participants" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"characterId" TEXT,
|
||||
"monsterId" TEXT,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"initiative" INTEGER NOT NULL DEFAULT 0,
|
||||
"currentHp" INTEGER NOT NULL,
|
||||
"maxHp" INTEGER NOT NULL,
|
||||
"conditions" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"isMonster" BOOLEAN NOT NULL,
|
||||
|
||||
CONSTRAINT "encounter_participants_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "notes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT,
|
||||
"characterId" TEXT,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"visibility" "NoteVisibility" NOT NULL DEFAULT 'SHARED',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "notes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "campaigns_inviteCode_key" ON "campaigns"("inviteCode");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "campaign_members_campaignId_userId_key" ON "campaign_members"("campaignId", "userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "character_skill_proficiencies_characterId_skill_key" ON "character_skill_proficiencies"("characterId", "skill");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "character_save_proficiencies_characterId_ability_key" ON "character_save_proficiencies"("characterId", "ability");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "character_weapons_characterId_weaponId_key" ON "character_weapons"("characterId", "weaponId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "character_spells_characterId_spellId_key" ON "character_spells"("characterId", "spellId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "campaign_members" ADD CONSTRAINT "campaign_members_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "campaign_members" ADD CONSTRAINT "campaign_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "characters" ADD CONSTRAINT "characters_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "characters" ADD CONSTRAINT "characters_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "characters" ADD CONSTRAINT "characters_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_skill_proficiencies" ADD CONSTRAINT "character_skill_proficiencies_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_save_proficiencies" ADD CONSTRAINT "character_save_proficiencies_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "weapons" ADD CONSTRAINT "weapons_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_weapons" ADD CONSTRAINT "character_weapons_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_weapons" ADD CONSTRAINT "character_weapons_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventory_items" ADD CONSTRAINT "inventory_items_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "spells" ADD CONSTRAINT "spells_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_spells" ADD CONSTRAINT "character_spells_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_spells" ADD CONSTRAINT "character_spells_spellId_fkey" FOREIGN KEY ("spellId") REFERENCES "spells"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "monsters" ADD CONSTRAINT "monsters_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_participants" ADD CONSTRAINT "encounter_participants_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_participants" ADD CONSTRAINT "encounter_participants_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_participants" ADD CONSTRAINT "encounter_participants_monsterId_fkey" FOREIGN KEY ("monsterId") REFERENCES "monsters"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "notes" ADD CONSTRAINT "notes_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,412 @@
|
||||
// D&D Campaign Manager — Prisma schema
|
||||
// Postgres. Run `npx prisma migrate dev --name init` after `DATABASE_URL` is set,
|
||||
// then `npx prisma db seed` using the seed script built from /seed-data/*.json
|
||||
// (see CLAUDE_BUILD_PROMPT.md, "Database" section).
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enums
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum CampaignRole {
|
||||
DM
|
||||
PLAYER
|
||||
}
|
||||
|
||||
enum Species {
|
||||
HUMAN
|
||||
HALF_ELF
|
||||
ELF
|
||||
GNOME
|
||||
DWARF
|
||||
HALFLING
|
||||
DRAGONBORN
|
||||
TIEFLING
|
||||
HALF_ORC
|
||||
ORC
|
||||
}
|
||||
|
||||
enum CharacterClass {
|
||||
FIGHTER
|
||||
BARBARIAN
|
||||
ROGUE
|
||||
RANGER
|
||||
WIZARD
|
||||
SORCERER
|
||||
CLERIC
|
||||
PALADIN
|
||||
BARD
|
||||
WARLOCK
|
||||
DRUID
|
||||
MONK
|
||||
}
|
||||
|
||||
enum Ability {
|
||||
STR
|
||||
DEX
|
||||
CON
|
||||
INT
|
||||
WIS
|
||||
CHA
|
||||
}
|
||||
|
||||
enum Skill {
|
||||
ACROBATICS
|
||||
ANIMAL_HANDLING
|
||||
ARCANA
|
||||
ATHLETICS
|
||||
DECEPTION
|
||||
HISTORY
|
||||
INSIGHT
|
||||
INTIMIDATION
|
||||
INVESTIGATION
|
||||
MEDICINE
|
||||
NATURE
|
||||
PERCEPTION
|
||||
PERFORMANCE
|
||||
PERSUASION
|
||||
RELIGION
|
||||
SLEIGHT_OF_HAND
|
||||
STEALTH
|
||||
SURVIVAL
|
||||
}
|
||||
|
||||
enum BuildStage {
|
||||
NOT_STARTED
|
||||
ROLLING_SPECIES
|
||||
ROLLING_CLASS
|
||||
ROLLING_MOTIVATION
|
||||
ASSIGNING_SCORES
|
||||
ASSIGNING_PROFICIENCIES
|
||||
CHOOSING_EQUIPMENT
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
enum EncounterStatus {
|
||||
PLANNING
|
||||
ACTIVE
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
enum NoteVisibility {
|
||||
DM_ONLY
|
||||
SHARED
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity & campaigns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
displayName String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
memberships CampaignMember[]
|
||||
charactersOwned Character[] @relation("PlayerCharacters")
|
||||
charactersCreated Character[] @relation("CreatedBy")
|
||||
notesAuthored Note[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Campaign {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
inviteCode String @unique
|
||||
startingGold Int @default(0)
|
||||
startingItemCount Int @default(0)
|
||||
startingSpellCount Int @default(0)
|
||||
skipMotivationRoll Boolean @default(false)
|
||||
ownerId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
members CampaignMember[]
|
||||
characters Character[]
|
||||
weapons Weapon[]
|
||||
spells Spell[]
|
||||
monsters Monster[]
|
||||
encounters Encounter[]
|
||||
notes Note[]
|
||||
|
||||
@@map("campaigns")
|
||||
}
|
||||
|
||||
model CampaignMember {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
userId String
|
||||
role CampaignRole @default(PLAYER)
|
||||
joinedAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([campaignId, userId])
|
||||
@@map("campaign_members")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Characters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Character {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
userId String? // owning player; null for a DM-controlled NPC/ally
|
||||
createdById String
|
||||
name String
|
||||
species Species?
|
||||
characterClass CharacterClass?
|
||||
level Int @default(1)
|
||||
xp Int @default(0)
|
||||
motivation String?
|
||||
|
||||
// Rolled ability scores (before species bonus)
|
||||
strScore Int @default(10)
|
||||
dexScore Int @default(10)
|
||||
conScore Int @default(10)
|
||||
intScore Int @default(10)
|
||||
wisScore Int @default(10)
|
||||
chaScore Int @default(10)
|
||||
|
||||
// Half-Elf's flexible +1/+1 choice; null unless species = HALF_ELF
|
||||
halfElfBonusAbility1 Ability?
|
||||
halfElfBonusAbility2 Ability?
|
||||
|
||||
currentHp Int @default(0)
|
||||
tempHp Int @default(0)
|
||||
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
|
||||
speedOverride Int?
|
||||
|
||||
specialAbilities String? // free-text list shown in the quick-stats panel
|
||||
gold Int @default(0)
|
||||
|
||||
buildStage BuildStage @default(NOT_STARTED)
|
||||
buildState Json? // in-progress dice rolls / pending choices while building
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
user User? @relation("PlayerCharacters", fields: [userId], references: [id])
|
||||
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
|
||||
|
||||
skillProficiencies CharacterSkillProficiency[]
|
||||
saveProficiencies CharacterSaveProficiency[]
|
||||
inventoryItems InventoryItem[]
|
||||
weapons CharacterWeapon[]
|
||||
spells CharacterSpell[]
|
||||
notes Note[]
|
||||
encounterEntries EncounterParticipant[]
|
||||
|
||||
@@map("characters")
|
||||
}
|
||||
|
||||
model CharacterSkillProficiency {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
skill Skill
|
||||
proficient Boolean @default(true)
|
||||
expertise Boolean @default(false) // double proficiency bonus, e.g. Rogue
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([characterId, skill])
|
||||
@@map("character_skill_proficiencies")
|
||||
}
|
||||
|
||||
model CharacterSaveProficiency {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
ability Ability
|
||||
proficient Boolean @default(true)
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([characterId, ability])
|
||||
@@map("character_save_proficiencies")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons & inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Weapon {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
name String
|
||||
damageDice String // e.g. "1d8"
|
||||
damageType String // e.g. "slashing"
|
||||
attackAbility String // "STR" | "DEX" | "FINESSE" (finesse = better of STR/DEX)
|
||||
properties String[] // e.g. ["finesse", "light", "thrown"]
|
||||
rangeNormal Int?
|
||||
rangeLong Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
assigned CharacterWeapon[]
|
||||
|
||||
@@map("weapons")
|
||||
}
|
||||
|
||||
model CharacterWeapon {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
weaponId String
|
||||
quantity Int @default(1)
|
||||
equipped Boolean @default(false)
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
weapon Weapon @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([characterId, weaponId])
|
||||
@@map("character_weapons")
|
||||
}
|
||||
|
||||
model InventoryItem {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
name String
|
||||
description String?
|
||||
quantity Int @default(1)
|
||||
equipped Boolean @default(false)
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("inventory_items")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spells
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Spell {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
name String
|
||||
level Int // 0 = cantrip
|
||||
school String?
|
||||
castingTime String?
|
||||
range String?
|
||||
duration String?
|
||||
description String?
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
known CharacterSpell[]
|
||||
|
||||
@@map("spells")
|
||||
}
|
||||
|
||||
model CharacterSpell {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
spellId String
|
||||
prepared Boolean @default(false)
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
spell Spell @relation(fields: [spellId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([characterId, spellId])
|
||||
@@map("character_spells")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monsters & encounters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Monster {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
name String
|
||||
ac Int
|
||||
maxHp Int
|
||||
speed Int @default(30)
|
||||
strScore Int @default(10)
|
||||
dexScore Int @default(10)
|
||||
conScore Int @default(10)
|
||||
intScore Int @default(10)
|
||||
wisScore Int @default(10)
|
||||
chaScore Int @default(10)
|
||||
attackBonus Int @default(0)
|
||||
damageDice String?
|
||||
challengeRating String?
|
||||
specialTrait String?
|
||||
notes String?
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
|
||||
@@map("monsters")
|
||||
}
|
||||
|
||||
model Encounter {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
name String
|
||||
status EncounterStatus @default(PLANNING)
|
||||
currentRound Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
participants EncounterParticipant[]
|
||||
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
// A row per combatant in an encounter — either a character or a monster.
|
||||
// currentHp/maxHp are snapshotted here (not just read off Character/Monster)
|
||||
// so damage tracking during a fight doesn't need to mutate the character's or
|
||||
// monster's permanent record until the DM chooses to apply it back.
|
||||
model EncounterParticipant {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
characterId String?
|
||||
monsterId String?
|
||||
displayName String // denormalized so a monster can be renamed per-instance, e.g. "Goblin #2"
|
||||
initiative Int @default(0)
|
||||
currentHp Int
|
||||
maxHp Int
|
||||
conditions String[] @default([]) // e.g. ["poisoned", "prone"]
|
||||
isMonster Boolean
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
|
||||
monster Monster? @relation(fields: [monsterId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("encounter_participants")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model Note {
|
||||
id String @id @default(cuid())
|
||||
campaignId String?
|
||||
characterId String?
|
||||
authorId String
|
||||
body String
|
||||
visibility NoteVisibility @default(SHARED)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign? @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
character Character? @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
|
||||
@@map("notes")
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Seeds a demo campaign for local dev. Species/classes/skills are NOT seeded
|
||||
// as rows — they're fixed game-rule tables ported into src/game/data/*.js and
|
||||
// used directly by the calculation engine. Weapons ARE seeded, but only into
|
||||
// this demo campaign (weapons are per-campaign in the schema, since a DM can
|
||||
// add homebrew weapons).
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { generateInviteCode } = require('../src/utils/inviteCode');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const weapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash('password123', 12);
|
||||
|
||||
const dm = await prisma.user.upsert({
|
||||
where: { email: 'dm@example.com' },
|
||||
update: {},
|
||||
create: { email: 'dm@example.com', passwordHash, displayName: 'Dungeon Master' },
|
||||
});
|
||||
|
||||
const player = await prisma.user.upsert({
|
||||
where: { email: 'player@example.com' },
|
||||
update: {},
|
||||
create: { email: 'player@example.com', passwordHash, displayName: 'Test Player' },
|
||||
});
|
||||
|
||||
let campaign = await prisma.campaign.findFirst({ where: { name: 'The Sunken Reach (Demo)' } });
|
||||
if (!campaign) {
|
||||
campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
name: 'The Sunken Reach (Demo)',
|
||||
description: 'A demo campaign seeded for local development.',
|
||||
inviteCode: generateInviteCode(),
|
||||
startingGold: 15,
|
||||
startingItemCount: 2,
|
||||
startingSpellCount: 2,
|
||||
ownerId: dm.id,
|
||||
members: {
|
||||
create: [
|
||||
{ userId: dm.id, role: 'DM' },
|
||||
{ userId: player.id, role: 'PLAYER' },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const weapon of weapons) {
|
||||
const existing = await prisma.weapon.findFirst({ where: { campaignId: campaign.id, name: weapon.name } });
|
||||
if (!existing) {
|
||||
await prisma.weapon.create({ data: { ...weapon, campaignId: campaign.id } });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Seed complete.');
|
||||
console.log(` DM login: dm@example.com / password123`);
|
||||
console.log(` Player login: player@example.com / password123`);
|
||||
console.log(` Campaign invite code: ${campaign.inviteCode}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
[
|
||||
{ "key": "FIGHTER", "name": "Fighter", "hitDie": 10, "primaryAbility": "Str/Dex", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null },
|
||||
{ "key": "BARBARIAN", "name": "Barbarian", "hitDie": 12, "primaryAbility": "Str", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null },
|
||||
{ "key": "ROGUE", "name": "Rogue", "hitDie": 8, "primaryAbility": "Dex", "saveProficiencies": ["DEX", "INT"], "spellcastingAbility": null },
|
||||
{ "key": "RANGER", "name": "Ranger", "hitDie": 10, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": "WIS" },
|
||||
{ "key": "WIZARD", "name": "Wizard", "hitDie": 6, "primaryAbility": "Int", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "INT" },
|
||||
{ "key": "SORCERER", "name": "Sorcerer", "hitDie": 6, "primaryAbility": "Cha", "saveProficiencies": ["CON", "CHA"], "spellcastingAbility": "CHA" },
|
||||
{ "key": "CLERIC", "name": "Cleric", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "WIS" },
|
||||
{ "key": "PALADIN", "name": "Paladin", "hitDie": 10, "primaryAbility": "Str/Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA" },
|
||||
{ "key": "BARD", "name": "Bard", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["DEX", "CHA"], "spellcastingAbility": "CHA" },
|
||||
{ "key": "WARLOCK", "name": "Warlock", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA" },
|
||||
{ "key": "DRUID", "name": "Druid", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "WIS" },
|
||||
{ "key": "MONK", "name": "Monk", "hitDie": 8, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": null }
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{ "key": "ACROBATICS", "name": "Acrobatics", "ability": "DEX" },
|
||||
{ "key": "ANIMAL_HANDLING", "name": "Animal Handling", "ability": "WIS" },
|
||||
{ "key": "ARCANA", "name": "Arcana", "ability": "INT" },
|
||||
{ "key": "ATHLETICS", "name": "Athletics", "ability": "STR" },
|
||||
{ "key": "DECEPTION", "name": "Deception", "ability": "CHA" },
|
||||
{ "key": "HISTORY", "name": "History", "ability": "INT" },
|
||||
{ "key": "INSIGHT", "name": "Insight", "ability": "WIS" },
|
||||
{ "key": "INTIMIDATION", "name": "Intimidation", "ability": "CHA" },
|
||||
{ "key": "INVESTIGATION", "name": "Investigation", "ability": "INT" },
|
||||
{ "key": "MEDICINE", "name": "Medicine", "ability": "WIS" },
|
||||
{ "key": "NATURE", "name": "Nature", "ability": "INT" },
|
||||
{ "key": "PERCEPTION", "name": "Perception", "ability": "WIS" },
|
||||
{ "key": "PERFORMANCE", "name": "Performance", "ability": "CHA" },
|
||||
{ "key": "PERSUASION", "name": "Persuasion", "ability": "CHA" },
|
||||
{ "key": "RELIGION", "name": "Religion", "ability": "INT" },
|
||||
{ "key": "SLEIGHT_OF_HAND", "name": "Sleight of Hand", "ability": "DEX" },
|
||||
{ "key": "STEALTH", "name": "Stealth", "ability": "DEX" },
|
||||
{ "key": "SURVIVAL", "name": "Survival", "ability": "WIS" }
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{ "key": "HUMAN", "name": "Human", "abilityBonuses": { "STR": 1, "DEX": 1, "CON": 1, "INT": 1, "WIS": 1, "CHA": 1 }, "speed": 30, "notes": null },
|
||||
{ "key": "HALF_ELF", "name": "Half-Elf", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 2 }, "speed": 30, "notes": "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default." },
|
||||
{ "key": "ELF", "name": "Elf", "abilityBonuses": { "STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null },
|
||||
{ "key": "GNOME", "name": "Gnome", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 2, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
|
||||
{ "key": "DWARF", "name": "Dwarf", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 2, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
|
||||
{ "key": "HALFLING", "name": "Halfling", "abilityBonuses": { "STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
|
||||
{ "key": "DRAGONBORN", "name": "Dragonborn", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 1 }, "speed": 30, "notes": null },
|
||||
{ "key": "TIEFLING", "name": "Tiefling", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 1, "WIS": 0, "CHA": 2 }, "speed": 30, "notes": null },
|
||||
{ "key": "HALF_ORC", "name": "Half-Orc", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null },
|
||||
{ "key": "ORC", "name": "Orc", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null }
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{ "name": "Dagger", "damageDice": "1d4", "damageType": "piercing", "attackAbility": "FINESSE", "properties": ["finesse", "light", "thrown"], "rangeNormal": 20, "rangeLong": 60 },
|
||||
{ "name": "Shortsword", "damageDice": "1d6", "damageType": "piercing", "attackAbility": "FINESSE", "properties": ["finesse", "light"], "rangeNormal": null, "rangeLong": null },
|
||||
{ "name": "Rapier", "damageDice": "1d8", "damageType": "piercing", "attackAbility": "FINESSE", "properties": ["finesse"], "rangeNormal": null, "rangeLong": null },
|
||||
{ "name": "Longsword", "damageDice": "1d8", "damageType": "slashing", "attackAbility": "STR", "properties": ["versatile (1d10)"], "rangeNormal": null, "rangeLong": null },
|
||||
{ "name": "Greatsword", "damageDice": "2d6", "damageType": "slashing", "attackAbility": "STR", "properties": ["heavy", "two-handed"], "rangeNormal": null, "rangeLong": null },
|
||||
{ "name": "Battleaxe", "damageDice": "1d8", "damageType": "slashing", "attackAbility": "STR", "properties": ["versatile (1d10)"], "rangeNormal": null, "rangeLong": null },
|
||||
{ "name": "Handaxe", "damageDice": "1d6", "damageType": "slashing", "attackAbility": "STR", "properties": ["light", "thrown"], "rangeNormal": 20, "rangeLong": 60 },
|
||||
{ "name": "Warhammer", "damageDice": "1d8", "damageType": "bludgeoning", "attackAbility": "STR", "properties": ["versatile (1d10)"], "rangeNormal": null, "rangeLong": null },
|
||||
{ "name": "Mace", "damageDice": "1d6", "damageType": "bludgeoning", "attackAbility": "STR", "properties": [], "rangeNormal": null, "rangeLong": null },
|
||||
{ "name": "Quarterstaff", "damageDice": "1d6", "damageType": "bludgeoning", "attackAbility": "STR", "properties": ["versatile (1d8)"], "rangeNormal": null, "rangeLong": null },
|
||||
{ "name": "Spear", "damageDice": "1d6", "damageType": "piercing", "attackAbility": "STR", "properties": ["thrown", "versatile (1d8)"], "rangeNormal": 20, "rangeLong": 60 },
|
||||
{ "name": "Shortbow", "damageDice": "1d6", "damageType": "piercing", "attackAbility": "DEX", "properties": ["ammunition", "two-handed"], "rangeNormal": 80, "rangeLong": 320 },
|
||||
{ "name": "Longbow", "damageDice": "1d8", "damageType": "piercing", "attackAbility": "DEX", "properties": ["ammunition", "heavy", "two-handed"], "rangeNormal": 150, "rangeLong": 600 },
|
||||
{ "name": "Light Crossbow", "damageDice": "1d8", "damageType": "piercing", "attackAbility": "DEX", "properties": ["ammunition", "loading", "two-handed"], "rangeNormal": 80, "rangeLong": 320 },
|
||||
{ "name": "Dart", "damageDice": "1d4", "damageType": "piercing", "attackAbility": "FINESSE", "properties": ["finesse", "thrown"], "rangeNormal": 20, "rangeLong": 60 },
|
||||
{ "name": "Unarmed Strike", "damageDice": "1", "damageType": "bludgeoning", "attackAbility": "STR", "properties": [], "rangeNormal": null, "rangeLong": null }
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const authRoutes = require('./routes/auth.routes');
|
||||
const campaignsRoutes = require('./routes/campaigns.routes');
|
||||
const charactersRoutes = require('./routes/characters.routes');
|
||||
const encountersRoutes = require('./routes/encounters.routes');
|
||||
const notesRoutes = require('./routes/notes.routes');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173' }));
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use('/auth', authRoutes);
|
||||
app.use('/campaigns', campaignsRoutes);
|
||||
app.use('/characters', charactersRoutes);
|
||||
app.use('/encounters', encountersRoutes);
|
||||
app.use('/notes', notesRoutes);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err);
|
||||
if (err.code === 'P2025') return res.status(404).json({ error: 'Record not found' });
|
||||
if (err.code === 'P2002') return res.status(409).json({ error: 'A record with that value already exists' });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
module.exports = { createApp };
|
||||
@@ -0,0 +1,5 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
@@ -0,0 +1,76 @@
|
||||
// Roll-Your-Character build flow tables — see /docs/ROLL_YOUR_CHARACTER.md.
|
||||
// Dice are always rolled server-side (crypto.randomInt), never trusted from the client.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { SPECIES } = require('./data/species');
|
||||
const { CLASSES } = require('./data/classes');
|
||||
|
||||
const ALL_SPECIES = Object.keys(SPECIES);
|
||||
const ALL_CLASSES = Object.keys(CLASSES);
|
||||
|
||||
const SPECIES_ROLL_TABLE = {
|
||||
1: ['HUMAN', 'HALF_ELF'],
|
||||
2: ['ELF', 'GNOME'],
|
||||
3: ['DWARF', 'HALFLING'],
|
||||
4: ['DRAGONBORN', 'TIEFLING'],
|
||||
5: ['HALF_ORC', 'ORC'],
|
||||
6: 'WILDCARD',
|
||||
};
|
||||
|
||||
const CLASS_ROLL_TABLE = {
|
||||
1: ['FIGHTER', 'BARBARIAN'],
|
||||
2: ['ROGUE', 'RANGER'],
|
||||
3: ['WIZARD', 'SORCERER'],
|
||||
4: ['CLERIC', 'PALADIN'],
|
||||
5: ['BARD', 'WARLOCK'],
|
||||
6: ['DRUID', 'MONK'],
|
||||
7: 'REROLL',
|
||||
8: 'WILDCARD',
|
||||
};
|
||||
|
||||
const MOTIVATION_TABLE = {
|
||||
1: 'Revenge for a past wrong',
|
||||
2: 'Seeking fortune or wealth',
|
||||
3: 'Protecting someone they love',
|
||||
4: 'Redemption for a past mistake',
|
||||
5: 'Driven by curiosity or knowledge',
|
||||
6: 'Duty or oath to a cause or order',
|
||||
};
|
||||
|
||||
function rollDie(sides) {
|
||||
return crypto.randomInt(1, sides + 1);
|
||||
}
|
||||
|
||||
// Standard method: 4d6, drop the lowest, sum the rest.
|
||||
function rollAbilityScore() {
|
||||
const dice = Array.from({ length: 4 }, () => rollDie(6));
|
||||
const sorted = [...dice].sort((a, b) => a - b);
|
||||
const dropped = sorted[0];
|
||||
const total = sorted.slice(1).reduce((a, b) => a + b, 0);
|
||||
return { dice, dropped, total };
|
||||
}
|
||||
|
||||
function speciesChoicesForRoll(roll) {
|
||||
const entry = SPECIES_ROLL_TABLE[roll];
|
||||
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_SPECIES };
|
||||
return { type: 'choice', options: entry };
|
||||
}
|
||||
|
||||
function classChoicesForRoll(roll) {
|
||||
const entry = CLASS_ROLL_TABLE[roll];
|
||||
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_CLASSES };
|
||||
if (entry === 'REROLL') return { type: 'reroll', options: [] };
|
||||
return { type: 'choice', options: entry };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SPECIES_ROLL_TABLE,
|
||||
CLASS_ROLL_TABLE,
|
||||
MOTIVATION_TABLE,
|
||||
ALL_SPECIES,
|
||||
ALL_CLASSES,
|
||||
rollDie,
|
||||
rollAbilityScore,
|
||||
speciesChoicesForRoll,
|
||||
classChoicesForRoll,
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
// D&D 5e rules calculation engine — the ONLY place these formulas may live.
|
||||
// See /docs/GAME_RULES_REFERENCE.md for the spec this implements.
|
||||
// Pure and dependency-free on purpose: no DB calls, no framework imports.
|
||||
|
||||
const { SPECIES } = require('./data/species');
|
||||
const { CLASSES } = require('./data/classes');
|
||||
const { SKILLS } = require('./data/skills');
|
||||
|
||||
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
function abilityModifier(score) {
|
||||
return Math.floor((score - 10) / 2);
|
||||
}
|
||||
|
||||
function proficiencyBonus(level) {
|
||||
return 2 + Math.floor((level - 1) / 4);
|
||||
}
|
||||
|
||||
// Half-Elf's flexible +1/+1 lands on whichever two abilities the player picked
|
||||
// (halfElfBonusAbilities), on top of the fixed +2 CHA in the species table.
|
||||
function speciesAbilityBonus(ability, species, halfElfBonusAbilities = []) {
|
||||
const speciesData = species ? SPECIES[species] : null;
|
||||
if (!speciesData) return 0;
|
||||
let bonus = speciesData.abilityBonuses[ability] || 0;
|
||||
if (species === 'HALF_ELF' && halfElfBonusAbilities.includes(ability)) {
|
||||
bonus += 1;
|
||||
}
|
||||
return bonus;
|
||||
}
|
||||
|
||||
function finalAbilityScore(rolledScore, ability, species, halfElfBonusAbilities = []) {
|
||||
return rolledScore + speciesAbilityBonus(ability, species, halfElfBonusAbilities);
|
||||
}
|
||||
|
||||
function speciesSpeed(species) {
|
||||
return species && SPECIES[species] ? SPECIES[species].speed : 30;
|
||||
}
|
||||
|
||||
function effectiveSpeed(species, speedOverride) {
|
||||
return speedOverride != null ? speedOverride : speciesSpeed(species);
|
||||
}
|
||||
|
||||
function saveBonus(abilityMod, proficient, profBonus) {
|
||||
return abilityMod + (proficient ? profBonus : 0);
|
||||
}
|
||||
|
||||
// expertise (e.g. Rogue) doubles the proficiency portion of the bonus.
|
||||
function skillBonus(abilityMod, proficient, profBonus, expertise = false) {
|
||||
if (!proficient) return abilityMod;
|
||||
return abilityMod + profBonus * (expertise ? 2 : 1);
|
||||
}
|
||||
|
||||
function passivePerception(perceptionSkillBonus) {
|
||||
return 10 + perceptionSkillBonus;
|
||||
}
|
||||
|
||||
function armorClass(dexMod, armorBonus = 0) {
|
||||
return 10 + dexMod + armorBonus;
|
||||
}
|
||||
|
||||
function initiative(dexMod) {
|
||||
return dexMod;
|
||||
}
|
||||
|
||||
// Level 1 always uses the maximum possible Hit Die roll; every level after
|
||||
// that uses the fixed "average" value (floor(hitDieSize / 2) + 1). Do not
|
||||
// regress this to a flat average-for-all-levels formula — that was a bug
|
||||
// caught while building the reference Excel calculator.
|
||||
function hpMax(level, hitDieSize, conMod) {
|
||||
const base = hitDieSize + conMod;
|
||||
if (level <= 1) return base;
|
||||
const perLevelAverage = Math.floor(hitDieSize / 2) + 1;
|
||||
return base + (level - 1) * (perLevelAverage + conMod);
|
||||
}
|
||||
|
||||
function spellSaveDC(profBonus, spellAbilityMod) {
|
||||
return 8 + profBonus + spellAbilityMod;
|
||||
}
|
||||
|
||||
function spellAttackBonus(profBonus, spellAbilityMod) {
|
||||
return profBonus + spellAbilityMod;
|
||||
}
|
||||
|
||||
// attackAbility is "STR" | "DEX" | "FINESSE" (finesse = better of STR/DEX).
|
||||
function weaponAbilityModifier(attackAbility, strMod, dexMod) {
|
||||
if (attackAbility === 'STR') return strMod;
|
||||
if (attackAbility === 'DEX') return dexMod;
|
||||
if (attackAbility === 'FINESSE') return Math.max(strMod, dexMod);
|
||||
throw new Error(`Unknown attackAbility: ${attackAbility}`);
|
||||
}
|
||||
|
||||
function weaponAttackBonus({ attackAbility, strMod, dexMod, proficient, profBonus }) {
|
||||
const abilityMod = weaponAbilityModifier(attackAbility, strMod, dexMod);
|
||||
return abilityMod + (proficient ? profBonus : 0);
|
||||
}
|
||||
|
||||
function weaponDamageBonus({ attackAbility, strMod, dexMod }) {
|
||||
return weaponAbilityModifier(attackAbility, strMod, dexMod);
|
||||
}
|
||||
|
||||
function getGoverningAbilityForSkill(skill) {
|
||||
const data = SKILLS[skill];
|
||||
if (!data) throw new Error(`Unknown skill: ${skill}`);
|
||||
return data.ability;
|
||||
}
|
||||
|
||||
// Saving throw proficiencies are fixed per class, not a player choice —
|
||||
// use this at character-build finalize time to seed CharacterSaveProficiency.
|
||||
function getClassSaveProficiencies(characterClass) {
|
||||
const data = characterClass ? CLASSES[characterClass] : null;
|
||||
return data ? data.saveProficiencies : [];
|
||||
}
|
||||
|
||||
function getClassHitDieSize(characterClass) {
|
||||
const data = characterClass ? CLASSES[characterClass] : null;
|
||||
return data ? data.hitDie : 8;
|
||||
}
|
||||
|
||||
function getClassSpellcastingAbility(characterClass) {
|
||||
const data = characterClass ? CLASSES[characterClass] : null;
|
||||
return data ? data.spellcastingAbility : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assembles the full derived character sheet from raw stored data. This is
|
||||
* the single function the API and quick-stats view should call rather than
|
||||
* re-deriving any of the above by hand.
|
||||
*
|
||||
* @param {object} character - rolled scores, species/class, level, armorBonus, etc.
|
||||
* @param {Array<{skill: string, proficient: boolean, expertise: boolean}>} skillProficiencies
|
||||
* @param {Array<{ability: string, proficient: boolean}>} saveProficiencies
|
||||
*/
|
||||
function computeCharacterSheet(character, skillProficiencies = [], saveProficiencies = []) {
|
||||
const {
|
||||
level = 1,
|
||||
species = null,
|
||||
characterClass = null,
|
||||
strScore = 10,
|
||||
dexScore = 10,
|
||||
conScore = 10,
|
||||
intScore = 10,
|
||||
wisScore = 10,
|
||||
chaScore = 10,
|
||||
halfElfBonusAbility1 = null,
|
||||
halfElfBonusAbility2 = null,
|
||||
armorBonus = 0,
|
||||
speedOverride = null,
|
||||
} = character;
|
||||
|
||||
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
|
||||
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
|
||||
|
||||
const abilities = {};
|
||||
for (const ability of ABILITIES) {
|
||||
const final = finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities);
|
||||
abilities[ability] = {
|
||||
rolled: rolled[ability],
|
||||
final,
|
||||
modifier: abilityModifier(final),
|
||||
};
|
||||
}
|
||||
|
||||
const profBonus = proficiencyBonus(level);
|
||||
|
||||
const saves = {};
|
||||
for (const ability of ABILITIES) {
|
||||
const entry = saveProficiencies.find((s) => s.ability === ability);
|
||||
const proficient = entry ? entry.proficient : false;
|
||||
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) };
|
||||
}
|
||||
|
||||
const skills = {};
|
||||
for (const skillKey of Object.keys(SKILLS)) {
|
||||
const ability = getGoverningAbilityForSkill(skillKey);
|
||||
const entry = skillProficiencies.find((s) => s.skill === skillKey);
|
||||
const proficient = entry ? entry.proficient : false;
|
||||
const expertise = entry ? Boolean(entry.expertise) : false;
|
||||
skills[skillKey] = {
|
||||
ability,
|
||||
proficient,
|
||||
expertise,
|
||||
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise),
|
||||
};
|
||||
}
|
||||
|
||||
const hitDieSize = getClassHitDieSize(characterClass);
|
||||
const spellcastingAbility = getClassSpellcastingAbility(characterClass);
|
||||
|
||||
return {
|
||||
abilities,
|
||||
proficiencyBonus: profBonus,
|
||||
armorClass: armorClass(abilities.DEX.modifier, armorBonus),
|
||||
initiative: initiative(abilities.DEX.modifier),
|
||||
speed: effectiveSpeed(species, speedOverride),
|
||||
hp: { max: hpMax(level, hitDieSize, abilities.CON.modifier) },
|
||||
hitDice: { size: hitDieSize, count: level },
|
||||
saves,
|
||||
skills,
|
||||
passivePerception: passivePerception(skills.PERCEPTION.bonus),
|
||||
spellcasting: spellcastingAbility
|
||||
? {
|
||||
ability: spellcastingAbility,
|
||||
saveDC: spellSaveDC(profBonus, abilities[spellcastingAbility].modifier),
|
||||
attackBonus: spellAttackBonus(profBonus, abilities[spellcastingAbility].modifier),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attack/damage bonus for a specific weapon against a character's abilities —
|
||||
* used by the quick-stats panel and weapon list.
|
||||
*/
|
||||
function computeWeaponStats(weapon, abilities, profBonus, proficient = true) {
|
||||
const strMod = abilities.STR.modifier;
|
||||
const dexMod = abilities.DEX.modifier;
|
||||
return {
|
||||
attackBonus: weaponAttackBonus({ attackAbility: weapon.attackAbility, strMod, dexMod, proficient, profBonus }),
|
||||
damageBonus: weaponDamageBonus({ attackAbility: weapon.attackAbility, strMod, dexMod }),
|
||||
damageDice: weapon.damageDice,
|
||||
damageType: weapon.damageType,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ABILITIES,
|
||||
abilityModifier,
|
||||
proficiencyBonus,
|
||||
speciesAbilityBonus,
|
||||
finalAbilityScore,
|
||||
speciesSpeed,
|
||||
effectiveSpeed,
|
||||
saveBonus,
|
||||
skillBonus,
|
||||
passivePerception,
|
||||
armorClass,
|
||||
initiative,
|
||||
hpMax,
|
||||
spellSaveDC,
|
||||
spellAttackBonus,
|
||||
weaponAbilityModifier,
|
||||
weaponAttackBonus,
|
||||
weaponDamageBonus,
|
||||
getGoverningAbilityForSkill,
|
||||
getClassSaveProficiencies,
|
||||
getClassHitDieSize,
|
||||
getClassSpellcastingAbility,
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
const {
|
||||
abilityModifier,
|
||||
proficiencyBonus,
|
||||
finalAbilityScore,
|
||||
saveBonus,
|
||||
skillBonus,
|
||||
passivePerception,
|
||||
armorClass,
|
||||
initiative,
|
||||
hpMax,
|
||||
spellSaveDC,
|
||||
spellAttackBonus,
|
||||
weaponAttackBonus,
|
||||
weaponDamageBonus,
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
} = require('./calculations');
|
||||
|
||||
describe('abilityModifier', () => {
|
||||
test('floors toward negative infinity, not toward zero', () => {
|
||||
expect(abilityModifier(7)).toBe(-2); // (7-10)/2 = -1.5 -> floor = -2
|
||||
expect(abilityModifier(10)).toBe(0);
|
||||
expect(abilityModifier(11)).toBe(0);
|
||||
expect(abilityModifier(12)).toBe(1);
|
||||
expect(abilityModifier(20)).toBe(5);
|
||||
expect(abilityModifier(1)).toBe(-5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proficiencyBonus', () => {
|
||||
test('matches the 5e level bands', () => {
|
||||
expect(proficiencyBonus(1)).toBe(2);
|
||||
expect(proficiencyBonus(4)).toBe(2);
|
||||
expect(proficiencyBonus(5)).toBe(3);
|
||||
expect(proficiencyBonus(8)).toBe(3);
|
||||
expect(proficiencyBonus(9)).toBe(4);
|
||||
expect(proficiencyBonus(12)).toBe(4);
|
||||
expect(proficiencyBonus(13)).toBe(5);
|
||||
expect(proficiencyBonus(16)).toBe(5);
|
||||
expect(proficiencyBonus(17)).toBe(6);
|
||||
expect(proficiencyBonus(20)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalAbilityScore / species bonuses', () => {
|
||||
test('applies a flat species bonus', () => {
|
||||
expect(finalAbilityScore(14, 'DEX', 'ELF')).toBe(16);
|
||||
expect(finalAbilityScore(14, 'STR', 'ELF')).toBe(14);
|
||||
});
|
||||
|
||||
test('human gets +1 to every ability', () => {
|
||||
expect(finalAbilityScore(10, 'STR', 'HUMAN')).toBe(11);
|
||||
expect(finalAbilityScore(10, 'CHA', 'HUMAN')).toBe(11);
|
||||
});
|
||||
|
||||
test('half-elf gets fixed +2 CHA plus chosen +1/+1', () => {
|
||||
expect(finalAbilityScore(10, 'CHA', 'HALF_ELF', ['STR', 'INT'])).toBe(12);
|
||||
expect(finalAbilityScore(10, 'STR', 'HALF_ELF', ['STR', 'INT'])).toBe(11);
|
||||
expect(finalAbilityScore(10, 'INT', 'HALF_ELF', ['STR', 'INT'])).toBe(11);
|
||||
expect(finalAbilityScore(10, 'WIS', 'HALF_ELF', ['STR', 'INT'])).toBe(10);
|
||||
});
|
||||
|
||||
test('no species leaves score untouched', () => {
|
||||
expect(finalAbilityScore(10, 'STR', null)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveBonus / skillBonus', () => {
|
||||
test('save bonus adds proficiency only when proficient', () => {
|
||||
expect(saveBonus(3, true, 2)).toBe(5);
|
||||
expect(saveBonus(3, false, 2)).toBe(3);
|
||||
});
|
||||
|
||||
test('skill bonus with expertise doubles the proficiency portion', () => {
|
||||
expect(skillBonus(2, true, 2, false)).toBe(4);
|
||||
expect(skillBonus(2, true, 2, true)).toBe(6);
|
||||
expect(skillBonus(2, false, 2, true)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('passivePerception / armorClass / initiative', () => {
|
||||
test('passive perception is 10 + perception bonus', () => {
|
||||
expect(passivePerception(3)).toBe(13);
|
||||
});
|
||||
|
||||
test('armor class is 10 + dex mod + armor bonus', () => {
|
||||
expect(armorClass(2, 3)).toBe(15);
|
||||
expect(armorClass(-1, 0)).toBe(9);
|
||||
});
|
||||
|
||||
test('initiative equals dex modifier', () => {
|
||||
expect(initiative(3)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hpMax — the level-1-max-die rule', () => {
|
||||
test('level 1 uses the max die roll, not the average', () => {
|
||||
expect(hpMax(1, 10, 2)).toBe(12); // 10 (max d10) + 2 CON
|
||||
expect(hpMax(1, 6, 0)).toBe(6);
|
||||
});
|
||||
|
||||
test('level 2+ adds the fixed average per additional level', () => {
|
||||
// d10 average = floor(10/2)+1 = 6
|
||||
expect(hpMax(2, 10, 2)).toBe(12 + (6 + 2)); // 20
|
||||
expect(hpMax(5, 10, 2)).toBe(12 + 4 * (6 + 2)); // 44
|
||||
});
|
||||
|
||||
test('d6 and d12 averages', () => {
|
||||
expect(Math.floor(6 / 2) + 1).toBe(4);
|
||||
expect(Math.floor(12 / 2) + 1).toBe(7);
|
||||
expect(hpMax(2, 6, 0)).toBe(6 + 4);
|
||||
expect(hpMax(2, 12, 0)).toBe(12 + 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spellcasting', () => {
|
||||
test('spell save DC and attack bonus', () => {
|
||||
expect(spellSaveDC(3, 4)).toBe(15);
|
||||
expect(spellAttackBonus(3, 4)).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('weapon bonuses', () => {
|
||||
test('STR weapon uses STR modifier', () => {
|
||||
expect(weaponAttackBonus({ attackAbility: 'STR', strMod: 3, dexMod: 1, proficient: true, profBonus: 2 })).toBe(5);
|
||||
expect(weaponDamageBonus({ attackAbility: 'STR', strMod: 3, dexMod: 1 })).toBe(3);
|
||||
});
|
||||
|
||||
test('DEX weapon uses DEX modifier', () => {
|
||||
expect(weaponAttackBonus({ attackAbility: 'DEX', strMod: 3, dexMod: 1, proficient: false, profBonus: 2 })).toBe(1);
|
||||
});
|
||||
|
||||
test('FINESSE weapon uses the better of STR/DEX', () => {
|
||||
expect(weaponDamageBonus({ attackAbility: 'FINESSE', strMod: 1, dexMod: 4 })).toBe(4);
|
||||
expect(weaponDamageBonus({ attackAbility: 'FINESSE', strMod: 5, dexMod: 2 })).toBe(5);
|
||||
});
|
||||
|
||||
test('unknown attackAbility throws rather than silently defaulting', () => {
|
||||
expect(() => weaponDamageBonus({ attackAbility: 'BOGUS', strMod: 1, dexMod: 1 })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCharacterSheet — full assembly', () => {
|
||||
test('level 1 human fighter', () => {
|
||||
const sheet = computeCharacterSheet(
|
||||
{
|
||||
level: 1,
|
||||
species: 'HUMAN',
|
||||
characterClass: 'FIGHTER',
|
||||
strScore: 15,
|
||||
dexScore: 13,
|
||||
conScore: 14,
|
||||
intScore: 10,
|
||||
wisScore: 12,
|
||||
chaScore: 8,
|
||||
armorBonus: 4,
|
||||
},
|
||||
[{ skill: 'ATHLETICS', proficient: true, expertise: false }],
|
||||
[
|
||||
{ ability: 'STR', proficient: true },
|
||||
{ ability: 'CON', proficient: true },
|
||||
]
|
||||
);
|
||||
|
||||
// Human: +1 to every score -> STR 16 (+3), DEX 14 (+2), CON 15 (+2)
|
||||
expect(sheet.abilities.STR.final).toBe(16);
|
||||
expect(sheet.abilities.STR.modifier).toBe(3);
|
||||
expect(sheet.abilities.CON.modifier).toBe(2);
|
||||
expect(sheet.proficiencyBonus).toBe(2);
|
||||
expect(sheet.armorClass).toBe(10 + sheet.abilities.DEX.modifier + 4);
|
||||
expect(sheet.hp.max).toBe(10 + sheet.abilities.CON.modifier); // d10 fighter, level 1 max die
|
||||
expect(sheet.saves.STR.proficient).toBe(true);
|
||||
expect(sheet.saves.STR.bonus).toBe(sheet.abilities.STR.modifier + 2);
|
||||
expect(sheet.saves.DEX.proficient).toBe(false);
|
||||
expect(sheet.skills.ATHLETICS.bonus).toBe(sheet.abilities.STR.modifier + 2);
|
||||
expect(sheet.spellcasting).toBeNull(); // Fighter has no spellcasting ability
|
||||
});
|
||||
|
||||
test('spellcasting classes expose saveDC/attackBonus', () => {
|
||||
const sheet = computeCharacterSheet({
|
||||
level: 5,
|
||||
species: 'ELF',
|
||||
characterClass: 'WIZARD',
|
||||
intScore: 16,
|
||||
});
|
||||
expect(sheet.spellcasting).not.toBeNull();
|
||||
expect(sheet.spellcasting.ability).toBe('INT');
|
||||
expect(sheet.spellcasting.saveDC).toBe(8 + sheet.proficiencyBonus + sheet.abilities.INT.modifier);
|
||||
});
|
||||
|
||||
test('half-elf bonus abilities flow through the full sheet', () => {
|
||||
const sheet = computeCharacterSheet({
|
||||
level: 1,
|
||||
species: 'HALF_ELF',
|
||||
characterClass: 'BARD',
|
||||
strScore: 10,
|
||||
chaScore: 10,
|
||||
halfElfBonusAbility1: 'STR',
|
||||
halfElfBonusAbility2: 'INT',
|
||||
});
|
||||
expect(sheet.abilities.CHA.final).toBe(12); // fixed +2
|
||||
expect(sheet.abilities.STR.final).toBe(11); // chosen +1
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeWeaponStats', () => {
|
||||
test('assembles attack/damage bonus for a weapon from a computed sheet', () => {
|
||||
const sheet = computeCharacterSheet({ level: 1, strScore: 16, dexScore: 12 });
|
||||
const stats = computeWeaponStats({ attackAbility: 'STR', damageDice: '1d8', damageType: 'slashing' }, sheet.abilities, sheet.proficiencyBonus, true);
|
||||
expect(stats.attackBonus).toBe(sheet.abilities.STR.modifier + sheet.proficiencyBonus);
|
||||
expect(stats.damageBonus).toBe(sheet.abilities.STR.modifier);
|
||||
expect(stats.damageDice).toBe('1d8');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
// Generated from /seed-data/classes.json — keep in sync by hand if that file changes.
|
||||
// See docs/GAME_RULES_REFERENCE.md "Class Quick Facts".
|
||||
|
||||
const CLASSES = {
|
||||
FIGHTER: { key: 'FIGHTER', name: 'Fighter', hitDie: 10, primaryAbility: 'Str/Dex', saveProficiencies: ['STR', 'CON'], spellcastingAbility: null },
|
||||
BARBARIAN: { key: 'BARBARIAN', name: 'Barbarian', hitDie: 12, primaryAbility: 'Str', saveProficiencies: ['STR', 'CON'], spellcastingAbility: null },
|
||||
ROGUE: { key: 'ROGUE', name: 'Rogue', hitDie: 8, primaryAbility: 'Dex', saveProficiencies: ['DEX', 'INT'], spellcastingAbility: null },
|
||||
RANGER: { key: 'RANGER', name: 'Ranger', hitDie: 10, primaryAbility: 'Dex/Wis', saveProficiencies: ['STR', 'DEX'], spellcastingAbility: 'WIS' },
|
||||
WIZARD: { key: 'WIZARD', name: 'Wizard', hitDie: 6, primaryAbility: 'Int', saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'INT' },
|
||||
SORCERER: { key: 'SORCERER', name: 'Sorcerer', hitDie: 6, primaryAbility: 'Cha', saveProficiencies: ['CON', 'CHA'], spellcastingAbility: 'CHA' },
|
||||
CLERIC: { key: 'CLERIC', name: 'Cleric', hitDie: 8, primaryAbility: 'Wis', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'WIS' },
|
||||
PALADIN: { key: 'PALADIN', name: 'Paladin', hitDie: 10, primaryAbility: 'Str/Cha', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA' },
|
||||
BARD: { key: 'BARD', name: 'Bard', hitDie: 8, primaryAbility: 'Cha', saveProficiencies: ['DEX', 'CHA'], spellcastingAbility: 'CHA' },
|
||||
WARLOCK: { key: 'WARLOCK', name: 'Warlock', hitDie: 8, primaryAbility: 'Cha', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA' },
|
||||
DRUID: { key: 'DRUID', name: 'Druid', hitDie: 8, primaryAbility: 'Wis', saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'WIS' },
|
||||
MONK: { key: 'MONK', name: 'Monk', hitDie: 8, primaryAbility: 'Dex/Wis', saveProficiencies: ['STR', 'DEX'], spellcastingAbility: null },
|
||||
};
|
||||
|
||||
module.exports = { CLASSES };
|
||||
@@ -0,0 +1,25 @@
|
||||
// Generated from /seed-data/skills.json — keep in sync by hand if that file changes.
|
||||
// See docs/GAME_RULES_REFERENCE.md "Skill -> Governing Ability".
|
||||
|
||||
const SKILLS = {
|
||||
ACROBATICS: { key: 'ACROBATICS', name: 'Acrobatics', ability: 'DEX' },
|
||||
ANIMAL_HANDLING: { key: 'ANIMAL_HANDLING', name: 'Animal Handling', ability: 'WIS' },
|
||||
ARCANA: { key: 'ARCANA', name: 'Arcana', ability: 'INT' },
|
||||
ATHLETICS: { key: 'ATHLETICS', name: 'Athletics', ability: 'STR' },
|
||||
DECEPTION: { key: 'DECEPTION', name: 'Deception', ability: 'CHA' },
|
||||
HISTORY: { key: 'HISTORY', name: 'History', ability: 'INT' },
|
||||
INSIGHT: { key: 'INSIGHT', name: 'Insight', ability: 'WIS' },
|
||||
INTIMIDATION: { key: 'INTIMIDATION', name: 'Intimidation', ability: 'CHA' },
|
||||
INVESTIGATION: { key: 'INVESTIGATION', name: 'Investigation', ability: 'INT' },
|
||||
MEDICINE: { key: 'MEDICINE', name: 'Medicine', ability: 'WIS' },
|
||||
NATURE: { key: 'NATURE', name: 'Nature', ability: 'INT' },
|
||||
PERCEPTION: { key: 'PERCEPTION', name: 'Perception', ability: 'WIS' },
|
||||
PERFORMANCE: { key: 'PERFORMANCE', name: 'Performance', ability: 'CHA' },
|
||||
PERSUASION: { key: 'PERSUASION', name: 'Persuasion', ability: 'CHA' },
|
||||
RELIGION: { key: 'RELIGION', name: 'Religion', ability: 'INT' },
|
||||
SLEIGHT_OF_HAND: { key: 'SLEIGHT_OF_HAND', name: 'Sleight of Hand', ability: 'DEX' },
|
||||
STEALTH: { key: 'STEALTH', name: 'Stealth', ability: 'DEX' },
|
||||
SURVIVAL: { key: 'SURVIVAL', name: 'Survival', ability: 'WIS' },
|
||||
};
|
||||
|
||||
module.exports = { SKILLS };
|
||||
@@ -0,0 +1,23 @@
|
||||
// Generated from /seed-data/species.json — keep in sync by hand if that file changes.
|
||||
// See docs/GAME_RULES_REFERENCE.md "Species Ability Score Increases & Speed".
|
||||
|
||||
const SPECIES = {
|
||||
HUMAN: { key: 'HUMAN', name: 'Human', abilityBonuses: { STR: 1, DEX: 1, CON: 1, INT: 1, WIS: 1, CHA: 1 }, speed: 30, notes: null },
|
||||
HALF_ELF: {
|
||||
key: 'HALF_ELF',
|
||||
name: 'Half-Elf',
|
||||
abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 2 },
|
||||
speed: 30,
|
||||
notes: "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default.",
|
||||
},
|
||||
ELF: { key: 'ELF', name: 'Elf', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
|
||||
GNOME: { key: 'GNOME', name: 'Gnome', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 2, WIS: 0, CHA: 0 }, speed: 25, notes: null },
|
||||
DWARF: { key: 'DWARF', name: 'Dwarf', abilityBonuses: { STR: 0, DEX: 0, CON: 2, INT: 0, WIS: 0, CHA: 0 }, speed: 25, notes: null },
|
||||
HALFLING: { key: 'HALFLING', name: 'Halfling', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 25, notes: null },
|
||||
DRAGONBORN: { key: 'DRAGONBORN', name: 'Dragonborn', abilityBonuses: { STR: 2, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 1 }, speed: 30, notes: null },
|
||||
TIEFLING: { key: 'TIEFLING', name: 'Tiefling', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 1, WIS: 0, CHA: 2 }, speed: 30, notes: null },
|
||||
HALF_ORC: { key: 'HALF_ORC', name: 'Half-Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
|
||||
ORC: { key: 'ORC', name: 'Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
|
||||
};
|
||||
|
||||
module.exports = { SPECIES };
|
||||
@@ -0,0 +1,20 @@
|
||||
require('dotenv').config();
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const { createApp } = require('./app');
|
||||
const { attachSockets } = require('./sockets');
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
|
||||
const app = createApp();
|
||||
const server = http.createServer(app);
|
||||
|
||||
const io = new Server(server, {
|
||||
cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:5173' },
|
||||
});
|
||||
attachSockets(io);
|
||||
app.set('io', io);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`D&D Campaign Manager API listening on http://localhost:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
}
|
||||
const token = header.slice('Bearer '.length);
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = { id: payload.sub, email: payload.email, displayName: payload.displayName };
|
||||
return next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { requireAuth };
|
||||
@@ -0,0 +1,40 @@
|
||||
const prisma = require('../db');
|
||||
|
||||
async function loadCampaignMembership(userId, campaignId) {
|
||||
return prisma.campaignMember.findUnique({
|
||||
where: { campaignId_userId: { campaignId, userId } },
|
||||
});
|
||||
}
|
||||
|
||||
// Loads req.campaign + req.membership for a route keyed by :id or :campaignId.
|
||||
// 404s if the campaign doesn't exist, 403s if the user isn't a member.
|
||||
function requireCampaignMember() {
|
||||
return async (req, res, next) => {
|
||||
const campaignId = req.params.campaignId || req.params.id;
|
||||
try {
|
||||
const campaign = await prisma.campaign.findUnique({ where: { id: campaignId } });
|
||||
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
|
||||
|
||||
const membership = await loadCampaignMembership(req.user.id, campaignId);
|
||||
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
|
||||
|
||||
req.campaign = campaign;
|
||||
req.membership = membership;
|
||||
return next();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Must run after requireCampaignMember.
|
||||
function requireDM() {
|
||||
return (req, res, next) => {
|
||||
if (!req.membership || req.membership.role !== 'DM') {
|
||||
return res.status(403).json({ error: 'DM only' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { loadCampaignMembership, requireCampaignMember, requireDM };
|
||||
@@ -0,0 +1,38 @@
|
||||
const prisma = require('../db');
|
||||
const { loadCampaignMembership } = require('./campaignAccess');
|
||||
|
||||
// Loads req.character (+ req.campaign, req.membership, req.isDM, req.isOwner)
|
||||
// for a route keyed by :id (the character id). A DM can read/write any
|
||||
// character in their campaign; a player can only read/write their own.
|
||||
function requireCharacterAccess({ write = false } = {}) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const character = await prisma.character.findUnique({
|
||||
where: { id: req.params.id },
|
||||
include: { campaign: true },
|
||||
});
|
||||
if (!character) return res.status(404).json({ error: 'Character not found' });
|
||||
|
||||
const membership = await loadCampaignMembership(req.user.id, character.campaignId);
|
||||
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
|
||||
|
||||
const isDM = membership.role === 'DM';
|
||||
const isOwner = character.userId === req.user.id;
|
||||
|
||||
if (write && !isDM && !isOwner) {
|
||||
return res.status(403).json({ error: 'You can only edit your own character' });
|
||||
}
|
||||
|
||||
req.character = character;
|
||||
req.campaign = character.campaign;
|
||||
req.membership = membership;
|
||||
req.isDM = isDM;
|
||||
req.isOwner = isOwner;
|
||||
return next();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { requireCharacterAccess };
|
||||
@@ -0,0 +1,199 @@
|
||||
// Integration tests exercising the real Express app + a real Postgres
|
||||
// database through Prisma. These need DATABASE_URL pointed at a reachable,
|
||||
// migrated database (see /README.md) — they are skipped automatically if the
|
||||
// DB isn't reachable so `npm test` still passes in an environment with no DB.
|
||||
const request = require('supertest');
|
||||
const crypto = require('crypto');
|
||||
const prisma = require('../db');
|
||||
const { createApp } = require('../app');
|
||||
|
||||
const app = createApp();
|
||||
app.set('io', { to: () => ({ emit: () => {} }) }); // stub Socket.io for tests
|
||||
|
||||
let dbAvailable = true;
|
||||
const cleanupCampaignIds = [];
|
||||
const cleanupUserIds = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
} catch {
|
||||
dbAvailable = false;
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (dbAvailable) {
|
||||
for (const id of cleanupCampaignIds) {
|
||||
await prisma.campaign.delete({ where: { id } }).catch(() => {});
|
||||
}
|
||||
for (const id of cleanupUserIds) {
|
||||
await prisma.user.delete({ where: { id } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function uniqueEmail(prefix) {
|
||||
return `${prefix}-${crypto.randomUUID()}@test.local`;
|
||||
}
|
||||
|
||||
function itIfDb(name, fn) {
|
||||
test(name, async () => {
|
||||
if (!dbAvailable) {
|
||||
console.warn(`Skipping "${name}" — no reachable database (set DATABASE_URL and run migrations).`);
|
||||
return;
|
||||
}
|
||||
await fn();
|
||||
});
|
||||
}
|
||||
|
||||
describe('auth', () => {
|
||||
itIfDb('registers, logs in, and reads the current user', async () => {
|
||||
const email = uniqueEmail('dm');
|
||||
const registerRes = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({ email, password: 'password123', displayName: 'Test DM' });
|
||||
expect(registerRes.status).toBe(201);
|
||||
expect(registerRes.body.token).toBeTruthy();
|
||||
cleanupUserIds.push(registerRes.body.user.id);
|
||||
|
||||
const loginRes = await request(app).post('/auth/login').send({ email, password: 'password123' });
|
||||
expect(loginRes.status).toBe(200);
|
||||
|
||||
const meRes = await request(app).get('/auth/me').set('Authorization', `Bearer ${loginRes.body.token}`);
|
||||
expect(meRes.status).toBe(200);
|
||||
expect(meRes.body.user.email).toBe(email);
|
||||
});
|
||||
|
||||
itIfDb('rejects a bad password', async () => {
|
||||
const email = uniqueEmail('baduser');
|
||||
const registerRes = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({ email, password: 'password123', displayName: 'Bad Login' });
|
||||
cleanupUserIds.push(registerRes.body.user.id);
|
||||
|
||||
const loginRes = await request(app).post('/auth/login').send({ email, password: 'wrong-password' });
|
||||
expect(loginRes.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('campaigns + characters + build flow', () => {
|
||||
itIfDb('DM creates a campaign, player joins, DM builds a character end-to-end', async () => {
|
||||
const dmEmail = uniqueEmail('dm2');
|
||||
const playerEmail = uniqueEmail('player2');
|
||||
|
||||
const dmRegister = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({ email: dmEmail, password: 'password123', displayName: 'DM Two' });
|
||||
const dmToken = dmRegister.body.token;
|
||||
cleanupUserIds.push(dmRegister.body.user.id);
|
||||
|
||||
const playerRegister = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({ email: playerEmail, password: 'password123', displayName: 'Player Two' });
|
||||
cleanupUserIds.push(playerRegister.body.user.id);
|
||||
|
||||
const campaignRes = await request(app)
|
||||
.post('/campaigns')
|
||||
.set('Authorization', `Bearer ${dmToken}`)
|
||||
.send({ name: 'Integration Test Campaign', startingItemCount: 1, startingSpellCount: 0, skipMotivationRoll: true });
|
||||
expect(campaignRes.status).toBe(201);
|
||||
const campaign = campaignRes.body.campaign;
|
||||
cleanupCampaignIds.push(campaign.id);
|
||||
|
||||
const joinRes = await request(app)
|
||||
.post(`/campaigns/join/${campaign.inviteCode}`)
|
||||
.set('Authorization', `Bearer ${playerRegister.body.token}`);
|
||||
expect(joinRes.status).toBe(201);
|
||||
|
||||
// DM adds a homebrew weapon to the pool so the equipment step has something to pick.
|
||||
const weaponRes = await request(app)
|
||||
.post(`/campaigns/${campaign.id}/weapons`)
|
||||
.set('Authorization', `Bearer ${dmToken}`)
|
||||
.send({ name: 'Test Dagger', damageDice: '1d4', damageType: 'piercing', attackAbility: 'FINESSE', properties: [] });
|
||||
expect(weaponRes.status).toBe(201);
|
||||
const weaponId = weaponRes.body.weapon.id;
|
||||
|
||||
const characterRes = await request(app)
|
||||
.post(`/campaigns/${campaign.id}/characters`)
|
||||
.set('Authorization', `Bearer ${dmToken}`)
|
||||
.send({ name: 'Integration Hero' });
|
||||
expect(characterRes.status).toBe(201);
|
||||
const characterId = characterRes.body.character.id;
|
||||
const auth = { Authorization: `Bearer ${dmToken}` };
|
||||
|
||||
// Step 1: species
|
||||
let rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
|
||||
expect(rollRes.status).toBe(200);
|
||||
let speciesChoices = rollRes.body.buildState.speciesChoices;
|
||||
const species = speciesChoices.options[0];
|
||||
let chooseRes = await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
.set(auth)
|
||||
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
|
||||
expect(chooseRes.status).toBe(200);
|
||||
expect(chooseRes.body.buildStage).toBe('ROLLING_CLASS');
|
||||
|
||||
// Step 2: class (skip any reroll results deterministically)
|
||||
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
|
||||
while (rollRes.body.buildState.classChoices.type === 'reroll') {
|
||||
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
|
||||
}
|
||||
const characterClass = rollRes.body.buildState.classChoices.options[0];
|
||||
chooseRes = await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ characterClass });
|
||||
expect(chooseRes.status).toBe(200);
|
||||
// skipMotivationRoll was true, so motivation is skipped entirely.
|
||||
expect(chooseRes.body.buildStage).toBe('ASSIGNING_SCORES');
|
||||
|
||||
// Step 4: ability scores
|
||||
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
|
||||
expect(rollRes.body.buildState.abilityRolls).toHaveLength(6);
|
||||
chooseRes = await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
.set(auth)
|
||||
.send({ assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
|
||||
expect(chooseRes.status).toBe(200);
|
||||
expect(chooseRes.body.buildStage).toBe('ASSIGNING_PROFICIENCIES');
|
||||
|
||||
// Step 5: skills
|
||||
chooseRes = await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
.set(auth)
|
||||
.send({ skillProficiencies: [{ skill: 'ATHLETICS', proficient: true, expertise: false }] });
|
||||
expect(chooseRes.status).toBe(200);
|
||||
expect(chooseRes.body.buildStage).toBe('CHOOSING_EQUIPMENT');
|
||||
|
||||
// Step 6: equipment
|
||||
chooseRes = await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
.set(auth)
|
||||
.send({ weaponIds: [weaponId], spellIds: [] });
|
||||
expect(chooseRes.status).toBe(200);
|
||||
expect(chooseRes.body.buildStage).toBe('CHOOSING_EQUIPMENT'); // stays here until finalize
|
||||
|
||||
// Step 7: finalize
|
||||
const finalizeRes = await request(app).post(`/characters/${characterId}/build/finalize`).set(auth);
|
||||
expect(finalizeRes.status).toBe(200);
|
||||
const character = finalizeRes.body.character;
|
||||
expect(character.buildStage).toBe('COMPLETE');
|
||||
expect(character.sheet).not.toBeNull();
|
||||
expect(character.currentHp).toBe(character.sheet.hp.max);
|
||||
expect(character.weapons).toHaveLength(1);
|
||||
|
||||
// Quick stats should now work.
|
||||
const quickStatsRes = await request(app).get(`/characters/${characterId}/quick-stats`).set(auth);
|
||||
expect(quickStatsRes.status).toBe(200);
|
||||
expect(quickStatsRes.body.quickStats.weapons).toHaveLength(1);
|
||||
|
||||
// A non-member cannot read the character.
|
||||
const outsiderRegister = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({ email: uniqueEmail('outsider'), password: 'password123', displayName: 'Outsider' });
|
||||
cleanupUserIds.push(outsiderRegister.body.user.id);
|
||||
const forbiddenRes = await request(app)
|
||||
.get(`/characters/${characterId}`)
|
||||
.set('Authorization', `Bearer ${outsiderRegister.body.token}`);
|
||||
expect(forbiddenRes.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { z } = require('zod');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
displayName: z.string().min(1).max(60),
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
function issueToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, email: user.email, displayName: user.displayName },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
|
||||
);
|
||||
}
|
||||
|
||||
function publicUser(user) {
|
||||
return { id: user.id, email: user.email, displayName: user.displayName };
|
||||
}
|
||||
|
||||
router.post('/register', async (req, res, next) => {
|
||||
try {
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { email, password, displayName } = parsed.data;
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) return res.status(409).json({ error: 'An account with that email already exists' });
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.user.create({ data: { email, passwordHash, displayName } });
|
||||
|
||||
return res.status(201).json({ token: issueToken(user), user: publicUser(user) });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res, next) => {
|
||||
try {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { email, password } = parsed.data;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return res.status(401).json({ error: 'Invalid email or password' });
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) return res.status(401).json({ error: 'Invalid email or password' });
|
||||
|
||||
return res.json({ token: issueToken(user), user: publicUser(user) });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
return res.json({ user: publicUser(user) });
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,429 @@
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireCampaignMember, requireDM } = require('../middleware/campaignAccess');
|
||||
const { generateInviteCode } = require('../utils/inviteCode');
|
||||
const { computeCharacterSheet } = require('../game/calculations');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
function campaignSummary(campaign, role) {
|
||||
return {
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
description: campaign.description,
|
||||
inviteCode: campaign.inviteCode,
|
||||
startingGold: campaign.startingGold,
|
||||
startingItemCount: campaign.startingItemCount,
|
||||
startingSpellCount: campaign.startingSpellCount,
|
||||
skipMotivationRoll: campaign.skipMotivationRoll,
|
||||
ownerId: campaign.ownerId,
|
||||
role,
|
||||
createdAt: campaign.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /campaigns — campaigns I'm a DM or player in
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const memberships = await prisma.campaignMember.findMany({
|
||||
where: { userId: req.user.id },
|
||||
include: { campaign: true },
|
||||
orderBy: { joinedAt: 'desc' },
|
||||
});
|
||||
res.json({ campaigns: memberships.map((m) => campaignSummary(m.campaign, m.role)) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const createCampaignSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
startingGold: z.number().int().min(0).optional(),
|
||||
startingItemCount: z.number().int().min(0).optional(),
|
||||
startingSpellCount: z.number().int().min(0).optional(),
|
||||
skipMotivationRoll: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// POST /campaigns — create, creator becomes DM/owner
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const parsed = createCampaignSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
let inviteCode = generateInviteCode();
|
||||
// Extremely unlikely to collide, but guard anyway.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const clash = await prisma.campaign.findUnique({ where: { inviteCode } });
|
||||
if (!clash) break;
|
||||
inviteCode = generateInviteCode();
|
||||
}
|
||||
|
||||
const campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
...parsed.data,
|
||||
inviteCode,
|
||||
ownerId: req.user.id,
|
||||
members: { create: { userId: req.user.id, role: 'DM' } },
|
||||
},
|
||||
});
|
||||
|
||||
res.status(201).json({ campaign: campaignSummary(campaign, 'DM') });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /campaigns/join/:inviteCode — must be before requireCampaignMember-gated routes below
|
||||
router.post('/join/:inviteCode', async (req, res, next) => {
|
||||
try {
|
||||
const campaign = await prisma.campaign.findUnique({ where: { inviteCode: req.params.inviteCode } });
|
||||
if (!campaign) return res.status(404).json({ error: 'Invalid invite code' });
|
||||
|
||||
const existing = await prisma.campaignMember.findUnique({
|
||||
where: { campaignId_userId: { campaignId: campaign.id, userId: req.user.id } },
|
||||
});
|
||||
if (existing) return res.json({ campaign: campaignSummary(campaign, existing.role) });
|
||||
|
||||
await prisma.campaignMember.create({ data: { campaignId: campaign.id, userId: req.user.id, role: 'PLAYER' } });
|
||||
res.status(201).json({ campaign: campaignSummary(campaign, 'PLAYER') });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const members = await prisma.campaignMember.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
include: { user: { select: { id: true, displayName: true, email: true } } },
|
||||
});
|
||||
res.json({
|
||||
campaign: campaignSummary(req.campaign, req.membership.role),
|
||||
members: members.map((m) => ({ userId: m.userId, displayName: m.user.displayName, role: m.role })),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const updateCampaignSchema = createCampaignSchema.partial();
|
||||
|
||||
router.patch('/:id', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = updateCampaignSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data: parsed.data });
|
||||
res.json({ campaign: campaignSummary(campaign, 'DM') });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/invite', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const regenerate = req.body && req.body.regenerate === true;
|
||||
if (!regenerate) return res.json({ inviteCode: req.campaign.inviteCode });
|
||||
|
||||
let inviteCode = generateInviteCode();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const clash = await prisma.campaign.findUnique({ where: { inviteCode } });
|
||||
if (!clash) break;
|
||||
inviteCode = generateInviteCode();
|
||||
}
|
||||
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data: { inviteCode } });
|
||||
res.json({ inviteCode: campaign.inviteCode });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Characters within a campaign
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function characterSummary(character) {
|
||||
const sheet = computeCharacterSheet(
|
||||
character,
|
||||
character.skillProficiencies || [],
|
||||
character.saveProficiencies || []
|
||||
);
|
||||
return {
|
||||
id: character.id,
|
||||
campaignId: character.campaignId,
|
||||
userId: character.userId,
|
||||
createdById: character.createdById,
|
||||
name: character.name,
|
||||
species: character.species,
|
||||
characterClass: character.characterClass,
|
||||
level: character.level,
|
||||
xp: character.xp,
|
||||
buildStage: character.buildStage,
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
|
||||
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/:id/characters', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const characters = await prisma.character.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
include: { skillProficiencies: true, saveProficiencies: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
res.json({ characters: characters.map(characterSummary) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const createCharacterSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
userId: z.string().optional().nullable(), // DM building for a player; omitted => for self
|
||||
});
|
||||
|
||||
router.post('/:id/characters', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = createCharacterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const isDM = req.membership.role === 'DM';
|
||||
let targetUserId = req.user.id;
|
||||
if (parsed.data.userId) {
|
||||
if (!isDM && parsed.data.userId !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Only the DM can build a character for another player' });
|
||||
}
|
||||
const targetMembership = await prisma.campaignMember.findUnique({
|
||||
where: { campaignId_userId: { campaignId: req.campaign.id, userId: parsed.data.userId } },
|
||||
});
|
||||
if (!targetMembership) return res.status(400).json({ error: 'That user is not a member of this campaign' });
|
||||
targetUserId = parsed.data.userId;
|
||||
}
|
||||
|
||||
const character = await prisma.character.create({
|
||||
data: {
|
||||
campaignId: req.campaign.id,
|
||||
userId: targetUserId,
|
||||
createdById: req.user.id,
|
||||
name: parsed.data.name,
|
||||
buildStage: 'ROLLING_SPECIES',
|
||||
gold: req.campaign.startingGold,
|
||||
},
|
||||
});
|
||||
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { character: characterSummary(character) });
|
||||
res.status(201).json({ character: characterSummary(character) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons (campaign-level pool; DM manages, anyone in campaign can read)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const weapons = await prisma.weapon.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
res.json({ weapons });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const weaponSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
damageDice: z.string().min(1).max(20),
|
||||
damageType: z.string().min(1).max(40),
|
||||
attackAbility: z.enum(['STR', 'DEX', 'FINESSE']),
|
||||
properties: z.array(z.string()).default([]),
|
||||
rangeNormal: z.number().int().positive().optional().nullable(),
|
||||
rangeLong: z.number().int().positive().optional().nullable(),
|
||||
});
|
||||
|
||||
router.post('/:id/weapons', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = weaponSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const weapon = await prisma.weapon.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ weapon });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = weaponSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const weapon = await prisma.weapon.update({
|
||||
where: { id: req.params.weaponId },
|
||||
data: parsed.data,
|
||||
});
|
||||
res.json({ weapon });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
await prisma.weapon.delete({ where: { id: req.params.weaponId } });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spells (campaign-level list; DM manages, used as the starting-spell pool)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/spells', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const spells = await prisma.spell.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
res.json({ spells });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const spellSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
level: z.number().int().min(0).max(9),
|
||||
school: z.string().max(40).optional().nullable(),
|
||||
castingTime: z.string().max(60).optional().nullable(),
|
||||
range: z.string().max(60).optional().nullable(),
|
||||
duration: z.string().max(60).optional().nullable(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
});
|
||||
|
||||
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = spellSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const spell = await prisma.spell.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ spell });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monsters (DM only, campaign-scoped)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/monsters', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const monsters = await prisma.monster.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
res.json({ monsters });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const monsterSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
ac: z.number().int().min(0),
|
||||
maxHp: z.number().int().min(1),
|
||||
speed: z.number().int().min(0).optional(),
|
||||
strScore: z.number().int().min(1).max(30).optional(),
|
||||
dexScore: z.number().int().min(1).max(30).optional(),
|
||||
conScore: z.number().int().min(1).max(30).optional(),
|
||||
intScore: z.number().int().min(1).max(30).optional(),
|
||||
wisScore: z.number().int().min(1).max(30).optional(),
|
||||
chaScore: z.number().int().min(1).max(30).optional(),
|
||||
attackBonus: z.number().int().optional(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
challengeRating: z.string().max(10).optional().nullable(),
|
||||
specialTrait: z.string().max(500).optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
});
|
||||
|
||||
router.post('/:id/monsters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = monsterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const monster = await prisma.monster.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ monster });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = monsterSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const monster = await prisma.monster.update({ where: { id: req.params.monsterId }, data: parsed.data });
|
||||
res.json({ monster });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
await prisma.monster.delete({ where: { id: req.params.monsterId } });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encounters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const encounters = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json({ encounters });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ name: z.string().min(1).max(120) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const encounter = await prisma.encounter.create({ data: { campaignId: req.campaign.id, name: parsed.data.name } });
|
||||
res.status(201).json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/notes', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const isDM = req.membership.role === 'DM';
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
campaignId: req.campaign.id,
|
||||
...(isDM ? {} : { OR: [{ visibility: 'SHARED' }, { authorId: req.user.id }] }),
|
||||
},
|
||||
include: { author: { select: { displayName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json({ notes });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,582 @@
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireCharacterAccess } = require('../middleware/characterAccess');
|
||||
const {
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
getClassSaveProficiencies,
|
||||
getClassSpellcastingAbility,
|
||||
ABILITIES,
|
||||
} = require('../game/calculations');
|
||||
const {
|
||||
rollDie,
|
||||
rollAbilityScore,
|
||||
speciesChoicesForRoll,
|
||||
classChoicesForRoll,
|
||||
MOTIVATION_TABLE,
|
||||
} = require('../game/buildFlow');
|
||||
const { SKILLS } = require('../game/data/skills');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
async function loadFullCharacter(id) {
|
||||
return prisma.character.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
skillProficiencies: true,
|
||||
saveProficiencies: true,
|
||||
inventoryItems: true,
|
||||
weapons: { include: { weapon: true } },
|
||||
spells: { include: { spell: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function serializeCharacter(character) {
|
||||
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
return {
|
||||
id: character.id,
|
||||
campaignId: character.campaignId,
|
||||
userId: character.userId,
|
||||
createdById: character.createdById,
|
||||
name: character.name,
|
||||
species: character.species,
|
||||
characterClass: character.characterClass,
|
||||
level: character.level,
|
||||
xp: character.xp,
|
||||
motivation: character.motivation,
|
||||
halfElfBonusAbility1: character.halfElfBonusAbility1,
|
||||
halfElfBonusAbility2: character.halfElfBonusAbility2,
|
||||
rolledScores: {
|
||||
STR: character.strScore,
|
||||
DEX: character.dexScore,
|
||||
CON: character.conScore,
|
||||
INT: character.intScore,
|
||||
WIS: character.wisScore,
|
||||
CHA: character.chaScore,
|
||||
},
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
armorBonus: character.armorBonus,
|
||||
specialAbilities: character.specialAbilities,
|
||||
gold: character.gold,
|
||||
buildStage: character.buildStage,
|
||||
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
|
||||
skillProficiencies: character.skillProficiencies,
|
||||
saveProficiencies: character.saveProficiencies,
|
||||
inventoryItems: character.inventoryItems,
|
||||
weapons: character.weapons,
|
||||
spells: character.spells,
|
||||
sheet: character.buildStage === 'COMPLETE' ? sheet : null,
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
res.json({ character: serializeCharacter(character) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const patchCharacterSchema = z.object({
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
currentHp: z.number().int().optional(),
|
||||
tempHp: z.number().int().min(0).optional(),
|
||||
armorBonus: z.number().int().min(0).optional(),
|
||||
speedOverride: z.number().int().min(0).nullable().optional(),
|
||||
specialAbilities: z.string().max(4000).nullable().optional(),
|
||||
gold: z.number().int().min(0).optional(),
|
||||
level: z.number().int().min(1).max(20).optional(),
|
||||
xp: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchCharacterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const updated = await prisma.character.update({ where: { id: req.character.id }, data: parsed.data });
|
||||
const full = await loadFullCharacter(updated.id);
|
||||
const payload = serializeCharacter(full);
|
||||
|
||||
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { character: payload });
|
||||
res.json({ character: payload });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
|
||||
await prisma.character.delete({ where: { id: req.character.id } });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roll-Your-Character build flow — docs/ROLL_YOUR_CHARACTER.md
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const character = req.character;
|
||||
const stage = character.buildStage;
|
||||
const buildState = { ...(character.buildState || {}) };
|
||||
|
||||
if (stage === 'ROLLING_SPECIES') {
|
||||
const roll = rollDie(6);
|
||||
buildState.speciesRoll = roll;
|
||||
buildState.speciesChoices = speciesChoicesForRoll(roll);
|
||||
delete buildState.species;
|
||||
delete buildState.halfElfBonusAbilities;
|
||||
} else if (stage === 'ROLLING_CLASS') {
|
||||
const alreadyRolled = buildState.classChoices && buildState.classChoices.type !== 'reroll';
|
||||
if (alreadyRolled) {
|
||||
return res.status(400).json({ error: 'Class die already rolled for this character — submit a choice, or reroll if the current roll allows it.' });
|
||||
}
|
||||
const roll = rollDie(8);
|
||||
buildState.classRoll = roll;
|
||||
buildState.classChoices = classChoicesForRoll(roll);
|
||||
delete buildState.characterClass;
|
||||
} else if (stage === 'ROLLING_MOTIVATION') {
|
||||
const roll = rollDie(6);
|
||||
buildState.motivationRoll = roll;
|
||||
buildState.motivationText = MOTIVATION_TABLE[roll];
|
||||
} else if (stage === 'ASSIGNING_SCORES') {
|
||||
if (buildState.abilityRolls) {
|
||||
return res.status(400).json({ error: 'Ability scores were already rolled for this character.' });
|
||||
}
|
||||
buildState.abilityRolls = Array.from({ length: 6 }, () => rollAbilityScore());
|
||||
} else {
|
||||
return res.status(400).json({ error: `No die roll is required at the "${stage}" step.` });
|
||||
}
|
||||
|
||||
const updated = await prisma.character.update({ where: { id: character.id }, data: { buildState } });
|
||||
res.json({ buildStage: updated.buildStage, buildState: updated.buildState });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const HALF_ELF_ELIGIBLE_ABILITIES = ABILITIES.filter((a) => a !== 'CHA');
|
||||
|
||||
const chooseSchemas = {
|
||||
ROLLING_SPECIES: z.object({
|
||||
species: z.string(),
|
||||
halfElfBonusAbilities: z.array(z.enum(HALF_ELF_ELIGIBLE_ABILITIES)).length(2).optional(),
|
||||
}),
|
||||
ROLLING_CLASS: z.object({ characterClass: z.string() }),
|
||||
ROLLING_MOTIVATION: z.object({ motivation: z.string().max(500).optional() }),
|
||||
ASSIGNING_SCORES: z.object({
|
||||
assignments: z.object({
|
||||
STR: z.number().int().min(0).max(5),
|
||||
DEX: z.number().int().min(0).max(5),
|
||||
CON: z.number().int().min(0).max(5),
|
||||
INT: z.number().int().min(0).max(5),
|
||||
WIS: z.number().int().min(0).max(5),
|
||||
CHA: z.number().int().min(0).max(5),
|
||||
}),
|
||||
}),
|
||||
ASSIGNING_PROFICIENCIES: z.object({
|
||||
skillProficiencies: z.array(
|
||||
z.object({
|
||||
skill: z.enum(Object.keys(SKILLS)),
|
||||
proficient: z.boolean().default(true),
|
||||
expertise: z.boolean().default(false),
|
||||
})
|
||||
),
|
||||
}),
|
||||
CHOOSING_EQUIPMENT: z.object({
|
||||
weaponIds: z.array(z.string()).default([]),
|
||||
spellIds: z.array(z.string()).default([]),
|
||||
}),
|
||||
};
|
||||
|
||||
const STAGE_ORDER = [
|
||||
'ROLLING_SPECIES',
|
||||
'ROLLING_CLASS',
|
||||
'ROLLING_MOTIVATION',
|
||||
'ASSIGNING_SCORES',
|
||||
'ASSIGNING_PROFICIENCIES',
|
||||
'CHOOSING_EQUIPMENT',
|
||||
'COMPLETE',
|
||||
];
|
||||
|
||||
function nextStage(current) {
|
||||
const idx = STAGE_ORDER.indexOf(current);
|
||||
return STAGE_ORDER[idx + 1];
|
||||
}
|
||||
|
||||
router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const character = req.character;
|
||||
const stage = character.buildStage;
|
||||
const schema = chooseSchemas[stage];
|
||||
if (!schema) return res.status(400).json({ error: `No choice is expected at the "${stage}" step.` });
|
||||
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const buildState = { ...(character.buildState || {}) };
|
||||
let advanceTo = nextStage(stage);
|
||||
|
||||
if (stage === 'ROLLING_SPECIES') {
|
||||
const choices = buildState.speciesChoices;
|
||||
if (!choices) return res.status(400).json({ error: 'Roll for species before choosing one.' });
|
||||
const { species, halfElfBonusAbilities } = parsed.data;
|
||||
if (choices.type !== 'wildcard' && !choices.options.includes(species)) {
|
||||
return res.status(400).json({ error: `${species} was not offered by this roll.` });
|
||||
}
|
||||
if (species === 'HALF_ELF') {
|
||||
if (!halfElfBonusAbilities) {
|
||||
return res.status(400).json({ error: 'Half-Elf requires choosing two abilities (other than CHA) for the +1/+1 bonus.' });
|
||||
}
|
||||
if (new Set(halfElfBonusAbilities).size !== 2) {
|
||||
return res.status(400).json({ error: 'The two Half-Elf bonus abilities must be different.' });
|
||||
}
|
||||
buildState.halfElfBonusAbilities = halfElfBonusAbilities;
|
||||
} else {
|
||||
delete buildState.halfElfBonusAbilities;
|
||||
}
|
||||
buildState.species = species;
|
||||
} else if (stage === 'ROLLING_CLASS') {
|
||||
const choices = buildState.classChoices;
|
||||
if (!choices || choices.type === 'reroll') {
|
||||
return res.status(400).json({ error: 'Roll for class before choosing one.' });
|
||||
}
|
||||
const { characterClass } = parsed.data;
|
||||
if (choices.type !== 'wildcard' && !choices.options.includes(characterClass)) {
|
||||
return res.status(400).json({ error: `${characterClass} was not offered by this roll.` });
|
||||
}
|
||||
buildState.characterClass = characterClass;
|
||||
if (req.campaign.skipMotivationRoll) {
|
||||
advanceTo = 'ASSIGNING_SCORES';
|
||||
buildState.motivationText = null;
|
||||
}
|
||||
} else if (stage === 'ROLLING_MOTIVATION') {
|
||||
if (!buildState.motivationRoll) return res.status(400).json({ error: 'Roll for motivation first.' });
|
||||
if (parsed.data.motivation) buildState.motivationText = parsed.data.motivation;
|
||||
} else if (stage === 'ASSIGNING_SCORES') {
|
||||
if (!buildState.abilityRolls) return res.status(400).json({ error: 'Roll ability scores first.' });
|
||||
const { assignments } = parsed.data;
|
||||
const usedIndexes = Object.values(assignments);
|
||||
const uniqueIndexes = new Set(usedIndexes);
|
||||
if (uniqueIndexes.size !== 6 || Math.max(...usedIndexes) > 5) {
|
||||
return res.status(400).json({ error: 'Each of the 6 rolled scores must be assigned to exactly one ability.' });
|
||||
}
|
||||
buildState.abilityAssignments = assignments;
|
||||
} else if (stage === 'ASSIGNING_PROFICIENCIES') {
|
||||
buildState.skillProficiencies = parsed.data.skillProficiencies;
|
||||
} else if (stage === 'CHOOSING_EQUIPMENT') {
|
||||
const { weaponIds, spellIds } = parsed.data;
|
||||
const maxItems = req.campaign.startingItemCount;
|
||||
const maxSpells = req.campaign.startingSpellCount;
|
||||
if (weaponIds.length > maxItems) {
|
||||
return res.status(400).json({ error: `This campaign allows at most ${maxItems} starting item(s).` });
|
||||
}
|
||||
const hasSpellcasting = Boolean(getClassSpellcastingAbility(buildState.characterClass));
|
||||
if (spellIds.length > 0 && !hasSpellcasting) {
|
||||
return res.status(400).json({ error: 'This class has no spellcasting ability — it cannot pick starting spells.' });
|
||||
}
|
||||
if (spellIds.length > maxSpells) {
|
||||
return res.status(400).json({ error: `This campaign allows at most ${maxSpells} starting spell(s).` });
|
||||
}
|
||||
buildState.equipment = { weaponIds, spellIds };
|
||||
advanceTo = 'CHOOSING_EQUIPMENT'; // stay here; POST /build/finalize completes the character
|
||||
}
|
||||
|
||||
const updated = await prisma.character.update({
|
||||
where: { id: character.id },
|
||||
data: { buildState, buildStage: advanceTo },
|
||||
});
|
||||
res.json({ buildStage: updated.buildStage, buildState: updated.buildState });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const character = req.character;
|
||||
if (character.buildStage !== 'CHOOSING_EQUIPMENT') {
|
||||
return res.status(400).json({ error: `Character isn't ready to finalize (currently at "${character.buildStage}").` });
|
||||
}
|
||||
const bs = character.buildState || {};
|
||||
if (!bs.species || !bs.characterClass || !bs.abilityAssignments || !bs.equipment) {
|
||||
return res.status(400).json({ error: 'Build is incomplete — species, class, ability assignments, and equipment are all required.' });
|
||||
}
|
||||
|
||||
const rolledScores = {};
|
||||
for (const ability of ABILITIES) {
|
||||
const rollIndex = bs.abilityAssignments[ability];
|
||||
rolledScores[ability] = bs.abilityRolls[rollIndex].total;
|
||||
}
|
||||
|
||||
const [halfElfBonusAbility1, halfElfBonusAbility2] = bs.halfElfBonusAbilities || [null, null];
|
||||
const saveProfs = getClassSaveProficiencies(bs.characterClass);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.characterSkillProficiency.deleteMany({ where: { characterId: character.id } });
|
||||
await tx.characterSaveProficiency.deleteMany({ where: { characterId: character.id } });
|
||||
|
||||
const updated = await tx.character.update({
|
||||
where: { id: character.id },
|
||||
data: {
|
||||
species: bs.species,
|
||||
characterClass: bs.characterClass,
|
||||
motivation: bs.motivationText || null,
|
||||
strScore: rolledScores.STR,
|
||||
dexScore: rolledScores.DEX,
|
||||
conScore: rolledScores.CON,
|
||||
intScore: rolledScores.INT,
|
||||
wisScore: rolledScores.WIS,
|
||||
chaScore: rolledScores.CHA,
|
||||
halfElfBonusAbility1,
|
||||
halfElfBonusAbility2,
|
||||
buildStage: 'COMPLETE',
|
||||
buildState: null,
|
||||
skillProficiencies: {
|
||||
create: (bs.skillProficiencies || []).map((sp) => ({
|
||||
skill: sp.skill,
|
||||
proficient: sp.proficient !== false,
|
||||
expertise: Boolean(sp.expertise),
|
||||
})),
|
||||
},
|
||||
saveProficiencies: {
|
||||
create: ABILITIES.map((ability) => ({ ability, proficient: saveProfs.includes(ability) })),
|
||||
},
|
||||
weapons: {
|
||||
create: (bs.equipment.weaponIds || []).map((weaponId) => ({ weaponId, quantity: 1, equipped: false })),
|
||||
},
|
||||
spells: {
|
||||
create: (bs.equipment.spellIds || []).map((spellId) => ({ spellId, prepared: false })),
|
||||
},
|
||||
},
|
||||
include: { skillProficiencies: true, saveProficiencies: true },
|
||||
});
|
||||
|
||||
const sheet = computeCharacterSheet(updated, updated.skillProficiencies, updated.saveProficiencies);
|
||||
const finalized = await tx.character.update({
|
||||
where: { id: character.id },
|
||||
data: { currentHp: sheet.hp.max },
|
||||
});
|
||||
return finalized;
|
||||
});
|
||||
|
||||
const full = await loadFullCharacter(result.id);
|
||||
const payload = serializeCharacter(full);
|
||||
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { character: payload });
|
||||
res.json({ character: payload });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick Stats — combat-context panel (docs/GAME_RULES_REFERENCE.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
if (character.buildStage !== 'COMPLETE') {
|
||||
return res.status(400).json({ error: 'Character build is not finished yet.' });
|
||||
}
|
||||
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
|
||||
const weapons = character.weapons.map((cw) => ({
|
||||
characterWeaponId: cw.id,
|
||||
weaponId: cw.weaponId,
|
||||
name: cw.weapon.name,
|
||||
equipped: cw.equipped,
|
||||
quantity: cw.quantity,
|
||||
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true),
|
||||
}));
|
||||
|
||||
res.json({
|
||||
quickStats: {
|
||||
name: character.name,
|
||||
currentHp: character.currentHp,
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
armorClass: sheet.armorClass,
|
||||
initiative: sheet.initiative,
|
||||
speed: sheet.speed,
|
||||
proficiencyBonus: sheet.proficiencyBonus,
|
||||
spellcasting: sheet.spellcasting,
|
||||
specialAbilities: character.specialAbilities,
|
||||
weapons,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons assigned to this character
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/:id/weapons', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ weaponId: z.string(), quantity: z.number().int().min(1).default(1) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const weapon = await prisma.weapon.findUnique({ where: { id: parsed.data.weaponId } });
|
||||
if (!weapon || weapon.campaignId !== req.character.campaignId) {
|
||||
return res.status(400).json({ error: 'That weapon is not part of this campaign.' });
|
||||
}
|
||||
|
||||
const characterWeapon = await prisma.characterWeapon.upsert({
|
||||
where: { characterId_weaponId: { characterId: req.character.id, weaponId: parsed.data.weaponId } },
|
||||
create: { characterId: req.character.id, weaponId: parsed.data.weaponId, quantity: parsed.data.quantity },
|
||||
update: { quantity: { increment: parsed.data.quantity } },
|
||||
include: { weapon: true },
|
||||
});
|
||||
res.status(201).json({ characterWeapon });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/weapons/:weaponId', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({ quantity: z.number().int().min(0).optional(), equipped: z.boolean().optional() })
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const characterWeapon = await prisma.characterWeapon.update({
|
||||
where: { characterId_weaponId: { characterId: req.character.id, weaponId: req.params.weaponId } },
|
||||
data: parsed.data,
|
||||
include: { weapon: true },
|
||||
});
|
||||
res.json({ characterWeapon });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/:id/inventory', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
quantity: z.number().int().min(1).default(1),
|
||||
equipped: z.boolean().default(false),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const item = await prisma.inventoryItem.create({ data: { ...parsed.data, characterId: req.character.id } });
|
||||
res.status(201).json({ item });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/inventory/:itemId', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
quantity: z.number().int().min(0).optional(),
|
||||
equipped: z.boolean().optional(),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const item = await prisma.inventoryItem.update({ where: { id: req.params.itemId }, data: parsed.data });
|
||||
res.json({ item });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/inventory/:itemId', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
await prisma.inventoryItem.delete({ where: { id: req.params.itemId } });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spells known by this character
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/:id/spells', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ spellId: z.string(), prepared: z.boolean().default(false) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const spell = await prisma.spell.findUnique({ where: { id: parsed.data.spellId } });
|
||||
if (!spell || spell.campaignId !== req.character.campaignId) {
|
||||
return res.status(400).json({ error: 'That spell is not part of this campaign.' });
|
||||
}
|
||||
|
||||
const characterSpell = await prisma.characterSpell.upsert({
|
||||
where: { characterId_spellId: { characterId: req.character.id, spellId: parsed.data.spellId } },
|
||||
create: { characterId: req.character.id, spellId: parsed.data.spellId, prepared: parsed.data.prepared },
|
||||
update: { prepared: parsed.data.prepared },
|
||||
include: { spell: true },
|
||||
});
|
||||
res.status(201).json({ characterSpell });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/spells/:spellId', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ prepared: z.boolean() }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const characterSpell = await prisma.characterSpell.update({
|
||||
where: { characterId_spellId: { characterId: req.character.id, spellId: req.params.spellId } },
|
||||
data: parsed.data,
|
||||
include: { spell: true },
|
||||
});
|
||||
res.json({ characterSpell });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes about this character
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/notes', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
characterId: req.character.id,
|
||||
...(req.isDM ? {} : { OR: [{ visibility: 'SHARED' }, { authorId: req.user.id }] }),
|
||||
},
|
||||
include: { author: { select: { displayName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
res.json({ notes });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,187 @@
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const { computeCharacterSheet } = require('../game/calculations');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
async function requireEncounterAccess(req, res, next) {
|
||||
try {
|
||||
const encounter = await prisma.encounter.findUnique({ where: { id: req.params.id } });
|
||||
if (!encounter) return res.status(404).json({ error: 'Encounter not found' });
|
||||
|
||||
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
|
||||
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
|
||||
|
||||
req.encounter = encounter;
|
||||
req.membership = membership;
|
||||
req.isDM = membership.role === 'DM';
|
||||
return next();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function serializeEncounter(encounterId) {
|
||||
return prisma.encounter.findUnique({
|
||||
where: { id: encounterId },
|
||||
include: {
|
||||
participants: {
|
||||
include: {
|
||||
character: { select: { id: true, name: true } },
|
||||
monster: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { initiative: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const encounter = await serializeEncounter(req.encounter.id);
|
||||
res.json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const participantSchema = z
|
||||
.object({
|
||||
characterId: z.string().optional(),
|
||||
monsterId: z.string().optional(),
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
initiative: z.number().int().default(0),
|
||||
})
|
||||
.refine((data) => Boolean(data.characterId) !== Boolean(data.monsterId), {
|
||||
message: 'Provide exactly one of characterId or monsterId',
|
||||
});
|
||||
|
||||
router.post('/:id/participants', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
|
||||
const parsed = participantSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, monsterId, displayName, initiative } = parsed.data;
|
||||
|
||||
let currentHp, maxHp, resolvedName, isMonster;
|
||||
|
||||
if (characterId) {
|
||||
const character = await prisma.character.findUnique({ where: { id: characterId } });
|
||||
if (!character || character.campaignId !== req.encounter.campaignId) {
|
||||
return res.status(400).json({ error: 'That character is not part of this campaign.' });
|
||||
}
|
||||
const full = await prisma.character.findUnique({
|
||||
where: { id: characterId },
|
||||
include: { skillProficiencies: true, saveProficiencies: true },
|
||||
});
|
||||
const sheet = computeCharacterSheet(full, full.skillProficiencies, full.saveProficiencies);
|
||||
currentHp = character.currentHp;
|
||||
maxHp = sheet.hp.max;
|
||||
resolvedName = displayName || character.name;
|
||||
isMonster = false;
|
||||
} else {
|
||||
const monster = await prisma.monster.findUnique({ where: { id: monsterId } });
|
||||
if (!monster || monster.campaignId !== req.encounter.campaignId) {
|
||||
return res.status(400).json({ error: 'That monster is not part of this campaign.' });
|
||||
}
|
||||
currentHp = monster.maxHp;
|
||||
maxHp = monster.maxHp;
|
||||
resolvedName = displayName || monster.name;
|
||||
isMonster = true;
|
||||
}
|
||||
|
||||
const participant = await prisma.encounterParticipant.create({
|
||||
data: {
|
||||
encounterId: req.encounter.id,
|
||||
characterId: characterId || null,
|
||||
monsterId: monsterId || null,
|
||||
displayName: resolvedName,
|
||||
initiative,
|
||||
currentHp,
|
||||
maxHp,
|
||||
isMonster,
|
||||
},
|
||||
});
|
||||
|
||||
const encounter = await serializeEncounter(req.encounter.id);
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
|
||||
res.status(201).json({ participant, encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const updateParticipantSchema = z.object({
|
||||
currentHp: z.number().int().optional(),
|
||||
maxHp: z.number().int().min(1).optional(),
|
||||
initiative: z.number().int().optional(),
|
||||
conditions: z.array(z.string()).optional(),
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
});
|
||||
|
||||
router.patch('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
|
||||
const parsed = updateParticipantSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const participant = await prisma.encounterParticipant.update({
|
||||
where: { id: req.params.participantId },
|
||||
data: parsed.data,
|
||||
});
|
||||
|
||||
const encounter = await serializeEncounter(req.encounter.id);
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
|
||||
res.json({ participant, encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
|
||||
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
|
||||
const encounter = await serializeEncounter(req.encounter.id);
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
|
||||
res.json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/advance-round', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
|
||||
const updated = await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: { currentRound: { increment: 1 }, status: 'ACTIVE' },
|
||||
});
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
|
||||
encounterId: updated.id,
|
||||
currentRound: updated.currentRound,
|
||||
});
|
||||
res.json({ encounter: updated });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
|
||||
const parsed = z.object({ status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(), name: z.string().min(1).max(120).optional() }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const updated = await prisma.encounter.update({ where: { id: req.encounter.id }, data: parsed.data });
|
||||
res.json({ encounter: updated });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,63 @@
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
const createNoteSchema = z
|
||||
.object({
|
||||
campaignId: z.string().optional(),
|
||||
characterId: z.string().optional(),
|
||||
body: z.string().min(1).max(10000),
|
||||
visibility: z.enum(['DM_ONLY', 'SHARED']).default('SHARED'),
|
||||
})
|
||||
.refine((data) => Boolean(data.campaignId) || Boolean(data.characterId), {
|
||||
message: 'Provide campaignId and/or characterId',
|
||||
});
|
||||
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const parsed = createNoteSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { campaignId, characterId, body, visibility } = parsed.data;
|
||||
|
||||
let resolvedCampaignId = campaignId || null;
|
||||
if (characterId) {
|
||||
const character = await prisma.character.findUnique({ where: { id: characterId } });
|
||||
if (!character) return res.status(404).json({ error: 'Character not found' });
|
||||
resolvedCampaignId = resolvedCampaignId || character.campaignId;
|
||||
}
|
||||
if (resolvedCampaignId) {
|
||||
const membership = await loadCampaignMembership(req.user.id, resolvedCampaignId);
|
||||
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
|
||||
}
|
||||
|
||||
const note = await prisma.note.create({
|
||||
data: { campaignId, characterId, authorId: req.user.id, body, visibility },
|
||||
include: { author: { select: { displayName: true } } },
|
||||
});
|
||||
res.status(201).json({ note });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const note = await prisma.note.findUnique({ where: { id: req.params.id } });
|
||||
if (!note) return res.status(404).json({ error: 'Note not found' });
|
||||
if (note.authorId !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You can only delete your own notes' });
|
||||
}
|
||||
await prisma.note.delete({ where: { id: req.params.id } });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,29 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Clients join a room per campaign (`campaign:<id>`) after authenticating with
|
||||
// the same JWT issued by /auth/login. Server-side route handlers emit into
|
||||
// these rooms (see character:updated, encounter:*-updated, character:created).
|
||||
function attachSockets(io) {
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth && socket.handshake.auth.token;
|
||||
if (!token) return next(new Error('Missing auth token'));
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET);
|
||||
socket.user = { id: payload.sub, email: payload.email };
|
||||
return next();
|
||||
} catch (err) {
|
||||
return next(new Error('Invalid or expired token'));
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('campaign:join', (campaignId) => {
|
||||
if (typeof campaignId === 'string') socket.join(`campaign:${campaignId}`);
|
||||
});
|
||||
socket.on('campaign:leave', (campaignId) => {
|
||||
if (typeof campaignId === 'string') socket.leave(`campaign:${campaignId}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { attachSockets };
|
||||
@@ -0,0 +1,13 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no O/0/I/1 to avoid ambiguity
|
||||
|
||||
function generateInviteCode(length = 6) {
|
||||
let code = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
code += ALPHABET[crypto.randomInt(0, ALPHABET.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
module.exports = { generateInviteCode };
|
||||
Reference in New Issue
Block a user