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
+1
View File
@@ -6,3 +6,4 @@ build/
*.log
.DS_Store
.idea/
temp/
+9
View File
@@ -71,6 +71,15 @@ already has an active "Aldenfell"; archive or delete that one in the app
`SEED_ADMIN_PASSWORD` and `SEED_ADMIN_NAME` set the account it creates.
`npx prisma db seed -- --reset` wipes every user and campaign first.
A second campaign, Hollowcrown: The Rift Between Worlds, has its own seed and
is not part of the default one. It follows the same rules (it only adds, and
skips if the admin already has an active Hollowcrown):
```bash
cd backend
npm run seed:hollowcrown
```
On a server, apply new migrations with `npx prisma migrate deploy`. Change
your password from the account page (click your name in the top bar).
+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 };
+2 -2
View File
@@ -552,7 +552,7 @@ character in the same campaign.
| Appearance | Everyone in the campaign | Built from picks; afterwards the DM only |
| Special abilities | Everyone in the campaign (sheet and quick stats) | Built from picks; afterwards the DM only |
| Private notes | **The owner only — not even the DM** | Owner only |
| Current location | Everyone in the campaign | DM picks it on the sheet, from the campaign's locations |
| Current location | The owner and the DM. Another player sees it in the party list only if their own character has been to that place | DM picks it on the sheet; an encounter in progress with a location also moves its characters there |
**Character notes** are a separate, running log on the sheet:
@@ -677,7 +677,7 @@ Another Campaign" card on its sheet.
| Level a weapon or item up or down | No | Yes | No |
| Record a gold gain or spend | Yes | Yes | No |
| Give gold, weapons, items or spells to another character | Yes | Yes | No |
| View the money log | Yes | Yes | Yes |
| View the money log, gold and inventory | Yes | Yes | No |
| Change level, XP, location or speed override | No | Yes | No |
| Post a character note | Yes | Yes | Yes |
| Copy to another campaign | Yes | Yes | No |
+36 -9
View File
@@ -30,7 +30,11 @@ This describes the code as it stands. The rules live in
- An encounter is one **scene** the DM runs: a fight, or a meeting with
people who want something.
- Encounters belong to a campaign and are listed on the campaign page. The DM
creates them; every member can open them.
creates them and sees all of them.
- A player only sees an encounter that **one of their own characters is in**.
Until the DM adds their character it is not in their list and its page will
not open, so scenes the DM is still preparing stay hidden, name included.
Taking their character out hides it again.
- The encounter page updates live for everyone who has it open.
- The DM runs everything. Players watch, and see less than the DM does.
@@ -44,7 +48,7 @@ time from the top of the page.
| For | A fight | A scene built around people and an objective |
| Layout | Everyone in one list, in initiative order | Three groups: "People you meet", "The party", "Creatures" |
| Rounds and turns | Yes | No |
| Initiative | Typed in per combatant | Not shown |
| Initiative | The d20 roll, typed in per combatant; the bonus is added | Not shown |
| Start button | Begin Battle | Begin Quest |
| End button | End Battle | Complete Quest |
@@ -63,6 +67,17 @@ Status is a label. Nothing is locked when an encounter is complete: the DM
can still change HP, add people and award rewards. Encounters cannot be
renamed from the screen.
### Location
The DM can give an encounter a location from the campaign's list, with the
picker at the top of the page. While the encounter is **in progress** its
characters are at that location: beginning it, changing its location, or
adding a character to it moves them there. Each place a character is moved to
(this way or by the DM on the sheet) is remembered, and a player can see only
the locations one of their own characters has been to — on the campaign page
under "Places You've Been", and on the encounter itself. Archiving or deleting
the encounter does not take a visited place away again.
### Order, archive and delete
**Order.** The campaign page lists encounters in the **campaign order**, which
@@ -73,8 +88,8 @@ anyone else. ▲ and ▼ only show while sorting by campaign order.
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
away without changing it. It moves to the DM's "Archived" section and
disappears from the players' list; anyone who already has its page open or
bookmarked can still open it. "Restore" puts it back on the list.
disappears from the players' list; a player whose character is in it can still
open its page from a bookmark. "Restore" puts it back on the list.
**Delete.** "Delete" (on the encounter page, or on an archived encounter in
the list) removes the scene, who was in it and its reward list, after a
@@ -130,6 +145,10 @@ The DM types an amount and clicks Damage or Heal on a card.
| NPC | In the encounter. An NPC with no HP set has no health bar. |
- HP always stays between 0 and max.
- **What players see of it.** A character's HP is always shown in numbers. For
monsters and NPCs it depends on the campaign setting "Players see the exact
hit points of monsters and NPCs": on (the default) shows the numbers, off
shows only the health bar. The DM always sees the numbers.
- At 0 HP a combatant is marked **Down** and dimmed. It keeps its place in
the order; nothing is removed automatically.
- A character's temporary HP is shown beside its HP but is not used up
@@ -139,9 +158,16 @@ The DM types an amount and clicks Damage or Heal on a card.
Battles only.
- The DM types each combatant's **initiative** when adding them, and can
change it on the card. The app does not roll it. The list is ordered
highest first; ties keep the order they were added in.
- Each combatant has an **initiative roll** field. Type what the d20 showed,
1 to 20; the field accepts nothing else. The app does not roll the die, but
it adds the bonus for you and shows the total: a character's initiative
bonus from their sheet, a monster's Dexterity modifier, nothing for an NPC.
- The DM can enter the roll for anyone, when adding them or on the card. A
player can enter the roll for **their own character** on its card.
- The list is ordered by the total, highest first; ties keep the order they
were added in. Anyone who has not rolled yet waits at the bottom.
- The bonus is worked out when the roll is entered. If a character's sheet
changes afterwards, enter the roll again to pick up the new bonus.
- **Begin Battle** puts the first combatant on turn. Their card is
highlighted and the header names them.
- **Next Turn** passes the turn to the next in the order. After the last
@@ -210,12 +236,13 @@ information cannot be dug out of the page.
| Action | DM | Players |
|---|---|---|
| Create an encounter | Yes | No |
| Open an encounter | Yes | Yes |
| See and open an encounter | Yes, all of them | Only ones their own character is in |
| Change mode, status, scene or DM notes | Yes | No |
| Rearrange, archive, restore or delete encounters | Yes | No |
| See archived encounters in the list | Yes | No |
| Add or remove anyone | Yes | No |
| Change HP, initiative or conditions | Yes | No |
| Change HP or conditions | Yes | No |
| Enter an initiative roll | Yes, for anyone | For their own character only |
| Advance turns and rounds | Yes | No |
| Add, remove or award rewards | Yes | No |
+1 -1
View File
@@ -132,7 +132,7 @@ character's inventory, stacking with any they already hold. See
| Action | DM | Owning player | Other players |
|---|---|---|---|
| See the item pool | Yes | Yes | Yes |
| See the item pool | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
| Add, change or remove pool items | Yes | No | No |
| Give an item from the pool, or take one away | Yes | No | No |
| Give a held item to another character | Yes | Yes | No |
+2 -1
View File
@@ -187,7 +187,8 @@ spells arrive unprepared.
| Action | DM | Owning player | Other players |
|---|---|---|---|
| See the spell list and the library | Yes | Yes | Yes |
| See the spell list | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
| See the built-in library | Yes | No | No |
| Add, change or remove spells in the list | Yes | No | No |
| Grant a spell from the list, or remove one | Yes | No | No |
| Give a known spell to another character | Yes | Yes | No |
+2 -1
View File
@@ -196,7 +196,8 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
| Action | DM | Owning player | Other players |
|---|---|---|---|
| See the pool and the library | Yes | Yes | Yes |
| See the pool | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
| See the built-in library | Yes | No | No |
| Add, change or remove pool entries | Yes | No | No |
| Give a weapon from the pool, or take one away | Yes | No | No |
| Give a held weapon to another character | Yes | Yes | No |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+13 -7
View File
@@ -373,10 +373,11 @@ export function useRemoveCharacterSpell(characterId: string) {
// Money tracker
// --------------------------------------------------------------------------
export function useGoldLedger(characterId: string | undefined) {
// Only the character's own player and the DM may read it.
export function useGoldLedger(characterId: string | undefined, enabled = true) {
return useQuery({
queryKey: ['character', characterId, 'gold'],
enabled: Boolean(characterId),
enabled: Boolean(characterId) && enabled,
queryFn: async () =>
(await apiClient.get<{ gold: number; transactions: GoldTransaction[] }>(`/characters/${characterId}/gold`)).data
.transactions,
@@ -514,10 +515,10 @@ export function useDeleteMonster(campaignId: string) {
// Default content library — search/browse, "add to campaign"
// --------------------------------------------------------------------------
export function useCampaignLibrary(campaignId: string | undefined) {
export function useCampaignLibrary(campaignId: string | undefined, enabled = true) {
return useQuery({
queryKey: ['campaign', campaignId, 'library'],
enabled: Boolean(campaignId),
enabled: Boolean(campaignId) && enabled, // DM only
staleTime: Infinity, // static reference data — never changes under a campaign
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
});
@@ -660,7 +661,7 @@ export function useEncounter(encounterId: string | undefined) {
export function useAddParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiative?: number }) =>
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
@@ -701,11 +702,16 @@ export function usePatchEncounter(encounterId: string) {
name?: string;
description?: string | null;
dmNotes?: string | null;
locationId?: string | null;
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
// The campaign page lists encounters by name, mode and status.
if (variables.status || variables.mode || variables.name) qc.invalidateQueries({ queryKey: ['campaign'] });
// The campaign page lists encounters by name, mode and status, and an
// encounter in progress carries its characters to its location.
if (variables.status || variables.mode || variables.name || variables.locationId !== undefined) {
qc.invalidateQueries({ queryKey: ['campaign'] });
qc.invalidateQueries({ queryKey: ['character'] });
}
},
});
}
+20 -6
View File
@@ -87,11 +87,12 @@ export interface Campaign {
id: string;
name: string;
description: string | null;
inviteCode: string;
inviteCode?: string; // DM only
startingGold: number;
startingItemCount: number;
startingSpellCount: number;
skipMotivationRoll: boolean;
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
@@ -117,7 +118,7 @@ export interface CharacterSummary {
buildStage: BuildStage;
currentHp: number;
tempHp: number;
gold: number;
gold: number | null; // null for another player's character
maxHp: number | null;
armorClass: number | null;
currentLocationId: string | null;
@@ -276,7 +277,10 @@ export interface CharacterDetail {
diceMode: DiceMode;
currentLocationId: string | null;
currentLocation: Location | null;
gold: number;
gold: number | null;
// True when this is another player's character: no gold, inventory or
// location is sent, only what the party can see across the table.
restricted: boolean;
buildStage: BuildStage;
buildState: Record<string, unknown> | null;
skillProficiencies: SkillProficiency[];
@@ -390,14 +394,21 @@ export interface EncounterParticipant {
characterId: string | null;
monsterId: string | null;
displayName: string;
// The d20 rolled for initiative (1–20), null until rolled. `initiative` is
// that roll plus the combatant's bonus, and is what the order sorts on.
initiativeRoll: number | null;
initiative: number;
currentHp: number;
maxHp: number;
initiativeBonus?: number; // a monster's or NPC's is sent to the DM only
// Null for a player when the campaign hides monster hit points; hpPercent
// (0–100) is sent in their place to draw the health bar.
currentHp: number | null;
maxHp: number | null;
hpPercent?: number | null;
conditions: string[];
isMonster: boolean;
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
npcId: string | null;
character?: { id: string; name: string } | null;
character?: { id: string; name: string; userId: string | null } | null;
// The DM gets the whole stat block; players only the id and name.
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
npc?: (NpcPublic & Partial<Npc>) | null;
@@ -414,6 +425,9 @@ export interface Encounter {
activeParticipantId: string | null;
description: string | null;
dmNotes?: string | null; // DM only
// Where the scene is. A player is only told once their character has been there.
locationId: string | null;
location?: Pick<Location, 'id' | 'name' | 'description'> | null;
archivedAt: string | null; // archived encounters only reach the DM's list
sortOrder: number; // place in the campaign's own order, lowest first
createdAt: string;
+24
View File
@@ -0,0 +1,24 @@
import { hpBarClass } from '../lib/format';
// A health bar with temporary hit points on it. Temp HP sits on top of real
// HP, so it is drawn as its own blue segment after the HP fill, and the bar
// stretches to fit when the two together come to more than the maximum (full
// health plus a buffer). The fill's colour still reflects real HP only: temp
// HP doesn't make a bloodied character look healthy.
export function HpBar({ current, max, temp = 0, height, className = 'mt-sm' }: {
current: number;
max: number;
temp?: number;
height?: number;
className?: string;
}) {
const hp = Math.max(0, Math.min(current, max));
const buffer = Math.max(0, temp);
const scale = Math.max(max, hp + buffer, 1);
return (
<div className={`hp-bar-track ${className}`} style={height ? { height } : undefined}>
<div className={hpBarClass(hp, max)} style={{ width: `${(hp / scale) * 100}%` }} />
{buffer > 0 && <div className="hp-bar-fill hp-temp" style={{ width: `${(buffer / scale) * 100}%` }} title="Temporary HP" />}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { ChangeEvent, FocusEvent, InputHTMLAttributes } from 'react';
type NumberInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'min' | 'max' | 'step'> & {
min?: number;
max?: number;
// For a field that always holds a number (a level, a bonus): if it is left
// empty, it goes back to its starting value instead of being saved blank.
keepValue?: boolean;
};
// A whole-number field that cannot hold a value outside its limits. A plain
// <input type="number" max={20}> only limits the arrow buttons; typing 500
// into it works. This one corrects the text as it is typed: anything above
// `max` becomes `max`, anything below `min` becomes `min`, decimals are
// dropped. Works the same whether the field is controlled (`value`) or not
// (`defaultValue`): the handlers passed in see the corrected value.
export function NumberInput({ min, max, keepValue, onChange, onBlur, ...rest }: NumberInputProps) {
function limit(input: HTMLInputElement, leaving: boolean) {
const raw = input.value;
if (raw === '' || raw === '-') {
// Nothing typed yet, or the start of a negative number.
if (leaving) input.value = keepValue ? String(rest.defaultValue ?? rest.value ?? min ?? 0) : '';
return;
}
let value = Math.trunc(Number(raw));
if (Number.isNaN(value)) {
input.value = '';
return;
}
if (max !== undefined && value > max) value = max;
// While typing, a value below a minimum above 1 may just be unfinished
// ("1" on the way to "15"), so that case waits until the field is left.
if (min !== undefined && value < min && (leaving || min <= 1)) value = min;
if (String(value) !== raw) input.value = String(value);
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
limit(e.target, false);
onChange?.(e);
}
function handleBlur(e: FocusEvent<HTMLInputElement>) {
limit(e.target, true);
onBlur?.(e);
}
return <input {...rest} type="number" inputMode="numeric" step={1} min={min} max={max} onChange={handleChange} onBlur={handleBlur} />;
}
+3 -4
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useQuickStats } from '../api/queries';
import { Spinner, ErrorBanner } from './Spinner';
import { fmtMod, hpBarClass } from '../lib/format';
import { fmtMod } from '../lib/format';
import { HpBar } from './HpBar';
export function QuickStatsFab({ characterId }: { characterId: string }) {
const [open, setOpen] = useState(false);
@@ -50,9 +51,7 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
<span className="stat-value">{fmtMod(data.initiative)}</span>
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(data.currentHp, data.maxHp)} style={{ width: `${Math.max(0, Math.min(100, (data.currentHp / data.maxHp) * 100))}%` }} />
</div>
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
{data.spellcasting && (
<div className="card mt-md">
+4
View File
@@ -29,12 +29,16 @@ export function useCampaignSocket(campaignId: string | undefined) {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet HP
// A character arriving somewhere new adds it to the places their player can see.
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] });
};
const onCharacterCreated = () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
};
const onParticipantUpdated = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
// A player's list only holds the encounters their characters are in.
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
};
const onRoundAdvanced = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
+5
View File
@@ -450,6 +450,7 @@ input[type='radio'] {
}
.hp-bar-track {
display: flex;
width: 100%;
height: 10px;
background: var(--bg-inset);
@@ -470,6 +471,10 @@ input[type='radio'] {
.hp-bar-fill.hp-critical {
background: var(--danger-strong);
}
/* Temporary HP: a separate segment after the real HP. */
.hp-bar-fill.hp-temp {
background: var(--arcane-strong);
}
.divider {
border: none;
+32 -11
View File
@@ -40,6 +40,7 @@ import {
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { usePagination, Pagination } from '../components/Paginated';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
@@ -293,7 +294,7 @@ export function CampaignHomePage() {
const { data: npcs } = useCampaignNpcs(campaignId, data?.campaign.role === 'DM');
const { data: notes } = useCampaignNotes(campaignId);
const { data: locations } = useCampaignLocations(campaignId);
const { data: library } = useCampaignLibrary(campaignId);
const { data: library } = useCampaignLibrary(campaignId, data?.campaign.role === 'DM');
const createCharacter = useCreateCharacter(campaignId);
const deleteCharacter = useDeleteCharacter(campaignId);
@@ -486,27 +487,32 @@ export function CampaignHomePage() {
startingItemCount: Number(fd.get('startingItemCount')),
startingSpellCount: Number(fd.get('startingSpellCount')),
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
showMonsterHp: fd.get('showMonsterHp') === 'on',
});
}}
>
<div className="grid grid-cols-3">
<div className="field">
<label>Starting gold</label>
<input name="startingGold" type="number" min={0} defaultValue={campaign.startingGold} />
<NumberInput name="startingGold" min={0} max={1000000} keepValue defaultValue={campaign.startingGold} />
</div>
<div className="field">
<label>Starting items (weapons + items)</label>
<input name="startingItemCount" type="number" min={0} defaultValue={campaign.startingItemCount} />
<NumberInput name="startingItemCount" min={0} max={50} keepValue defaultValue={campaign.startingItemCount} />
</div>
<div className="field">
<label>Starting spells</label>
<input name="startingSpellCount" type="number" min={0} defaultValue={campaign.startingSpellCount} />
<NumberInput name="startingSpellCount" min={0} max={50} keepValue defaultValue={campaign.startingSpellCount} />
</div>
</div>
<label className="checkbox-row">
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
Skip the motivation step when building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings
</button>
@@ -750,6 +756,18 @@ export function CampaignHomePage() {
)}
</div>
{!isDM && locations && locations.length > 0 && (
<div className="mt-lg card">
<div className="card-title">Places You've Been</div>
{locations.map((loc) => (
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<strong>📍 {loc.name}</strong>
{loc.description && <p className="subtitle" style={{ margin: '0.2rem 0 0', whiteSpace: 'pre-wrap' }}>{loc.description}</p>}
</div>
))}
</div>
)}
{isDM && (
<div className="mt-lg card">
<div className="card-title">
@@ -800,9 +818,9 @@ export function CampaignHomePage() {
<textarea name="description" rows={2} className="textarea-block mt-sm" placeholder="What the party sees and knows (shown to players)" />
<textarea name="dmNotes" rows={2} className="textarea-block mt-sm" placeholder="Secrets and motives (only you see this)" />
<div className="grid grid-cols-3 mt-sm">
<input name="ac" type="number" min={0} placeholder="AC (optional)" />
<input name="maxHp" type="number" min={1} placeholder="HP (optional)" />
<input name="attackBonus" type="number" placeholder="Attack bonus (optional)" />
<NumberInput name="ac" min={0} max={50} placeholder="AC (optional)" />
<NumberInput name="maxHp" min={1} max={100000} placeholder="HP (optional)" />
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus (optional)" />
<input name="damageDice" placeholder="Damage (optional, e.g. 1d6)" maxLength={20} />
<button type="submit" className="btn btn-primary" disabled={createNpc.isPending}>Add NPC</button>
</div>
@@ -838,7 +856,10 @@ export function CampaignHomePage() {
<div className="card-title">
<span>Locations</span>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>Track where each character currently is.</p>
<p className="subtitle" style={{ marginTop: 0 }}>
Track where each character currently is. Give an encounter a location and its characters move there when it
begins. Players only see the places their own characters have been.
</p>
<form onSubmit={handleCreateLocation} className="flex-row flex-wrap">
<input
placeholder="Location name (e.g. The Rusty Tankard)"
@@ -1200,9 +1221,9 @@ export function CampaignHomePage() {
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="ac" type="number" placeholder="AC" required />
<input name="maxHp" type="number" placeholder="Max HP" required />
<input name="attackBonus" type="number" placeholder="Attack bonus" />
<NumberInput name="ac" min={0} max={50} placeholder="AC" required />
<NumberInput name="maxHp" min={1} max={100000} placeholder="Max HP" required />
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus" />
<input name="damageDice" placeholder="Damage dice" />
<button type="submit" className="btn btn-primary">Add</button>
</form>
+3 -4
View File
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { InfoTip } from '../components/InfoTip';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { SPECIES_ABILITY_BONUSES, abilityModifier, speciesBonusFor } from '../lib/speciesData';
@@ -898,8 +899,7 @@ function RollControl({
<>
<p>{label} Roll it at the table, then type the result in below.</p>
<div className="flex-row" style={{ justifyContent: 'center' }}>
<input
type="number"
<NumberInput
min={1}
max={sides}
placeholder={`1–${sides}`}
@@ -1091,9 +1091,8 @@ function PhysicalAbilityRollForm({ onRoll, rolling }: { onRoll: (body?: { abilit
<div key={i} className="flex-row flex-wrap mt-sm">
<span className="badge">Set {i + 1}</span>
{set.map((v, j) => (
<input
<NumberInput
key={j}
type="number"
min={1}
max={6}
value={v}
+38 -20
View File
@@ -30,10 +30,12 @@ import {
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { InfoTip } from '../components/InfoTip';
import { usePagination, Pagination } from '../components/Paginated';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, hpBarClass, extractErrorMessage, spellLevelLabel } from '../lib/format';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, extractErrorMessage, spellLevelLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import { CLASS_HIT_DIE } from '../lib/classData';
@@ -91,10 +93,11 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
const [kind, id] = what.split(':');
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
const max = kind === 'gold' ? character.gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
const gold = character.gold ?? 0;
const max = kind === 'gold' ? gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
const count = Math.floor(Number(amount));
const countOk = kind === 'spell' || (count >= 1 && count <= max);
const nothingToGive = character.gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
const nothingToGive = gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
function handleGive() {
const gift: Gift | null =
@@ -142,7 +145,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
style={{ flex: 2, minWidth: 160 }}
>
<option value="">What to give…</option>
{character.gold > 0 && <option value="gold:">Gold ({character.gold} gp)</option>}
{gold > 0 && <option value="gold:">Gold ({gold} gp)</option>}
{character.weapons.length > 0 && (
<optgroup label="Weapons">
{character.weapons.map((w) => (
@@ -172,8 +175,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
)}
</select>
{what && kind !== 'spell' && (
<input
type="number"
<NumberInput
min={1}
max={max}
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
@@ -212,7 +214,7 @@ export function CharacterSheetPage() {
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
const { data: campaignItems } = useCampaignItems(character?.campaignId);
const { data: goldLedger } = useGoldLedger(characterId);
const { data: goldLedger } = useGoldLedger(characterId, Boolean(character) && !character?.restricted);
const { data: locations } = useCampaignLocations(character?.campaignId);
const { data: notes } = useCharacterNotes(characterId);
@@ -412,15 +414,15 @@ export function CharacterSheetPage() {
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(character.currentHp, sheet.hp.max)} style={{ width: `${Math.max(0, Math.min(100, (character.currentHp / sheet.hp.max) * 100))}%` }} />
</div>
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<input
type="number"
<NumberInput
min={1}
max={9999}
placeholder="Amount"
aria-label="HP change"
value={hpDelta}
onChange={(e) => setHpDelta(e.target.value)}
style={{ width: 100 }}
@@ -433,9 +435,11 @@ export function CharacterSheetPage() {
</button>
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
Temp HP <InfoTip term="TEMP_HP" />
<input
type="number"
<NumberInput
key={`temp:${character.tempHp}`}
min={0}
max={9999}
keepValue
defaultValue={character.tempHp}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
@@ -443,9 +447,11 @@ export function CharacterSheetPage() {
</label>
<label className="flex-row gap-sm">
Armor Bonus <InfoTip term="ARMOR_BONUS" />
<input
type="number"
<NumberInput
key={`armor:${character.armorBonus}`}
min={0}
max={30}
keepValue
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
@@ -458,10 +464,11 @@ export function CharacterSheetPage() {
<div className="flex-row mt-sm flex-wrap">
<label className="flex-row gap-sm">
Level
<input
type="number"
<NumberInput
key={`level:${character.level}`}
min={1}
max={20}
keepValue
defaultValue={character.level}
style={{ width: 60 }}
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
@@ -671,6 +678,15 @@ export function CharacterSheetPage() {
</div>
)}
{character.restricted && (
<p className="subtitle mt-md">
This is another player's character. You can see what the party sees across the table; what they carry and how
much gold they have is theirs to share.
</p>
)}
{!character.restricted && (
<>
<div className="card mt-md">
<div className="card-title">Inventory</div>
{itemsPage.pageItems.map((held) => (
@@ -758,9 +774,9 @@ export function CharacterSheetPage() {
</div>
{canEdit && (
<div className="flex-row flex-wrap">
<input
type="number"
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Gold amount"
value={goldAmount}
@@ -801,6 +817,8 @@ export function CharacterSheetPage() {
</div>
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
</div>
</>
)}
{/* Composed by the character builder from the player's picks. Players
read them; only the DM can rewrite them (new abilities on level-up,
+68 -30
View File
@@ -8,6 +8,7 @@ import {
useCampaignNpcs,
useCampaignWeapons,
useCampaignItems,
useCampaignLocations,
useAddParticipant,
usePatchParticipant,
useRemoveParticipant,
@@ -22,8 +23,11 @@ import {
type NewReward,
} from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { useAuth } from '../auth/AuthContext';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { ABILITY_LABELS, CLASS_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, hpBarClass, spellLevelLabel } from '../lib/format';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { ABILITY_LABELS, CLASS_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type {
Ability,
@@ -93,6 +97,7 @@ function CombatantCard({
mode,
isActive,
onPatch,
canRollInitiative,
onRemove,
}: {
participant: EncounterParticipant;
@@ -100,6 +105,8 @@ function CombatantCard({
mode: EncounterMode;
isActive: boolean;
onPatch: (data: Partial<EncounterParticipant>) => void;
// The DM for anyone; a player for their own character.
canRollInitiative: boolean;
onRemove: () => void;
}) {
const [hpDelta, setHpDelta] = useState('');
@@ -112,9 +119,13 @@ function CombatantCard({
const info = p.characterCombatInfo;
const monster = p.monster;
const npc = p.npc;
const tracksHp = p.maxHp > 0;
const isDown = tracksHp && p.currentHp <= 0;
const pct = tracksHp ? Math.max(0, Math.min(100, (p.currentHp / p.maxHp) * 100)) : 0;
// A player may get a percentage in place of a monster's or NPC's numbers.
const currentHp = p.currentHp ?? 0;
const maxHp = p.maxHp ?? 0;
const barOnly = p.maxHp == null && p.hpPercent != null;
const tracksHp = barOnly || maxHp > 0;
const pct = barOnly ? (p.hpPercent ?? 0) : tracksHp ? Math.max(0, Math.min(100, (currentHp / maxHp) * 100)) : 0;
const isDown = tracksHp && pct <= 0;
// Players get a monster's name and health bar only; the stat block is the DM's.
const monsterStats = monster && monster.ac !== undefined ? monster : null;
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
@@ -122,7 +133,7 @@ function CombatantCard({
function applyDelta(sign: 1 | -1) {
const amount = Number(hpDelta);
if (!amount) return;
onPatch({ currentHp: p.currentHp + sign * amount });
onPatch({ currentHp: currentHp + sign * amount });
setHpDelta('');
}
@@ -152,26 +163,36 @@ function CombatantCard({
<div className="combatant-sub">{subline(p)}</div>
</div>
{mode === 'BATTLE' &&
(isDM ? (
<label className="flex-row gap-sm">
Initiative
<input
key={`${p.id}:${p.initiative}`}
type="number"
defaultValue={p.initiative}
(canRollInitiative ? (
<label className="flex-row gap-sm" title="Type what the d20 showed (1 to 20). The initiative bonus is added for you.">
Initiative roll
<NumberInput
key={`${p.id}:${p.initiativeRoll}`}
min={1}
max={20}
placeholder="d20"
aria-label={`Initiative roll for ${p.displayName}`}
defaultValue={p.initiativeRoll ?? ''}
style={{ width: 64 }}
onBlur={(e) => Number(e.target.value) !== p.initiative && onPatch({ initiative: Number(e.target.value) })}
onBlur={(e) => {
const roll = e.target.value === '' ? null : Number(e.target.value);
if (roll !== p.initiativeRoll) onPatch({ initiativeRoll: roll });
}}
/>
<span className="mono">
{p.initiativeBonus !== undefined && fmtMod(p.initiativeBonus)}
{p.initiativeRoll != null && ` = ${p.initiative}`}
</span>
</label>
) : (
<StatChip label="Initiative">{p.initiative}</StatChip>
<StatChip label="Initiative">{p.initiativeRoll == null ? '—' : p.initiative}</StatChip>
))}
</div>
<div className="stat-chips mt-sm">
{tracksHp && (
{tracksHp && !barOnly && (
<StatChip label="HP">
{p.currentHp}/{p.maxHp}
{currentHp}/{maxHp}
{info && info.tempHp > 0 && ` +${info.tempHp}`}
</StatChip>
)}
@@ -203,14 +224,12 @@ function CombatantCard({
{tracksHp && (
<>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(p.currentHp, p.maxHp)} style={{ width: `${pct}%` }} />
</div>
<HpBar current={pct} max={100} temp={info && maxHp > 0 ? (info.tempHp / maxHp) * 100 : 0} />
{isDM && (
<div className="flex-row gap-sm mt-sm">
<input
type="number"
<NumberInput
min={1}
max={99999}
placeholder="Amount"
aria-label={`HP change for ${p.displayName}`}
value={hpDelta}
@@ -505,9 +524,9 @@ function RewardsPanel({
<option value="ITEM">Item</option>
</select>
{type === 'GOLD' ? (
<input
type="number"
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Gold amount"
value={gold}
@@ -542,9 +561,9 @@ function RewardsPanel({
</option>
))}
</select>
<input
type="number"
<NumberInput
min={1}
max={999}
aria-label="Quantity"
title="Quantity"
value={quantity}
@@ -577,6 +596,7 @@ function RewardsPanel({
export function EncounterPage() {
const { encounterId = '' } = useParams();
const { user } = useAuth();
const { data: encounter, isLoading, error } = useEncounter(encounterId);
useCampaignSocket(encounter?.campaignId);
@@ -587,6 +607,7 @@ export function EncounterPage() {
const { data: npcs } = useCampaignNpcs(encounter?.campaignId, isDM);
const { data: weapons } = useCampaignWeapons(encounter?.campaignId);
const { data: items } = useCampaignItems(encounter?.campaignId);
const { data: locations } = useCampaignLocations(encounter?.campaignId);
const addParticipant = useAddParticipant(encounterId);
const patchParticipant = usePatchParticipant(encounterId);
@@ -624,7 +645,7 @@ export function EncounterPage() {
if (!pickId) return;
setFormError(null);
addParticipant.mutate(
{ [`${pickType}Id`]: pickId, initiative: Number(initiative) || 0 },
{ [`${pickType}Id`]: pickId, initiativeRoll: initiative ? Number(initiative) : undefined },
{ onError, onSuccess: () => setPickId('') }
);
}
@@ -637,6 +658,7 @@ export function EncounterPage() {
mode={encounter.mode}
isActive={p.id === active?.id}
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
onRemove={() => removeParticipant.mutate(p.id, fail)}
/>
);
@@ -661,6 +683,7 @@ export function EncounterPage() {
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
<span className={`badge ${encounter.status === 'ACTIVE' ? 'badge-live' : ''}`}>{STATUS_LABELS[encounter.status]}</span>
{isBattle && <span className="badge">Round {encounter.currentRound}</span>}
{encounter.location && <span className="badge">📍 {encounter.location.name}</span>}
{encounter.archivedAt && <span className="badge">Archived</span>}
{active && <span className="turn-marker">▶ {active.displayName}'s turn</span>}
</div>
@@ -679,6 +702,19 @@ export function EncounterPage() {
<option value="BATTLE">Battle</option>
<option value="QUEST">NPC quest</option>
</select>
<select
aria-label="Location"
title="Where this scene takes place. Its characters move here when it begins."
value={encounter.locationId ?? ''}
onChange={(e) => patchEncounter.mutate({ locationId: e.target.value || null }, fail)}
>
<option value="">No location</option>
{locations?.map((loc) => (
<option key={loc.id} value={loc.id}>
📍 {loc.name}
</option>
))}
</select>
{encounter.status === 'PLANNING' && (
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
{isBattle ? 'Begin Battle' : 'Begin Quest'}
@@ -804,10 +840,12 @@ export function EncounterPage() {
</div>
<div className="flex-row gap-sm mt-sm">
{isBattle && (
<input
type="number"
placeholder="Initiative"
aria-label="Initiative"
<NumberInput
min={1}
max={20}
placeholder="Initiative roll (d20)"
title="What the d20 showed, 1 to 20. Leave empty to roll later; the bonus is added for you."
aria-label="Initiative roll"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
+8 -9
View File
@@ -5,7 +5,8 @@ import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { usePagination, Pagination } from '../components/Paginated';
import { InfoTip } from '../components/InfoTip';
import { CLASS_LABELS, SPECIES_LABELS, hpBarClass } from '../lib/format';
import { CLASS_LABELS, SPECIES_LABELS } from '../lib/format';
import { HpBar } from '../components/HpBar';
import { speciesIcon } from '../lib/speciesIcons';
// A DM's one-screen view of every character's stats at a glance; a player
@@ -77,20 +78,18 @@ export function PartyOverviewPage() {
<td>
{c.maxHp != null ? (
<div style={{ minWidth: 90 }}>
<span className="mono">{c.currentHp}/{c.maxHp}</span>
<div className="hp-bar-track mt-sm" style={{ height: 6 }}>
<div
className={hpBarClass(c.currentHp, c.maxHp)}
style={{ width: `${Math.max(0, Math.min(100, (c.currentHp / c.maxHp) * 100))}%` }}
/>
</div>
<span className="mono">
{c.currentHp}/{c.maxHp}
{c.tempHp > 0 && ` +${c.tempHp}`}
</span>
<HpBar current={c.currentHp} max={c.maxHp} temp={c.tempHp} height={6} />
</div>
) : (
'—'
)}
</td>
<td>{c.armorClass ?? '—'}</td>
<td className="mono">{c.gold}</td>
<td className="mono">{c.gold ?? '—'}</td>
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
</tr>
);
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.3.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.0.0",
"version": "1.3.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.0.0",
"version": "1.3.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.3.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [
@@ -10,6 +10,7 @@
"scripts": {
"dev:backend": "npm run dev --workspace backend",
"dev:frontend": "npm run dev --workspace frontend",
"test:backend": "npm run test --workspace backend"
"test:backend": "npm run test --workspace backend",
"seed:hollowcrown": "npm run seed:hollowcrown --workspace backend"
}
}