V1.3.0: Hollowcrown seed, player visibility rules, initiative rolls, number limits, temp HP

- Hollowcrown: The Rift Between Worlds as its own seed (npm run
  seed:hollowcrown), built from the campaign book: pools, 58 monsters,
  57 NPCs, 28 locations, 72 encounters with rewards, and DM notes. Shared
  seeding logic moved to prisma/seedCampaign.js.
- What players can see: only encounters their own character is in; only
  the pool entries their character holds (plus starting options while
  building); only locations their character has been to; no invite code;
  no gold, inventory or money log of other players' characters. Socket
  events carry ids only and campaign rooms are members-only.
- Encounters have a location; while one is in progress its characters are
  there, and visits are remembered.
- Campaign setting to show players a health bar only for monsters and NPCs.
- Initiative is entered as the d20 roll (1-20) and the bonus is added;
  players can enter their own character's roll.
- Number fields enforce their limits when typing, and the API enforces
  the same limits.
- Temp HP shows on HP bars and absorbs damage before real HP.
- All packages at version 1.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:53:48 +02:00
co-authored by Claude Opus 5.5
parent 2637a510df
commit 932e755895
38 changed files with 5841 additions and 409 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.3.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -15,6 +15,7 @@
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:seed": "prisma db seed",
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
"prisma:studio": "prisma studio"
},
"dependencies": {
@@ -0,0 +1,23 @@
-- AlterTable: whether players see a monster's exact hit points or only its health bar
ALTER TABLE "campaigns" ADD COLUMN "showMonsterHp" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable: where an encounter takes place
ALTER TABLE "encounters" ADD COLUMN "locationId" TEXT;
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable: every location a character has been to
CREATE TABLE "character_location_visits" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"firstVisitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "character_location_visits_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "character_location_visits_characterId_locationId_key" ON "character_location_visits"("characterId", "locationId");
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Characters have been to wherever they are now.
INSERT INTO "character_location_visits" ("id", "characterId", "locationId")
SELECT 'visit_' || "id", "id", "currentLocationId" FROM "characters" WHERE "currentLocationId" IS NOT NULL;
@@ -0,0 +1,9 @@
-- AlterTable: the d20 a combatant rolled for initiative; "initiative" is now roll + bonus
ALTER TABLE "encounter_participants" ADD COLUMN "initiativeRoll" INTEGER;
-- Initiatives typed in before this were totals. Keep them as they are, and
-- treat the nearest possible die face as the roll so those combatants still
-- count as having rolled.
UPDATE "encounter_participants"
SET "initiativeRoll" = LEAST(GREATEST("initiative", 1), 20)
WHERE "initiative" <> 0;
+29
View File
@@ -158,6 +158,8 @@ model Campaign {
startingItemCount Int @default(0)
startingSpellCount Int @default(0)
skipMotivationRoll Boolean @default(false)
// Off: players see only a health bar for monsters and NPCs, not the numbers.
showMonsterHp Boolean @default(true)
ownerId String
// Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it.
@@ -188,10 +190,28 @@ model Location {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
characters Character[]
encounters Encounter[]
visits CharacterLocationVisit[]
@@map("locations")
}
// A location a character has been to. Written whenever a character is put
// somewhere (by the DM, or by an encounter held there), and never removed by
// moving on: it is what decides which locations a player may see.
model CharacterLocationVisit {
id String @id @default(cuid())
characterId String
locationId String
firstVisitedAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
@@unique([characterId, locationId])
@@map("character_location_visits")
}
model CampaignMember {
id String @id @default(cuid())
campaignId String
@@ -271,6 +291,7 @@ model Character {
spells CharacterSpell[]
notes Note[]
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
@@map("characters")
}
@@ -497,6 +518,9 @@ model Encounter {
activeParticipantId String?
description String? // the scene / objective, shown to everyone
dmNotes String? // never sent to players
// Where the scene takes place. While the encounter is in progress, the
// characters in it are there too.
locationId String?
// Set while archived: kept in full, but off the campaign's list — players
// no longer see it there at all.
archivedAt DateTime?
@@ -506,6 +530,7 @@ model Encounter {
updatedAt DateTime @updatedAt
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
participants EncounterParticipant[]
rewards EncounterReward[]
@@ -548,6 +573,10 @@ model EncounterParticipant {
monsterId String?
npcId String?
displayName String // denormalized so a monster can be renamed per-instance, e.g. "Goblin #2"
// The d20 rolled for initiative (1–20); null until it is rolled.
initiativeRoll Int?
// The roll plus the combatant's initiative bonus, worked out when the roll
// is entered. This is what the turn order sorts on.
initiative Int @default(0)
currentHp Int
maxHp Int
+32
View File
@@ -0,0 +1,32 @@
// Adds the Hollowcrown: The Rift Between Worlds campaign
// (seed-data/hollowcrown.js) for the admin account. Not part of the default
// seed — run it on its own:
//
// npm run seed:hollowcrown
//
// Like every seed it only adds (see seedCampaign.js): it never touches other
// campaigns, and does nothing if the admin already has an active Hollowcrown.
const { run, fromLibrary } = require('./seedCampaign');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const hollowcrown = require('../seed-data/hollowcrown.js');
const startingSpells = new Set(hollowcrown.startingSpells);
for (const key of startingSpells) fromLibrary(SPELL_LIBRARY, key); // fail early on a misspelt key
run({
...hollowcrown,
// The book's starting picks come from the site library; its modern relics
// and magic weapons are never starting options.
weapons: [
...hollowcrown.libraryWeapons.map((key) => fromLibrary(WEAPON_LIBRARY, key, { isStartingOption: true })),
...hollowcrown.weapons,
],
// The whole spell library goes in, so a spell is there to hand out the
// moment a character unlocks it; only the book's Session 0 list is offered
// in the builder.
spells: [
...SPELL_LIBRARY.map(({ key }) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: startingSpells.has(key) })),
...hollowcrown.spells,
],
monsters: [...hollowcrown.libraryMonsters.map((key) => fromLibrary(MONSTER_LIBRARY, key)), ...hollowcrown.monsters],
});
+19 -194
View File
@@ -1,207 +1,32 @@
// Seeds the Aldenfell campaign (seed-data/aldenfell.js) for the admin account.
//
// Safe to run against a live database: it only ever ADDS. The admin account is
// created if it is missing (an existing one, and its password, is left alone),
// and the campaign is added unless that account already owns an active
// campaign of the same name. To get a fresh copy, archive or delete the old
// one in the app and run the seed again.
// The default seed: the Aldenfell campaign (seed-data/aldenfell.js) for the
// admin account. See seedCampaign.js for what a seed does and why it is safe
// to run against a live database.
//
// npx prisma db seed add what is missing
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
// character, note, etc. first
//
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
// account that is created.
// Other campaigns have their own command (npm run seed:hollowcrown).
//
// Species/classes/skills are NOT seeded as rows — they're fixed game-rule
// tables ported into src/game/data/*.js, as is the default weapon/spell/monster
// library DMs pull from. The default weapons (seed-data/weapons.json) ARE
// seeded, into the campaign, since weapons are per-campaign in the schema.
const path = require('path');
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { generateInviteCode } = require('../src/utils/inviteCode');
const { run, fromLibrary } = require('./seedCampaign');
const { SPELL_LIBRARY } = require('../src/game/data/library');
const defaultWeapons = require('../seed-data/weapons.json');
const aldenfell = require('../seed-data/aldenfell.js');
const prisma = new PrismaClient();
const defaultWeapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
const aldenfell = require(path.join(__dirname, '..', 'seed-data', 'aldenfell.js'));
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
const RESET = process.argv.includes('--reset');
async function resetDatabase() {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
}
// Notes list newest-first on screen, so they are stamped a second apart,
// backwards: the first entry in the seed data is the one at the top.
function bookOrder(index) {
return new Date(Date.now() - index * 1000);
}
function byName(rows) {
return new Map(rows.map((row) => [row.name, row]));
}
function lookup(map, name, what, encounterName) {
const row = map.get(name);
if (!row) throw new Error(`Encounter "${encounterName}" references unknown ${what} "${name}"`);
return row;
}
async function seedCampaign(db, admin) {
const campaign = await db.campaign.create({
data: {
...aldenfell.campaign,
inviteCode: generateInviteCode(),
ownerId: admin.id,
members: { create: { userId: admin.id, role: 'DM' } },
},
});
const campaignId = campaign.id;
await db.location.createMany({ data: aldenfell.locations.map((l) => ({ ...l, campaignId })) });
// Everyday weapons are what a new character picks from; the rest are earned.
await db.weapon.createMany({
data: [
...defaultWeapons.map((w) => ({ ...w, isStartingOption: true, campaignId })),
...aldenfell.weapons.map((w) => ({ ...w, campaignId })),
run(
{
...aldenfell,
// Everyday weapons are what a new character picks from; the rest are earned.
weapons: [...defaultWeapons.map((w) => ({ ...w, isStartingOption: true })), ...aldenfell.weapons],
items: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting) })),
spells: [
...aldenfell.spells.starting.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: true })),
...aldenfell.spells.earned.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: false })),
],
});
await db.item.createMany({
data: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting), campaignId })),
});
const spellRow = (isStartingOption) => (spellKey) => {
const template = SPELL_LIBRARY.find((s) => s.key === spellKey);
if (!template) throw new Error(`Unknown library spell "${spellKey}"`);
const { key, classes, ...spell } = template;
return { ...spell, isStartingOption, campaignId };
};
const spells = [...aldenfell.spells.starting.map(spellRow(true)), ...aldenfell.spells.earned.map(spellRow(false))];
await db.spell.createMany({ data: spells });
await db.monster.createMany({ data: aldenfell.monsters.map((m) => ({ ...m, campaignId })) });
await db.npc.createMany({ data: aldenfell.npcs.map((n) => ({ ...n, campaignId })) });
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
const items = byName(await db.item.findMany({ where: { campaignId } }));
let rewardCount = 0;
for (const [index, enc] of aldenfell.encounters.entries()) {
const participants = [];
for (const [name, count, displayName] of enc.monsters || []) {
const monster = lookup(monsters, name, 'monster', enc.name);
const label = displayName || monster.name;
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${label} #${i}` : label,
currentHp: monster.maxHp,
maxHp: monster.maxHp,
isMonster: true,
});
}
}
for (const name of enc.npcs || []) {
const npc = lookup(npcs, name, 'NPC', enc.name);
// An NPC with no combat stats takes part with no hit points to track.
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: npc.maxHp ?? 0, maxHp: npc.maxHp ?? 0, isMonster: false });
}
// Rewards list oldest-first, so these are stamped forwards.
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity = 1, level, note }, i) => {
const createdAt = new Date(Date.now() + i);
if (gold) return { type: 'GOLD', gold, note, createdAt };
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
return {
type: weapon ? 'WEAPON' : 'ITEM',
[weapon ? 'weaponId' : 'itemId']: entry.id,
quantity,
level: Math.max(level ?? entry.minLevel, entry.minLevel),
note,
createdAt,
};
});
rewardCount += rewards.length;
await db.encounter.create({
data: {
campaignId,
name: enc.name,
mode: enc.mode,
description: enc.description,
dmNotes: enc.dmNotes,
sortOrder: index,
participants: { create: participants },
rewards: { create: rewards },
},
});
}
const notes = [
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
await db.note.createMany({
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
});
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${defaultWeapons.length} starting + ${aldenfell.weapons.length} earned weapons, ${aldenfell.items.length} items, ` +
`${spells.length} spells, ${aldenfell.locations.length} locations`,
);
console.log(
` ${aldenfell.monsters.length} monsters, ${aldenfell.npcs.length} NPCs, ${aldenfell.encounters.length} encounters ` +
`(${rewardCount} rewards lined up), ${notes.length} notes`,
);
}
async function main() {
if (RESET) {
await resetDatabase();
console.log('Database reset: every user and campaign was deleted.');
}
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
if (admin) {
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
} else {
admin = await prisma.user.create({
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
});
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
}
const existing = await prisma.campaign.findFirst({
where: { ownerId: admin.id, name: aldenfell.campaign.name, archivedAt: null },
});
if (existing) {
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
return;
}
// One transaction, so a failed seed doesn't leave half a campaign behind.
console.log('Seeding campaign...');
await prisma.$transaction((tx) => seedCampaign(tx, admin), { timeout: 120000, maxWait: 20000 });
console.log('Seed complete.');
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
},
{ reset: process.argv.includes('--reset') },
);
+210
View File
@@ -0,0 +1,210 @@
// Shared by the campaign seeds (seed.js, seed-hollowcrown.js): makes sure the
// admin account exists, then adds one fully-built campaign for it.
//
// A seed only ever ADDS, so it is safe against a live database. The admin
// account is created if it is missing (an existing one, and its password, is
// left alone), and the campaign is added unless that account already owns an
// active campaign of the same name. To get a fresh copy, archive or delete the
// old one in the app and run the seed again.
//
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
// account that is created.
//
// `content` is one campaign, with every pool as rows shaped like the Prisma
// models minus ids:
// campaign, locations, weapons, items, spells, monsters, npcs,
// sharedNotes, dmNotes (note bodies, in reading order), and
// encounters, in the order they should list:
// { name, mode, description, dmNotes,
// location: a location's name (optional) — where the scene is,
// monsters: [[monster name, count, display name?, max HP?], ...],
// npcs: [NPC name | { name, hp }, ...],
// rewards: [{ gold } | { weapon } | { item }, each with optional
// quantity, level (defaults to the entry's starting level)
// and note] }
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { generateInviteCode } = require('../src/utils/inviteCode');
const prisma = new PrismaClient();
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
// Notes list newest-first on screen, so they are stamped a second apart,
// backwards: the first entry in the seed data is the one at the top.
function bookOrder(index) {
return new Date(Date.now() - index * 1000);
}
function byName(rows) {
return new Map(rows.map((row) => [row.name, row]));
}
function lookup(map, name, what, encounterName) {
const row = map.get(name);
if (!row) throw new Error(`Encounter "${encounterName}" references unknown ${what} "${name}"`);
return row;
}
async function createCampaign(db, admin, content) {
const campaign = await db.campaign.create({
data: {
...content.campaign,
inviteCode: generateInviteCode(),
ownerId: admin.id,
members: { create: { userId: admin.id, role: 'DM' } },
},
});
const campaignId = campaign.id;
const inCampaign = (rows) => rows.map((row) => ({ ...row, campaignId }));
await db.location.createMany({ data: inCampaign(content.locations) });
await db.weapon.createMany({ data: inCampaign(content.weapons) });
await db.item.createMany({ data: inCampaign(content.items) });
await db.spell.createMany({ data: inCampaign(content.spells) });
await db.monster.createMany({ data: inCampaign(content.monsters) });
await db.npc.createMany({ data: inCampaign(content.npcs) });
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
const items = byName(await db.item.findMany({ where: { campaignId } }));
const locations = byName(await db.location.findMany({ where: { campaignId } }));
let rewardCount = 0;
for (const [index, enc] of content.encounters.entries()) {
const participants = [];
for (const [name, count, displayName, hp] of enc.monsters || []) {
const monster = lookup(monsters, name, 'monster', enc.name);
const label = displayName || monster.name;
const maxHp = hp ?? monster.maxHp;
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${label} #${i}` : label,
currentHp: maxHp,
maxHp,
isMonster: true,
});
}
}
for (const entry of enc.npcs || []) {
const { name, hp } = typeof entry === 'string' ? { name: entry } : entry;
const npc = lookup(npcs, name, 'NPC', enc.name);
// An NPC with no combat stats takes part with no hit points to track.
const maxHp = hp ?? npc.maxHp ?? 0;
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: maxHp, maxHp, isMonster: false });
}
// Rewards list oldest-first, so these are stamped forwards.
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity, level, note }, i) => {
const createdAt = new Date(Date.now() + i);
if (gold) return { type: 'GOLD', gold, note, createdAt };
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
return {
type: weapon ? 'WEAPON' : 'ITEM',
[weapon ? 'weaponId' : 'itemId']: entry.id,
quantity: quantity ?? 1,
level: Math.max(level ?? entry.minLevel, entry.minLevel),
note,
createdAt,
};
});
rewardCount += rewards.length;
await db.encounter.create({
data: {
campaignId,
name: enc.name,
mode: enc.mode,
description: enc.description,
dmNotes: enc.dmNotes,
locationId: enc.location ? lookup(locations, enc.location, 'location', enc.name).id : null,
sortOrder: index,
participants: { create: participants },
rewards: { create: rewards },
},
});
}
const notes = [
...content.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...content.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
await db.note.createMany({
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
});
const starting = (rows) => rows.filter((row) => row.isStartingOption).length;
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${content.weapons.length} weapons (${starting(content.weapons)} starting), ${content.items.length} items ` +
`(${starting(content.items)} starting), ${content.spells.length} spells (${starting(content.spells)} starting), ` +
`${content.locations.length} locations`,
);
console.log(
` ${content.monsters.length} monsters, ${content.npcs.length} NPCs, ${content.encounters.length} encounters ` +
`(${rewardCount} rewards lined up), ${notes.length} notes`,
);
}
async function resetDatabase() {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
}
// `reset` is DESTRUCTIVE: every user, campaign, character, note, etc. is
// deleted before anything is added.
async function seed(content, { reset = false } = {}) {
if (reset) {
await resetDatabase();
console.log('Database reset: every user and campaign was deleted.');
}
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
if (admin) {
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
} else {
admin = await prisma.user.create({
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
});
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
}
const existing = await prisma.campaign.findFirst({
where: { ownerId: admin.id, name: content.campaign.name, archivedAt: null },
});
if (existing) {
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
return;
}
// One transaction, so a failed seed doesn't leave half a campaign behind.
console.log('Seeding campaign...');
await prisma.$transaction((tx) => createCampaign(tx, admin, content), { timeout: 120000, maxWait: 20000 });
console.log('Seed complete.');
}
// Runs a seed script to completion: exit code 1 on failure, connection closed either way.
function run(content, options) {
return seed(content, options)
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
// A copy of a library entry as a pool row: its `key` and picker-only tags dropped.
function fromLibrary(library, key, extra = {}) {
const template = library.find((entry) => entry.key === key);
if (!template) throw new Error(`Unknown library entry "${key}"`);
const { key: _key, category, classes, ...row } = template;
return { ...row, ...extra };
}
module.exports = { run, fromLibrary };
+25 -23
View File
@@ -300,7 +300,9 @@ const npcs = [
// Encounters — pre-built (PLANNING) scenes for the beats of the Campaign Book,
// in book order. BATTLE scenes run in initiative order; QUEST scenes are built
// around NPCs and an objective.
// description — the scene, shown to every player who opens it: no spoilers.
// location — a location's name: where the scene is. Its characters move
// there when it begins.
// description — the scene, shown to the players in it: no spoilers.
// dmNotes — DCs, branches and secrets, DM only.
// monsters — [monster name, count, optional display name]; a count above
// one gets "#n" suffixes.
@@ -313,33 +315,33 @@ const npcs = [
const encounters = [
{
name: 'E1 — The Founding Fair', mode: 'QUEST',
name: 'E1 — The Founding Fair', mode: 'QUEST', location: 'Highcrown — The Lantern Market',
description: 'Highcrown’s Lantern Market at the height of the Founding Fair: lantern-strung stalls, contests of strength and skill, minstrels, and strangers from every corner of the kingdom. This is where you meet.',
dmNotes: 'Character creation happens here (Roll-Your-Character System) — it can be the whole first half of session one. No combat. Let each PC win or lose something small at a stall, and introduce Mireille and Ser Kavan in passing so they are familiar faces by E4. Ends when the chapel bell fails to ring → E2.',
npcs: ['Mireille Costa', 'Ser Kavan Thorne'],
},
{
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST',
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST', location: 'Highcrown — The Founding Chapel',
description: 'The Founding Chapel’s bell has rung every hour for as long as anyone remembers. It has stopped. A crowd is gathering outside, and someone is asking for volunteers to go in and look.',
dmNotes: 'No break-in. The bell rope is neatly cut and left coiled; the bell-ringer is missing, not dead; a line of unfamiliar script is scratched inside the bell (Hollow King iconography). DC 13 Investigation finds the rope and script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking”.\n• E2A investigate immediately: full clues; skip Vault Room 1.\n• E2B distracted first: the guard missed the script; DC 13 Investigation to find it; start at Room 1.\n• E2C talk to the crowd first: three witness accounts, no script lead; E10 Insight later at advantage.\nThe trail leads to the cellar → E3.',
npcs: ['Ser Kavan Thorne', 'A Frightened Local'],
},
{
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE',
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
description: 'A long hall of burial niches beneath the chapel, some sealed, some broken open. Something is growling in the dark between the pillars.',
dmNotes: 'Vault Room 2, the hub (→ Rooms 3, 4, stair to 6). The dog is starving, not vicious: DC 12 Animal Handling or a thrown ration ends it. DC 13 Investigation: the open niches are just old theft. DC 12 Perception finds the locket.',
monsters: [['Feral Guard Dog', 1]],
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Perception' }],
},
{
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE',
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
description: 'Bones stacked floor to ceiling, and an offering box furred with dust. The air is still. Then it isn’t.',
dmNotes: 'Vault Room 4, a dead end. One or two skeletons rise halfway through the room — ordinary restless dead, not tied to the Hollow King. DC 12 Religion: proper deconsecration would settle them without a fight. DC 13 to open the offering box.',
monsters: [['Skeleton', 2]],
rewards: [{ gold: 25, note: 'Offering box — DC 13 to open' }],
},
{
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST',
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
description: 'A side chamber with a bedroll, a cold lantern, and a frightened old man who very much hopes you are who you look like.',
dmNotes: 'Vault Room 5. The bell-ringer is alive but shaken: he saw “someone who looked like his neighbor, except wrong”. Getting him out is the win — the bell rings again, the fair goes on, nothing is solved.\nRoom 3 (Smugglers’ Cache) is on the way: DC 14 Investigation ties the crates’ sigil to a lender linked to the corrupted house.\nRoom 6 (The Descent) leads to the Root: wrong, cold air. Signal the tone shift; the party can always turn back. → E4.',
npcs: ['The Bell-Ringer'],
@@ -370,14 +372,14 @@ const encounters = [
],
},
{
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST',
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST', location: 'Salt Coast — Tidewatch',
description: 'Tidewatch, the Salt Coast’s largest port: proud, salt-stained, and suspicious of anyone asking questions. Someone is skimming the town’s quiet trade, and the Concord wants to know who.',
dmNotes: 'Lv 1–3. Mireille hires the party to learn why a cut of Tidewatch’s smuggled goods vanishes before reaching buyers. TRUTH: a Bandit Captain is extorting the smugglers and funnelling the “tithe” to arm one side of the Bramble Marches conflict. Smugglers distrust outsiders. Track a shipment to Smuggler’s Cove → next scene.\nOutcome decides whether Greyford is supplied at E11/E16.',
npcs: ['Mireille Costa'],
rewards: [{ gold: 100, note: 'Mireille’s fee — paid in full if it ends quietly' }],
},
{
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE',
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE', location: 'Salt Coast — Smuggler’s Cove',
description: 'A hidden inlet south of Tidewatch. Lanterns hooded, a longboat drawn up on the shingle, and a crew that was not expecting company.',
dmNotes: 'Fight, negotiate, or both. The Captain leads from the front (multiattack) and bolts for the boat below half HP. End it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward).',
monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]],
@@ -387,13 +389,13 @@ const encounters = [
],
},
{
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE',
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
description: 'The trail to the shrine runs through old growth where the light barely reaches the ground. The forest has gone quiet, and there are eyes between the trees.',
dmNotes: 'Lv 1–2. The pack is agitated by the shrine, not evil — DC 13 Animal Handling or Nature to back away without a fight. Pack tactics make three wolves the clearest action-economy lesson for a new table.',
monsters: [['Greywood Direwolf', 3]],
},
{
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE',
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
description: 'A mossed-over shrine older than the kingdom, split by a crack you could lose an arm in. The missing Warden is standing in front of it. She turns and greets you by name.',
dmNotes: 'HORROR register — signal it. The Warden was Hollowed after investigating the crack alone: the party’s first, sympathetic Hollowed. DC 15 Insight notices the wrongness (no blink, a repeated phrase) before it acts. Fight, restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced.',
monsters: [['Hollowed', 1, 'The Missing Warden']],
@@ -403,7 +405,7 @@ const encounters = [
],
},
{
name: 'Side Quest — Trial of Stone', mode: 'QUEST',
name: 'Side Quest — Trial of Stone', mode: 'QUEST', location: 'The Spine — Stonejaw Hold',
description: 'Stonejaw Hold, a giant clan’s valley in the Spine foothills. The chieftain has heard you out. He does not offer agreement. He offers a trial.',
dmNotes: 'Lv 2–4. A genuine test of character, nothing sinister. Trials (per PC or shared): non-lethal wrestling with a giant (the Hill Giant here — HP is a “pin” clock, nobody dies); a test of honesty; proving their word is good (retrieve something, deliver a message honestly). Beats: arrive → formal challenge → trial → feast.\nSuccess = standing alliance (Spine Clans track, unlocks E9D and E11C). Failure is not fatal: Korgath offers a second, harder trial.',
monsters: [['Stonejaw Hill Giant', 1, 'Stonejaw Champion']],
@@ -414,7 +416,7 @@ const encounters = [
],
},
{
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST',
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST', location: 'Bramble Marches — The Sorrow Camp',
description: 'The Sorrow Camp: hundreds of people under canvas just inside the border, and a garrison at Greyford Keep that wants them gone. Food sent for the camp is not all arriving.',
dmNotes: 'Lv 2–4, the clearest moral-choice quest. No clean villain: the commander is not wrong about the strategic liability, and the refugees have nowhere else to go. Beats: tension between camp and garrison → find who is skimming aid (the Captain’s crew) → confront them → broker, or fail to broker, an arrangement.\nIntroduce a named refugee NPC here — they are at risk at E16B.',
monsters: [['Bandit Captain', 1, 'Aid-Skimmer Boss'], ['Highcrown Cutthroat', 2]],
@@ -422,7 +424,7 @@ const encounters = [
rewards: [{ gold: 50, note: 'Recovered aid money — keeping it is a choice the camp will notice' }],
},
{
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE',
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE', location: 'Salt Coast — Tidewatch',
description: 'A fishing boat washed up empty this morning: sails intact, catch in the hold, crew gone. The fishers will point out the reef. None of them will row you there.',
dmNotes: 'Lv 2–3, horror-lite. NOT connected to the Hollow King — a deliberate palate-cleanser. DC 13 Investigation on the boat finds claw marks, not violence. Confront it at sea (it swims, the party does not) or lure it to the shallows. Outcome: safe fishing reopens and real goodwill in Tidewatch.',
monsters: [['Reef Terror', 1]],
@@ -432,21 +434,21 @@ const encounters = [
],
},
{
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST',
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST', location: 'The Spine — Threndir’s Eyrie',
description: 'A hard climb above the snow line, and a cave mouth wide enough to sail a ship into. The dragon inside already knows you are here. He has not moved, and he has not told you to leave.',
dmNotes: 'A negotiation, not a fight: Threndir wants proof the crown can still be trusted (Persuasion DC 15–18; real evidence from E10 lowers it). He talks first unless attacked. If it goes wrong, add the “Threndir Ashclaw (old copper dragon)” monster to this scene — and remember he is far beyond a low-level party.',
npcs: ['Threndir Ashclaw'],
rewards: [{ weapon: 'Threndir’s Fang', note: 'A shed tooth, set in a hilt — given only if he comes to respect them' }],
},
{
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE',
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE', location: 'Hollow Below — The Weeping Passage',
description: 'A long tunnel beneath the Greywood where the walls weep, and the sound they make is almost a voice. Somewhere ahead, very faintly, a bell tolls.',
dmNotes: 'HORROR register — signal the tone shift. Root Room 2. DC 14 Survival: the dripping is a Hollowed’s footsteps. The Bellcrawler uses Stillness (DC 17 Perception to spot) and mimics the bell to lure the curious.\n• E9A retreat: keep what they learned, full rest before re-entry.\n• E9B push through: the marking here matches the bell script (DC 16 Arcana/Religion).\n• E9C (took E4A): Ressa Vane escorts and reports back — remove her from the scene otherwise.\n• E9D (won Trial of Stone): a Spine guide enters via Room 4.',
monsters: [['Bellcrawler', 1]],
npcs: ['Corporal Ressa Vane'],
},
{
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE',
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'A cavern kept unnervingly tidy: bedrolls squared away, boots in a row. Two people sit facing the wall. They stand when you enter, and smile at exactly the same moment.',
dmNotes: 'Root Room 3. DC 15 Insight before combat. Consider making one a named missing person. Slam hits force DC 12 Wis or frightened for a round; each can whisper one sentence in Vessa’s voice. The letter (DC 14) hints at the corrupted house.',
monsters: [['Hollowed', 2]],
@@ -456,7 +458,7 @@ const encounters = [
],
},
{
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE',
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'The roots in this chamber are as thick as a man’s waist, and they are not growing from anything. One of them shifts when your light touches it.',
dmNotes: 'Root Room 4, a dead end (optional secret passage to Room 6). DC 14 Perception or its first attack has advantage. Run it as a slow, dreadful stalk: tremorsense, burrow, grasping roots (restrained, DC 14 Str). E9D: DC 13 Survival or it wakes at the entrance.',
monsters: [['Root Stalker', 1]],
@@ -466,7 +468,7 @@ const encounters = [
],
},
{
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE',
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'Narrow ledges over a drop your torch cannot find the bottom of. From three different directions at once, a bell begins to toll.',
dmNotes: 'Root Room 6. DC 15 Perception to place them; DC 13 Dex on the ledges or fall 2d6. They skitter (bonus-action disengage) and freeze when watched — stealth and dread, not a straight fight. → Room 7, the stair to the Hollow Court: let them see it and turn back.',
monsters: [['Bellcrawler', 3]],
@@ -476,14 +478,14 @@ const encounters = [
],
},
{
name: 'E10 — The House Unmasked', mode: 'QUEST',
name: 'E10 — The House Unmasked', mode: 'QUEST', location: 'Highcrown — The Palace of Aldenfell',
description: 'Back in Highcrown with a name, a ledger, and a question nobody at court wants asked out loud: what do you do with it?',
dmNotes: 'The ledger: every shrine “donation” falls three days after a livestock death.\n• E10A confront the house directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.\n• E10B report to the crown, Isolde or Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).\n• E10C (E2C + the servant thread): private DC 13 check at advantage → confession and the sealed letter; the house never learns it is suspected. Best route into Act 3.',
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
rewards: [{ item: 'Sealed Letter', note: 'E10C only' }],
},
{
name: 'E11 — Silence on the Border', mode: 'QUEST',
name: 'E11 — Silence on the Border', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
description: 'Greyford Keep. The gate stands open. No smoke from the chimneys, no one on the walls, no bodies. A kettle is still warm on a guardroom stove.',
dmNotes: 'DC 13 Perception: drag marks — the defenders left. The Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): the clans are already here and share Root Stalker migration intel. Remove the Commander from the scene if this is a recovery.',
npcs: ['Greyford’s Commander'],
@@ -513,7 +515,7 @@ const encounters = [
monsters: [['The Hollow King’s Shadow', 1]],
},
{
name: 'E19 — Vessa’s Seat', mode: 'QUEST',
name: 'E19 — Vessa’s Seat', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'A single worn stone seat at the heart of the Hollow Court, and a woman who has been expecting you. She asks you to sit. She says she would like to explain.',
dmNotes: 'Court Room 4. The party learns the Hollow King’s true nature from Vessa: the imprisoned will of everyone the founders sacrificed — a suppression of many, not a prison for one. DC 16 Insight: she believes what she says. A strong candidate for a negotiated outcome.\nRoot-fiber + seal-stone (DC 15 Arcana, automatic with a Religion-proficient caster) → advantage on the first check at E22. Then E20: the clock turns to Stage 4.',
npcs: ['Vessa Hollow'],
@@ -526,14 +528,14 @@ const encounters = [
rewards: [{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' }],
},
{
name: 'E22 — The Seal Chamber', mode: 'QUEST',
name: 'E22 — The Seal Chamber', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'A wound in the world, stitched shut long ago, and coming undone one thread at a time. Whatever you came here to decide, this is where you decide it.',
dmNotes: 'Court Room 5 — the key branch. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation.\n• E22A reinforce (DC 18 Arcana/Religion): the Sealed Ending — time bought.\n• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.\n• E22C do nothing: the seal fails anyway; E24–E25 are harder.\nHollowbane is the campaign’s single most significant magic item: legendary growth, starts at level 7.',
npcs: ['Vessa Hollow'],
rewards: [{ weapon: 'Hollowbane', note: 'Drawn from the seal itself' }],
},
{
name: 'E25 — The Last Approach', mode: 'BATTLE',
name: 'E25 — The Last Approach', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'The final stretch of the Hollow Court, where gravity, light and memory have all stopped agreeing with one another. The things that live here know you are coming.',
dmNotes: 'A gauntlet of horror-register creatures; whichever faction the party rallied fights alongside them (add those NPCs). Grey Dawn = a disciplined line; Spine Clans = terrain and ritual (collapse passages); Independent = hardest, and entirely theirs. → E26, the Hollow King: build that stat block from DM Secrets, scaled to the party.',
monsters: [['Root Stalker', 1], ['Bellcrawler', 2]],
File diff suppressed because one or more lines are too long
+334 -22
View File
@@ -439,8 +439,8 @@ describe('who can change what on a character', () => {
expect(itemRes.status).toBe(201);
const itemId = itemRes.body.item.id;
// Players can see the pool but not add to it or help themselves from it.
expect((await request(app).get(`/campaigns/${campaign.id}/items`).set(playerAuth)).body.items).toHaveLength(1);
// Players can't see what is in the pool, add to it, or help themselves from it.
expect((await request(app).get(`/campaigns/${campaign.id}/items`).set(playerAuth)).body.items).toHaveLength(0);
expect((await request(app).post(`/campaigns/${campaign.id}/items`).set(playerAuth).send({ name: 'Crown' })).status).toBe(403);
expect((await request(app).post(`/characters/${characterId}/weapons`).set(playerAuth).send({ weaponId })).status).toBe(403);
expect((await request(app).post(`/characters/${characterId}/inventory`).set(playerAuth).send({ itemId })).status).toBe(403);
@@ -598,7 +598,7 @@ describe('hit points', () => {
const maxHp = (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.sheet.hp.max;
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Brawl' })).body.encounter.id;
const added = await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId, initiative: 12 });
const added = await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId, initiativeRoll: 12 });
const participantId = added.body.participant.id;
const combatant = async () => (await request(app).get(`/encounters/${encounterId}`).set(playerAuth)).body.encounter.participants[0];
const sheetHp = async () => (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.currentHp;
@@ -629,7 +629,7 @@ describe('hit points', () => {
// A combatant can only be edited through its own encounter.
const otherEncounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Elsewhere' })).body.encounter.id;
const wrong = await request(app).patch(`/encounters/${otherEncounterId}/participants/${participantId}`).set(dmAuth).send({ initiative: 1 });
const wrong = await request(app).patch(`/encounters/${otherEncounterId}/participants/${participantId}`).set(dmAuth).send({ initiativeRoll: 1 });
expect(wrong.status).toBe(404);
});
});
@@ -654,7 +654,7 @@ describe('money tracker', () => {
expect((await gold(playerAuth, { amount: 0 })).status).toBe(400);
expect((await gold(playerAuth, { amount: 1.5 })).status).toBe(400);
// Another player can read the ledger but not write to it.
// Another player can neither write to the ledger nor read it.
const stranger = await request(app)
.post('/auth/register')
.send({ email: uniqueEmail('gold-other'), password: 'password123', displayName: 'Other' });
@@ -663,7 +663,9 @@ describe('money tracker', () => {
await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(strangerAuth);
expect((await gold(strangerAuth, { amount: 5 })).status).toBe(403);
const ledger = await request(app).get(`/characters/${characterId}/gold`).set(strangerAuth);
expect((await request(app).get(`/characters/${characterId}/gold`).set(strangerAuth)).status).toBe(403);
const ledger = await request(app).get(`/characters/${characterId}/gold`).set(playerAuth);
expect(ledger.status).toBe(200);
expect(ledger.body.gold).toBe(45);
expect(ledger.body.transactions.map((t) => [t.amount, t.balanceAfter, t.reason, t.createdBy.displayName])).toEqual([
@@ -890,13 +892,17 @@ describe('encounters', () => {
}
itIfDb('have a mode, a public description and DM-only notes', async () => {
const { dmAuth, playerAuth, c } = await scene('encmode');
const { dmAuth, playerAuth, c, heroId } = await scene('encmode');
const quest = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'The Missing Miller', mode: 'QUEST', description: 'Find the miller.' })).body.encounter;
expect(quest.mode).toBe('QUEST');
const battle = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter;
expect(battle.mode).toBe('BATTLE');
const e = `/encounters/${quest.id}`;
// A player whose character isn't in a scene can't see it exists.
expect((await request(app).get(e).set(playerAuth)).status).toBe(404);
expect((await request(app).get(`${c}/encounters`).set(playerAuth)).body.encounters).toEqual([]);
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
expect((await request(app).patch(e).set(playerAuth).send({ mode: 'BATTLE' })).status).toBe(403);
const patched = await request(app).patch(e).set(dmAuth).send({ dmNotes: 'The miller is the werewolf.' });
expect(patched.body.encounter.dmNotes).toBe('The miller is the werewolf.');
@@ -905,14 +911,20 @@ describe('encounters', () => {
expect(playerView.description).toBe('Find the miller.');
expect(playerView.dmNotes).toBeUndefined();
const playerList = (await request(app).get(`${c}/encounters`).set(playerAuth)).body.encounters;
expect(playerList.map((x) => [x.name, x.mode]).sort()).toEqual([['Ambush', 'BATTLE'], ['The Missing Miller', 'QUEST']]);
expect(playerList.map((x) => [x.name, x.mode])).toEqual([['The Missing Miller', 'QUEST']]);
expect((await request(app).get(`/encounters/${battle.id}`).set(playerAuth)).status).toBe(404);
expect(playerList.every((x) => x.dmNotes === undefined)).toBe(true);
const dmList = (await request(app).get(`${c}/encounters`).set(dmAuth)).body.encounters;
expect(dmList.map((x) => x.name).sort()).toEqual(['Ambush', 'The Missing Miller']);
expect(dmList.find((x) => x.id === quest.id).dmNotes).toBe('The miller is the werewolf.');
// Taken out of the scene, the player loses sight of it again.
const heroRow = (await request(app).get(e).set(dmAuth)).body.encounter.participants.find((p) => p.characterId === heroId);
await request(app).delete(`${e}/participants/${heroRow.id}`).set(dmAuth);
expect((await request(app).get(e).set(playerAuth)).status).toBe(404);
});
itIfDb('NPCs are DM-managed and join encounters without leaking the DM\'s notes or stats', async () => {
const { dmAuth, playerAuth, c } = await scene('npcs');
const { dmAuth, playerAuth, c, heroId } = await scene('npcs');
const other = await setupCampaign('npcs-other');
expect((await request(app).post(`${c}/npcs`).set(playerAuth).send({ name: 'Nope' })).status).toBe(403);
@@ -942,12 +954,13 @@ describe('encounters', () => {
expect(dmView['Mayor Orla'].npc).toMatchObject({ role: 'Mayor', disposition: 'HOSTILE', dmNotes: 'Secretly bankrupt.', ac: 11 });
expect(dmView['Old Hermit']).toMatchObject({ kind: 'NPC', maxHp: 0 }); // no combat stats
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
const playerView = byName((await request(app).get(e).set(playerAuth)).body.encounter);
expect(playerView['Mayor Orla'].npc).toEqual({ id: mayor.id, name: 'Mayor Orla', role: 'Mayor', disposition: 'HOSTILE', description: 'Tired but kind.' });
// Deleting an NPC takes it out of the scenes it was in.
expect((await request(app).delete(`${c}/npcs/${hermit.id}`).set(dmAuth)).status).toBe(204);
expect(Object.keys(byName((await request(app).get(e).set(dmAuth)).body.encounter))).toEqual(['Mayor Orla']);
expect(Object.keys(byName((await request(app).get(e).set(dmAuth)).body.encounter)).sort()).toEqual(['Aria', 'Mayor Orla']);
});
itIfDb('show the DM full monster stat blocks and everyone a character\'s headline numbers', async () => {
@@ -955,8 +968,8 @@ describe('encounters', () => {
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
const encounterId = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Cave' })).body.encounter.id;
const e = `/encounters/${encounterId}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ monsterId: goblin.id, initiative: 5 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId, initiative: 15 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ monsterId: goblin.id, initiativeRoll: 5 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId, initiativeRoll: 20 });
const [hero, monster] = (await request(app).get(e).set(dmAuth)).body.encounter.participants;
expect(hero.kind).toBe('CHARACTER');
@@ -980,9 +993,9 @@ describe('encounters', () => {
expect((await request(app).post(`${e}/next-turn`).set(dmAuth)).status).toBe(400); // nobody to take a turn
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant.id;
const slow = await add({ monsterId: goblin.id, initiative: 3 });
const fast = await add({ characterId: heroId, initiative: 18 });
const mid = await add({ characterId: allyId, initiative: 10 });
const slow = await add({ monsterId: goblin.id, initiativeRoll: 1 });
const fast = await add({ characterId: heroId, initiativeRoll: 20 });
const mid = await add({ characterId: allyId, initiativeRoll: 10 });
const state = (res) => [res.body.encounter.activeParticipantId, res.body.encounter.currentRound, res.body.encounter.status];
expect((await request(app).post(`${e}/next-turn`).set(playerAuth)).status).toBe(403);
@@ -1009,8 +1022,8 @@ describe('encounters', () => {
const encounterId = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Dragon Hoard' })).body.encounter.id;
const e = `/encounters/${encounterId}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId, initiative: 12 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: allyId, initiative: 8 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId, initiativeRoll: 20 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: allyId, initiativeRoll: 1 });
const add = (body, auth = dmAuth) => request(app).post(`${e}/rewards`).set(auth).send(body);
expect((await add({ type: 'GOLD', gold: 5 }, playerAuth)).status).toBe(403);
@@ -1350,7 +1363,7 @@ describe('locations', () => {
const listRes = await request(app).get(`/campaigns/${campaign.id}/locations`).set(playerAuth);
expect(listRes.status).toBe(200);
expect(listRes.body.locations.map((l) => l.id)).toContain(locationId);
expect(listRes.body.locations).toEqual([]); // nowhere this player's character has been
const charRes = await request(app)
.post(`/campaigns/${campaign.id}/characters`)
@@ -1375,7 +1388,8 @@ describe('default content library', () => {
itIfDb('lists library entries and copies one into the campaign', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('library');
const listRes = await request(app).get(`/campaigns/${campaign.id}/library`).set(playerAuth);
expect((await request(app).get(`/campaigns/${campaign.id}/library`).set(playerAuth)).status).toBe(403);
const listRes = await request(app).get(`/campaigns/${campaign.id}/library`).set(dmAuth);
expect(listRes.status).toBe(200);
expect(listRes.body.weapons.length).toBeGreaterThan(20);
expect(listRes.body.spells.length).toBeGreaterThan(50);
@@ -1468,7 +1482,7 @@ describe('DM-only campaign notes', () => {
});
describe('encounter combat info', () => {
itIfDb('shows a character combatant\'s weapons to every campaign member, not just the DM', async () => {
itIfDb('shows a character combatant\'s weapons to the other players in the encounter, not just the DM', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('encountercombat');
const weaponRes = await request(app)
@@ -1511,7 +1525,9 @@ describe('encounter combat info', () => {
const encounterRes = await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Ambush' });
const encounterId = encounterRes.body.encounter.id;
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId, initiative: 10 });
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId, initiativeRoll: 10 });
const watcherId = await buildCharacter(campaign.id, playerAuth, 'Watcher');
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId: watcherId });
const playerViewRes = await request(app).get(`/encounters/${encounterId}`).set(playerAuth);
expect(playerViewRes.status).toBe(200);
@@ -1611,6 +1627,7 @@ describe('ordering, archiving and deleting encounters', () => {
const ids = {};
for (const name of ['One', 'Two', 'Three']) {
ids[name] = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name })).body.encounter.id;
await request(app).post(`/encounters/${ids[name]}/participants`).set(dmAuth).send({ characterId: heroId });
}
const names = async (auth) => (await request(app).get(`${c}/encounters`).set(auth)).body.encounters.map((e) => e.name);
@@ -1637,7 +1654,6 @@ describe('ordering, archiving and deleting encounters', () => {
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
// Deleting takes the scene but leaves what it already gave out.
await request(app).post(`/encounters/${ids.One}/participants`).set(dmAuth).send({ characterId: heroId });
const rewardRes = await request(app).post(`/encounters/${ids.One}/rewards`).set(dmAuth).send({ type: 'GOLD', gold: 12 });
await request(app).post(`/encounters/${ids.One}/rewards/${rewardRes.body.reward.id}/award`).set(dmAuth).send({ characterId: heroId });
const goldBefore = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.gold;
@@ -1651,3 +1667,299 @@ describe('ordering, archiving and deleting encounters', () => {
expect(goldBefore).toBeGreaterThanOrEqual(12);
});
});
describe('what a player may see', () => {
itIfDb('the pools: starting options while building, then only what their character holds', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('poolview');
const c = `/campaigns/${campaign.id}`;
const weapon = (name, isStartingOption) =>
request(app).post(`${c}/weapons`).set(dmAuth).send({ name, damageDice: '1d6', damageType: 'slashing', attackAbility: 'STR', isStartingOption });
await weapon('Starter Sword', true);
const secret = (await weapon('Secret Blade', false)).body.weapon;
await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope', isStartingOption: true });
await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Crown Shard' });
await request(app).post(`${c}/spells`).set(dmAuth).send({ name: 'Spark', level: 0, isStartingOption: true });
await request(app).post(`${c}/spells`).set(dmAuth).send({ name: 'Doom', level: 5 });
const names = async (pool, auth) => (await request(app).get(`${c}/${pool}`).set(auth)).body[pool].map((x) => x.name);
expect(await names('weapons', dmAuth)).toEqual(['Secret Blade', 'Starter Sword']);
// No character yet: nothing to choose from, nothing held.
expect(await names('weapons', playerAuth)).toEqual([]);
expect(await names('items', playerAuth)).toEqual([]);
expect(await names('spells', playerAuth)).toEqual([]);
// Building: the starting options, and only those.
const hero = (await request(app).post(`${c}/characters`).set(playerAuth).send({ name: 'Newcomer' })).body.character;
expect(await names('weapons', playerAuth)).toEqual(['Starter Sword']);
expect(await names('items', playerAuth)).toEqual(['Rope']);
expect(await names('spells', playerAuth)).toEqual(['Spark']);
await request(app).delete(`/characters/${hero.id}`).set(dmAuth);
// Built: only what the character holds.
const heroId = await buildCharacter(campaign.id, playerAuth, 'Veteran');
expect(await names('weapons', playerAuth)).toEqual([]);
await request(app).post(`/characters/${heroId}/weapons`).set(dmAuth).send({ weaponId: secret.id });
expect(await names('weapons', playerAuth)).toEqual(['Secret Blade']);
expect(await names('items', playerAuth)).toEqual([]);
// The built-in library is the DM's tool.
expect((await request(app).get(`${c}/library`).set(playerAuth)).status).toBe(403);
expect((await request(app).get(`${c}/library`).set(dmAuth)).status).toBe(200);
});
itIfDb('the invite code is the DM’s alone', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('invitehide');
const c = `/campaigns/${campaign.id}`;
expect((await request(app).get(c).set(dmAuth)).body.campaign.inviteCode).toBe(campaign.inviteCode);
expect((await request(app).get(c).set(playerAuth)).body.campaign.inviteCode).toBeUndefined();
const listed = (await request(app).get('/campaigns').set(playerAuth)).body.campaigns.find((x) => x.id === campaign.id);
expect(listed.inviteCode).toBeUndefined();
});
itIfDb('another player’s character: HP and AC, but not their gold, pack or money log', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('sheetview');
const c = `/campaigns/${campaign.id}`;
await request(app).patch(c).set(dmAuth).send({ startingGold: 20 });
const mineId = await buildCharacter(campaign.id, playerAuth, 'Mine');
const theirsId = await buildCharacter(campaign.id, dmAuth, 'Theirs');
const item = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Love Letter' })).body.item;
await request(app).post(`/characters/${theirsId}/inventory`).set(dmAuth).send({ itemId: item.id });
const theirs = (await request(app).get(`/characters/${theirsId}`).set(playerAuth)).body.character;
expect(theirs.restricted).toBe(true);
expect(theirs.gold).toBeNull();
expect(theirs.inventoryItems).toEqual([]);
expect(theirs.sheet.armorClass).toEqual(expect.any(Number));
expect(theirs.currentHp).toEqual(expect.any(Number));
expect((await request(app).get(`/characters/${theirsId}/gold`).set(playerAuth)).status).toBe(403);
const mine = (await request(app).get(`/characters/${mineId}`).set(playerAuth)).body.character;
expect(mine.restricted).toBe(false);
expect(mine.gold).toBe(20);
expect((await request(app).get(`/characters/${mineId}/gold`).set(playerAuth)).status).toBe(200);
const dmView = (await request(app).get(`/characters/${theirsId}`).set(dmAuth)).body.character;
expect(dmView.gold).toBe(20);
expect(dmView.inventoryItems.map((i) => i.item.name)).toEqual(['Love Letter']);
const list = (await request(app).get(`${c}/characters`).set(playerAuth)).body.characters;
expect(list.find((x) => x.id === mineId).gold).toBe(20);
expect(list.find((x) => x.id === theirsId)).toMatchObject({ gold: null, armorClass: expect.any(Number), currentHp: expect.any(Number) });
const dmList = (await request(app).get(`${c}/characters`).set(dmAuth)).body.characters;
expect(dmList.find((x) => x.id === theirsId).gold).toBe(20);
});
itIfDb('locations: only where their character has been, and an encounter in progress takes them there', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('locview');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Wanderer');
const strangerId = await buildCharacter(campaign.id, dmAuth, 'Stranger');
const place = async (name) => (await request(app).post(`${c}/locations`).set(dmAuth).send({ name, description: `About ${name}.` })).body.location;
const cave = await place('Cave');
const keep = await place('Keep');
const vault = await place('Vault');
const seen = async () => (await request(app).get(`${c}/locations`).set(playerAuth)).body.locations.map((l) => l.name);
expect((await request(app).get(`${c}/locations`).set(dmAuth)).body.locations).toHaveLength(3);
expect(await seen()).toEqual([]);
// A planned encounter with a location moves nobody and reveals nothing.
const other = await setupCampaign('locview-other');
const foreign = (await request(app).post(`/campaigns/${other.campaign.id}/locations`).set(other.dmAuth).send({ name: 'Elsewhere' })).body.location;
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Bad', locationId: foreign.id })).status).toBe(400);
const enc = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Into the Cave', locationId: cave.id })).body.encounter;
const e = `/encounters/${enc.id}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
expect(await seen()).toEqual([]);
expect((await request(app).get(e).set(playerAuth)).body.encounter.location).toBeNull();
expect((await request(app).get(e).set(dmAuth)).body.encounter.location.name).toBe('Cave');
// Beginning it puts the characters in it there.
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
expect((await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.currentLocation.name).toBe('Cave');
expect(await seen()).toEqual(['Cave']);
expect((await request(app).get(e).set(playerAuth)).body.encounter.location).toMatchObject({ name: 'Cave', description: 'About Cave.' });
// Moving a running encounter, or joining one, does the same.
expect((await request(app).patch(e).set(dmAuth).send({ locationId: foreign.id })).status).toBe(400);
await request(app).patch(e).set(dmAuth).send({ locationId: keep.id });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: strangerId });
expect((await request(app).get(`/characters/${strangerId}`).set(dmAuth)).body.character.currentLocation.name).toBe('Keep');
expect(await seen()).toEqual(['Cave', 'Keep']);
// Places stay known after moving on, and after the encounter is archived or deleted.
await request(app).patch(e).set(dmAuth).send({ status: 'COMPLETE', archived: true });
expect(await seen()).toEqual(['Cave', 'Keep']);
await request(app).delete(e).set(dmAuth);
expect(await seen()).toEqual(['Cave', 'Keep']);
// The DM placing a character by hand counts as a visit too.
await request(app).patch(`/characters/${heroId}`).set(dmAuth).send({ currentLocationId: vault.id });
expect(await seen()).toEqual(['Cave', 'Keep', 'Vault']);
// Where the party is: named only if this player has been there themselves.
await request(app).patch(`/characters/${strangerId}`).set(dmAuth).send({ currentLocationId: cave.id });
const list = async () => (await request(app).get(`${c}/characters`).set(playerAuth)).body.characters.find((x) => x.id === strangerId);
expect((await list()).currentLocationName).toBe('Cave');
const hidden = await place('Hidden Grove');
await request(app).patch(`/characters/${strangerId}`).set(dmAuth).send({ currentLocationId: hidden.id });
expect((await list()).currentLocationName).toBeNull();
expect((await request(app).get(`/characters/${strangerId}`).set(playerAuth)).body.character.currentLocation).toBeNull();
});
itIfDb('monster hit points: numbers, or only a bar when the campaign says so', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('hpview');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Watcher');
const monster = (await request(app).post(`${c}/monsters`).set(dmAuth).send({ name: 'Ogre', ac: 11, maxHp: 59 })).body.monster;
const npc = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Captain', maxHp: 40 })).body.npc;
const enc = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Bridge' })).body.encounter;
const e = `/encounters/${enc.id}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
const ogreRow = (await request(app).post(`${e}/participants`).set(dmAuth).send({ monsterId: monster.id })).body.participant;
await request(app).post(`${e}/participants`).set(dmAuth).send({ npcId: npc.id });
const view = async (auth) => Object.fromEntries((await request(app).get(e).set(auth)).body.encounter.participants.map((p) => [p.displayName, p]));
expect(campaign.showMonsterHp).toBe(true);
expect((await view(playerAuth)).Ogre).toMatchObject({ currentHp: 59, maxHp: 59 });
expect((await request(app).patch(c).set(playerAuth).send({ showMonsterHp: false })).status).toBe(403);
expect((await request(app).patch(c).set(dmAuth).send({ showMonsterHp: false })).body.campaign.showMonsterHp).toBe(false);
await request(app).patch(`${e}/participants/${ogreRow.id}`).set(dmAuth).send({ currentHp: 1 });
const player = await view(playerAuth);
expect(player.Ogre).toMatchObject({ currentHp: null, maxHp: null, hpPercent: 2 }); // 1 of 59, rounded up
expect(player.Captain).toMatchObject({ currentHp: null, maxHp: null, hpPercent: 100 });
expect(player.Watcher.currentHp).toEqual(expect.any(Number)); // characters keep their numbers
expect(player.Watcher.hpPercent).toBeUndefined();
const dm = await view(dmAuth);
expect(dm.Ogre).toMatchObject({ currentHp: 1, maxHp: 59 });
expect(dm.Ogre.hpPercent).toBeUndefined();
await request(app).patch(`${e}/participants/${ogreRow.id}`).set(dmAuth).send({ currentHp: 0 });
expect((await view(playerAuth)).Ogre.hpPercent).toBe(0);
});
});
describe('initiative', () => {
itIfDb('is the d20 roll plus the combatant\'s bonus, and a player enters only their own', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('initroll');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Quick');
const allyId = await buildCharacter(campaign.id, dmAuth, 'Steady');
const heroBonus = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.sheet.initiative;
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster; // DEX 14: +2
const npc = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Mayor', maxHp: 9 })).body.npc;
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Roll for it' })).body.encounter.id}`;
const add = (body) => request(app).post(`${e}/participants`).set(dmAuth).send(body);
// A roll is a d20: nothing outside 1 to 20, and no fractions.
expect((await add({ monsterId: goblin.id, initiativeRoll: 21 })).status).toBe(400);
expect((await add({ monsterId: goblin.id, initiativeRoll: 0 })).status).toBe(400);
expect((await add({ monsterId: goblin.id, initiativeRoll: 7.5 })).status).toBe(400);
const goblinRow = (await add({ monsterId: goblin.id, initiativeRoll: 10 })).body.participant;
expect(goblinRow).toMatchObject({ initiativeRoll: 10, initiative: 12 });
const npcRow = (await add({ npcId: npc.id, initiativeRoll: 10 })).body.participant;
expect(npcRow).toMatchObject({ initiativeRoll: 10, initiative: 10 }); // an NPC has no bonus
const heroRow = (await add({ characterId: heroId })).body.participant;
expect(heroRow).toMatchObject({ initiativeRoll: null, initiative: 0 });
const allyRow = (await add({ characterId: allyId })).body.participant;
const patch = (row, auth, body) => request(app).patch(`${e}/participants/${row.id}`).set(auth).send(body);
const order = async (auth) => (await request(app).get(e).set(auth)).body.encounter.participants;
// Not rolled yet: at the bottom of the order.
expect((await order(dmAuth)).slice(0, 2).map((p) => p.displayName)).toEqual(['Goblin', 'Mayor']);
// The player types what their die showed; the bonus is added for them.
expect((await patch(heroRow, playerAuth, { initiativeRoll: 25 })).status).toBe(400);
const rolled = await patch(heroRow, playerAuth, { initiativeRoll: 20 });
expect(rolled.status).toBe(200);
expect(rolled.body.participant).toMatchObject({ initiativeRoll: 20, initiative: 20 + heroBonus });
expect((await order(playerAuth))[0]).toMatchObject({ displayName: 'Quick', initiativeRoll: 20, initiativeBonus: heroBonus });
// ...and nothing else: not someone else's roll, not a monster's, not their own HP.
expect((await patch(allyRow, playerAuth, { initiativeRoll: 15 })).status).toBe(403);
expect((await patch(goblinRow, playerAuth, { initiativeRoll: 1 })).status).toBe(403);
expect((await patch(heroRow, playerAuth, { initiativeRoll: 20, currentHp: 999 })).status).toBe(403);
expect((await patch(heroRow, playerAuth, { conditions: ['hasted'] })).status).toBe(403);
// The DM sees every bonus; a player isn't handed a monster's.
const dmView = Object.fromEntries((await order(dmAuth)).map((p) => [p.displayName, p]));
expect(dmView.Goblin.initiativeBonus).toBe(2);
expect(dmView.Mayor.initiativeBonus).toBe(0);
const playerView = Object.fromEntries((await order(playerAuth)).map((p) => [p.displayName, p]));
expect(playerView.Goblin.initiativeBonus).toBeUndefined();
expect(playerView.Goblin.initiative).toBe(12);
expect(playerView.Quick.character.userId).toBeTruthy();
// The DM can re-roll anyone, or clear a roll.
expect((await patch(goblinRow, dmAuth, { initiativeRoll: 1 })).body.participant.initiative).toBe(3);
expect((await patch(heroRow, dmAuth, { initiativeRoll: null })).body.participant).toMatchObject({ initiativeRoll: null, initiative: 0 });
expect((await order(dmAuth)).map((p) => p.displayName).slice(0, 2)).toEqual(['Mayor', 'Goblin']);
});
});
describe('number limits', () => {
itIfDb('are enforced by the server, whatever a form sends', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('limits');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Bounded');
const sheet = (body) => request(app).patch(`/characters/${heroId}`).set(dmAuth).send(body);
expect((await sheet({ level: 21 })).status).toBe(400);
expect((await sheet({ level: 0 })).status).toBe(400);
expect((await sheet({ level: 20 })).status).toBe(200);
expect((await sheet({ armorBonus: 31 })).status).toBe(400);
expect((await sheet({ tempHp: -1 })).status).toBe(400);
expect((await sheet({ tempHp: 10000 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ startingGold: -5 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ startingItemCount: 51 })).status).toBe(400);
expect((await request(app).post(`${c}/monsters`).set(dmAuth).send({ name: 'Wall', ac: 51, maxHp: 10 })).status).toBe(400);
expect((await request(app).post(`${c}/monsters`).set(dmAuth).send({ name: 'Ghost', ac: 10, maxHp: 0 })).status).toBe(400);
expect((await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Titan', attackBonus: 51 })).status).toBe(400);
});
});
describe('temporary hit points', () => {
itIfDb('soak damage before real HP, on the sheet and in an encounter; healing leaves them alone', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('temphp');
const heroId = await buildCharacter(campaign.id, playerAuth, 'Shielded');
const ch = `/characters/${heroId}`;
const sheet = async () => (await request(app).get(ch).set(playerAuth)).body.character;
const max = (await sheet()).sheet.hp.max;
expect((await sheet()).currentHp).toBe(max);
// On the sheet: 5 temp, 3 damage — all of it soaked.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 5 });
let now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max - 3 })).body.character;
expect([now.currentHp, now.tempHp]).toEqual([max, 2]);
// More damage than is left: the rest reaches real HP.
now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max - 3 })).body.character;
expect([now.currentHp, now.tempHp]).toEqual([max - 1, 0]);
// Healing doesn't touch temp HP, and setting temp HP doesn't touch HP.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 4 });
now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max })).body.character;
expect([now.currentHp, now.tempHp]).toEqual([max, 4]);
// In an encounter the damage lands on the same sheet, the same way.
const e = `/encounters/${(await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Arrows' })).body.encounter.id}`;
const row = (await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId })).body.participant;
const hit = await request(app).patch(`${e}/participants/${row.id}`).set(dmAuth).send({ currentHp: max - 6 });
expect(hit.body.participant.currentHp).toBe(max - 2);
now = await sheet();
expect([now.currentHp, now.tempHp]).toEqual([max - 2, 0]);
// A blow far past zero still only takes what there is.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 2 });
await request(app).patch(`${e}/participants/${row.id}`).set(dmAuth).send({ currentHp: -500 });
now = await sheet();
expect([now.currentHp, now.tempHp]).toEqual([0, 0]);
});
});
+66 -21
View File
@@ -6,6 +6,7 @@ const { requireCampaignMember, requireDM } = require('../middleware/campaignAcce
const { generateInviteCode } = require('../utils/inviteCode');
const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { visitedLocationIds } = require('../utils/locations');
const router = express.Router();
@@ -16,11 +17,13 @@ function campaignSummary(campaign, role) {
id: campaign.id,
name: campaign.name,
description: campaign.description,
inviteCode: campaign.inviteCode,
// The invite code is the DM's to hand out.
inviteCode: role === 'DM' ? campaign.inviteCode : undefined,
startingGold: campaign.startingGold,
startingItemCount: campaign.startingItemCount,
startingSpellCount: campaign.startingSpellCount,
skipMotivationRoll: campaign.skipMotivationRoll,
showMonsterHp: campaign.showMonsterHp,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
@@ -45,10 +48,11 @@ router.get('/', async (req, res, next) => {
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(),
startingGold: z.number().int().min(0).max(1000000).optional(),
startingItemCount: z.number().int().min(0).max(50).optional(),
startingSpellCount: z.number().int().min(0).max(50).optional(),
skipMotivationRoll: z.boolean().optional(),
showMonsterHp: z.boolean().optional(),
});
// POST /campaigns — create, creator becomes DM/owner
@@ -205,7 +209,19 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
orderBy: { createdAt: 'asc' },
});
res.json({ characters: characters.map(characterSummary) });
const summaries = characters.map(characterSummary);
if (req.membership.role === 'DM') return res.json({ characters: summaries });
// A player sees the party's headline numbers, but another character's
// purse is their own business, and so is a place this player has not been.
const visited = await visitedLocationIds(prisma, req.user.id, req.campaign.id);
res.json({
characters: summaries.map((c) => {
if (c.userId === req.user.id) return c;
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
return { ...c, gold: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
}),
});
} catch (err) {
next(err);
}
@@ -252,7 +268,7 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
},
});
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { character: characterSummary(character) });
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { characterId: character.id });
res.status(201).json({ character: characterSummary(character) });
} catch (err) {
next(err);
@@ -260,12 +276,28 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
});
// ---------------------------------------------------------------------------
// Weapons (campaign-level pool; DM manages, anyone in campaign can read)
// Weapons (campaign-level pool; the DM manages it and sees all of it)
// ---------------------------------------------------------------------------
// What a player may see of a pool: whatever one of their own characters holds,
// plus the starting options while they still have a character to build. The
// rest of the pool — gear yet to be found, spells yet to be learned — is the
// DM's. `held` is the pool model's relation to the per-character rows.
async function poolScope(req, held) {
const where = { campaignId: req.campaign.id };
if (req.membership.role === 'DM') return where;
const mine = { userId: req.user.id, campaignId: req.campaign.id };
const building = await prisma.character.count({ where: { ...mine, buildStage: { not: 'COMPLETE' } } });
return {
...where,
OR: [{ [held]: { some: { character: mine } } }, ...(building > 0 ? [{ isStartingOption: true }] : [])],
};
}
router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
try {
const weapons = await prisma.weapon.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const weapons = await prisma.weapon.findMany({ where: await poolScope(req, 'assigned'), orderBy: { name: 'asc' } });
res.json({ weapons });
} catch (err) {
next(err);
@@ -350,7 +382,7 @@ router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), as
router.get('/:id/spells', requireCampaignMember(), async (req, res, next) => {
try {
const spells = await prisma.spell.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const spells = await prisma.spell.findMany({ where: await poolScope(req, 'known'), orderBy: { name: 'asc' } });
res.json({ spells });
} catch (err) {
next(err);
@@ -409,7 +441,7 @@ router.delete('/:id/spells/:spellId', requireCampaignMember(), requireDM(), asyn
router.get('/:id/items', requireCampaignMember(), async (req, res, next) => {
try {
const items = await prisma.item.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const items = await prisma.item.findMany({ where: await poolScope(req, 'held'), orderBy: { name: 'asc' } });
res.json({ items });
} catch (err) {
next(err);
@@ -476,8 +508,8 @@ router.get('/:id/monsters', requireCampaignMember(), requireDM(), async (req, re
const monsterSchema = z.object({
name: z.string().min(1).max(80),
ac: z.number().int().min(0),
maxHp: z.number().int().min(1),
ac: z.number().int().min(0).max(50),
maxHp: z.number().int().min(1).max(100000),
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(),
@@ -485,7 +517,7 @@ const monsterSchema = z.object({
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(),
attackBonus: z.number().int().min(-20).max(50).optional(),
damageDice: z.string().max(20).optional().nullable(),
challengeRating: z.string().max(10).optional().nullable(),
specialTrait: z.string().max(500).optional().nullable(),
@@ -533,9 +565,11 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
try {
const isDM = req.membership.role === 'DM';
// Archived encounters are the DM's to see; players only get the live list.
// The DM sees every encounter. A player sees the live ones that one of
// their own characters is in, and nothing of the rest.
const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } };
const encounters = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id, ...(isDM ? {} : { archivedAt: null }) },
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
// dmNotes never leaves the server for a player.
@@ -549,12 +583,18 @@ const createEncounterSchema = z.object({
name: z.string().min(1).max(120),
mode: z.enum(['BATTLE', 'QUEST']).optional(),
description: z.string().max(4000).optional().nullable(),
locationId: z.string().optional().nullable(),
});
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = createEncounterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (parsed.data.locationId) {
const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } });
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
// A new encounter goes to the top of the campaign's order.
const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
const encounter = await prisma.encounter.create({
@@ -610,9 +650,9 @@ const npcSchema = z.object({
disposition: z.enum(['FRIENDLY', 'NEUTRAL', 'HOSTILE']).optional(),
description: z.string().max(2000).optional().nullable(),
dmNotes: z.string().max(4000).optional().nullable(),
ac: z.number().int().min(0).optional().nullable(),
maxHp: z.number().int().min(1).optional().nullable(),
attackBonus: z.number().int().optional().nullable(),
ac: z.number().int().min(0).max(50).optional().nullable(),
maxHp: z.number().int().min(1).max(100000).optional().nullable(),
attackBonus: z.number().int().min(-20).max(50).optional().nullable(),
damageDice: z.string().max(20).optional().nullable(),
});
@@ -676,12 +716,17 @@ router.get('/:id/notes', requireCampaignMember(), async (req, res, next) => {
});
// ---------------------------------------------------------------------------
// Locations — DM-managed, used to track where each character currently is
// Locations — DM-managed, used to track where each character currently is.
// A player only sees the places one of their own characters has been to.
// ---------------------------------------------------------------------------
router.get('/:id/locations', requireCampaignMember(), async (req, res, next) => {
try {
const locations = await prisma.location.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const where = { campaignId: req.campaign.id };
if (req.membership.role !== 'DM') {
where.id = { in: [...(await visitedLocationIds(prisma, req.user.id, req.campaign.id))] };
}
const locations = await prisma.location.findMany({ where, orderBy: { name: 'asc' } });
res.json({ locations });
} catch (err) {
next(err);
@@ -733,7 +778,7 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
// backend/src/game/data/{weapons,spells,monsters}Library.js for the sets.
// ---------------------------------------------------------------------------
router.get('/:id/library', requireCampaignMember(), async (req, res, next) => {
router.get('/:id/library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
res.json({ weapons: WEAPON_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
} catch (err) {
+32 -12
View File
@@ -30,6 +30,8 @@ const {
const characterOptions = require('../game/data/characterOptions');
const { SKILLS } = require('../game/data/skills');
const { InsufficientGoldError, adjustGold, giveGear } = require('../utils/grants');
const { recordVisits } = require('../utils/locations');
const { absorbDamage } = require('../utils/hp');
const router = express.Router();
@@ -51,7 +53,14 @@ async function loadFullCharacter(id) {
// privateNotes is a real privacy boundary, not a UI hide — it's only ever
// included in the payload for the character's own owning player.
function serializeCharacter(character, isOwner) {
//
// Another player (neither the owner nor the DM) gets the sheet the party can
// see across the table — who the character is, HP, AC, abilities, what they
// fight with — but not what is in their pack, their purse, or where they are.
// `isDM` defaults to true because every caller but the plain read is a write
// route, which only the owner or the DM can reach.
function serializeCharacter(character, isOwner, isDM = true) {
const restricted = !isOwner && !isDM;
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
return {
id: character.id,
@@ -82,16 +91,17 @@ function serializeCharacter(character, isOwner) {
appearance: character.appearance,
privateNotes: isOwner ? character.privateNotes : undefined,
diceMode: character.diceMode,
currentLocationId: character.currentLocationId,
currentLocation: character.currentLocation || null,
gold: character.gold,
currentLocationId: restricted ? null : character.currentLocationId,
currentLocation: restricted ? null : character.currentLocation || null,
gold: restricted ? null : character.gold,
restricted,
buildStage: character.buildStage,
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
skillProficiencies: character.skillProficiencies,
saveProficiencies: character.saveProficiencies,
// Each held entry carries what its level works out to: the dice at that
// level, and whether this character is high enough level to use it.
inventoryItems: character.inventoryItems.map((ii) => ({
inventoryItems: (restricted ? [] : character.inventoryItems).map((ii) => ({
...ii,
scaledDamageDice: ii.item.damageDice
? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel)
@@ -126,7 +136,7 @@ router.get('/build/options', (req, res) => {
router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
try {
const character = await loadFullCharacter(req.character.id);
res.json({ character: serializeCharacter(character, req.isOwner) });
res.json({ character: serializeCharacter(character, req.isOwner, req.isDM) });
} catch (err) {
next(err);
}
@@ -135,8 +145,8 @@ router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
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(),
tempHp: z.number().int().min(0).max(9999).optional(),
armorBonus: z.number().int().min(0).max(30).optional(),
speedOverride: z.number().int().min(0).nullable().optional(),
specialAbilities: z.string().max(4000).nullable().optional(),
background: z.string().max(4000).nullable().optional(),
@@ -170,6 +180,14 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
return res.status(403).json({ error: 'Only the DM can change level, XP, speed, location, special abilities, background or appearance.' });
}
// Damage is soaked by temp HP before it reaches real HP — unless this
// same request sets temp HP outright.
if (parsed.data.currentHp !== undefined && parsed.data.tempHp === undefined) {
const after = absorbDamage(req.character.currentHp, req.character.tempHp, parsed.data.currentHp);
parsed.data.currentHp = after.currentHp;
if (after.tempHp !== req.character.tempHp) parsed.data.tempHp = after.tempHp;
}
// Current HP stays within 0..max, including when a level change moves max.
if (req.character.buildStage === 'COMPLETE' && (parsed.data.currentHp !== undefined || parsed.data.level !== undefined)) {
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
@@ -191,6 +209,7 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
}
const updated = await prisma.character.update({ where: { id: req.character.id }, data: parsed.data });
if (parsed.data.currentLocationId) await recordVisits(prisma, [updated.id], parsed.data.currentLocationId);
if (updated.level < req.character.level) {
// Dropping a level can put equipped gear and prepared spells out of reach.
const tooHigh = { characterId: updated.id, level: { gt: updated.level } };
@@ -206,7 +225,7 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
// Broadcast payload never includes privateNotes — a socket room has
// every campaign member in it, not just the owner. Only the direct HTTP
// response (below) is allowed to include it.
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { character: serializeCharacter(full, false) });
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { characterId: full.id });
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
@@ -364,7 +383,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
});
const full = await loadFullCharacter(copyId);
req.app.get('io').to(`campaign:${targetCampaignId}`).emit('character:created', { character: serializeCharacter(full, false) });
req.app.get('io').to(`campaign:${targetCampaignId}`).emit('character:created', { characterId: full.id });
res.status(201).json({ character: serializeCharacter(full, true) });
} catch (err) {
next(err);
@@ -781,7 +800,7 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
});
const full = await loadFullCharacter(result.id);
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { character: serializeCharacter(full, false) });
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { characterId: full.id });
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
@@ -840,6 +859,7 @@ const GOLD_TRANSACTION_INCLUDE = { createdBy: { select: { displayName: true } }
router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
try {
if (!req.isDM && !req.isOwner) return res.status(403).json({ error: "Only the character's player and the DM can see its money" });
const transactions = await prisma.goldTransaction.findMany({
where: { characterId: req.character.id },
include: GOLD_TRANSACTION_INCLUDE,
@@ -857,7 +877,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
try {
const parsed = z
.object({
amount: z.number().int().refine((n) => n !== 0, 'Amount cannot be zero'),
amount: z.number().int().min(-1000000).max(1000000).refine((n) => n !== 0, 'Amount cannot be zero'),
reason: z.string().trim().max(200).optional().nullable(),
})
.safeParse(req.body);
+111 -22
View File
@@ -3,8 +3,10 @@ const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet, computeWeaponStats, canUseGear, MAX_GEAR_LEVEL } = require('../game/calculations');
const { computeCharacterSheet, computeWeaponStats, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
const { adjustGold, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { absorbDamage } = require('../utils/hp');
const router = express.Router();
@@ -18,9 +20,20 @@ async function requireEncounterAccess(req, res, next) {
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
// A player only gets into a scene one of their own characters is in. To
// anyone else it doesn't exist — the name alone can be a spoiler.
const isDM = membership.role === 'DM';
if (!isDM) {
const present = await prisma.encounterParticipant.findFirst({
where: { encounterId: encounter.id, character: { userId: req.user.id } },
select: { id: true },
});
if (!present) return res.status(404).json({ error: 'Encounter not found' });
}
req.encounter = encounter;
req.membership = membership;
req.isDM = membership.role === 'DM';
req.isDM = isDM;
return next();
} catch (err) {
return next(err);
@@ -37,10 +50,24 @@ function clamp(value, min, max) {
}
// Highest initiative first; ties keep the order combatants were added in.
// Anyone who has not rolled yet waits at the bottom.
function inTurnOrder(participants) {
return [...participants].sort((a, b) => b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
}
// What is added to a combatant's d20 for initiative: the sheet's initiative
// bonus for a character, the Dexterity modifier for a monster, and nothing for
// an NPC (they have no ability scores). Takes a participant with its
// character (and proficiencies) and monster loaded.
function initiativeBonus({ character, monster }) {
if (character) return computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).initiative;
if (monster) return abilityModifier(monster.dexScore);
return 0;
}
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
// What every campaign member sees about a character in an encounter: the
// sheet's headline numbers plus the weapons and spells it can actually use —
// so the party can plan around each other mid-fight, not only the DM. Uses the
@@ -92,10 +119,17 @@ function characterCombatInfo(character) {
// The DM gets everything. Players get the scene as their characters see it:
// no DM notes, no monster stat blocks, no NPC secrets or stats, and only the
// rewards that have actually been handed out.
async function serializeEncounter(encounterId, isDM) {
//
// `viewerId` is the user the payload is for. A player is told where a scene
// is once one of their characters has been there, and — when the campaign
// hides monster hit points — gets a percentage for the health bar in place of
// the numbers for anyone who is not a character.
async function serializeEncounter(encounterId, isDM, viewerId) {
const encounter = await prisma.encounter.findUnique({
where: { id: encounterId },
include: {
campaign: { select: { showMonsterHp: true } },
location: { select: { id: true, name: true, description: true } },
participants: {
include: {
character: {
@@ -115,16 +149,20 @@ async function serializeEncounter(encounterId, isDM) {
});
if (!encounter) return encounter;
const hideHp = !isDM && !encounter.campaign.showMonsterHp;
const participants = inTurnOrder(encounter.participants).map((p) => {
const { character, monster, npc, ...row } = p;
const out = { ...row, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER' };
// The bonus is part of a monster's stat block, so only the DM gets it for
// those; a character's is on their sheet for all to see.
const bonus = isDM || character ? { initiativeBonus: initiativeBonus(p) } : {};
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER' };
if (character) {
// A character combatant's HP is the character's own HP, not a
// per-encounter copy, so the tracker and the sheet can never disagree.
const { maxHp, ...combatInfo } = characterCombatInfo(character);
out.currentHp = character.currentHp;
out.maxHp = maxHp;
out.character = { id: character.id, name: character.name };
out.character = { id: character.id, name: character.name, userId: character.userId };
out.characterCombatInfo = combatInfo;
}
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
@@ -132,12 +170,21 @@ async function serializeEncounter(encounterId, isDM) {
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
}
if (hideHp && !character) {
// Rounded up, so anything still standing shows a sliver of bar.
out.hpPercent = p.maxHp > 0 ? Math.ceil((clamp(p.currentHp, 0, p.maxHp) / p.maxHp) * 100) : null;
out.currentHp = null;
out.maxHp = null;
}
return out;
});
const { dmNotes, rewards, ...scene } = encounter;
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
return {
...scene,
locationId: knowsPlace ? scene.locationId : null,
location: knowsPlace ? location : null,
...(isDM ? { dmNotes } : {}),
participants,
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
@@ -154,15 +201,31 @@ function emitCharacterUpdated(req, characterId) {
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('character:updated', { characterId });
}
// While an encounter with a location is in progress, the characters in it
// are at that location: starting it, moving it, or adding someone to it puts
// them there, and that goes on their record of places visited.
async function syncCharacterLocations(req) {
const encounter = await prisma.encounter.findUnique({
where: { id: req.encounter.id },
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
});
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
for (const character of elsewhere) emitCharacterUpdated(req, character.id);
}
async function respondWithEncounter(req, res, status = 200, extra = {}) {
const encounter = await serializeEncounter(req.encounter.id, req.isDM);
await syncCharacterLocations(req);
const encounter = await serializeEncounter(req.encounter.id, req.isDM, req.user.id);
emitEncounterUpdated(req);
res.status(status).json({ ...extra, encounter });
}
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
try {
res.json({ encounter: await serializeEncounter(req.encounter.id, req.isDM) });
res.json({ encounter: await serializeEncounter(req.encounter.id, req.isDM, req.user.id) });
} catch (err) {
next(err);
}
@@ -174,6 +237,7 @@ const patchEncounterSchema = z.object({
name: z.string().min(1).max(120).optional(),
description: z.string().max(4000).nullable().optional(),
dmNotes: z.string().max(4000).nullable().optional(),
locationId: z.string().nullable().optional(),
archived: z.boolean().optional(), // put away (true) or bring back (false)
});
@@ -185,6 +249,11 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
if (archived === true && !req.encounter.archivedAt) data.archivedAt = new Date();
if (archived === false) data.archivedAt = null;
if (data.locationId) {
const location = await prisma.location.findFirst({ where: { id: data.locationId, campaignId: req.encounter.campaignId } });
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
// Starting a battle puts the first combatant in the initiative order on turn.
const mode = data.mode ?? req.encounter.mode;
if (data.status === 'ACTIVE' && mode === 'BATTLE' && !req.encounter.activeParticipantId) {
@@ -221,7 +290,7 @@ const participantSchema = z
monsterId: z.string().optional(),
npcId: z.string().optional(),
displayName: z.string().min(1).max(80).optional(),
initiative: z.number().int().default(0),
initiativeRoll: initiativeRollSchema.optional(),
})
.refine((data) => [data.characterId, data.monsterId, data.npcId].filter(Boolean).length === 1, {
message: 'Provide exactly one of characterId, monsterId or npcId',
@@ -231,9 +300,10 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
try {
const parsed = participantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, monsterId, npcId, displayName, initiative } = parsed.data;
const { characterId, monsterId, npcId, displayName, initiativeRoll } = parsed.data;
let currentHp, maxHp, resolvedName;
let bonus = 0;
if (characterId) {
const character = await prisma.character.findUnique({
@@ -246,6 +316,7 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
currentHp = character.currentHp;
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
resolvedName = displayName || character.name;
bonus = initiativeBonus({ character });
} else if (monsterId) {
const monster = await prisma.monster.findUnique({ where: { id: monsterId } });
if (!monster || monster.campaignId !== req.encounter.campaignId) {
@@ -254,6 +325,7 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
currentHp = monster.maxHp;
maxHp = monster.maxHp;
resolvedName = displayName || monster.name;
bonus = initiativeBonus({ monster });
} else {
const npc = await prisma.npc.findUnique({ where: { id: npcId } });
if (!npc || npc.campaignId !== req.encounter.campaignId) {
@@ -272,7 +344,8 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
monsterId: monsterId || null,
npcId: npcId || null,
displayName: resolvedName,
initiative,
initiativeRoll: initiativeRoll ?? null,
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
currentHp,
maxHp,
isMonster: Boolean(monsterId),
@@ -287,34 +360,49 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
const updateParticipantSchema = z.object({
currentHp: z.number().int().optional(),
maxHp: z.number().int().min(1).optional(),
initiative: z.number().int().optional(),
maxHp: z.number().int().min(1).max(100000).optional(),
initiativeRoll: initiativeRollSchema.nullable().optional(), // null clears the roll
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
displayName: z.string().min(1).max(80).optional(),
});
router.patch('/:id/participants/:participantId', requireEncounterAccess, requireDM, async (req, res, next) => {
// The DM runs every combatant. A player may do one thing here: enter the
// initiative roll for their own character.
router.patch('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
try {
const parsed = updateParticipantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const existing = await prisma.encounterParticipant.findUnique({
where: { id: req.params.participantId },
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } } },
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } }, monster: true },
});
if (!existing || existing.encounterId !== req.encounter.id) {
return res.status(404).json({ error: 'Combatant not found in this encounter' });
}
if (!req.isDM) {
const onlyTheRoll = Object.keys(parsed.data).every((key) => key === 'initiativeRoll');
const ownCharacter = existing.character && existing.character.userId === req.user.id;
if (!onlyTheRoll || !ownCharacter) {
return res.status(403).json({ error: 'You can only enter the initiative roll for your own character' });
}
}
const data = { ...parsed.data };
// The roll is what was on the die; the bonus is added here.
if (data.initiativeRoll !== undefined) {
data.initiative = data.initiativeRoll == null ? 0 : data.initiativeRoll + initiativeBonus(existing);
}
if (existing.character) {
// Max HP comes from the sheet, and damage/healing lands on the character.
const { character } = existing;
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
data.maxHp = maxHp;
if (data.currentHp !== undefined) {
data.currentHp = clamp(data.currentHp, 0, maxHp);
await prisma.character.update({ where: { id: character.id }, data: { currentHp: data.currentHp } });
// Temp HP soaks damage first; what is left lands on the character.
const after = absorbDamage(character.currentHp, character.tempHp, data.currentHp);
data.currentHp = clamp(after.currentHp, 0, maxHp);
await prisma.character.update({ where: { id: character.id }, data: { currentHp: data.currentHp, tempHp: after.tempHp } });
emitCharacterUpdated(req, character.id);
}
} else if (data.currentHp !== undefined) {
@@ -389,11 +477,12 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
},
});
await syncCharacterLocations(req);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
encounterId: updated.id,
currentRound: updated.currentRound,
});
res.json({ encounter: await serializeEncounter(updated.id, req.isDM) });
res.json({ encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
} catch (err) {
next(err);
}
@@ -404,18 +493,18 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
// ---------------------------------------------------------------------------
const rewardSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1), note: z.string().trim().max(200).optional().nullable() }),
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1).max(1000000), note: z.string().trim().max(200).optional().nullable() }),
z.object({
type: z.literal('WEAPON'),
weaponId: z.string(),
quantity: z.number().int().min(1).default(1),
quantity: z.number().int().min(1).max(999).default(1),
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
note: z.string().trim().max(200).optional().nullable(),
}),
z.object({
type: z.literal('ITEM'),
itemId: z.string(),
quantity: z.number().int().min(1).default(1),
quantity: z.number().int().min(1).max(999).default(1),
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
note: z.string().trim().max(200).optional().nullable(),
}),
+8 -2
View File
@@ -1,8 +1,11 @@
const jwt = require('jsonwebtoken');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
// 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).
// Events only ever carry ids: each client refetches its own view, since what
// the DM, an owner and another player may see of the same thing differs.
function attachSockets(io) {
io.use((socket, next) => {
const token = socket.handshake.auth && socket.handshake.auth.token;
@@ -17,8 +20,11 @@ function attachSockets(io) {
});
io.on('connection', (socket) => {
socket.on('campaign:join', (campaignId) => {
if (typeof campaignId === 'string') socket.join(`campaign:${campaignId}`);
socket.on('campaign:join', async (campaignId) => {
if (typeof campaignId !== 'string') return;
// Members only: a room's events are not for anyone who knows the id.
const membership = await loadCampaignMembership(socket.user.id, campaignId).catch(() => null);
if (membership) socket.join(`campaign:${campaignId}`);
});
socket.on('campaign:leave', (campaignId) => {
if (typeof campaignId === 'string') socket.leave(`campaign:${campaignId}`);
+12
View File
@@ -0,0 +1,12 @@
// Temporary hit points are a buffer: damage comes off them first, and only
// what is left over reaches real HP. Takes the character's HP and temp HP as
// they are and the HP a damage/heal asked for (which may be below zero), and
// returns what both should become. Healing never touches temp HP.
function absorbDamage(currentHp, tempHp, requestedHp) {
const damage = currentHp - requestedHp;
if (damage <= 0 || tempHp <= 0) return { currentHp: requestedHp, tempHp };
const absorbed = Math.min(tempHp, damage);
return { currentHp: requestedHp + absorbed, tempHp: tempHp - absorbed };
}
module.exports = { absorbDamage };
+30
View File
@@ -0,0 +1,30 @@
// Where characters are, and where they have been. A visit is recorded every
// time a character is put somewhere and is never removed by moving on: the
// set of visited locations is what a player is allowed to see. Every function
// takes a Prisma client or transaction client as `db`.
async function recordVisits(db, characterIds, locationId) {
if (characterIds.length === 0) return;
await db.characterLocationVisit.createMany({
data: characterIds.map((characterId) => ({ characterId, locationId })),
skipDuplicates: true,
});
}
async function moveCharacters(db, characterIds, locationId) {
if (characterIds.length === 0) return;
await db.character.updateMany({ where: { id: { in: characterIds } }, data: { currentLocationId: locationId } });
await recordVisits(db, characterIds, locationId);
}
// Ids of the locations any of this user's characters in the campaign has been to.
async function visitedLocationIds(db, userId, campaignId) {
const visits = await db.characterLocationVisit.findMany({
where: { character: { userId, campaignId } },
select: { locationId: true },
distinct: ['locationId'],
});
return new Set(visits.map((v) => v.locationId));
}
module.exports = { recordVisits, moveCharacters, visitedLocationIds };