Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
932e755895 | ||
|
|
2637a510df | ||
|
|
28e1a84b64 |
@@ -6,3 +6,4 @@ build/
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
temp/
|
||||
@@ -44,7 +44,7 @@ npm install # installs both workspaces (backend + frontend)
|
||||
cd backend
|
||||
cp .env.example .env # edit DATABASE_URL and JWT_SECRET
|
||||
npx prisma migrate dev --name init
|
||||
npx prisma db seed # creates a demo campaign + demo users (see output for login/invite code)
|
||||
npx prisma db seed # adds the admin account + the Aldenfell campaign if missing (see output for login/invite code)
|
||||
|
||||
# Frontend
|
||||
cd ../frontend
|
||||
@@ -60,10 +60,29 @@ npm run dev:backend # Express + Socket.io on http://localhost:4000
|
||||
npm run dev:frontend # Vite dev server, prints its own port (5173 by default)
|
||||
```
|
||||
|
||||
Open the frontend URL, register an account (or log in with the seeded demo
|
||||
users — see the seed script's console output for credentials and invite
|
||||
Open the frontend URL, register an account (or log in with the seeded admin
|
||||
account — see the seed script's console output for credentials and invite
|
||||
code), and start playing.
|
||||
|
||||
The seed only adds: it never touches an existing account or campaign, so it is
|
||||
safe to run on a server with real data. It skips the campaign if the admin
|
||||
already has an active "Aldenfell"; archive or delete that one in the app
|
||||
(Campaign Settings) and run it again for a fresh copy. `SEED_ADMIN_EMAIL`,
|
||||
`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).
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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,2 @@
|
||||
-- AlterTable: a campaign can be put away without deleting it
|
||||
ALTER TABLE "campaigns" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- AlterTable: an encounter can be put away, and has a place in the campaign's own order
|
||||
ALTER TABLE "encounters" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "encounters" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Existing encounters keep the order the list has always shown: newest first.
|
||||
UPDATE "encounters" AS e
|
||||
SET "sortOrder" = r.pos
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY "campaignId" ORDER BY "createdAt" DESC, id) - 1 AS pos
|
||||
FROM "encounters"
|
||||
) AS r
|
||||
WHERE e.id = r.id;
|
||||
@@ -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;
|
||||
@@ -158,7 +158,12 @@ 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.
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -185,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
|
||||
@@ -268,6 +291,7 @@ model Character {
|
||||
spells CharacterSpell[]
|
||||
notes Note[]
|
||||
encounterEntries EncounterParticipant[]
|
||||
locationVisits CharacterLocationVisit[]
|
||||
|
||||
@@map("characters")
|
||||
}
|
||||
@@ -494,10 +518,19 @@ 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?
|
||||
// Place in the campaign's own order, lowest first. The DM rearranges it.
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
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[]
|
||||
|
||||
@@ -540,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
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
+25
-103
@@ -1,110 +1,32 @@
|
||||
// Resets the database and seeds the Aldenfell campaign (see
|
||||
// docs/Aldenfell - Full Campaign Book.docx and seed-data/aldenfell.js).
|
||||
// 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.
|
||||
//
|
||||
// DESTRUCTIVE: every user, campaign, character, note, etc. is deleted first.
|
||||
// npx prisma db seed add what is missing
|
||||
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
|
||||
// character, note, etc. first
|
||||
//
|
||||
// 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 weapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
|
||||
const aldenfell = require(path.join(__dirname, '..', 'seed-data', 'aldenfell.js'));
|
||||
|
||||
const ADMIN_EMAIL = 'joshua@crosscode.co.za';
|
||||
const ADMIN_PASSWORD = '12345678';
|
||||
|
||||
async function resetDatabase() {
|
||||
// Truncating the two roots cascades through every table that references them.
|
||||
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await resetDatabase();
|
||||
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email: ADMIN_EMAIL,
|
||||
passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12),
|
||||
displayName: 'Joshua',
|
||||
},
|
||||
});
|
||||
|
||||
const campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
...aldenfell.campaign,
|
||||
inviteCode: generateInviteCode(),
|
||||
ownerId: admin.id,
|
||||
members: { create: { userId: admin.id, role: 'DM' } },
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.weapon.createMany({ data: weapons.map((w) => ({ ...w, isStartingOption: true, campaignId: campaign.id })) });
|
||||
|
||||
await prisma.location.createMany({
|
||||
data: aldenfell.locations.map((l) => ({ ...l, campaignId: campaign.id })),
|
||||
});
|
||||
|
||||
const monsterByName = {};
|
||||
for (const monster of aldenfell.monsters) {
|
||||
const created = await prisma.monster.create({ data: { ...monster, campaignId: campaign.id } });
|
||||
monsterByName[created.name] = created;
|
||||
}
|
||||
|
||||
for (const enc of aldenfell.encounters) {
|
||||
const participants = [];
|
||||
for (const [name, count] of enc.monsters) {
|
||||
const monster = monsterByName[name];
|
||||
if (!monster) throw new Error(`Encounter "${enc.name}" references unknown monster "${name}"`);
|
||||
for (let i = 1; i <= count; i++) {
|
||||
participants.push({
|
||||
monsterId: monster.id,
|
||||
displayName: count > 1 ? `${monster.name} #${i}` : monster.name,
|
||||
currentHp: monster.maxHp,
|
||||
maxHp: monster.maxHp,
|
||||
isMonster: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.encounter.create({
|
||||
data: { campaignId: campaign.id, name: enc.name, participants: { create: participants } },
|
||||
});
|
||||
}
|
||||
|
||||
// Notes list newest-first, so stamp them in reverse to keep book order on screen.
|
||||
const allNotes = [
|
||||
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
|
||||
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
|
||||
];
|
||||
const now = Date.now();
|
||||
await prisma.note.createMany({
|
||||
data: allNotes.map((n, i) => ({
|
||||
...n,
|
||||
campaignId: campaign.id,
|
||||
authorId: admin.id,
|
||||
createdAt: new Date(now - i * 1000),
|
||||
})),
|
||||
});
|
||||
|
||||
console.log('Seed complete.');
|
||||
console.log(` Admin/DM login: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
|
||||
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
|
||||
console.log(
|
||||
` ${weapons.length} weapons, ${aldenfell.locations.length} locations, ${aldenfell.monsters.length} monsters, ` +
|
||||
`${aldenfell.encounters.length} encounters, ${allNotes.length} notes`,
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
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 })),
|
||||
],
|
||||
},
|
||||
{ reset: process.argv.includes('--reset') },
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
+409
-20
@@ -1,6 +1,8 @@
|
||||
// Aldenfell campaign content, transcribed (and condensed where a note would
|
||||
// otherwise be unreadable) from docs/Aldenfell - Full Campaign Book.docx.
|
||||
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids.
|
||||
// Aldenfell campaign content: the gazetteer, bestiary and notes are condensed
|
||||
// from the Aldenfell Campaign Book; the gear, item pool, NPC list and scenes
|
||||
// are built out from it to use everything a campaign can hold.
|
||||
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids, except
|
||||
// where a section's comment says otherwise.
|
||||
|
||||
const campaign = {
|
||||
name: 'Aldenfell',
|
||||
@@ -129,28 +131,415 @@ const monsters = [
|
||||
specialTrait: 'Undead — vulnerable to bludgeoning',
|
||||
notes: 'Ordinary restless dead for the Sunken Vaults’ Restless Ossuary (Room 4). Not tied to the Hollow King; DC 12 Religion identifies that proper deconsecration would settle them.',
|
||||
},
|
||||
{
|
||||
name: 'Feral Guard Dog', challengeRating: '1/8', ac: 12, maxHp: 5, speed: 40,
|
||||
strScore: 13, dexScore: 14, conScore: 12, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+1',
|
||||
specialTrait: 'Keen hearing and smell — advantage on Perception checks that rely on them.',
|
||||
notes: 'Medium beast, 25 XP. Bite +3 (1d6+1 piercing, DC 11 Str or prone). A chapel mastiff gone half-wild in the Sunken Vaults’ Old Crypt Hall (Room 2). Starving, not vicious — DC 12 Animal Handling or a thrown ration ends the fight.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encounters — pre-built (PLANNING) for the combat beats of the Campaign Book.
|
||||
// Each participant is [monster name, count]; instances get "#n" suffixes.
|
||||
// Weapons the party earns. The everyday weapons (seed-data/weapons.json) are
|
||||
// seeded as starting options; these are not, so they only reach a character as
|
||||
// a reward or a gift. damageDice is the damage at minLevel, and a character
|
||||
// must be at least a held copy's level to use it. damageScaling: 1 standard,
|
||||
// 2 powerful, 3 legendary.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const weapons = [
|
||||
{ name: 'Masterwork Dagger', damageDice: '1d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'thrown'], rangeNormal: 20, rangeLong: 60, minLevel: 2, damageScaling: 1 },
|
||||
{ name: 'Smuggler’s Cutlass', damageDice: '1d8', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse'], rangeNormal: null, rangeLong: null, minLevel: 2, damageScaling: 1 },
|
||||
{ name: 'Warden’s Longbow', damageDice: '1d10', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'two-handed'], rangeNormal: 150, rangeLong: 600, minLevel: 2, damageScaling: 2 },
|
||||
{ name: 'Grey Dawn Longsword', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d12)'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 2 },
|
||||
{ name: 'Greyford Halberd', damageDice: '1d12', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 1 },
|
||||
{ name: 'Stonejaw Maul', damageDice: '2d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 4, damageScaling: 2 },
|
||||
{ name: 'Threndir’s Fang', damageDice: '1d8', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null, minLevel: 5, damageScaling: 2 },
|
||||
{ name: 'Hollowbane', damageDice: '2d8', damageType: 'radiant', attackAbility: 'FINESSE', properties: ['finesse', 'versatile (2d10)'], rangeNormal: null, rangeLong: null, minLevel: 7, damageScaling: 3 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item pool. Items are descriptive: holding one never changes a number on the
|
||||
// sheet. `starting: true` offers the item in the character builder.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const items = [
|
||||
// Starting kit
|
||||
{ name: 'Potion of Healing', description: 'A red liquid that glimmers when shaken. Drink it (an action) to regain the hit points shown.', damageDice: '2d4+2', starting: true },
|
||||
{ name: 'Traveller’s Pack', description: 'Bedroll, mess kit, tinderbox, waterskin and five days of rations.', starting: true },
|
||||
{ name: 'Rope (50 ft)', description: 'Hempen rope. Holds a climber; DC 17 Strength to burst.', starting: true },
|
||||
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', starting: true },
|
||||
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', starting: true },
|
||||
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', starting: true },
|
||||
{ name: 'Shield', description: 'A wooden or steel shield, +2 Armor Class while carried. Ask the DM to add it to your armor bonus.', starting: true },
|
||||
{ name: 'Leather Armor', description: 'Boiled leather, Armor Class 11 + Dexterity. Ask the DM to set your armor bonus.', starting: true },
|
||||
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', starting: true },
|
||||
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', starting: true },
|
||||
|
||||
// Found and earned
|
||||
{ name: 'Potion of Greater Healing', description: 'A deeper red than the common potion. Drink it (an action) to regain the hit points shown.', damageDice: '4d4+4', minLevel: 3 },
|
||||
{ name: 'Alchemist’s Fire', description: 'A sticky flask that ignites in air. Throw it up to 20 feet: the target burns for the damage shown at the start of each of its turns until someone spends an action putting it out.', damageDice: '1d4' },
|
||||
{ name: 'Silver Locket', description: 'A tarnished locket from the Old Crypt Hall, worth about 15 gp. The portrait inside has been scratched out.' },
|
||||
{ name: 'Smugglers’ Ledger Page', description: 'A torn page from a crate in the Sunken Vaults, stamped with a merchant sigil nobody at the Concord Exchange recognises. Half a name survives in the margin.' },
|
||||
{ name: 'Bell-Ringer’s Keepsake', description: 'A worn brass bell-clapper charm, pressed into your hand with more gratitude than its owner could put into words.' },
|
||||
{ name: 'Grey Dawn Signet', description: 'An iron ring bearing the Order’s rising sun. Shown at Grey Dawn Hall or to any patrol, it marks you as someone the Order vouches for.' },
|
||||
{ name: 'Concord Writ of Passage', description: 'A sealed Merchant Concord contract. Waives tolls and gate fees across Aldenfell while the Concord still considers you good for business.' },
|
||||
{ name: 'Warden’s Charm', description: 'A knot of greywood bark and red thread. The Wardens will know you by it, and the forest’s paths seem a little easier to find while you carry it.' },
|
||||
{ name: 'Stonejaw Oath-Token', description: 'A river stone the size of a fist, carved with the Stonejaw clan mark. Proof that your word was tested and held.' },
|
||||
{ name: 'Half-Burned Letter', description: 'Found among a missing person’s belongings in the Root. Most of it is ash. What is left mentions payments, a shrine, and a family crest.' },
|
||||
{ name: 'Silver Shrine Relic', description: 'A small silver figure lifted from black water beneath the Greywood, worth about 75 gp. Its style is older than the kingdom.' },
|
||||
{ name: 'Sealed Letter', description: 'Heavy paper, an unbroken wax seal, and a servant’s whispered warning about who must never know you have it.' },
|
||||
{ name: 'Root-Fiber Scrap', description: 'A length of pale root that is still, very slightly, moving. It is cold, and it does not rot.' },
|
||||
{ name: 'Seal-Stone Fragment', description: 'A shard of worked stone from the edge of the breach. Script runs along one face. It is warm, and holding it makes the bell script easier to look at.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spells, pulled from the default library (src/game/data/spellsLibrary.js) by
|
||||
// key. A new character can only pick starting spells of their own level or
|
||||
// lower, so the starting set is cantrips and 1st level; the rest are for the
|
||||
// DM to teach later.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const spells = {
|
||||
starting: [
|
||||
'chill-touch', 'eldritch-blast', 'fire-bolt', 'guidance', 'light', 'mage-hand', 'minor-illusion',
|
||||
'prestidigitation', 'produce-flame', 'ray-of-frost', 'sacred-flame', 'shocking-grasp', 'spare-the-dying',
|
||||
'thorn-whip', 'vicious-mockery',
|
||||
'bless', 'burning-hands', 'charm-person', 'command', 'cure-wounds', 'detect-magic', 'entangle', 'faerie-fire',
|
||||
'guiding-bolt', 'healing-word', 'mage-armor', 'magic-missile', 'shield', 'sleep', 'thunderwave',
|
||||
],
|
||||
earned: [
|
||||
'hold-person', 'invisibility', 'lesser-restoration', 'misty-step', 'moonbeam', 'scorching-ray', 'shatter',
|
||||
'spiritual-weapon',
|
||||
'counterspell', 'daylight', 'dispel-magic', 'fireball', 'remove-curse', 'revivify',
|
||||
'banishment',
|
||||
],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NPCs. `description` is what the party sees once the NPC is in a scene;
|
||||
// `dmNotes` never leaves the DM's screen. Combat stats only where a fight is
|
||||
// plausible.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const npcs = [
|
||||
{
|
||||
name: 'King Aldric Veyrmont', role: 'King of Aldenfell', disposition: 'NEUTRAL',
|
||||
description: 'An aging king with a soldier’s posture and tired eyes. Courteous, proud, and quick to steer talk away from anything that sounds like bad news.',
|
||||
dmNotes: 'Tired, proud, in denial — cornered, not ill. Pays well for quiet problem-solving and reacts badly to blunt truth. An audience needs a sponsor (a knight, a noble, a Concord representative). The crown fractures publicly at Stage 3.',
|
||||
},
|
||||
{
|
||||
name: 'Princess Isolde Veyrmont', role: 'Heir to the throne', disposition: 'FRIENDLY',
|
||||
description: 'Sharp, pragmatic and well liked. She keeps a small private study at the palace and has a habit of asking capable outsiders exactly the right question.',
|
||||
dmNotes: 'The best long-term ally, and she recruits outsiders on purpose. Already suspects something is deeply wrong, so she is the most likely to believe the party early. Reporting to her at E10B sets up Act 3’s politics. Rumour says she will move against her father — misleading.',
|
||||
},
|
||||
{
|
||||
name: 'Ser Kavan Thorne', role: 'Knight-Commander of the Grey Dawn', disposition: 'FRIENDLY',
|
||||
description: 'Rigid, honorable and loyal to a fault. He runs Grey Dawn Hall by the book and judges people by whether they do what they said they would.',
|
||||
dmNotes: 'Recruits the party (E4A → Grey Dawn track). SECRET: the Order’s founding is tied to the original sacrifice, and Thorne does not know. He struggles when evidence points at the crown; whether and how the party tells him is theirs to choose. If Ressa Vane escorted them (E9C) he already suspects by E10B.',
|
||||
ac: 18, maxHp: 65, attackBonus: 6, damageDice: '1d8+4',
|
||||
},
|
||||
{
|
||||
name: 'Mireille Costa', role: 'Merchant Concord representative', disposition: 'NEUTRAL',
|
||||
description: 'Charming and transactional, usually found at the Concord Exchange with a contract already drafted. She always has a job, and she always pays what she promised.',
|
||||
dmNotes: 'Source of paying side work (E4B → The Smugglers’ Tithe). The Concord is neutral and pragmatic, and drops anyone who becomes bad for business — including the party.',
|
||||
},
|
||||
{
|
||||
name: 'Ashwen', role: 'Warden of the Greywood', disposition: 'NEUTRAL',
|
||||
description: 'Terse and watchful, with a longbow that never seems to be out of reach. She answers to the forest, not to the crown.',
|
||||
dmNotes: 'Suspicious, not hostile — a slow-burn ally. The first to notice the old shrines acting strangely (E4C → The Warden’s Silence). After that quest she is convinced something is deeply wrong, whatever happened to her Warden.',
|
||||
ac: 15, maxHp: 44, attackBonus: 6, damageDice: '1d8+3',
|
||||
},
|
||||
{
|
||||
name: 'Threndir Ashclaw', role: 'Old copper dragon of the Spine', disposition: 'NEUTRAL',
|
||||
description: 'Vast, scarred and weary, coiled in an eyrie high on the eastern peaks. He grants an audience to anyone who approaches openly, and he prefers words to fire.',
|
||||
dmNotes: 'Blunt, and wants the kingdom to survive. He wants proof the crown can still be trusted — the intended “win” is a negotiation (Persuasion DC 15–18). Always offers to talk unless attacked first. If it does come to a fight, use the “Threndir Ashclaw (old copper dragon)” monster stat block.',
|
||||
},
|
||||
{
|
||||
name: 'Korgath Stonejaw', role: 'Chieftain of the Stonejaw clan', disposition: 'NEUTRAL',
|
||||
description: 'A hill giant chieftain with a voice like a rockslide and no patience for politics. Outsiders in his hold are tested, not attacked.',
|
||||
dmNotes: 'Honor-bound: trials, contests and bargains, never ambushes. Offers the Trial of Stone instead of an agreement; success is a standing alliance (Spine Clans track), failure earns a second, harder trial. Nothing sinister here — it is a genuine test of character.',
|
||||
ac: 13, maxHp: 105, attackBonus: 8, damageDice: '3d8+5',
|
||||
},
|
||||
{
|
||||
name: 'Vessa Hollow', role: 'A voice in the dark', disposition: 'NEUTRAL',
|
||||
description: 'A woman’s voice — calm, patient, almost kind. She does not blink. She speaks as though she has been waiting a very long time and can wait a little longer.',
|
||||
dmNotes: 'SECRET: one of the people Highcrown’s founders sacrificed, and the Hollow King’s sympathetic “face”. She believes waking the rest is justice. Unsettling, never mustache-twirling; DC 16 Insight confirms she believes what she says. Use her in dreams and omens long before she appears. Negotiating with her is a legitimate outcome. She does not fight — any combat near her uses Hollowed guardians.',
|
||||
},
|
||||
{
|
||||
name: 'Corporal Ressa Vane', role: 'Grey Dawn warden escort', disposition: 'FRIENDLY',
|
||||
description: 'A steady, plain-spoken corporal of the Grey Dawn, assigned to see you safely through the tunnels. She writes things down.',
|
||||
dmNotes: 'Only appears if the party took Grey Dawn recruitment (E4A → E9C). She lets them skip to Root Room 2, and she reports everything back to Grey Dawn Hall — which matters at E10B and E15.',
|
||||
ac: 16, maxHp: 27, attackBonus: 4, damageDice: '1d8+2',
|
||||
},
|
||||
{
|
||||
name: 'The Bell-Ringer', role: 'Bell-ringer of the Founding Chapel', disposition: 'FRIENDLY',
|
||||
description: 'A stooped, soft-spoken man who has rung the chapel bell every hour for most of his life. Badly shaken, and very grateful to see a friendly face.',
|
||||
dmNotes: 'Found alive in Vault Room 5 (E3). He saw “someone who looked like his neighbor, except wrong”. Returns in The Bell-Ringer’s Debt: he owes a Highcrown lender who operates under the corrupted house’s protection (not knowable yet). A loyal low-key ally either way.',
|
||||
},
|
||||
{
|
||||
name: 'A Frightened Local', role: 'Witness outside the chapel', disposition: 'NEUTRAL',
|
||||
description: 'A market porter who was passing the chapel when the bell stopped. Pale, talking too fast, and not keen to go back inside.',
|
||||
dmNotes: 'DC 12 Persuasion or Insight gets the account of “a woman who wasn’t blinking”. If the party talks to the crowd first (E2C) they get three witness accounts but no script lead, and their E10 Insight check is at advantage.',
|
||||
},
|
||||
{
|
||||
name: 'The Lender', role: 'Highcrown moneylender', disposition: 'HOSTILE',
|
||||
description: 'A neat, unhurried man who keeps his ledgers tidier than his conscience. He never raises his voice; he has collectors for that.',
|
||||
dmNotes: 'Holds the bell-ringer’s debt. Operates under the corrupted noble house’s protection — digging into who backs him is harder but plants a clue toward the house before Act 2. Can be paid off, intimidated, or exposed.',
|
||||
ac: 12, maxHp: 22, attackBonus: 3, damageDice: '1d4+1',
|
||||
},
|
||||
{
|
||||
name: 'Greyford’s Commander', role: 'Commander of Greyford Keep', disposition: 'NEUTRAL',
|
||||
description: 'An exhausted officer holding a border fortress with half the troops and a third of the supplies it needs. Blunt, overworked, and out of patience.',
|
||||
dmNotes: 'Wants the Sorrow Camp “moved along” — and is not wrong about the strategic liability. No clean villain in Refugees of the Marches. Whether Greyford is supplied (The Smugglers’ Tithe outcome) decides rescue vs recovery at E11 and whether the keep holds at E16.',
|
||||
ac: 17, maxHp: 52, attackBonus: 5, damageDice: '1d8+3',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
// 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.
|
||||
// npcs — NPC names.
|
||||
// rewards — lined up unawarded, so they stay the DM's secret until
|
||||
// handed out. { gold } | { weapon } | { item }, with optional
|
||||
// quantity, level (defaults to the entry's starting level) and
|
||||
// note.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const encounters = [
|
||||
{ name: 'E3 — Sunken Vaults: The Restless Ossuary', monsters: [['Skeleton', 2]] },
|
||||
{ name: 'E6 — The Bell-Ringer’s Debt: Collectors', monsters: [['Highcrown Cutthroat', 3]] },
|
||||
{ name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]] },
|
||||
{ name: 'Side Quest — The Warden’s Silence: Direwolf Pack', monsters: [['Greywood Direwolf', 3]] },
|
||||
{ name: 'Side Quest — The Warden’s Silence: The Shrine', monsters: [['Hollowed', 1]] },
|
||||
{ name: 'Side Quest — What the Tide Brought In: The Reef', monsters: [['Reef Terror', 1]] },
|
||||
{ name: 'E9 — The Root: Weeping Passage', monsters: [['Bellcrawler', 1]] },
|
||||
{ name: 'E9 — The Root: Hollowed Nest', monsters: [['Hollowed', 2]] },
|
||||
{ name: 'E9 — The Root: Root Stalker’s Warren', monsters: [['Root Stalker', 1]] },
|
||||
{ name: 'E9 — The Root: Bellcrawler Gallery', monsters: [['Bellcrawler', 3]] },
|
||||
{ name: 'E12 — A Line Thins', monsters: [['Root Stalker', 1]] },
|
||||
{ name: 'E14 — The Break', monsters: [['Hollowed', 4]] },
|
||||
{ name: 'Mid-campaign scare — The Hollow King’s Shadow', monsters: [['The Hollow King’s Shadow', 1]] },
|
||||
{ name: 'E25 — The Last Approach', monsters: [['Root Stalker', 1], ['Bellcrawler', 2]] },
|
||||
{
|
||||
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', 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', 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', 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', 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'],
|
||||
rewards: [
|
||||
{ gold: 40, note: 'Room 3 — trade goods from the smugglers’ cache' },
|
||||
{ item: 'Smugglers’ Ledger Page', note: 'Room 3 — DC 14 Investigation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E4 — Aftermath & Faction Hooks', mode: 'QUEST',
|
||||
description: 'Word travels fast in Highcrown. By the next morning three different people have sent for you, and each of them has work.',
|
||||
dmNotes: 'All open doors; they can run in parallel.\n• E4A Grey Dawn recruitment (Kavan) → Grey Dawn ending track; unlocks Ressa Vane at E9C.\n• E4B Concord job board (Mireille) → The Smugglers’ Tithe.\n• E4C Warden contact (Ashwen) → The Warden’s Silence.\n• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.\n• E4E Investigate independently (DC 15 Investigation) → Independent track.\nAfter two or more threads and one E5 omen, advance the clock to Stage 2 (E8).',
|
||||
npcs: ['Ser Kavan Thorne', 'Mireille Costa', 'Ashwen'],
|
||||
rewards: [
|
||||
{ item: 'Grey Dawn Signet', note: 'E4A — if they accept recruitment' },
|
||||
{ item: 'Concord Writ of Passage', note: 'E4B — on taking Mireille’s contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E6 — The Bell-Ringer’s Debt: Collectors', mode: 'BATTLE',
|
||||
description: 'Three hard-faced men are waiting outside the bell-ringer’s door when you arrive. They are not here to hear him ring.',
|
||||
dmNotes: 'Optional side quest, Lv 1–2. The collectors are ordinary desperate people: kill them, scare them off, or find out why they are desperate. Beats: hear the bell-ringer out → track the collectors → pay the debt, intimidate the lender, or dig into who backs him (harder, more rewarding — a clue toward the corrupted house before Act 2).',
|
||||
monsters: [['Highcrown Cutthroat', 3]],
|
||||
npcs: ['The Bell-Ringer', 'The Lender'],
|
||||
rewards: [
|
||||
{ gold: 30, note: 'The collectors’ take for the week' },
|
||||
{ item: 'Bell-Ringer’s Keepsake', note: 'Given once the debt is dealt with' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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', 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]],
|
||||
rewards: [
|
||||
{ gold: 60, note: 'The tithe chest' },
|
||||
{ weapon: 'Smuggler’s Cutlass', note: 'The Captain’s own blade' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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', 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']],
|
||||
rewards: [
|
||||
{ item: 'Warden’s Charm', note: 'From Ashwen, whatever the outcome' },
|
||||
{ weapon: 'Warden’s Longbow', note: 'The missing Warden’s bow — Ashwen says it should be used' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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']],
|
||||
npcs: ['Korgath Stonejaw'],
|
||||
rewards: [
|
||||
{ item: 'Stonejaw Oath-Token', note: 'On passing the trial' },
|
||||
{ weapon: 'Stonejaw Maul', note: 'Feast-gift for whoever wrestled the champion' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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]],
|
||||
npcs: ['Greyford’s Commander'],
|
||||
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', 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]],
|
||||
rewards: [
|
||||
{ gold: 80, note: 'Tidewatch’s collection for whoever cleared the reef' },
|
||||
{ item: 'Potion of Healing', quantity: 2, note: 'From the harbour apothecary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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', 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', 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]],
|
||||
rewards: [
|
||||
{ gold: 20, note: 'Personal effects' },
|
||||
{ item: 'Half-Burned Letter', note: 'DC 14 Investigation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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]],
|
||||
rewards: [
|
||||
{ gold: 60, note: 'From what the roots grew around' },
|
||||
{ weapon: 'Masterwork Dagger', note: 'Still in a dead hand' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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]],
|
||||
rewards: [
|
||||
{ gold: 90, note: 'Scattered along the ledges' },
|
||||
{ item: 'Potion of Healing', note: 'In a fallen pack' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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', 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'],
|
||||
rewards: [
|
||||
{ gold: 70, note: 'Trade goods in the keep’s stores (2d6×10 gp)' },
|
||||
{ weapon: 'Greyford Halberd', note: 'From the armoury' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E12 — A Line Thins', mode: 'BATTLE',
|
||||
description: 'The ground splits somewhere you thought was safe, and something comes up through it that should have stayed far below.',
|
||||
dmNotes: 'A Root Stalker surfaces near a location the party did not secure — pick it from what they left undone. DC 15 Arcana/Religion: this is deliberate erosion from below. The root-fiber scrap is a key item (needed at E14/E18/E19). Then E13: the clock turns to Stage 3.',
|
||||
monsters: [['Root Stalker', 1]],
|
||||
rewards: [{ item: 'Root-Fiber Scrap', note: 'Key item — cut from the Stalker' }],
|
||||
},
|
||||
{
|
||||
name: 'E14 — The Break', mode: 'BATTLE',
|
||||
description: 'The crack has become a breach. People are coming out of it — neighbours, guards, people you have met — walking calmly, smiling, and not blinking.',
|
||||
dmNotes: 'Two to four Hollowed (scale to the party). DC 15 Arcana: the breach is shaped for something to come through.\n• E14A seal it (DC 17 Arcana or the chapel ritual): closes, but a debt comes due at E17.\n• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.\nThe seal-stone fragment is a key item (needed at E19).',
|
||||
monsters: [['Hollowed', 4]],
|
||||
rewards: [{ item: 'Seal-Stone Fragment', note: 'Key item — from the breach’s edge' }],
|
||||
},
|
||||
{
|
||||
name: 'Mid-campaign scare — The Hollow King’s Shadow', mode: 'BATTLE',
|
||||
description: 'The light goes thin and the air goes cold. Something tall is standing where the wall should be, and the wall does not seem to mind.',
|
||||
dmNotes: 'DO NOT run as a fair fight. At 0 HP it disperses for the scene and never dies; treat its HP as a ticking clock. Give the party a way out: close a ritual circle, reach sunlight or a warded threshold, or survive N rounds. Echoing dread: DC 15 Wis or frightened within 30 ft. Save its true, killable form for Act 4.',
|
||||
monsters: [['The Hollow King’s Shadow', 1]],
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
{
|
||||
name: 'E21 — Rally Point', mode: 'QUEST',
|
||||
description: 'One last gathering before the descent. Whoever is going to stand with you is here now — and they do not all agree about what happens next.',
|
||||
dmNotes: 'Who shows up depends on the branch list — remove anyone the party lost or alienated.\n• E21A Grey Dawn track: Kavan and his wardens, by the book.\n• E21B Spine Clans track: Korgath’s war-band; advantage on the first Survival check at E22.\n• E21C Independent: no reinforcements; every choice stays theirs.\n• Several tracks: the groups arrive and disagree — the party mediates.',
|
||||
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne', 'Korgath Stonejaw', 'Ashwen'],
|
||||
rewards: [{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' }],
|
||||
},
|
||||
{
|
||||
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', 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]],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -414,4 +803,4 @@ The Salt Coast: 1 smugglers wary of witnesses · 2 short-handed fishing crew ·
|
||||
The Bramble Marches: 1 Bandit Captain’s crew · 2 refugees on the road · 3 tense border patrol · 4 signs of the neighboring power · 5 a skirmish in progress · 6 watchful quiet.`,
|
||||
];
|
||||
|
||||
module.exports = { campaign, locations, monsters, encounters, sharedNotes, dmNotes };
|
||||
module.exports = { campaign, locations, monsters, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
@@ -1521,3 +1537,429 @@ describe('encounter combat info', () => {
|
||||
expect(typeof participant.characterCombatInfo.weapons[0].attackBonus).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing a password', () => {
|
||||
itIfDb('needs the current password, then only the new one logs in', async () => {
|
||||
const email = uniqueEmail('pw');
|
||||
const registerRes = await request(app).post('/auth/register').send({ email, password: 'password123', displayName: 'Pw' });
|
||||
cleanupUserIds.push(registerRes.body.user.id);
|
||||
const auth = { Authorization: `Bearer ${registerRes.body.token}` };
|
||||
|
||||
expect((await request(app).post('/auth/password').send({ currentPassword: 'password123', newPassword: 'new-password1' })).status).toBe(401);
|
||||
const wrong = await request(app).post('/auth/password').set(auth).send({ currentPassword: 'nope', newPassword: 'new-password1' });
|
||||
expect(wrong.status).toBe(400);
|
||||
const short = await request(app).post('/auth/password').set(auth).send({ currentPassword: 'password123', newPassword: 'short' });
|
||||
expect(short.status).toBe(400);
|
||||
const same = await request(app).post('/auth/password').set(auth).send({ currentPassword: 'password123', newPassword: 'password123' });
|
||||
expect(same.status).toBe(400);
|
||||
|
||||
const changed = await request(app).post('/auth/password').set(auth).send({ currentPassword: 'password123', newPassword: 'new-password1' });
|
||||
expect(changed.status).toBe(204);
|
||||
|
||||
expect((await request(app).post('/auth/login').send({ email, password: 'password123' })).status).toBe(401);
|
||||
expect((await request(app).post('/auth/login').send({ email, password: 'new-password1' })).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiving and deleting a campaign', () => {
|
||||
itIfDb('the DM archives and restores; an archived campaign keeps its contents but takes no new members', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('archive');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
expect(campaign.archivedAt).toBeNull();
|
||||
|
||||
expect((await request(app).patch(c).set(playerAuth).send({ archived: true })).status).toBe(403);
|
||||
|
||||
const archived = await request(app).patch(c).set(dmAuth).send({ archived: true });
|
||||
expect(archived.status).toBe(200);
|
||||
expect(archived.body.campaign.archivedAt).toBeTruthy();
|
||||
|
||||
// Still there for its members, and flagged in their lists.
|
||||
const playerList = await request(app).get('/campaigns').set(playerAuth);
|
||||
expect(playerList.body.campaigns.find((x) => x.id === campaign.id).archivedAt).toBeTruthy();
|
||||
expect((await request(app).get(c).set(playerAuth)).status).toBe(200);
|
||||
|
||||
const outsider = await request(app)
|
||||
.post('/auth/register')
|
||||
.send({ email: uniqueEmail('archive-outsider'), password: 'password123', displayName: 'Outsider' });
|
||||
cleanupUserIds.push(outsider.body.user.id);
|
||||
const outsiderAuth = { Authorization: `Bearer ${outsider.body.token}` };
|
||||
expect((await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(outsiderAuth)).status).toBe(403);
|
||||
|
||||
// Other settings can change without un-archiving it.
|
||||
const renamed = await request(app).patch(c).set(dmAuth).send({ startingGold: 7 });
|
||||
expect(renamed.body.campaign.archivedAt).toBeTruthy();
|
||||
|
||||
const restored = await request(app).patch(c).set(dmAuth).send({ archived: false });
|
||||
expect(restored.body.campaign.archivedAt).toBeNull();
|
||||
expect(restored.body.campaign.startingGold).toBe(7);
|
||||
expect((await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(outsiderAuth)).status).toBe(201);
|
||||
});
|
||||
|
||||
itIfDb('only the owner deletes, and everything in the campaign goes with it', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('delete');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Doomed');
|
||||
const encounterRes = await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Last Stand' });
|
||||
const encounterId = encounterRes.body.encounter.id;
|
||||
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
await request(app).post('/notes').set(dmAuth).send({ campaignId: campaign.id, body: 'Secret', visibility: 'DM_ONLY' });
|
||||
|
||||
expect((await request(app).delete(c).set(playerAuth)).status).toBe(403);
|
||||
expect((await request(app).get(c).set(dmAuth)).status).toBe(200);
|
||||
|
||||
expect((await request(app).delete(c).set(dmAuth)).status).toBe(204);
|
||||
|
||||
expect((await request(app).get(c).set(dmAuth)).status).toBe(404);
|
||||
expect((await request(app).get(`/characters/${heroId}`).set(playerAuth)).status).toBe(404);
|
||||
expect((await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).status).toBe(404);
|
||||
expect(await prisma.note.count({ where: { campaignId: campaign.id } })).toBe(0);
|
||||
const dmList = await request(app).get('/campaigns').set(dmAuth);
|
||||
expect(dmList.body.campaigns.find((x) => x.id === campaign.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ordering, archiving and deleting encounters', () => {
|
||||
itIfDb('the DM arranges the list, archives out of players\' sight, and deletes without undoing rewards', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('encorder');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Keeper');
|
||||
|
||||
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);
|
||||
|
||||
// New encounters go to the top.
|
||||
expect(await names(dmAuth)).toEqual(['Three', 'Two', 'One']);
|
||||
|
||||
const order = (encounterIds, auth = dmAuth) => request(app).put(`${c}/encounters/order`).set(auth).send({ encounterIds });
|
||||
expect((await order([ids.One, ids.Two, ids.Three], playerAuth)).status).toBe(403);
|
||||
expect((await order([ids.One, ids.One])).status).toBe(400);
|
||||
expect((await order([ids.One, 'not-an-encounter'])).status).toBe(400);
|
||||
expect((await order([ids.One, ids.Two, ids.Three])).status).toBe(204);
|
||||
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
|
||||
|
||||
// Archived: the DM still lists it, players don't, and it can still be opened.
|
||||
expect((await request(app).patch(`/encounters/${ids.Two}`).set(playerAuth).send({ archived: true })).status).toBe(403);
|
||||
const archived = await request(app).patch(`/encounters/${ids.Two}`).set(dmAuth).send({ archived: true });
|
||||
expect(archived.body.encounter.archivedAt).toBeTruthy();
|
||||
expect(await names(playerAuth)).toEqual(['One', 'Three']);
|
||||
expect(await names(dmAuth)).toEqual(['One', 'Two', 'Three']);
|
||||
expect((await request(app).get(`/encounters/${ids.Two}`).set(playerAuth)).status).toBe(200);
|
||||
|
||||
const restored = await request(app).patch(`/encounters/${ids.Two}`).set(dmAuth).send({ archived: false });
|
||||
expect(restored.body.encounter.archivedAt).toBeNull();
|
||||
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
|
||||
|
||||
// Deleting takes the scene but leaves what it already gave out.
|
||||
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;
|
||||
|
||||
expect((await request(app).delete(`/encounters/${ids.One}`).set(playerAuth)).status).toBe(403);
|
||||
expect((await request(app).delete(`/encounters/${ids.One}`).set(dmAuth)).status).toBe(204);
|
||||
expect((await request(app).get(`/encounters/${ids.One}`).set(dmAuth)).status).toBe(404);
|
||||
expect(await names(dmAuth)).toEqual(['Two', 'Three']);
|
||||
const after = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
expect(after.gold).toBe(goldBefore);
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,11 @@ const loginSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
function issueToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, email: user.email, displayName: user.displayName },
|
||||
@@ -76,4 +81,28 @@ router.get('/me', requireAuth, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Tokens already issued stay valid until they expire; this only changes what
|
||||
// the next login needs.
|
||||
router.post('/password', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = changePasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) return res.status(400).json({ error: 'Your current password is incorrect' });
|
||||
if (newPassword === currentPassword) {
|
||||
return res.status(400).json({ error: 'Your new password must be different from the current one' });
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: await bcrypt.hash(newPassword, 12) } });
|
||||
return res.status(204).end();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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,12 +17,15 @@ 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,
|
||||
createdAt: campaign.createdAt,
|
||||
};
|
||||
@@ -44,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
|
||||
@@ -84,6 +89,7 @@ router.post('/join/:inviteCode', async (req, res, next) => {
|
||||
try {
|
||||
const campaign = await prisma.campaign.findUnique({ where: { inviteCode: req.params.inviteCode } });
|
||||
if (!campaign) return res.status(404).json({ error: 'Invalid invite code' });
|
||||
if (campaign.archivedAt) return res.status(403).json({ error: 'That campaign is archived and is not taking new members' });
|
||||
|
||||
const existing = await prisma.campaignMember.findUnique({
|
||||
where: { campaignId_userId: { campaignId: campaign.id, userId: req.user.id } },
|
||||
@@ -112,20 +118,41 @@ router.get('/:id', requireCampaignMember(), async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
const updateCampaignSchema = createCampaignSchema.partial();
|
||||
// `archived` puts the campaign away (true) or brings it back (false).
|
||||
const updateCampaignSchema = createCampaignSchema.partial().extend({ archived: z.boolean().optional() });
|
||||
|
||||
router.patch('/:id', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = updateCampaignSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data: parsed.data });
|
||||
const { archived, ...data } = parsed.data;
|
||||
// Re-archiving keeps the original date.
|
||||
if (archived === true && !req.campaign.archivedAt) data.archivedAt = new Date();
|
||||
if (archived === false) data.archivedAt = null;
|
||||
|
||||
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data });
|
||||
res.json({ campaign: campaignSummary(campaign, 'DM') });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Deleting takes everything in the campaign with it — characters, pools,
|
||||
// encounters, notes — so it is the owner's call alone, not any DM's.
|
||||
router.delete('/:id', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
if (req.campaign.ownerId !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Only the campaign owner can delete it' });
|
||||
}
|
||||
await prisma.campaign.delete({ where: { id: req.campaign.id } });
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('campaign:deleted', { campaignId: req.campaign.id });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/invite', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const regenerate = req.body && req.body.regenerate === true;
|
||||
@@ -182,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);
|
||||
}
|
||||
@@ -229,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);
|
||||
@@ -237,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);
|
||||
@@ -327,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);
|
||||
@@ -386,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);
|
||||
@@ -453,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(),
|
||||
@@ -462,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(),
|
||||
@@ -510,9 +565,12 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
|
||||
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const isDM = req.membership.role === 'DM';
|
||||
// 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 },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
// dmNotes never leaves the server for a player.
|
||||
res.json({ encounters: isDM ? encounters : encounters.map(({ dmNotes, ...encounter }) => encounter) });
|
||||
@@ -525,19 +583,53 @@ 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() });
|
||||
const encounter = await prisma.encounter.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
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({
|
||||
data: { ...parsed.data, campaignId: req.campaign.id, sortOrder: (first._min.sortOrder ?? 1) - 1 },
|
||||
});
|
||||
res.status(201).json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const encounterOrderSchema = z.object({ encounterIds: z.array(z.string()).min(1).max(1000) });
|
||||
|
||||
// Sets the campaign's own encounter order: the ids given take positions
|
||||
// 0, 1, 2... in the order sent.
|
||||
router.put('/:id/encounters/order', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = encounterOrderSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { encounterIds } = parsed.data;
|
||||
|
||||
const owned = await prisma.encounter.count({ where: { id: { in: encounterIds }, campaignId: req.campaign.id } });
|
||||
if (new Set(encounterIds).size !== encounterIds.length || owned !== encounterIds.length) {
|
||||
return res.status(400).json({ error: 'Send each encounter of this campaign once.' });
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
encounterIds.map((id, sortOrder) => prisma.encounter.update({ where: { id }, data: { sortOrder } }))
|
||||
);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NPCs (DM only — players meet them through encounters, without the DM's
|
||||
// notes or stats)
|
||||
@@ -558,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(),
|
||||
});
|
||||
|
||||
@@ -624,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);
|
||||
@@ -681,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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,13 +237,22 @@ 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)
|
||||
});
|
||||
|
||||
router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const data = { ...parsed.data };
|
||||
const { archived, ...data } = parsed.data;
|
||||
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;
|
||||
@@ -196,6 +268,18 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
||||
}
|
||||
});
|
||||
|
||||
// Deleting removes the scene, its combatant rows and its reward list. What
|
||||
// was already handed out stays with the characters, as does their HP.
|
||||
router.delete('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
await prisma.encounter.delete({ where: { id: req.encounter.id } });
|
||||
emitEncounterUpdated(req);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combatants
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,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',
|
||||
@@ -216,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({
|
||||
@@ -231,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) {
|
||||
@@ -239,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) {
|
||||
@@ -257,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),
|
||||
@@ -272,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) {
|
||||
@@ -374,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);
|
||||
}
|
||||
@@ -389,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(),
|
||||
}),
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
+56
-9
@@ -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 |
|
||||
|
||||
@@ -61,7 +65,36 @@ notes) works the same in both.
|
||||
|
||||
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
|
||||
deleted or renamed from the screen.
|
||||
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
|
||||
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter
|
||||
goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
|
||||
date instead; that choice is remembered per browser and changes nothing for
|
||||
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; 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
|
||||
confirmation. There is no undo. Rewards already handed out stay with the
|
||||
characters, and characters keep their current HP.
|
||||
|
||||
## 4. The scene and DM notes
|
||||
|
||||
@@ -112,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
|
||||
@@ -121,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
|
||||
@@ -192,10 +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 |
|
||||
|
||||
@@ -211,5 +258,5 @@ People outside the campaign cannot open its encounters.
|
||||
- Maps, positions, distances and areas of effect
|
||||
- Experience points and encounter difficulty
|
||||
- Spells as rewards
|
||||
- Deleting or renaming an encounter from the screen
|
||||
- Renaming an encounter from the screen
|
||||
- A log of what happened during the encounter
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PartyOverviewPage } from './pages/PartyOverviewPage';
|
||||
import { CharacterSheetPage } from './pages/CharacterSheetPage';
|
||||
import { CharacterBuilderPage } from './pages/CharacterBuilderPage';
|
||||
import { EncounterPage } from './pages/EncounterPage';
|
||||
import { AccountPage } from './pages/AccountPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -24,6 +25,7 @@ function App() {
|
||||
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
|
||||
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
|
||||
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -62,7 +62,7 @@ export function useCreateCampaign() {
|
||||
export function useUpdateCampaign(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Partial<Campaign>) =>
|
||||
mutationFn: async (data: Partial<Campaign> & { archived?: boolean }) =>
|
||||
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, data)).data.campaign,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
|
||||
@@ -71,6 +71,31 @@ export function useUpdateCampaign(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Archived campaigns stay whole; they only move off the main list.
|
||||
export function useSetCampaignArchived() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ campaignId, archived }: { campaignId: string; archived: boolean }) =>
|
||||
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, { archived })).data.campaign,
|
||||
onSuccess: (campaign) => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaign.id] });
|
||||
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Permanent: takes every character, encounter and note in the campaign with it.
|
||||
export function useDeleteCampaign() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (campaignId: string) => apiClient.delete(`/campaigns/${campaignId}`),
|
||||
onSuccess: (_res, campaignId) => {
|
||||
qc.removeQueries({ queryKey: ['campaign', campaignId] });
|
||||
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useJoinCampaign() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -348,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,
|
||||
@@ -489,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,
|
||||
});
|
||||
@@ -579,6 +605,51 @@ export function useCreateEncounter(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Sets the campaign's own encounter order to the ids given, first to last.
|
||||
export function useReorderEncounters(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['campaign', campaignId, 'encounters'];
|
||||
return useMutation({
|
||||
mutationFn: async (encounterIds: string[]) => apiClient.put(`/campaigns/${campaignId}/encounters/order`, { encounterIds }),
|
||||
// Show the move straight away; the refetch below confirms or undoes it.
|
||||
onMutate: (encounterIds) => {
|
||||
qc.setQueryData<Encounter[]>(key, (old) => {
|
||||
if (!old) return old;
|
||||
const byId = new Map(old.map((e) => [e.id, e]));
|
||||
return encounterIds.flatMap((id, sortOrder) => {
|
||||
const encounter = byId.get(id);
|
||||
return encounter ? [{ ...encounter, sortOrder }] : [];
|
||||
});
|
||||
});
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: key }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetEncounterArchived(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ encounterId, archived }: { encounterId: string; archived: boolean }) =>
|
||||
(await apiClient.patch(`/encounters/${encounterId}`, { archived })).data,
|
||||
onSuccess: (_data, { encounterId }) => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Permanent. Rewards already handed out stay with the characters.
|
||||
export function useDeleteEncounter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (encounterId: string) => apiClient.delete(`/encounters/${encounterId}`),
|
||||
onSuccess: (_res, encounterId) => {
|
||||
qc.removeQueries({ queryKey: ['encounter', encounterId] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEncounter(encounterId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['encounter', encounterId],
|
||||
@@ -590,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] }),
|
||||
});
|
||||
@@ -631,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'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,12 +87,14 @@ 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';
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -116,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;
|
||||
@@ -275,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[];
|
||||
@@ -389,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;
|
||||
@@ -413,6 +425,11 @@ 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;
|
||||
participants?: EncounterParticipant[];
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,9 @@ export function Layout() {
|
||||
<nav className="topbar-nav">
|
||||
{user && (
|
||||
<div className="topbar-user">
|
||||
<span>{user.displayName}</span>
|
||||
<Link to="/account" title="Account settings">
|
||||
{user.displayName}
|
||||
</Link>
|
||||
<button className="btn btn-ghost" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -29,21 +29,30 @@ 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'] });
|
||||
};
|
||||
const onCampaignDeleted = () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
|
||||
};
|
||||
|
||||
socket.on('character:updated', onCharacterUpdated);
|
||||
socket.on('character:created', onCharacterCreated);
|
||||
socket.on('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.on('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.on('campaign:deleted', onCampaignDeleted);
|
||||
|
||||
return () => {
|
||||
socket.emit('campaign:leave', campaignId);
|
||||
@@ -51,6 +60,7 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
socket.off('character:created', onCharacterCreated);
|
||||
socket.off('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.off('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.off('campaign:deleted', onCampaignDeleted);
|
||||
};
|
||||
}, [campaignId, token, qc]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { extractErrorMessage } from '../lib/format';
|
||||
import { ErrorBanner } from '../components/Spinner';
|
||||
|
||||
export function AccountPage() {
|
||||
const { user } = useAuth();
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('The two new passwords do not match.');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiClient.post('/auth/password', { currentPassword, newPassword });
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setSaved(true);
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 480 }}>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Your Account</h1>
|
||||
<p className="subtitle">
|
||||
{user?.displayName} · {user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-title">Change Password</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<label htmlFor="currentPassword">Current password</label>
|
||||
<input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="newPassword">New password (at least 8 characters)</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="confirmPassword">New password again</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{saved && <p className="subtitle">Password changed. Use the new one next time you log in.</p>}
|
||||
<button type="submit" className="btn btn-primary mt-md" disabled={submitting}>
|
||||
{submitting ? 'Saving…' : 'Change Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
useDeleteCharacter,
|
||||
useCampaignEncounters,
|
||||
useCreateEncounter,
|
||||
useReorderEncounters,
|
||||
useSetEncounterArchived,
|
||||
useDeleteEncounter,
|
||||
useCampaignMonsters,
|
||||
useCreateMonster,
|
||||
useDeleteMonster,
|
||||
@@ -23,6 +26,8 @@ import {
|
||||
useCampaignNotes,
|
||||
useCreateNote,
|
||||
useUpdateCampaign,
|
||||
useSetCampaignArchived,
|
||||
useDeleteCampaign,
|
||||
useRegenerateInvite,
|
||||
useCampaignLocations,
|
||||
useCreateLocation,
|
||||
@@ -35,11 +40,46 @@ 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';
|
||||
import { DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type { CharacterSummary, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
|
||||
import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
|
||||
|
||||
// How the encounter list can be ordered. "custom" is the campaign's own order,
|
||||
// which the DM arranges; the rest only change what this browser shows.
|
||||
type EncounterSort = 'custom' | 'name' | 'status' | 'type' | 'newest' | 'oldest';
|
||||
const ENCOUNTER_SORT_LABELS: Record<EncounterSort, string> = {
|
||||
custom: 'Campaign order',
|
||||
name: 'Name (A–Z)',
|
||||
status: 'Status (in progress first)',
|
||||
type: 'Type (battles first)',
|
||||
newest: 'Newest first',
|
||||
oldest: 'Oldest first',
|
||||
};
|
||||
const ENCOUNTER_SORT_KEY = 'dnd_encounter_sort';
|
||||
const STATUS_RANK = { ACTIVE: 0, PLANNING: 1, COMPLETE: 2 };
|
||||
|
||||
function storedEncounterSort(): EncounterSort {
|
||||
const stored = localStorage.getItem(ENCOUNTER_SORT_KEY);
|
||||
return stored && stored in ENCOUNTER_SORT_LABELS ? (stored as EncounterSort) : 'custom';
|
||||
}
|
||||
|
||||
// `encounters` arrives in campaign order, and the sort is stable, so ties
|
||||
// (same status, same type) stay in that order.
|
||||
function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter[] {
|
||||
const time = (e: Encounter) => new Date(e.createdAt).getTime();
|
||||
const compare: Record<EncounterSort, (a: Encounter, b: Encounter) => number> = {
|
||||
custom: () => 0,
|
||||
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
status: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status],
|
||||
type: (a, b) => Number(a.mode === 'QUEST') - Number(b.mode === 'QUEST'),
|
||||
newest: (a, b) => time(b) - time(a),
|
||||
oldest: (a, b) => time(a) - time(b),
|
||||
};
|
||||
return [...encounters].sort(compare[sort]);
|
||||
}
|
||||
|
||||
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
|
||||
const inProgress = character.buildStage !== 'COMPLETE';
|
||||
@@ -254,11 +294,14 @@ 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);
|
||||
const createEncounter = useCreateEncounter(campaignId);
|
||||
const reorderEncounters = useReorderEncounters(campaignId);
|
||||
const setEncounterArchived = useSetEncounterArchived(campaignId);
|
||||
const deleteEncounter = useDeleteEncounter(campaignId);
|
||||
const createMonster = useCreateMonster(campaignId);
|
||||
const deleteMonster = useDeleteMonster(campaignId);
|
||||
const createWeapon = useCreateWeapon(campaignId);
|
||||
@@ -275,6 +318,8 @@ export function CampaignHomePage() {
|
||||
const createNote = useCreateNote();
|
||||
const updateCampaign = useUpdateCampaign(campaignId);
|
||||
const regenerateInvite = useRegenerateInvite(campaignId);
|
||||
const setArchived = useSetCampaignArchived();
|
||||
const deleteCampaign = useDeleteCampaign();
|
||||
const createLocation = useCreateLocation(campaignId);
|
||||
const deleteLocation = useDeleteLocation(campaignId);
|
||||
const addWeaponFromLibrary = useAddWeaponFromLibrary(campaignId);
|
||||
@@ -284,12 +329,16 @@ export function CampaignHomePage() {
|
||||
const [newCharName, setNewCharName] = useState('');
|
||||
const [newEncounterName, setNewEncounterName] = useState('');
|
||||
const [newEncounterMode, setNewEncounterMode] = useState<EncounterMode>('BATTLE');
|
||||
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
|
||||
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
|
||||
const [showNpcForm, setShowNpcForm] = useState(false);
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [dmNoteBody, setDmNoteBody] = useState('');
|
||||
const [newLocationName, setNewLocationName] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
const [showMonsterForm, setShowMonsterForm] = useState(false);
|
||||
const [showWeaponForm, setShowWeaponForm] = useState(false);
|
||||
const [showSpellForm, setShowSpellForm] = useState(false);
|
||||
@@ -303,6 +352,9 @@ export function CampaignHomePage() {
|
||||
const itemsPage = usePagination(items, 8);
|
||||
const npcsPage = usePagination(npcs, 8);
|
||||
const monstersPage = usePagination(monsters, 8);
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
|
||||
const sharedNotesPage = usePagination(sharedNotes, 6);
|
||||
@@ -341,6 +393,17 @@ export function CampaignHomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Swaps an encounter with its neighbour in the campaign's own order.
|
||||
function moveEncounter(index: number, step: -1 | 1) {
|
||||
const ids = liveEncounters.map((e) => e.id);
|
||||
const target = index + step;
|
||||
if (target < 0 || target >= ids.length) return;
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
reorderEncounters.mutate([...ids, ...archivedEncounters.map((e) => e.id)], {
|
||||
onError: (err) => setFormError(extractErrorMessage(err)),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateNote(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!noteBody.trim()) return;
|
||||
@@ -355,6 +418,26 @@ export function CampaignHomePage() {
|
||||
setDmNoteBody('');
|
||||
}
|
||||
|
||||
async function handleSetArchived(archived: boolean) {
|
||||
setFormError(null);
|
||||
try {
|
||||
await setArchived.mutateAsync({ campaignId, archived });
|
||||
} catch (err) {
|
||||
setFormError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCampaign(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
try {
|
||||
await deleteCampaign.mutateAsync(campaignId);
|
||||
navigate('/campaigns');
|
||||
} catch (err) {
|
||||
setFormError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateLocation(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newLocationName.trim()) return;
|
||||
@@ -373,6 +456,7 @@ export function CampaignHomePage() {
|
||||
<Link to={`/campaigns/${campaignId}/party`} className="btn">
|
||||
📊 Party Overview
|
||||
</Link>
|
||||
{campaign.archivedAt && <span className="badge">Archived</span>}
|
||||
<span className={`badge ${isDM ? 'badge-dm' : 'badge-player'}`}>{isDM ? 'Dungeon Master' : 'Player'}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,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>
|
||||
@@ -439,6 +528,53 @@ export function CampaignHomePage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-md">
|
||||
<div className="card-title" style={{ marginBottom: '0.4rem' }}>Archive or Delete</div>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
{campaign.archivedAt
|
||||
? 'This campaign is archived: it sits under “Archived” on your campaign list and its invite code is switched off. Nothing in it has been removed.'
|
||||
: 'Archiving moves the campaign off everyone’s main list and switches off its invite code. Nothing is removed, and you can restore it at any time.'}
|
||||
</p>
|
||||
<div className="flex-row flex-wrap">
|
||||
<button className="btn" disabled={setArchived.isPending} onClick={() => handleSetArchived(!campaign.archivedAt)}>
|
||||
{campaign.archivedAt ? 'Restore Campaign' : 'Archive Campaign'}
|
||||
</button>
|
||||
{campaign.ownerId === user?.id && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => {
|
||||
setShowDelete((s) => !s);
|
||||
setDeleteConfirmName('');
|
||||
}}
|
||||
>
|
||||
{showDelete ? 'Cancel' : 'Delete Campaign…'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showDelete && (
|
||||
<form className="mt-md" onSubmit={handleDeleteCampaign}>
|
||||
<div className="field">
|
||||
<label htmlFor="deleteConfirmName">
|
||||
This permanently deletes every character, encounter, note and pool entry in the campaign, for
|
||||
every player. There is no undo. Type <strong>{campaign.name}</strong> to confirm.
|
||||
</label>
|
||||
<input
|
||||
id="deleteConfirmName"
|
||||
autoComplete="off"
|
||||
value={deleteConfirmName}
|
||||
onChange={(e) => setDeleteConfirmName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-danger"
|
||||
disabled={deleteCampaign.isPending || deleteConfirmName.trim() !== campaign.name.trim()}
|
||||
>
|
||||
{deleteCampaign.isPending ? 'Deleting…' : 'Delete Forever'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -484,6 +620,25 @@ export function CampaignHomePage() {
|
||||
<div className="mt-lg">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
||||
{liveEncounters.length > 1 && (
|
||||
<label className="flex-row subtitle">
|
||||
Sort by
|
||||
<select
|
||||
value={encounterSort}
|
||||
onChange={(e) => {
|
||||
const sort = e.target.value as EncounterSort;
|
||||
setEncounterSort(sort);
|
||||
localStorage.setItem(ENCOUNTER_SORT_KEY, sort);
|
||||
}}
|
||||
>
|
||||
{(Object.keys(ENCOUNTER_SORT_LABELS) as EncounterSort[]).map((sort) => (
|
||||
<option key={sort} value={sort}>
|
||||
{ENCOUNTER_SORT_LABELS[sort]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{isDM && (
|
||||
<form onSubmit={handleCreateEncounter} className="flex-row flex-wrap" style={{ marginBottom: '0.75rem' }}>
|
||||
@@ -502,21 +657,117 @@ export function CampaignHomePage() {
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{encounters?.length === 0 && <div className="empty-state">No encounters yet.</div>}
|
||||
{encounters?.map((enc) => (
|
||||
<Link key={enc.id} to={`/encounters/${enc.id}`} className="card flex-between" style={{ display: 'flex' }}>
|
||||
<span>
|
||||
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it.
|
||||
</p>
|
||||
)}
|
||||
{encounters && liveEncounters.length === 0 && (
|
||||
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
|
||||
)}
|
||||
{shownEncounters.map((enc, index) => (
|
||||
<div key={enc.id} className="card flex-between flex-wrap">
|
||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||
{enc.name}
|
||||
</Link>
|
||||
<span className="flex-row">
|
||||
<span className="badge">
|
||||
{enc.status}
|
||||
{enc.mode === 'BATTLE' && ` · Round ${enc.currentRound}`}
|
||||
</span>
|
||||
{isDM && encounterSort === 'custom' && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Move ${enc.name} up`}
|
||||
title="Move up"
|
||||
disabled={index === 0 || reorderEncounters.isPending}
|
||||
onClick={() => moveEncounter(index, -1)}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Move ${enc.name} down`}
|
||||
title="Move down"
|
||||
disabled={index === shownEncounters.length - 1 || reorderEncounters.isPending}
|
||||
onClick={() => moveEncounter(index, 1)}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isDM && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
title="Archive: take it off the list without deleting it"
|
||||
disabled={setEncounterArchived.isPending}
|
||||
onClick={() => setEncounterArchived.mutate({ encounterId: enc.id, archived: true })}
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
<span className="badge">
|
||||
{enc.status}
|
||||
{enc.mode === 'BATTLE' && ` · Round ${enc.currentRound}`}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isDM && archivedEncounters.length > 0 && (
|
||||
<div className="mt-md">
|
||||
<div className="flex-between">
|
||||
<h3 className="section-label" style={{ margin: 0 }}>Archived ({archivedEncounters.length})</h3>
|
||||
<button className="btn btn-ghost" onClick={() => setShowArchivedEncounters((s) => !s)}>
|
||||
{showArchivedEncounters ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
{showArchivedEncounters && (
|
||||
<>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p>
|
||||
{archivedEncounters.map((enc) => (
|
||||
<div key={enc.id} className="card flex-between flex-wrap">
|
||||
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
|
||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||
{enc.name}
|
||||
</Link>
|
||||
<span className="flex-row">
|
||||
<button
|
||||
className="btn"
|
||||
disabled={setEncounterArchived.isPending}
|
||||
onClick={() => setEncounterArchived.mutate({ encounterId: enc.id, archived: false })}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
disabled={deleteEncounter.isPending}
|
||||
onClick={() =>
|
||||
window.confirm(`Delete "${enc.name}" for good? Rewards already handed out stay with the characters.`) &&
|
||||
deleteEncounter.mutate(enc.id)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
@@ -567,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>
|
||||
@@ -605,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)"
|
||||
@@ -967,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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCampaigns, useCreateCampaign, useJoinCampaign } from '../api/queries';
|
||||
import { useCampaigns, useCreateCampaign, useJoinCampaign, useSetCampaignArchived } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { extractErrorMessage } from '../lib/format';
|
||||
|
||||
@@ -8,6 +8,7 @@ export function CampaignListPage() {
|
||||
const { data: campaigns, isLoading, error } = useCampaigns();
|
||||
const createCampaign = useCreateCampaign();
|
||||
const joinCampaign = useJoinCampaign();
|
||||
const setArchived = useSetCampaignArchived();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
@@ -15,6 +16,10 @@ export function CampaignListPage() {
|
||||
const [description, setDescription] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const activeCampaigns = campaigns?.filter((c) => !c.archivedAt);
|
||||
const archivedCampaigns = campaigns?.filter((c) => c.archivedAt) || [];
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -89,11 +94,15 @@ export function CampaignListPage() {
|
||||
<div className="mt-lg">
|
||||
{isLoading && <Spinner label="Consulting the tavern board…" />}
|
||||
{error && <ErrorBanner message="Could not load your campaigns." />}
|
||||
{campaigns && campaigns.length === 0 && (
|
||||
<div className="empty-state">No campaigns yet — found one, or join with an invite code above.</div>
|
||||
{activeCampaigns && activeCampaigns.length === 0 && (
|
||||
<div className="empty-state">
|
||||
{archivedCampaigns.length > 0
|
||||
? 'No active campaigns — found one, join with an invite code above, or restore one from the archive below.'
|
||||
: 'No campaigns yet — found one, or join with an invite code above.'}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2">
|
||||
{campaigns?.map((c) => (
|
||||
{activeCampaigns?.map((c) => (
|
||||
<Link key={c.id} to={`/campaigns/${c.id}`} className="card" style={{ display: 'block' }}>
|
||||
<div className="card-title">
|
||||
<span>{c.name}</span>
|
||||
@@ -104,6 +113,39 @@ export function CampaignListPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{archivedCampaigns.length > 0 && (
|
||||
<div className="mt-lg">
|
||||
<div className="flex-between">
|
||||
<h2 className="section-label" style={{ margin: 0 }}>Archived ({archivedCampaigns.length})</h2>
|
||||
<button className="btn btn-ghost" onClick={() => setShowArchived((s) => !s)}>
|
||||
{showArchived ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
{showArchived && (
|
||||
<div className="grid grid-cols-2 mt-sm">
|
||||
{archivedCampaigns.map((c) => (
|
||||
<div key={c.id} className="card">
|
||||
<div className="card-title">
|
||||
<Link to={`/campaigns/${c.id}`}>{c.name}</Link>
|
||||
<span className={`badge ${c.role === 'DM' ? 'badge-dm' : 'badge-player'}`}>{c.role}</span>
|
||||
</div>
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: 0 }}>{c.description || 'No description yet.'}</p>
|
||||
{c.role === 'DM' && (
|
||||
<button
|
||||
className="btn"
|
||||
disabled={setArchived.isPending}
|
||||
onClick={() => setArchived.mutate({ campaignId: c.id, archived: false })}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -344,7 +346,7 @@ export function CharacterSheetPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const otherCampaigns = campaigns?.filter((c) => c.id !== character.campaignId) ?? [];
|
||||
const otherCampaigns = campaigns?.filter((c) => c.id !== character.campaignId && !c.archivedAt) ?? [];
|
||||
|
||||
const assignedWeaponIds = new Set(character.weapons.map((w) => w.weaponId));
|
||||
const assignedSpellIds = new Set(character.spells.map((s) => s.spellId));
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
useEncounter,
|
||||
useCampaign,
|
||||
@@ -8,20 +8,26 @@ import {
|
||||
useCampaignNpcs,
|
||||
useCampaignWeapons,
|
||||
useCampaignItems,
|
||||
useCampaignLocations,
|
||||
useAddParticipant,
|
||||
usePatchParticipant,
|
||||
useRemoveParticipant,
|
||||
useAdvanceRound,
|
||||
useNextTurn,
|
||||
usePatchEncounter,
|
||||
useSetEncounterArchived,
|
||||
useDeleteEncounter,
|
||||
useAddReward,
|
||||
useDeleteReward,
|
||||
useAwardReward,
|
||||
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,
|
||||
@@ -91,6 +97,7 @@ function CombatantCard({
|
||||
mode,
|
||||
isActive,
|
||||
onPatch,
|
||||
canRollInitiative,
|
||||
onRemove,
|
||||
}: {
|
||||
participant: EncounterParticipant;
|
||||
@@ -98,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('');
|
||||
@@ -110,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);
|
||||
@@ -120,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('');
|
||||
}
|
||||
|
||||
@@ -150,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>
|
||||
)}
|
||||
@@ -201,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}
|
||||
@@ -503,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}
|
||||
@@ -540,9 +561,9 @@ function RewardsPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={999}
|
||||
aria-label="Quantity"
|
||||
title="Quantity"
|
||||
value={quantity}
|
||||
@@ -575,6 +596,7 @@ function RewardsPanel({
|
||||
|
||||
export function EncounterPage() {
|
||||
const { encounterId = '' } = useParams();
|
||||
const { user } = useAuth();
|
||||
const { data: encounter, isLoading, error } = useEncounter(encounterId);
|
||||
useCampaignSocket(encounter?.campaignId);
|
||||
|
||||
@@ -585,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);
|
||||
@@ -592,6 +615,9 @@ export function EncounterPage() {
|
||||
const advanceRound = useAdvanceRound(encounterId);
|
||||
const nextTurn = useNextTurn(encounterId);
|
||||
const patchEncounter = usePatchEncounter(encounterId);
|
||||
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
||||
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
|
||||
const [pickId, setPickId] = useState('');
|
||||
@@ -619,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('') }
|
||||
);
|
||||
}
|
||||
@@ -632,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)}
|
||||
/>
|
||||
);
|
||||
@@ -656,6 +683,8 @@ 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>
|
||||
</div>
|
||||
@@ -673,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'}
|
||||
@@ -698,6 +740,24 @@ export function EncounterPage() {
|
||||
Reopen
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
title={encounter.archivedAt ? 'Put it back on the campaign list' : 'Take it off the campaign list without deleting it'}
|
||||
disabled={setArchived.isPending}
|
||||
onClick={() => setArchived.mutate({ encounterId, archived: !encounter.archivedAt }, fail)}
|
||||
>
|
||||
{encounter.archivedAt ? 'Restore' : 'Archive'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={deleteEncounter.isPending}
|
||||
onClick={() =>
|
||||
window.confirm(`Delete "${encounter.name}" for good? Rewards already handed out stay with the characters.`) &&
|
||||
deleteEncounter.mutate(encounterId, { ...fail, onSuccess: () => navigate(`/campaigns/${encounter.campaignId}`) })
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -780,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 }}
|
||||
|
||||
@@ -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 +4,10 @@ import { defineConfig } from 'vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
preview: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
allowedHosts: ['the-sunken-reach.crosscode.co.za'],
|
||||
},
|
||||
})
|
||||
|
||||
Generated
+4
-4
@@ -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": "0.0.0",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+3
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user