Initial commit

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