V1.3.0: Hollowcrown seed, player visibility rules, initiative rolls, number limits, temp HP
- Hollowcrown: The Rift Between Worlds as its own seed (npm run seed:hollowcrown), built from the campaign book: pools, 58 monsters, 57 NPCs, 28 locations, 72 encounters with rewards, and DM notes. Shared seeding logic moved to prisma/seedCampaign.js. - What players can see: only encounters their own character is in; only the pool entries their character holds (plus starting options while building); only locations their character has been to; no invite code; no gold, inventory or money log of other players' characters. Socket events carry ids only and campaign rooms are members-only. - Encounters have a location; while one is in progress its characters are there, and visits are remembered. - Campaign setting to show players a health bar only for monsters and NPCs. - Initiative is entered as the d20 roll (1-20) and the bonus is added; players can enter their own character's roll. - Number fields enforce their limits when typing, and the API enforces the same limits. - Temp HP shows on HP bars and absorbs damage before real HP. - All packages at version 1.3.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,3 +6,4 @@ build/
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
|
temp/
|
||||||
@@ -71,6 +71,15 @@ already has an active "Aldenfell"; archive or delete that one in the app
|
|||||||
`SEED_ADMIN_PASSWORD` and `SEED_ADMIN_NAME` set the account it creates.
|
`SEED_ADMIN_PASSWORD` and `SEED_ADMIN_NAME` set the account it creates.
|
||||||
`npx prisma db seed -- --reset` wipes every user and campaign first.
|
`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
|
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).
|
your password from the account page (click your name in the top bar).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"prisma:seed": "prisma db seed",
|
"prisma:seed": "prisma db seed",
|
||||||
|
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
|
||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- AlterTable: whether players see a monster's exact hit points or only its health bar
|
||||||
|
ALTER TABLE "campaigns" ADD COLUMN "showMonsterHp" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- AlterTable: where an encounter takes place
|
||||||
|
ALTER TABLE "encounters" ADD COLUMN "locationId" TEXT;
|
||||||
|
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- CreateTable: every location a character has been to
|
||||||
|
CREATE TABLE "character_location_visits" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"characterId" TEXT NOT NULL,
|
||||||
|
"locationId" TEXT NOT NULL,
|
||||||
|
"firstVisitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "character_location_visits_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "character_location_visits_characterId_locationId_key" ON "character_location_visits"("characterId", "locationId");
|
||||||
|
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Characters have been to wherever they are now.
|
||||||
|
INSERT INTO "character_location_visits" ("id", "characterId", "locationId")
|
||||||
|
SELECT 'visit_' || "id", "id", "currentLocationId" FROM "characters" WHERE "currentLocationId" IS NOT NULL;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable: the d20 a combatant rolled for initiative; "initiative" is now roll + bonus
|
||||||
|
ALTER TABLE "encounter_participants" ADD COLUMN "initiativeRoll" INTEGER;
|
||||||
|
|
||||||
|
-- Initiatives typed in before this were totals. Keep them as they are, and
|
||||||
|
-- treat the nearest possible die face as the roll so those combatants still
|
||||||
|
-- count as having rolled.
|
||||||
|
UPDATE "encounter_participants"
|
||||||
|
SET "initiativeRoll" = LEAST(GREATEST("initiative", 1), 20)
|
||||||
|
WHERE "initiative" <> 0;
|
||||||
@@ -158,6 +158,8 @@ model Campaign {
|
|||||||
startingItemCount Int @default(0)
|
startingItemCount Int @default(0)
|
||||||
startingSpellCount Int @default(0)
|
startingSpellCount Int @default(0)
|
||||||
skipMotivationRoll Boolean @default(false)
|
skipMotivationRoll Boolean @default(false)
|
||||||
|
// Off: players see only a health bar for monsters and NPCs, not the numbers.
|
||||||
|
showMonsterHp Boolean @default(true)
|
||||||
ownerId String
|
ownerId String
|
||||||
// Set while the campaign is archived: kept in full, but moved off the main
|
// 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.
|
// list and closed to new members until the DM restores it.
|
||||||
@@ -188,10 +190,28 @@ model Location {
|
|||||||
|
|
||||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
characters Character[]
|
characters Character[]
|
||||||
|
encounters Encounter[]
|
||||||
|
visits CharacterLocationVisit[]
|
||||||
|
|
||||||
@@map("locations")
|
@@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 {
|
model CampaignMember {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
campaignId String
|
campaignId String
|
||||||
@@ -271,6 +291,7 @@ model Character {
|
|||||||
spells CharacterSpell[]
|
spells CharacterSpell[]
|
||||||
notes Note[]
|
notes Note[]
|
||||||
encounterEntries EncounterParticipant[]
|
encounterEntries EncounterParticipant[]
|
||||||
|
locationVisits CharacterLocationVisit[]
|
||||||
|
|
||||||
@@map("characters")
|
@@map("characters")
|
||||||
}
|
}
|
||||||
@@ -497,6 +518,9 @@ model Encounter {
|
|||||||
activeParticipantId String?
|
activeParticipantId String?
|
||||||
description String? // the scene / objective, shown to everyone
|
description String? // the scene / objective, shown to everyone
|
||||||
dmNotes String? // never sent to players
|
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
|
// Set while archived: kept in full, but off the campaign's list — players
|
||||||
// no longer see it there at all.
|
// no longer see it there at all.
|
||||||
archivedAt DateTime?
|
archivedAt DateTime?
|
||||||
@@ -506,6 +530,7 @@ model Encounter {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||||
|
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||||
participants EncounterParticipant[]
|
participants EncounterParticipant[]
|
||||||
rewards EncounterReward[]
|
rewards EncounterReward[]
|
||||||
|
|
||||||
@@ -548,6 +573,10 @@ model EncounterParticipant {
|
|||||||
monsterId String?
|
monsterId String?
|
||||||
npcId String?
|
npcId String?
|
||||||
displayName String // denormalized so a monster can be renamed per-instance, e.g. "Goblin #2"
|
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)
|
initiative Int @default(0)
|
||||||
currentHp Int
|
currentHp Int
|
||||||
maxHp 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],
|
||||||
|
});
|
||||||
+19
-194
@@ -1,207 +1,32 @@
|
|||||||
// Seeds the Aldenfell campaign (seed-data/aldenfell.js) for the admin account.
|
// 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
|
||||||
// Safe to run against a live database: it only ever ADDS. The admin account is
|
// to run against a live database.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// npx prisma db seed add what is missing
|
// npx prisma db seed add what is missing
|
||||||
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
|
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
|
||||||
// character, note, etc. first
|
// character, note, etc. first
|
||||||
//
|
//
|
||||||
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
|
// Other campaigns have their own command (npm run seed:hollowcrown).
|
||||||
// account that is created.
|
|
||||||
//
|
//
|
||||||
// Species/classes/skills are NOT seeded as rows — they're fixed game-rule
|
// 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
|
// 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
|
// library DMs pull from. The default weapons (seed-data/weapons.json) ARE
|
||||||
// seeded, into the campaign, since weapons are per-campaign in the schema.
|
// seeded, into the campaign, since weapons are per-campaign in the schema.
|
||||||
const path = require('path');
|
const { run, fromLibrary } = require('./seedCampaign');
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
const { PrismaClient } = require('@prisma/client');
|
|
||||||
const { generateInviteCode } = require('../src/utils/inviteCode');
|
|
||||||
const { SPELL_LIBRARY } = require('../src/game/data/library');
|
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();
|
run(
|
||||||
const defaultWeapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
|
{
|
||||||
const aldenfell = require(path.join(__dirname, '..', 'seed-data', 'aldenfell.js'));
|
...aldenfell,
|
||||||
|
// Everyday weapons are what a new character picks from; the rest are earned.
|
||||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
|
weapons: [...defaultWeapons.map((w) => ({ ...w, isStartingOption: true })), ...aldenfell.weapons],
|
||||||
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
|
items: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting) })),
|
||||||
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
|
spells: [
|
||||||
const RESET = process.argv.includes('--reset');
|
...aldenfell.spells.starting.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: true })),
|
||||||
|
...aldenfell.spells.earned.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: false })),
|
||||||
async function resetDatabase() {
|
|
||||||
// Truncating the two roots cascades through every table that references them.
|
|
||||||
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notes list newest-first on screen, so they are stamped a second apart,
|
|
||||||
// backwards: the first entry in the seed data is the one at the top.
|
|
||||||
function bookOrder(index) {
|
|
||||||
return new Date(Date.now() - index * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function byName(rows) {
|
|
||||||
return new Map(rows.map((row) => [row.name, row]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function lookup(map, name, what, encounterName) {
|
|
||||||
const row = map.get(name);
|
|
||||||
if (!row) throw new Error(`Encounter "${encounterName}" references unknown ${what} "${name}"`);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedCampaign(db, admin) {
|
|
||||||
const campaign = await db.campaign.create({
|
|
||||||
data: {
|
|
||||||
...aldenfell.campaign,
|
|
||||||
inviteCode: generateInviteCode(),
|
|
||||||
ownerId: admin.id,
|
|
||||||
members: { create: { userId: admin.id, role: 'DM' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const campaignId = campaign.id;
|
|
||||||
|
|
||||||
await db.location.createMany({ data: aldenfell.locations.map((l) => ({ ...l, campaignId })) });
|
|
||||||
|
|
||||||
// Everyday weapons are what a new character picks from; the rest are earned.
|
|
||||||
await db.weapon.createMany({
|
|
||||||
data: [
|
|
||||||
...defaultWeapons.map((w) => ({ ...w, isStartingOption: true, campaignId })),
|
|
||||||
...aldenfell.weapons.map((w) => ({ ...w, campaignId })),
|
|
||||||
],
|
],
|
||||||
});
|
},
|
||||||
await db.item.createMany({
|
{ reset: process.argv.includes('--reset') },
|
||||||
data: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting), campaignId })),
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const spellRow = (isStartingOption) => (spellKey) => {
|
|
||||||
const template = SPELL_LIBRARY.find((s) => s.key === spellKey);
|
|
||||||
if (!template) throw new Error(`Unknown library spell "${spellKey}"`);
|
|
||||||
const { key, classes, ...spell } = template;
|
|
||||||
return { ...spell, isStartingOption, campaignId };
|
|
||||||
};
|
|
||||||
const spells = [...aldenfell.spells.starting.map(spellRow(true)), ...aldenfell.spells.earned.map(spellRow(false))];
|
|
||||||
await db.spell.createMany({ data: spells });
|
|
||||||
|
|
||||||
await db.monster.createMany({ data: aldenfell.monsters.map((m) => ({ ...m, campaignId })) });
|
|
||||||
await db.npc.createMany({ data: aldenfell.npcs.map((n) => ({ ...n, campaignId })) });
|
|
||||||
|
|
||||||
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
|
|
||||||
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
|
|
||||||
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
|
|
||||||
const items = byName(await db.item.findMany({ where: { campaignId } }));
|
|
||||||
|
|
||||||
let rewardCount = 0;
|
|
||||||
for (const [index, enc] of aldenfell.encounters.entries()) {
|
|
||||||
const participants = [];
|
|
||||||
for (const [name, count, displayName] of enc.monsters || []) {
|
|
||||||
const monster = lookup(monsters, name, 'monster', enc.name);
|
|
||||||
const label = displayName || monster.name;
|
|
||||||
for (let i = 1; i <= count; i++) {
|
|
||||||
participants.push({
|
|
||||||
monsterId: monster.id,
|
|
||||||
displayName: count > 1 ? `${label} #${i}` : label,
|
|
||||||
currentHp: monster.maxHp,
|
|
||||||
maxHp: monster.maxHp,
|
|
||||||
isMonster: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const name of enc.npcs || []) {
|
|
||||||
const npc = lookup(npcs, name, 'NPC', enc.name);
|
|
||||||
// An NPC with no combat stats takes part with no hit points to track.
|
|
||||||
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: npc.maxHp ?? 0, maxHp: npc.maxHp ?? 0, isMonster: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rewards list oldest-first, so these are stamped forwards.
|
|
||||||
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity = 1, level, note }, i) => {
|
|
||||||
const createdAt = new Date(Date.now() + i);
|
|
||||||
if (gold) return { type: 'GOLD', gold, note, createdAt };
|
|
||||||
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
|
|
||||||
return {
|
|
||||||
type: weapon ? 'WEAPON' : 'ITEM',
|
|
||||||
[weapon ? 'weaponId' : 'itemId']: entry.id,
|
|
||||||
quantity,
|
|
||||||
level: Math.max(level ?? entry.minLevel, entry.minLevel),
|
|
||||||
note,
|
|
||||||
createdAt,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
rewardCount += rewards.length;
|
|
||||||
|
|
||||||
await db.encounter.create({
|
|
||||||
data: {
|
|
||||||
campaignId,
|
|
||||||
name: enc.name,
|
|
||||||
mode: enc.mode,
|
|
||||||
description: enc.description,
|
|
||||||
dmNotes: enc.dmNotes,
|
|
||||||
sortOrder: index,
|
|
||||||
participants: { create: participants },
|
|
||||||
rewards: { create: rewards },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const notes = [
|
|
||||||
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
|
|
||||||
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
|
|
||||||
];
|
|
||||||
await db.note.createMany({
|
|
||||||
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
|
|
||||||
console.log(
|
|
||||||
` ${defaultWeapons.length} starting + ${aldenfell.weapons.length} earned weapons, ${aldenfell.items.length} items, ` +
|
|
||||||
`${spells.length} spells, ${aldenfell.locations.length} locations`,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
` ${aldenfell.monsters.length} monsters, ${aldenfell.npcs.length} NPCs, ${aldenfell.encounters.length} encounters ` +
|
|
||||||
`(${rewardCount} rewards lined up), ${notes.length} notes`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (RESET) {
|
|
||||||
await resetDatabase();
|
|
||||||
console.log('Database reset: every user and campaign was deleted.');
|
|
||||||
}
|
|
||||||
|
|
||||||
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
|
|
||||||
if (admin) {
|
|
||||||
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
|
|
||||||
} else {
|
|
||||||
admin = await prisma.user.create({
|
|
||||||
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
|
|
||||||
});
|
|
||||||
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
|
|
||||||
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = await prisma.campaign.findFirst({
|
|
||||||
where: { ownerId: admin.id, name: aldenfell.campaign.name, archivedAt: null },
|
|
||||||
});
|
|
||||||
if (existing) {
|
|
||||||
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
|
|
||||||
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One transaction, so a failed seed doesn't leave half a campaign behind.
|
|
||||||
console.log('Seeding campaign...');
|
|
||||||
await prisma.$transaction((tx) => seedCampaign(tx, admin), { timeout: 120000, maxWait: 20000 });
|
|
||||||
console.log('Seed complete.');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
})
|
|
||||||
.finally(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -300,7 +300,9 @@ const npcs = [
|
|||||||
// Encounters — pre-built (PLANNING) scenes for the beats of the Campaign Book,
|
// 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
|
// in book order. BATTLE scenes run in initiative order; QUEST scenes are built
|
||||||
// around NPCs and an objective.
|
// around NPCs and an objective.
|
||||||
// description — the scene, shown to every player who opens it: no spoilers.
|
// location — a location's name: where the scene is. Its characters move
|
||||||
|
// there when it begins.
|
||||||
|
// description — the scene, shown to the players in it: no spoilers.
|
||||||
// dmNotes — DCs, branches and secrets, DM only.
|
// dmNotes — DCs, branches and secrets, DM only.
|
||||||
// monsters — [monster name, count, optional display name]; a count above
|
// monsters — [monster name, count, optional display name]; a count above
|
||||||
// one gets "#n" suffixes.
|
// one gets "#n" suffixes.
|
||||||
@@ -313,33 +315,33 @@ const npcs = [
|
|||||||
|
|
||||||
const encounters = [
|
const encounters = [
|
||||||
{
|
{
|
||||||
name: 'E1 — The Founding Fair', mode: 'QUEST',
|
name: 'E1 — The Founding Fair', mode: 'QUEST', location: 'Highcrown — The Lantern Market',
|
||||||
description: 'Highcrown’s Lantern Market at the height of the Founding Fair: lantern-strung stalls, contests of strength and skill, minstrels, and strangers from every corner of the kingdom. This is where you meet.',
|
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.',
|
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'],
|
npcs: ['Mireille Costa', 'Ser Kavan Thorne'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST',
|
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST', location: 'Highcrown — The Founding Chapel',
|
||||||
description: 'The Founding Chapel’s bell has rung every hour for as long as anyone remembers. It has stopped. A crowd is gathering outside, and someone is asking for volunteers to go in and look.',
|
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.',
|
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'],
|
npcs: ['Ser Kavan Thorne', 'A Frightened Local'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE',
|
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
|
||||||
description: 'A long hall of burial niches beneath the chapel, some sealed, some broken open. Something is growling in the dark between the pillars.',
|
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.',
|
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]],
|
monsters: [['Feral Guard Dog', 1]],
|
||||||
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Perception' }],
|
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Perception' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE',
|
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
|
||||||
description: 'Bones stacked floor to ceiling, and an offering box furred with dust. The air is still. Then it isn’t.',
|
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.',
|
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]],
|
monsters: [['Skeleton', 2]],
|
||||||
rewards: [{ gold: 25, note: 'Offering box — DC 13 to open' }],
|
rewards: [{ gold: 25, note: 'Offering box — DC 13 to open' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST',
|
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
|
||||||
description: 'A side chamber with a bedroll, a cold lantern, and a frightened old man who very much hopes you are who you look like.',
|
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.',
|
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'],
|
npcs: ['The Bell-Ringer'],
|
||||||
@@ -370,14 +372,14 @@ const encounters = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST',
|
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST', location: 'Salt Coast — Tidewatch',
|
||||||
description: 'Tidewatch, the Salt Coast’s largest port: proud, salt-stained, and suspicious of anyone asking questions. Someone is skimming the town’s quiet trade, and the Concord wants to know who.',
|
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.',
|
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'],
|
npcs: ['Mireille Costa'],
|
||||||
rewards: [{ gold: 100, note: 'Mireille’s fee — paid in full if it ends quietly' }],
|
rewards: [{ gold: 100, note: 'Mireille’s fee — paid in full if it ends quietly' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE',
|
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE', location: 'Salt Coast — Smuggler’s Cove',
|
||||||
description: 'A hidden inlet south of Tidewatch. Lanterns hooded, a longboat drawn up on the shingle, and a crew that was not expecting company.',
|
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).',
|
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]],
|
monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]],
|
||||||
@@ -387,13 +389,13 @@ const encounters = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE',
|
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
|
||||||
description: 'The trail to the shrine runs through old growth where the light barely reaches the ground. The forest has gone quiet, and there are eyes between the trees.',
|
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.',
|
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]],
|
monsters: [['Greywood Direwolf', 3]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE',
|
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
|
||||||
description: 'A mossed-over shrine older than the kingdom, split by a crack you could lose an arm in. The missing Warden is standing in front of it. She turns and greets you by name.',
|
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.',
|
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']],
|
monsters: [['Hollowed', 1, 'The Missing Warden']],
|
||||||
@@ -403,7 +405,7 @@ const encounters = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Side Quest — Trial of Stone', mode: 'QUEST',
|
name: 'Side Quest — Trial of Stone', mode: 'QUEST', location: 'The Spine — Stonejaw Hold',
|
||||||
description: 'Stonejaw Hold, a giant clan’s valley in the Spine foothills. The chieftain has heard you out. He does not offer agreement. He offers a trial.',
|
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.',
|
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']],
|
monsters: [['Stonejaw Hill Giant', 1, 'Stonejaw Champion']],
|
||||||
@@ -414,7 +416,7 @@ const encounters = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST',
|
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST', location: 'Bramble Marches — The Sorrow Camp',
|
||||||
description: 'The Sorrow Camp: hundreds of people under canvas just inside the border, and a garrison at Greyford Keep that wants them gone. Food sent for the camp is not all arriving.',
|
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.',
|
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]],
|
monsters: [['Bandit Captain', 1, 'Aid-Skimmer Boss'], ['Highcrown Cutthroat', 2]],
|
||||||
@@ -422,7 +424,7 @@ const encounters = [
|
|||||||
rewards: [{ gold: 50, note: 'Recovered aid money — keeping it is a choice the camp will notice' }],
|
rewards: [{ gold: 50, note: 'Recovered aid money — keeping it is a choice the camp will notice' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE',
|
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE', location: 'Salt Coast — Tidewatch',
|
||||||
description: 'A fishing boat washed up empty this morning: sails intact, catch in the hold, crew gone. The fishers will point out the reef. None of them will row you there.',
|
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.',
|
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]],
|
monsters: [['Reef Terror', 1]],
|
||||||
@@ -432,21 +434,21 @@ const encounters = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST',
|
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST', location: 'The Spine — Threndir’s Eyrie',
|
||||||
description: 'A hard climb above the snow line, and a cave mouth wide enough to sail a ship into. The dragon inside already knows you are here. He has not moved, and he has not told you to leave.',
|
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.',
|
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'],
|
npcs: ['Threndir Ashclaw'],
|
||||||
rewards: [{ weapon: 'Threndir’s Fang', note: 'A shed tooth, set in a hilt — given only if he comes to respect them' }],
|
rewards: [{ weapon: 'Threndir’s Fang', note: 'A shed tooth, set in a hilt — given only if he comes to respect them' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE',
|
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE', location: 'Hollow Below — The Weeping Passage',
|
||||||
description: 'A long tunnel beneath the Greywood where the walls weep, and the sound they make is almost a voice. Somewhere ahead, very faintly, a bell tolls.',
|
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.',
|
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]],
|
monsters: [['Bellcrawler', 1]],
|
||||||
npcs: ['Corporal Ressa Vane'],
|
npcs: ['Corporal Ressa Vane'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE',
|
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
|
||||||
description: 'A cavern kept unnervingly tidy: bedrolls squared away, boots in a row. Two people sit facing the wall. They stand when you enter, and smile at exactly the same moment.',
|
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.',
|
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]],
|
monsters: [['Hollowed', 2]],
|
||||||
@@ -456,7 +458,7 @@ const encounters = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE',
|
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
|
||||||
description: 'The roots in this chamber are as thick as a man’s waist, and they are not growing from anything. One of them shifts when your light touches it.',
|
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.',
|
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]],
|
monsters: [['Root Stalker', 1]],
|
||||||
@@ -466,7 +468,7 @@ const encounters = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE',
|
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
|
||||||
description: 'Narrow ledges over a drop your torch cannot find the bottom of. From three different directions at once, a bell begins to toll.',
|
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.',
|
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]],
|
monsters: [['Bellcrawler', 3]],
|
||||||
@@ -476,14 +478,14 @@ const encounters = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E10 — The House Unmasked', mode: 'QUEST',
|
name: 'E10 — The House Unmasked', mode: 'QUEST', location: 'Highcrown — The Palace of Aldenfell',
|
||||||
description: 'Back in Highcrown with a name, a ledger, and a question nobody at court wants asked out loud: what do you do with it?',
|
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.',
|
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'],
|
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
|
||||||
rewards: [{ item: 'Sealed Letter', note: 'E10C only' }],
|
rewards: [{ item: 'Sealed Letter', note: 'E10C only' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E11 — Silence on the Border', mode: 'QUEST',
|
name: 'E11 — Silence on the Border', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
|
||||||
description: 'Greyford Keep. The gate stands open. No smoke from the chimneys, no one on the walls, no bodies. A kettle is still warm on a guardroom stove.',
|
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.',
|
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'],
|
npcs: ['Greyford’s Commander'],
|
||||||
@@ -513,7 +515,7 @@ const encounters = [
|
|||||||
monsters: [['The Hollow King’s Shadow', 1]],
|
monsters: [['The Hollow King’s Shadow', 1]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E19 — Vessa’s Seat', mode: 'QUEST',
|
name: 'E19 — Vessa’s Seat', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||||
description: 'A single worn stone seat at the heart of the Hollow Court, and a woman who has been expecting you. She asks you to sit. She says she would like to explain.',
|
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.',
|
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'],
|
npcs: ['Vessa Hollow'],
|
||||||
@@ -526,14 +528,14 @@ const encounters = [
|
|||||||
rewards: [{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' }],
|
rewards: [{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E22 — The Seal Chamber', mode: 'QUEST',
|
name: 'E22 — The Seal Chamber', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||||
description: 'A wound in the world, stitched shut long ago, and coming undone one thread at a time. Whatever you came here to decide, this is where you decide it.',
|
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.',
|
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'],
|
npcs: ['Vessa Hollow'],
|
||||||
rewards: [{ weapon: 'Hollowbane', note: 'Drawn from the seal itself' }],
|
rewards: [{ weapon: 'Hollowbane', note: 'Drawn from the seal itself' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'E25 — The Last Approach', mode: 'BATTLE',
|
name: 'E25 — The Last Approach', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||||
description: 'The final stretch of the Hollow Court, where gravity, light and memory have all stopped agreeing with one another. The things that live here know you are coming.',
|
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.',
|
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]],
|
monsters: [['Root Stalker', 1], ['Bellcrawler', 2]],
|
||||||
|
|||||||
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);
|
expect(itemRes.status).toBe(201);
|
||||||
const itemId = itemRes.body.item.id;
|
const itemId = itemRes.body.item.id;
|
||||||
|
|
||||||
// Players can see the pool but not add to it or help themselves from it.
|
// 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(1);
|
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(`/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}/weapons`).set(playerAuth).send({ weaponId })).status).toBe(403);
|
||||||
expect((await request(app).post(`/characters/${characterId}/inventory`).set(playerAuth).send({ itemId })).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 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 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 participantId = added.body.participant.id;
|
||||||
const combatant = async () => (await request(app).get(`/encounters/${encounterId}`).set(playerAuth)).body.encounter.participants[0];
|
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;
|
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.
|
// 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 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);
|
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: 0 })).status).toBe(400);
|
||||||
expect((await gold(playerAuth, { amount: 1.5 })).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)
|
const stranger = await request(app)
|
||||||
.post('/auth/register')
|
.post('/auth/register')
|
||||||
.send({ email: uniqueEmail('gold-other'), password: 'password123', displayName: 'Other' });
|
.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);
|
await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(strangerAuth);
|
||||||
expect((await gold(strangerAuth, { amount: 5 })).status).toBe(403);
|
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.status).toBe(200);
|
||||||
expect(ledger.body.gold).toBe(45);
|
expect(ledger.body.gold).toBe(45);
|
||||||
expect(ledger.body.transactions.map((t) => [t.amount, t.balanceAfter, t.reason, t.createdBy.displayName])).toEqual([
|
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 () => {
|
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;
|
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');
|
expect(quest.mode).toBe('QUEST');
|
||||||
const battle = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter;
|
const battle = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter;
|
||||||
expect(battle.mode).toBe('BATTLE');
|
expect(battle.mode).toBe('BATTLE');
|
||||||
|
|
||||||
const e = `/encounters/${quest.id}`;
|
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);
|
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.' });
|
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.');
|
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.description).toBe('Find the miller.');
|
||||||
expect(playerView.dmNotes).toBeUndefined();
|
expect(playerView.dmNotes).toBeUndefined();
|
||||||
const playerList = (await request(app).get(`${c}/encounters`).set(playerAuth)).body.encounters;
|
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);
|
expect(playerList.every((x) => x.dmNotes === undefined)).toBe(true);
|
||||||
const dmList = (await request(app).get(`${c}/encounters`).set(dmAuth)).body.encounters;
|
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.');
|
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 () => {
|
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');
|
const other = await setupCampaign('npcs-other');
|
||||||
|
|
||||||
expect((await request(app).post(`${c}/npcs`).set(playerAuth).send({ name: 'Nope' })).status).toBe(403);
|
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['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
|
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);
|
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.' });
|
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.
|
// 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((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 () => {
|
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 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 encounterId = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Cave' })).body.encounter.id;
|
||||||
const e = `/encounters/${encounterId}`;
|
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({ monsterId: goblin.id, initiativeRoll: 5 });
|
||||||
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId, initiative: 15 });
|
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;
|
const [hero, monster] = (await request(app).get(e).set(dmAuth)).body.encounter.participants;
|
||||||
expect(hero.kind).toBe('CHARACTER');
|
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
|
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 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 slow = await add({ monsterId: goblin.id, initiativeRoll: 1 });
|
||||||
const fast = await add({ characterId: heroId, initiative: 18 });
|
const fast = await add({ characterId: heroId, initiativeRoll: 20 });
|
||||||
const mid = await add({ characterId: allyId, initiative: 10 });
|
const mid = await add({ characterId: allyId, initiativeRoll: 10 });
|
||||||
|
|
||||||
const state = (res) => [res.body.encounter.activeParticipantId, res.body.encounter.currentRound, res.body.encounter.status];
|
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);
|
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 encounterId = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Dragon Hoard' })).body.encounter.id;
|
||||||
const e = `/encounters/${encounterId}`;
|
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: heroId, initiativeRoll: 20 });
|
||||||
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: allyId, initiative: 8 });
|
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);
|
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);
|
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);
|
const listRes = await request(app).get(`/campaigns/${campaign.id}/locations`).set(playerAuth);
|
||||||
expect(listRes.status).toBe(200);
|
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)
|
const charRes = await request(app)
|
||||||
.post(`/campaigns/${campaign.id}/characters`)
|
.post(`/campaigns/${campaign.id}/characters`)
|
||||||
@@ -1375,7 +1388,8 @@ describe('default content library', () => {
|
|||||||
itIfDb('lists library entries and copies one into the campaign', async () => {
|
itIfDb('lists library entries and copies one into the campaign', async () => {
|
||||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('library');
|
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.status).toBe(200);
|
||||||
expect(listRes.body.weapons.length).toBeGreaterThan(20);
|
expect(listRes.body.weapons.length).toBeGreaterThan(20);
|
||||||
expect(listRes.body.spells.length).toBeGreaterThan(50);
|
expect(listRes.body.spells.length).toBeGreaterThan(50);
|
||||||
@@ -1468,7 +1482,7 @@ describe('DM-only campaign notes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('encounter combat info', () => {
|
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 { dmAuth, playerAuth, campaign } = await setupCampaign('encountercombat');
|
||||||
|
|
||||||
const weaponRes = await request(app)
|
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 encounterRes = await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Ambush' });
|
||||||
const encounterId = encounterRes.body.encounter.id;
|
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);
|
const playerViewRes = await request(app).get(`/encounters/${encounterId}`).set(playerAuth);
|
||||||
expect(playerViewRes.status).toBe(200);
|
expect(playerViewRes.status).toBe(200);
|
||||||
@@ -1611,6 +1627,7 @@ describe('ordering, archiving and deleting encounters', () => {
|
|||||||
const ids = {};
|
const ids = {};
|
||||||
for (const name of ['One', 'Two', 'Three']) {
|
for (const name of ['One', 'Two', 'Three']) {
|
||||||
ids[name] = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name })).body.encounter.id;
|
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);
|
const names = async (auth) => (await request(app).get(`${c}/encounters`).set(auth)).body.encounters.map((e) => e.name);
|
||||||
|
|
||||||
@@ -1637,7 +1654,6 @@ describe('ordering, archiving and deleting encounters', () => {
|
|||||||
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
|
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
|
||||||
|
|
||||||
// Deleting takes the scene but leaves what it already gave out.
|
// Deleting takes the scene but leaves what it already gave out.
|
||||||
await request(app).post(`/encounters/${ids.One}/participants`).set(dmAuth).send({ characterId: heroId });
|
|
||||||
const rewardRes = await request(app).post(`/encounters/${ids.One}/rewards`).set(dmAuth).send({ type: 'GOLD', gold: 12 });
|
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 });
|
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;
|
const goldBefore = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.gold;
|
||||||
@@ -1651,3 +1667,299 @@ describe('ordering, archiving and deleting encounters', () => {
|
|||||||
expect(goldBefore).toBeGreaterThanOrEqual(12);
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { requireCampaignMember, requireDM } = require('../middleware/campaignAcce
|
|||||||
const { generateInviteCode } = require('../utils/inviteCode');
|
const { generateInviteCode } = require('../utils/inviteCode');
|
||||||
const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
|
const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||||
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
|
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
|
||||||
|
const { visitedLocationIds } = require('../utils/locations');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -16,11 +17,13 @@ function campaignSummary(campaign, role) {
|
|||||||
id: campaign.id,
|
id: campaign.id,
|
||||||
name: campaign.name,
|
name: campaign.name,
|
||||||
description: campaign.description,
|
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,
|
startingGold: campaign.startingGold,
|
||||||
startingItemCount: campaign.startingItemCount,
|
startingItemCount: campaign.startingItemCount,
|
||||||
startingSpellCount: campaign.startingSpellCount,
|
startingSpellCount: campaign.startingSpellCount,
|
||||||
skipMotivationRoll: campaign.skipMotivationRoll,
|
skipMotivationRoll: campaign.skipMotivationRoll,
|
||||||
|
showMonsterHp: campaign.showMonsterHp,
|
||||||
ownerId: campaign.ownerId,
|
ownerId: campaign.ownerId,
|
||||||
archivedAt: campaign.archivedAt,
|
archivedAt: campaign.archivedAt,
|
||||||
role,
|
role,
|
||||||
@@ -45,10 +48,11 @@ router.get('/', async (req, res, next) => {
|
|||||||
const createCampaignSchema = z.object({
|
const createCampaignSchema = z.object({
|
||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
description: z.string().max(2000).optional().nullable(),
|
description: z.string().max(2000).optional().nullable(),
|
||||||
startingGold: z.number().int().min(0).optional(),
|
startingGold: z.number().int().min(0).max(1000000).optional(),
|
||||||
startingItemCount: z.number().int().min(0).optional(),
|
startingItemCount: z.number().int().min(0).max(50).optional(),
|
||||||
startingSpellCount: z.number().int().min(0).optional(),
|
startingSpellCount: z.number().int().min(0).max(50).optional(),
|
||||||
skipMotivationRoll: z.boolean().optional(),
|
skipMotivationRoll: z.boolean().optional(),
|
||||||
|
showMonsterHp: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /campaigns — create, creator becomes DM/owner
|
// POST /campaigns — create, creator becomes DM/owner
|
||||||
@@ -205,7 +209,19 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
|
|||||||
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
|
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
|
||||||
orderBy: { createdAt: 'asc' },
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -252,7 +268,7 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { character: characterSummary(character) });
|
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { characterId: character.id });
|
||||||
res.status(201).json({ character: characterSummary(character) });
|
res.status(201).json({ character: characterSummary(character) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -260,12 +276,28 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Weapons (campaign-level pool; DM manages, anyone in campaign can read)
|
// Weapons (campaign-level pool; the DM manages it and sees all of it)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// What a player may see of a pool: whatever one of their own characters holds,
|
||||||
|
// plus the starting options while they still have a character to build. The
|
||||||
|
// rest of the pool — gear yet to be found, spells yet to be learned — is the
|
||||||
|
// DM's. `held` is the pool model's relation to the per-character rows.
|
||||||
|
async function poolScope(req, held) {
|
||||||
|
const where = { campaignId: req.campaign.id };
|
||||||
|
if (req.membership.role === 'DM') return where;
|
||||||
|
|
||||||
|
const mine = { userId: req.user.id, campaignId: req.campaign.id };
|
||||||
|
const building = await prisma.character.count({ where: { ...mine, buildStage: { not: 'COMPLETE' } } });
|
||||||
|
return {
|
||||||
|
...where,
|
||||||
|
OR: [{ [held]: { some: { character: mine } } }, ...(building > 0 ? [{ isStartingOption: true }] : [])],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
|
router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
|
||||||
try {
|
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 });
|
res.json({ weapons });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -350,7 +382,7 @@ router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), as
|
|||||||
|
|
||||||
router.get('/:id/spells', requireCampaignMember(), async (req, res, next) => {
|
router.get('/:id/spells', requireCampaignMember(), async (req, res, next) => {
|
||||||
try {
|
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 });
|
res.json({ spells });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -409,7 +441,7 @@ router.delete('/:id/spells/:spellId', requireCampaignMember(), requireDM(), asyn
|
|||||||
|
|
||||||
router.get('/:id/items', requireCampaignMember(), async (req, res, next) => {
|
router.get('/:id/items', requireCampaignMember(), async (req, res, next) => {
|
||||||
try {
|
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 });
|
res.json({ items });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -476,8 +508,8 @@ router.get('/:id/monsters', requireCampaignMember(), requireDM(), async (req, re
|
|||||||
|
|
||||||
const monsterSchema = z.object({
|
const monsterSchema = z.object({
|
||||||
name: z.string().min(1).max(80),
|
name: z.string().min(1).max(80),
|
||||||
ac: z.number().int().min(0),
|
ac: z.number().int().min(0).max(50),
|
||||||
maxHp: z.number().int().min(1),
|
maxHp: z.number().int().min(1).max(100000),
|
||||||
speed: z.number().int().min(0).optional(),
|
speed: z.number().int().min(0).optional(),
|
||||||
strScore: z.number().int().min(1).max(30).optional(),
|
strScore: z.number().int().min(1).max(30).optional(),
|
||||||
dexScore: z.number().int().min(1).max(30).optional(),
|
dexScore: z.number().int().min(1).max(30).optional(),
|
||||||
@@ -485,7 +517,7 @@ const monsterSchema = z.object({
|
|||||||
intScore: z.number().int().min(1).max(30).optional(),
|
intScore: z.number().int().min(1).max(30).optional(),
|
||||||
wisScore: 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(),
|
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(),
|
damageDice: z.string().max(20).optional().nullable(),
|
||||||
challengeRating: z.string().max(10).optional().nullable(),
|
challengeRating: z.string().max(10).optional().nullable(),
|
||||||
specialTrait: z.string().max(500).optional().nullable(),
|
specialTrait: z.string().max(500).optional().nullable(),
|
||||||
@@ -533,9 +565,11 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
|
|||||||
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
|
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const isDM = req.membership.role === 'DM';
|
const isDM = req.membership.role === 'DM';
|
||||||
// Archived encounters are the DM's to see; players only get the live list.
|
// The DM sees every encounter. A player sees the live ones that one of
|
||||||
|
// their own characters is in, and nothing of the rest.
|
||||||
|
const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } };
|
||||||
const encounters = await prisma.encounter.findMany({
|
const encounters = await prisma.encounter.findMany({
|
||||||
where: { campaignId: req.campaign.id, ...(isDM ? {} : { archivedAt: null }) },
|
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||||
});
|
});
|
||||||
// dmNotes never leaves the server for a player.
|
// dmNotes never leaves the server for a player.
|
||||||
@@ -549,12 +583,18 @@ const createEncounterSchema = z.object({
|
|||||||
name: z.string().min(1).max(120),
|
name: z.string().min(1).max(120),
|
||||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||||
description: z.string().max(4000).optional().nullable(),
|
description: z.string().max(4000).optional().nullable(),
|
||||||
|
locationId: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const parsed = createEncounterSchema.safeParse(req.body);
|
const parsed = createEncounterSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
if (parsed.data.locationId) {
|
||||||
|
const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } });
|
||||||
|
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||||
|
}
|
||||||
|
|
||||||
// A new encounter goes to the top of the campaign's order.
|
// 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 first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
|
||||||
const encounter = await prisma.encounter.create({
|
const encounter = await prisma.encounter.create({
|
||||||
@@ -610,9 +650,9 @@ const npcSchema = z.object({
|
|||||||
disposition: z.enum(['FRIENDLY', 'NEUTRAL', 'HOSTILE']).optional(),
|
disposition: z.enum(['FRIENDLY', 'NEUTRAL', 'HOSTILE']).optional(),
|
||||||
description: z.string().max(2000).optional().nullable(),
|
description: z.string().max(2000).optional().nullable(),
|
||||||
dmNotes: z.string().max(4000).optional().nullable(),
|
dmNotes: z.string().max(4000).optional().nullable(),
|
||||||
ac: z.number().int().min(0).optional().nullable(),
|
ac: z.number().int().min(0).max(50).optional().nullable(),
|
||||||
maxHp: z.number().int().min(1).optional().nullable(),
|
maxHp: z.number().int().min(1).max(100000).optional().nullable(),
|
||||||
attackBonus: z.number().int().optional().nullable(),
|
attackBonus: z.number().int().min(-20).max(50).optional().nullable(),
|
||||||
damageDice: z.string().max(20).optional().nullable(),
|
damageDice: z.string().max(20).optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -676,12 +716,17 @@ router.get('/:id/notes', requireCampaignMember(), async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Locations — DM-managed, used to track where each character currently is
|
// Locations — DM-managed, used to track where each character currently is.
|
||||||
|
// A player only sees the places one of their own characters has been to.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
router.get('/:id/locations', requireCampaignMember(), async (req, res, next) => {
|
router.get('/:id/locations', requireCampaignMember(), async (req, res, next) => {
|
||||||
try {
|
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 });
|
res.json({ locations });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -733,7 +778,7 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
|
|||||||
// backend/src/game/data/{weapons,spells,monsters}Library.js for the sets.
|
// 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 {
|
try {
|
||||||
res.json({ weapons: WEAPON_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
|
res.json({ weapons: WEAPON_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const {
|
|||||||
const characterOptions = require('../game/data/characterOptions');
|
const characterOptions = require('../game/data/characterOptions');
|
||||||
const { SKILLS } = require('../game/data/skills');
|
const { SKILLS } = require('../game/data/skills');
|
||||||
const { InsufficientGoldError, adjustGold, giveGear } = require('../utils/grants');
|
const { InsufficientGoldError, adjustGold, giveGear } = require('../utils/grants');
|
||||||
|
const { recordVisits } = require('../utils/locations');
|
||||||
|
const { absorbDamage } = require('../utils/hp');
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
// 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.
|
// 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);
|
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||||
return {
|
return {
|
||||||
id: character.id,
|
id: character.id,
|
||||||
@@ -82,16 +91,17 @@ function serializeCharacter(character, isOwner) {
|
|||||||
appearance: character.appearance,
|
appearance: character.appearance,
|
||||||
privateNotes: isOwner ? character.privateNotes : undefined,
|
privateNotes: isOwner ? character.privateNotes : undefined,
|
||||||
diceMode: character.diceMode,
|
diceMode: character.diceMode,
|
||||||
currentLocationId: character.currentLocationId,
|
currentLocationId: restricted ? null : character.currentLocationId,
|
||||||
currentLocation: character.currentLocation || null,
|
currentLocation: restricted ? null : character.currentLocation || null,
|
||||||
gold: character.gold,
|
gold: restricted ? null : character.gold,
|
||||||
|
restricted,
|
||||||
buildStage: character.buildStage,
|
buildStage: character.buildStage,
|
||||||
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
|
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
|
||||||
skillProficiencies: character.skillProficiencies,
|
skillProficiencies: character.skillProficiencies,
|
||||||
saveProficiencies: character.saveProficiencies,
|
saveProficiencies: character.saveProficiencies,
|
||||||
// Each held entry carries what its level works out to: the dice at that
|
// 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.
|
// level, and whether this character is high enough level to use it.
|
||||||
inventoryItems: character.inventoryItems.map((ii) => ({
|
inventoryItems: (restricted ? [] : character.inventoryItems).map((ii) => ({
|
||||||
...ii,
|
...ii,
|
||||||
scaledDamageDice: ii.item.damageDice
|
scaledDamageDice: ii.item.damageDice
|
||||||
? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel)
|
? 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) => {
|
router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const character = await loadFullCharacter(req.character.id);
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -135,8 +145,8 @@ router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
|
|||||||
const patchCharacterSchema = z.object({
|
const patchCharacterSchema = z.object({
|
||||||
name: z.string().min(1).max(80).optional(),
|
name: z.string().min(1).max(80).optional(),
|
||||||
currentHp: z.number().int().optional(),
|
currentHp: z.number().int().optional(),
|
||||||
tempHp: z.number().int().min(0).optional(),
|
tempHp: z.number().int().min(0).max(9999).optional(),
|
||||||
armorBonus: z.number().int().min(0).optional(),
|
armorBonus: z.number().int().min(0).max(30).optional(),
|
||||||
speedOverride: z.number().int().min(0).nullable().optional(),
|
speedOverride: z.number().int().min(0).nullable().optional(),
|
||||||
specialAbilities: z.string().max(4000).nullable().optional(),
|
specialAbilities: z.string().max(4000).nullable().optional(),
|
||||||
background: 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.' });
|
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.
|
// 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)) {
|
if (req.character.buildStage === 'COMPLETE' && (parsed.data.currentHp !== undefined || parsed.data.level !== undefined)) {
|
||||||
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
|
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 });
|
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) {
|
if (updated.level < req.character.level) {
|
||||||
// Dropping a level can put equipped gear and prepared spells out of reach.
|
// Dropping a level can put equipped gear and prepared spells out of reach.
|
||||||
const tooHigh = { characterId: updated.id, level: { gt: updated.level } };
|
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
|
// Broadcast payload never includes privateNotes — a socket room has
|
||||||
// every campaign member in it, not just the owner. Only the direct HTTP
|
// every campaign member in it, not just the owner. Only the direct HTTP
|
||||||
// response (below) is allowed to include it.
|
// 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) });
|
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -364,7 +383,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const full = await loadFullCharacter(copyId);
|
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) });
|
res.status(201).json({ character: serializeCharacter(full, true) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -781,7 +800,7 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
const full = await loadFullCharacter(result.id);
|
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) });
|
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -840,6 +859,7 @@ const GOLD_TRANSACTION_INCLUDE = { createdBy: { select: { displayName: true } }
|
|||||||
|
|
||||||
router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||||
try {
|
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({
|
const transactions = await prisma.goldTransaction.findMany({
|
||||||
where: { characterId: req.character.id },
|
where: { characterId: req.character.id },
|
||||||
include: GOLD_TRANSACTION_INCLUDE,
|
include: GOLD_TRANSACTION_INCLUDE,
|
||||||
@@ -857,7 +877,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
|
|||||||
try {
|
try {
|
||||||
const parsed = z
|
const parsed = z
|
||||||
.object({
|
.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(),
|
reason: z.string().trim().max(200).optional().nullable(),
|
||||||
})
|
})
|
||||||
.safeParse(req.body);
|
.safeParse(req.body);
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ const { z } = require('zod');
|
|||||||
const prisma = require('../db');
|
const prisma = require('../db');
|
||||||
const { requireAuth } = require('../middleware/auth');
|
const { requireAuth } = require('../middleware/auth');
|
||||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
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 { adjustGold, giveGear } = require('../utils/grants');
|
||||||
|
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
|
||||||
|
const { absorbDamage } = require('../utils/hp');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -18,9 +20,20 @@ async function requireEncounterAccess(req, res, next) {
|
|||||||
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
|
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
|
||||||
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
|
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.encounter = encounter;
|
||||||
req.membership = membership;
|
req.membership = membership;
|
||||||
req.isDM = membership.role === 'DM';
|
req.isDM = isDM;
|
||||||
return next();
|
return next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return next(err);
|
return next(err);
|
||||||
@@ -37,10 +50,24 @@ function clamp(value, min, max) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Highest initiative first; ties keep the order combatants were added in.
|
// Highest initiative first; ties keep the order combatants were added in.
|
||||||
|
// Anyone who has not rolled yet waits at the bottom.
|
||||||
function inTurnOrder(participants) {
|
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
|
// 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 —
|
// 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
|
// 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:
|
// 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
|
// no DM notes, no monster stat blocks, no NPC secrets or stats, and only the
|
||||||
// rewards that have actually been handed out.
|
// 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({
|
const encounter = await prisma.encounter.findUnique({
|
||||||
where: { id: encounterId },
|
where: { id: encounterId },
|
||||||
include: {
|
include: {
|
||||||
|
campaign: { select: { showMonsterHp: true } },
|
||||||
|
location: { select: { id: true, name: true, description: true } },
|
||||||
participants: {
|
participants: {
|
||||||
include: {
|
include: {
|
||||||
character: {
|
character: {
|
||||||
@@ -115,16 +149,20 @@ async function serializeEncounter(encounterId, isDM) {
|
|||||||
});
|
});
|
||||||
if (!encounter) return encounter;
|
if (!encounter) return encounter;
|
||||||
|
|
||||||
|
const hideHp = !isDM && !encounter.campaign.showMonsterHp;
|
||||||
const participants = inTurnOrder(encounter.participants).map((p) => {
|
const participants = inTurnOrder(encounter.participants).map((p) => {
|
||||||
const { character, monster, npc, ...row } = 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) {
|
if (character) {
|
||||||
// A character combatant's HP is the character's own HP, not a
|
// 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.
|
// per-encounter copy, so the tracker and the sheet can never disagree.
|
||||||
const { maxHp, ...combatInfo } = characterCombatInfo(character);
|
const { maxHp, ...combatInfo } = characterCombatInfo(character);
|
||||||
out.currentHp = character.currentHp;
|
out.currentHp = character.currentHp;
|
||||||
out.maxHp = maxHp;
|
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;
|
out.characterCombatInfo = combatInfo;
|
||||||
}
|
}
|
||||||
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
|
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;
|
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
|
||||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
|
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;
|
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 {
|
return {
|
||||||
...scene,
|
...scene,
|
||||||
|
locationId: knowsPlace ? scene.locationId : null,
|
||||||
|
location: knowsPlace ? location : null,
|
||||||
...(isDM ? { dmNotes } : {}),
|
...(isDM ? { dmNotes } : {}),
|
||||||
participants,
|
participants,
|
||||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
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 });
|
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 = {}) {
|
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);
|
emitEncounterUpdated(req);
|
||||||
res.status(status).json({ ...extra, encounter });
|
res.status(status).json({ ...extra, encounter });
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -174,6 +237,7 @@ const patchEncounterSchema = z.object({
|
|||||||
name: z.string().min(1).max(120).optional(),
|
name: z.string().min(1).max(120).optional(),
|
||||||
description: z.string().max(4000).nullable().optional(),
|
description: z.string().max(4000).nullable().optional(),
|
||||||
dmNotes: 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)
|
archived: z.boolean().optional(), // put away (true) or bring back (false)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,6 +249,11 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
|||||||
if (archived === true && !req.encounter.archivedAt) data.archivedAt = new Date();
|
if (archived === true && !req.encounter.archivedAt) data.archivedAt = new Date();
|
||||||
if (archived === false) data.archivedAt = null;
|
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.
|
// Starting a battle puts the first combatant in the initiative order on turn.
|
||||||
const mode = data.mode ?? req.encounter.mode;
|
const mode = data.mode ?? req.encounter.mode;
|
||||||
if (data.status === 'ACTIVE' && mode === 'BATTLE' && !req.encounter.activeParticipantId) {
|
if (data.status === 'ACTIVE' && mode === 'BATTLE' && !req.encounter.activeParticipantId) {
|
||||||
@@ -221,7 +290,7 @@ const participantSchema = z
|
|||||||
monsterId: z.string().optional(),
|
monsterId: z.string().optional(),
|
||||||
npcId: z.string().optional(),
|
npcId: z.string().optional(),
|
||||||
displayName: z.string().min(1).max(80).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, {
|
.refine((data) => [data.characterId, data.monsterId, data.npcId].filter(Boolean).length === 1, {
|
||||||
message: 'Provide exactly one of characterId, monsterId or npcId',
|
message: 'Provide exactly one of characterId, monsterId or npcId',
|
||||||
@@ -231,9 +300,10 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
|||||||
try {
|
try {
|
||||||
const parsed = participantSchema.safeParse(req.body);
|
const parsed = participantSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
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 currentHp, maxHp, resolvedName;
|
||||||
|
let bonus = 0;
|
||||||
|
|
||||||
if (characterId) {
|
if (characterId) {
|
||||||
const character = await prisma.character.findUnique({
|
const character = await prisma.character.findUnique({
|
||||||
@@ -246,6 +316,7 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
|||||||
currentHp = character.currentHp;
|
currentHp = character.currentHp;
|
||||||
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
||||||
resolvedName = displayName || character.name;
|
resolvedName = displayName || character.name;
|
||||||
|
bonus = initiativeBonus({ character });
|
||||||
} else if (monsterId) {
|
} else if (monsterId) {
|
||||||
const monster = await prisma.monster.findUnique({ where: { id: monsterId } });
|
const monster = await prisma.monster.findUnique({ where: { id: monsterId } });
|
||||||
if (!monster || monster.campaignId !== req.encounter.campaignId) {
|
if (!monster || monster.campaignId !== req.encounter.campaignId) {
|
||||||
@@ -254,6 +325,7 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
|||||||
currentHp = monster.maxHp;
|
currentHp = monster.maxHp;
|
||||||
maxHp = monster.maxHp;
|
maxHp = monster.maxHp;
|
||||||
resolvedName = displayName || monster.name;
|
resolvedName = displayName || monster.name;
|
||||||
|
bonus = initiativeBonus({ monster });
|
||||||
} else {
|
} else {
|
||||||
const npc = await prisma.npc.findUnique({ where: { id: npcId } });
|
const npc = await prisma.npc.findUnique({ where: { id: npcId } });
|
||||||
if (!npc || npc.campaignId !== req.encounter.campaignId) {
|
if (!npc || npc.campaignId !== req.encounter.campaignId) {
|
||||||
@@ -272,7 +344,8 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
|||||||
monsterId: monsterId || null,
|
monsterId: monsterId || null,
|
||||||
npcId: npcId || null,
|
npcId: npcId || null,
|
||||||
displayName: resolvedName,
|
displayName: resolvedName,
|
||||||
initiative,
|
initiativeRoll: initiativeRoll ?? null,
|
||||||
|
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
|
||||||
currentHp,
|
currentHp,
|
||||||
maxHp,
|
maxHp,
|
||||||
isMonster: Boolean(monsterId),
|
isMonster: Boolean(monsterId),
|
||||||
@@ -287,34 +360,49 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
|||||||
|
|
||||||
const updateParticipantSchema = z.object({
|
const updateParticipantSchema = z.object({
|
||||||
currentHp: z.number().int().optional(),
|
currentHp: z.number().int().optional(),
|
||||||
maxHp: z.number().int().min(1).optional(),
|
maxHp: z.number().int().min(1).max(100000).optional(),
|
||||||
initiative: z.number().int().optional(),
|
initiativeRoll: initiativeRollSchema.nullable().optional(), // null clears the roll
|
||||||
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
|
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
|
||||||
displayName: z.string().min(1).max(80).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 {
|
try {
|
||||||
const parsed = updateParticipantSchema.safeParse(req.body);
|
const parsed = updateParticipantSchema.safeParse(req.body);
|
||||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
|
||||||
const existing = await prisma.encounterParticipant.findUnique({
|
const existing = await prisma.encounterParticipant.findUnique({
|
||||||
where: { id: req.params.participantId },
|
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) {
|
if (!existing || existing.encounterId !== req.encounter.id) {
|
||||||
return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
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 };
|
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) {
|
if (existing.character) {
|
||||||
// Max HP comes from the sheet, and damage/healing lands on the character.
|
// Max HP comes from the sheet, and damage/healing lands on the character.
|
||||||
const { character } = existing;
|
const { character } = existing;
|
||||||
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
||||||
data.maxHp = maxHp;
|
data.maxHp = maxHp;
|
||||||
if (data.currentHp !== undefined) {
|
if (data.currentHp !== undefined) {
|
||||||
data.currentHp = clamp(data.currentHp, 0, maxHp);
|
// Temp HP soaks damage first; what is left lands on the character.
|
||||||
await prisma.character.update({ where: { id: character.id }, data: { currentHp: data.currentHp } });
|
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);
|
emitCharacterUpdated(req, character.id);
|
||||||
}
|
}
|
||||||
} else if (data.currentHp !== undefined) {
|
} else if (data.currentHp !== undefined) {
|
||||||
@@ -389,11 +477,12 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
|||||||
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
|
...(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', {
|
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
|
||||||
encounterId: updated.id,
|
encounterId: updated.id,
|
||||||
currentRound: updated.currentRound,
|
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) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
@@ -404,18 +493,18 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const rewardSchema = z.discriminatedUnion('type', [
|
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({
|
z.object({
|
||||||
type: z.literal('WEAPON'),
|
type: z.literal('WEAPON'),
|
||||||
weaponId: z.string(),
|
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(),
|
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||||
note: z.string().trim().max(200).optional().nullable(),
|
note: z.string().trim().max(200).optional().nullable(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal('ITEM'),
|
type: z.literal('ITEM'),
|
||||||
itemId: z.string(),
|
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(),
|
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||||
note: z.string().trim().max(200).optional().nullable(),
|
note: z.string().trim().max(200).optional().nullable(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||||
|
|
||||||
// Clients join a room per campaign (`campaign:<id>`) after authenticating with
|
// Clients join a room per campaign (`campaign:<id>`) after authenticating with
|
||||||
// the same JWT issued by /auth/login. Server-side route handlers emit into
|
// the same JWT issued by /auth/login. Server-side route handlers emit into
|
||||||
// these rooms (see character:updated, encounter:*-updated, character:created).
|
// 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) {
|
function attachSockets(io) {
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const token = socket.handshake.auth && socket.handshake.auth.token;
|
const token = socket.handshake.auth && socket.handshake.auth.token;
|
||||||
@@ -17,8 +20,11 @@ function attachSockets(io) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('campaign:join', (campaignId) => {
|
socket.on('campaign:join', async (campaignId) => {
|
||||||
if (typeof campaignId === 'string') socket.join(`campaign:${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) => {
|
socket.on('campaign:leave', (campaignId) => {
|
||||||
if (typeof campaignId === 'string') socket.leave(`campaign:${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 |
|
| 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 |
|
| 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 |
|
| 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:
|
**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 |
|
| Level a weapon or item up or down | No | Yes | No |
|
||||||
| Record a gold gain or spend | Yes | Yes | No |
|
| Record a gold gain or spend | Yes | Yes | No |
|
||||||
| Give gold, weapons, items or spells to another character | 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 |
|
| Change level, XP, location or speed override | No | Yes | No |
|
||||||
| Post a character note | Yes | Yes | Yes |
|
| Post a character note | Yes | Yes | Yes |
|
||||||
| Copy to another campaign | Yes | Yes | No |
|
| Copy to another campaign | Yes | Yes | No |
|
||||||
|
|||||||
+36
-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
|
- An encounter is one **scene** the DM runs: a fight, or a meeting with
|
||||||
people who want something.
|
people who want something.
|
||||||
- Encounters belong to a campaign and are listed on the campaign page. The DM
|
- 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 encounter page updates live for everyone who has it open.
|
||||||
- The DM runs everything. Players watch, and see less than the DM does.
|
- 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 |
|
| 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" |
|
| Layout | Everyone in one list, in initiative order | Three groups: "People you meet", "The party", "Creatures" |
|
||||||
| Rounds and turns | Yes | No |
|
| 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 |
|
| Start button | Begin Battle | Begin Quest |
|
||||||
| End button | End Battle | Complete Quest |
|
| End button | End Battle | Complete Quest |
|
||||||
|
|
||||||
@@ -63,6 +67,17 @@ Status is a label. Nothing is locked when an encounter is complete: the DM
|
|||||||
can still change HP, add people and award rewards. Encounters cannot be
|
can still change HP, add people and award rewards. Encounters cannot be
|
||||||
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, archive and delete
|
||||||
|
|
||||||
**Order.** The campaign page lists encounters in the **campaign order**, which
|
**Order.** The campaign page lists encounters in the **campaign order**, which
|
||||||
@@ -73,8 +88,8 @@ anyone else. ▲ and ▼ only show while sorting by campaign order.
|
|||||||
|
|
||||||
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
|
**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
|
away without changing it. It moves to the DM's "Archived" section and
|
||||||
disappears from the players' list; anyone who already has its page open or
|
disappears from the players' list; a player whose character is in it can still
|
||||||
bookmarked can still open it. "Restore" puts it back on the list.
|
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
|
**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
|
the list) removes the scene, who was in it and its reward list, after a
|
||||||
@@ -130,6 +145,10 @@ The DM types an amount and clicks Damage or Heal on a card.
|
|||||||
| NPC | In the encounter. An NPC with no HP set has no health bar. |
|
| NPC | In the encounter. An NPC with no HP set has no health bar. |
|
||||||
|
|
||||||
- HP always stays between 0 and max.
|
- 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
|
- At 0 HP a combatant is marked **Down** and dimmed. It keeps its place in
|
||||||
the order; nothing is removed automatically.
|
the order; nothing is removed automatically.
|
||||||
- A character's temporary HP is shown beside its HP but is not used up
|
- A character's temporary HP is shown beside its HP but is not used up
|
||||||
@@ -139,9 +158,16 @@ The DM types an amount and clicks Damage or Heal on a card.
|
|||||||
|
|
||||||
Battles only.
|
Battles only.
|
||||||
|
|
||||||
- The DM types each combatant's **initiative** when adding them, and can
|
- Each combatant has an **initiative roll** field. Type what the d20 showed,
|
||||||
change it on the card. The app does not roll it. The list is ordered
|
1 to 20; the field accepts nothing else. The app does not roll the die, but
|
||||||
highest first; ties keep the order they were added in.
|
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
|
- **Begin Battle** puts the first combatant on turn. Their card is
|
||||||
highlighted and the header names them.
|
highlighted and the header names them.
|
||||||
- **Next Turn** passes the turn to the next in the order. After the last
|
- **Next Turn** passes the turn to the next in the order. After the last
|
||||||
@@ -210,12 +236,13 @@ information cannot be dug out of the page.
|
|||||||
| Action | DM | Players |
|
| Action | DM | Players |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Create an encounter | Yes | No |
|
| 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 |
|
| Change mode, status, scene or DM notes | Yes | No |
|
||||||
| Rearrange, archive, restore or delete encounters | Yes | No |
|
| Rearrange, archive, restore or delete encounters | Yes | No |
|
||||||
| See archived encounters in the list | Yes | No |
|
| See archived encounters in the list | Yes | No |
|
||||||
| Add or remove anyone | 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 |
|
| Advance turns and rounds | Yes | No |
|
||||||
| Add, remove or award rewards | Yes | No |
|
| Add, remove or award rewards | Yes | No |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -132,7 +132,7 @@ character's inventory, stacking with any they already hold. See
|
|||||||
|
|
||||||
| Action | DM | Owning player | Other players |
|
| 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 |
|
| Add, change or remove pool items | Yes | No | No |
|
||||||
| Give an item from the pool, or take one away | 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 |
|
| 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 |
|
| 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 |
|
| Add, change or remove spells in the list | Yes | No | No |
|
||||||
| Grant a spell from the list, or remove one | 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 |
|
| 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 |
|
| 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 |
|
| Add, change or remove pool entries | Yes | No | No |
|
||||||
| Give a weapon from the pool, or take one away | 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 |
|
| Give a held weapon to another character | Yes | Yes | No |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -373,10 +373,11 @@ export function useRemoveCharacterSpell(characterId: string) {
|
|||||||
// Money tracker
|
// 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({
|
return useQuery({
|
||||||
queryKey: ['character', characterId, 'gold'],
|
queryKey: ['character', characterId, 'gold'],
|
||||||
enabled: Boolean(characterId),
|
enabled: Boolean(characterId) && enabled,
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
(await apiClient.get<{ gold: number; transactions: GoldTransaction[] }>(`/characters/${characterId}/gold`)).data
|
(await apiClient.get<{ gold: number; transactions: GoldTransaction[] }>(`/characters/${characterId}/gold`)).data
|
||||||
.transactions,
|
.transactions,
|
||||||
@@ -514,10 +515,10 @@ export function useDeleteMonster(campaignId: string) {
|
|||||||
// Default content library — search/browse, "add to campaign"
|
// Default content library — search/browse, "add to campaign"
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
export function useCampaignLibrary(campaignId: string | undefined) {
|
export function useCampaignLibrary(campaignId: string | undefined, enabled = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['campaign', campaignId, 'library'],
|
queryKey: ['campaign', campaignId, 'library'],
|
||||||
enabled: Boolean(campaignId),
|
enabled: Boolean(campaignId) && enabled, // DM only
|
||||||
staleTime: Infinity, // static reference data — never changes under a campaign
|
staleTime: Infinity, // static reference data — never changes under a campaign
|
||||||
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
|
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
|
||||||
});
|
});
|
||||||
@@ -660,7 +661,7 @@ export function useEncounter(encounterId: string | undefined) {
|
|||||||
export function useAddParticipant(encounterId: string) {
|
export function useAddParticipant(encounterId: string) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
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,
|
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||||
});
|
});
|
||||||
@@ -701,11 +702,16 @@ export function usePatchEncounter(encounterId: string) {
|
|||||||
name?: string;
|
name?: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
dmNotes?: string | null;
|
dmNotes?: string | null;
|
||||||
|
locationId?: string | null;
|
||||||
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
|
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||||
// The campaign page lists encounters by name, mode and status.
|
// The campaign page lists encounters by name, mode and status, and an
|
||||||
if (variables.status || variables.mode || variables.name) qc.invalidateQueries({ queryKey: ['campaign'] });
|
// 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,11 +87,12 @@ export interface Campaign {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
inviteCode: string;
|
inviteCode?: string; // DM only
|
||||||
startingGold: number;
|
startingGold: number;
|
||||||
startingItemCount: number;
|
startingItemCount: number;
|
||||||
startingSpellCount: number;
|
startingSpellCount: number;
|
||||||
skipMotivationRoll: boolean;
|
skipMotivationRoll: boolean;
|
||||||
|
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
archivedAt: string | null; // set while the campaign is archived
|
archivedAt: string | null; // set while the campaign is archived
|
||||||
role: 'DM' | 'PLAYER';
|
role: 'DM' | 'PLAYER';
|
||||||
@@ -117,7 +118,7 @@ export interface CharacterSummary {
|
|||||||
buildStage: BuildStage;
|
buildStage: BuildStage;
|
||||||
currentHp: number;
|
currentHp: number;
|
||||||
tempHp: number;
|
tempHp: number;
|
||||||
gold: number;
|
gold: number | null; // null for another player's character
|
||||||
maxHp: number | null;
|
maxHp: number | null;
|
||||||
armorClass: number | null;
|
armorClass: number | null;
|
||||||
currentLocationId: string | null;
|
currentLocationId: string | null;
|
||||||
@@ -276,7 +277,10 @@ export interface CharacterDetail {
|
|||||||
diceMode: DiceMode;
|
diceMode: DiceMode;
|
||||||
currentLocationId: string | null;
|
currentLocationId: string | null;
|
||||||
currentLocation: Location | 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;
|
buildStage: BuildStage;
|
||||||
buildState: Record<string, unknown> | null;
|
buildState: Record<string, unknown> | null;
|
||||||
skillProficiencies: SkillProficiency[];
|
skillProficiencies: SkillProficiency[];
|
||||||
@@ -390,14 +394,21 @@ export interface EncounterParticipant {
|
|||||||
characterId: string | null;
|
characterId: string | null;
|
||||||
monsterId: string | null;
|
monsterId: string | null;
|
||||||
displayName: string;
|
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;
|
initiative: number;
|
||||||
currentHp: number;
|
initiativeBonus?: number; // a monster's or NPC's is sent to the DM only
|
||||||
maxHp: number;
|
// 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[];
|
conditions: string[];
|
||||||
isMonster: boolean;
|
isMonster: boolean;
|
||||||
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
|
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
|
||||||
npcId: string | null;
|
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.
|
// The DM gets the whole stat block; players only the id and name.
|
||||||
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
|
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
|
||||||
npc?: (NpcPublic & Partial<Npc>) | null;
|
npc?: (NpcPublic & Partial<Npc>) | null;
|
||||||
@@ -414,6 +425,9 @@ export interface Encounter {
|
|||||||
activeParticipantId: string | null;
|
activeParticipantId: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
dmNotes?: string | null; // DM only
|
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
|
archivedAt: string | null; // archived encounters only reach the DM's list
|
||||||
sortOrder: number; // place in the campaign's own order, lowest first
|
sortOrder: number; // place in the campaign's own order, lowest first
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { useState } from 'react';
|
||||||
import { useQuickStats } from '../api/queries';
|
import { useQuickStats } from '../api/queries';
|
||||||
import { Spinner, ErrorBanner } from './Spinner';
|
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 }) {
|
export function QuickStatsFab({ characterId }: { characterId: string }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -50,9 +51,7 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
|
|||||||
<span className="stat-value">{fmtMod(data.initiative)}</span>
|
<span className="stat-value">{fmtMod(data.initiative)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hp-bar-track mt-sm">
|
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
|
||||||
<div className={hpBarClass(data.currentHp, data.maxHp)} style={{ width: `${Math.max(0, Math.min(100, (data.currentHp / data.maxHp) * 100))}%` }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.spellcasting && (
|
{data.spellcasting && (
|
||||||
<div className="card mt-md">
|
<div className="card mt-md">
|
||||||
|
|||||||
@@ -29,12 +29,16 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
|||||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||||
qc.invalidateQueries({ queryKey: ['character'] });
|
qc.invalidateQueries({ queryKey: ['character'] });
|
||||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet HP
|
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 = () => {
|
const onCharacterCreated = () => {
|
||||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||||
};
|
};
|
||||||
const onParticipantUpdated = () => {
|
const onParticipantUpdated = () => {
|
||||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||||
|
// A player's list only holds the encounters their characters are in.
|
||||||
|
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
||||||
};
|
};
|
||||||
const onRoundAdvanced = () => {
|
const onRoundAdvanced = () => {
|
||||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||||
|
|||||||
@@ -450,6 +450,7 @@ input[type='radio'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.hp-bar-track {
|
.hp-bar-track {
|
||||||
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
@@ -470,6 +471,10 @@ input[type='radio'] {
|
|||||||
.hp-bar-fill.hp-critical {
|
.hp-bar-fill.hp-critical {
|
||||||
background: var(--danger-strong);
|
background: var(--danger-strong);
|
||||||
}
|
}
|
||||||
|
/* Temporary HP: a separate segment after the real HP. */
|
||||||
|
.hp-bar-fill.hp-temp {
|
||||||
|
background: var(--arcane-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||||
|
import { NumberInput } from '../components/NumberInput';
|
||||||
import { usePagination, Pagination } from '../components/Paginated';
|
import { usePagination, Pagination } from '../components/Paginated';
|
||||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
|
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
|
||||||
import { speciesIcon } from '../lib/speciesIcons';
|
import { speciesIcon } from '../lib/speciesIcons';
|
||||||
@@ -293,7 +294,7 @@ export function CampaignHomePage() {
|
|||||||
const { data: npcs } = useCampaignNpcs(campaignId, data?.campaign.role === 'DM');
|
const { data: npcs } = useCampaignNpcs(campaignId, data?.campaign.role === 'DM');
|
||||||
const { data: notes } = useCampaignNotes(campaignId);
|
const { data: notes } = useCampaignNotes(campaignId);
|
||||||
const { data: locations } = useCampaignLocations(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 createCharacter = useCreateCharacter(campaignId);
|
||||||
const deleteCharacter = useDeleteCharacter(campaignId);
|
const deleteCharacter = useDeleteCharacter(campaignId);
|
||||||
@@ -486,27 +487,32 @@ export function CampaignHomePage() {
|
|||||||
startingItemCount: Number(fd.get('startingItemCount')),
|
startingItemCount: Number(fd.get('startingItemCount')),
|
||||||
startingSpellCount: Number(fd.get('startingSpellCount')),
|
startingSpellCount: Number(fd.get('startingSpellCount')),
|
||||||
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
|
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
|
||||||
|
showMonsterHp: fd.get('showMonsterHp') === 'on',
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3">
|
<div className="grid grid-cols-3">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Starting gold</label>
|
<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>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Starting items (weapons + items)</label>
|
<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>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>Starting spells</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<label className="checkbox-row">
|
<label className="checkbox-row">
|
||||||
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
|
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
|
||||||
Skip the motivation step when building a character
|
Skip the motivation step when building a character
|
||||||
</label>
|
</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}>
|
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
|
||||||
Save Settings
|
Save Settings
|
||||||
</button>
|
</button>
|
||||||
@@ -750,6 +756,18 @@ export function CampaignHomePage() {
|
|||||||
)}
|
)}
|
||||||
</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 && (
|
{isDM && (
|
||||||
<div className="mt-lg card">
|
<div className="mt-lg card">
|
||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
@@ -800,9 +818,9 @@ export function CampaignHomePage() {
|
|||||||
<textarea name="description" rows={2} className="textarea-block mt-sm" placeholder="What the party sees and knows (shown to players)" />
|
<textarea name="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)" />
|
<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">
|
<div className="grid grid-cols-3 mt-sm">
|
||||||
<input name="ac" type="number" min={0} placeholder="AC (optional)" />
|
<NumberInput name="ac" min={0} max={50} placeholder="AC (optional)" />
|
||||||
<input name="maxHp" type="number" min={1} placeholder="HP (optional)" />
|
<NumberInput name="maxHp" min={1} max={100000} placeholder="HP (optional)" />
|
||||||
<input name="attackBonus" type="number" placeholder="Attack bonus (optional)" />
|
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus (optional)" />
|
||||||
<input name="damageDice" placeholder="Damage (optional, e.g. 1d6)" maxLength={20} />
|
<input name="damageDice" placeholder="Damage (optional, e.g. 1d6)" maxLength={20} />
|
||||||
<button type="submit" className="btn btn-primary" disabled={createNpc.isPending}>Add NPC</button>
|
<button type="submit" className="btn btn-primary" disabled={createNpc.isPending}>Add NPC</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -838,7 +856,10 @@ export function CampaignHomePage() {
|
|||||||
<div className="card-title">
|
<div className="card-title">
|
||||||
<span>Locations</span>
|
<span>Locations</span>
|
||||||
</div>
|
</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">
|
<form onSubmit={handleCreateLocation} className="flex-row flex-wrap">
|
||||||
<input
|
<input
|
||||||
placeholder="Location name (e.g. The Rusty Tankard)"
|
placeholder="Location name (e.g. The Rusty Tankard)"
|
||||||
@@ -1200,9 +1221,9 @@ export function CampaignHomePage() {
|
|||||||
className="grid grid-cols-3"
|
className="grid grid-cols-3"
|
||||||
>
|
>
|
||||||
<input name="name" placeholder="Name" required />
|
<input name="name" placeholder="Name" required />
|
||||||
<input name="ac" type="number" placeholder="AC" required />
|
<NumberInput name="ac" min={0} max={50} placeholder="AC" required />
|
||||||
<input name="maxHp" type="number" placeholder="Max HP" required />
|
<NumberInput name="maxHp" min={1} max={100000} placeholder="Max HP" required />
|
||||||
<input name="attackBonus" type="number" placeholder="Attack bonus" />
|
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus" />
|
||||||
<input name="damageDice" placeholder="Damage dice" />
|
<input name="damageDice" placeholder="Damage dice" />
|
||||||
<button type="submit" className="btn btn-primary">Add</button>
|
<button type="submit" className="btn btn-primary">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react';
|
|||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
|
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
|
||||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||||
|
import { NumberInput } from '../components/NumberInput';
|
||||||
import { InfoTip } from '../components/InfoTip';
|
import { InfoTip } from '../components/InfoTip';
|
||||||
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
|
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
|
||||||
import { SPECIES_ABILITY_BONUSES, abilityModifier, speciesBonusFor } from '../lib/speciesData';
|
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>
|
<p>{label} Roll it at the table, then type the result in below.</p>
|
||||||
<div className="flex-row" style={{ justifyContent: 'center' }}>
|
<div className="flex-row" style={{ justifyContent: 'center' }}>
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
|
||||||
min={1}
|
min={1}
|
||||||
max={sides}
|
max={sides}
|
||||||
placeholder={`1–${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">
|
<div key={i} className="flex-row flex-wrap mt-sm">
|
||||||
<span className="badge">Set {i + 1}</span>
|
<span className="badge">Set {i + 1}</span>
|
||||||
{set.map((v, j) => (
|
{set.map((v, j) => (
|
||||||
<input
|
<NumberInput
|
||||||
key={j}
|
key={j}
|
||||||
type="number"
|
|
||||||
min={1}
|
min={1}
|
||||||
max={6}
|
max={6}
|
||||||
value={v}
|
value={v}
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ import {
|
|||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||||
|
import { NumberInput } from '../components/NumberInput';
|
||||||
|
import { HpBar } from '../components/HpBar';
|
||||||
import { QuickStatsFab } from '../components/QuickStatsDrawer';
|
import { QuickStatsFab } from '../components/QuickStatsDrawer';
|
||||||
import { InfoTip } from '../components/InfoTip';
|
import { InfoTip } from '../components/InfoTip';
|
||||||
import { usePagination, Pagination } from '../components/Paginated';
|
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 { speciesIcon } from '../lib/speciesIcons';
|
||||||
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||||
import { CLASS_HIT_DIE } from '../lib/classData';
|
import { CLASS_HIT_DIE } from '../lib/classData';
|
||||||
@@ -91,10 +93,11 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
|||||||
const [kind, id] = what.split(':');
|
const [kind, id] = what.split(':');
|
||||||
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
|
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
|
||||||
const heldItem = character.inventoryItems.find((i) => i.itemId === 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 count = Math.floor(Number(amount));
|
||||||
const countOk = kind === 'spell' || (count >= 1 && count <= max);
|
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() {
|
function handleGive() {
|
||||||
const gift: Gift | null =
|
const gift: Gift | null =
|
||||||
@@ -142,7 +145,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
|||||||
style={{ flex: 2, minWidth: 160 }}
|
style={{ flex: 2, minWidth: 160 }}
|
||||||
>
|
>
|
||||||
<option value="">What to give…</option>
|
<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 && (
|
{character.weapons.length > 0 && (
|
||||||
<optgroup label="Weapons">
|
<optgroup label="Weapons">
|
||||||
{character.weapons.map((w) => (
|
{character.weapons.map((w) => (
|
||||||
@@ -172,8 +175,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
{what && kind !== 'spell' && (
|
{what && kind !== 'spell' && (
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
|
||||||
min={1}
|
min={1}
|
||||||
max={max}
|
max={max}
|
||||||
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
|
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
|
||||||
@@ -212,7 +214,7 @@ export function CharacterSheetPage() {
|
|||||||
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
|
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
|
||||||
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
|
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
|
||||||
const { data: campaignItems } = useCampaignItems(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: locations } = useCampaignLocations(character?.campaignId);
|
||||||
const { data: notes } = useCharacterNotes(characterId);
|
const { data: notes } = useCharacterNotes(characterId);
|
||||||
|
|
||||||
@@ -412,15 +414,15 @@ export function CharacterSheetPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hp-bar-track mt-sm">
|
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
|
||||||
<div className={hpBarClass(character.currentHp, sheet.hp.max)} style={{ width: `${Math.max(0, Math.min(100, (character.currentHp / sheet.hp.max) * 100))}%` }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="flex-row mt-sm flex-wrap">
|
<div className="flex-row mt-sm flex-wrap">
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
min={1}
|
||||||
|
max={9999}
|
||||||
placeholder="Amount"
|
placeholder="Amount"
|
||||||
|
aria-label="HP change"
|
||||||
value={hpDelta}
|
value={hpDelta}
|
||||||
onChange={(e) => setHpDelta(e.target.value)}
|
onChange={(e) => setHpDelta(e.target.value)}
|
||||||
style={{ width: 100 }}
|
style={{ width: 100 }}
|
||||||
@@ -433,9 +435,11 @@ export function CharacterSheetPage() {
|
|||||||
</button>
|
</button>
|
||||||
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
|
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
|
||||||
Temp HP <InfoTip term="TEMP_HP" />
|
Temp HP <InfoTip term="TEMP_HP" />
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
key={`temp:${character.tempHp}`}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={9999}
|
||||||
|
keepValue
|
||||||
defaultValue={character.tempHp}
|
defaultValue={character.tempHp}
|
||||||
style={{ width: 70 }}
|
style={{ width: 70 }}
|
||||||
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
|
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
|
||||||
@@ -443,9 +447,11 @@ export function CharacterSheetPage() {
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex-row gap-sm">
|
<label className="flex-row gap-sm">
|
||||||
Armor Bonus <InfoTip term="ARMOR_BONUS" />
|
Armor Bonus <InfoTip term="ARMOR_BONUS" />
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
key={`armor:${character.armorBonus}`}
|
||||||
min={0}
|
min={0}
|
||||||
|
max={30}
|
||||||
|
keepValue
|
||||||
defaultValue={character.armorBonus}
|
defaultValue={character.armorBonus}
|
||||||
style={{ width: 70 }}
|
style={{ width: 70 }}
|
||||||
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
|
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
|
||||||
@@ -458,10 +464,11 @@ export function CharacterSheetPage() {
|
|||||||
<div className="flex-row mt-sm flex-wrap">
|
<div className="flex-row mt-sm flex-wrap">
|
||||||
<label className="flex-row gap-sm">
|
<label className="flex-row gap-sm">
|
||||||
Level
|
Level
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
key={`level:${character.level}`}
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={20}
|
||||||
|
keepValue
|
||||||
defaultValue={character.level}
|
defaultValue={character.level}
|
||||||
style={{ width: 60 }}
|
style={{ width: 60 }}
|
||||||
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
|
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
|
||||||
@@ -671,6 +678,15 @@ export function CharacterSheetPage() {
|
|||||||
</div>
|
</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 mt-md">
|
||||||
<div className="card-title">Inventory</div>
|
<div className="card-title">Inventory</div>
|
||||||
{itemsPage.pageItems.map((held) => (
|
{itemsPage.pageItems.map((held) => (
|
||||||
@@ -758,9 +774,9 @@ export function CharacterSheetPage() {
|
|||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div className="flex-row flex-wrap">
|
<div className="flex-row flex-wrap">
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
|
||||||
min={1}
|
min={1}
|
||||||
|
max={1000000}
|
||||||
placeholder="Amount"
|
placeholder="Amount"
|
||||||
aria-label="Gold amount"
|
aria-label="Gold amount"
|
||||||
value={goldAmount}
|
value={goldAmount}
|
||||||
@@ -801,6 +817,8 @@ export function CharacterSheetPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
|
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Composed by the character builder from the player's picks. Players
|
{/* Composed by the character builder from the player's picks. Players
|
||||||
read them; only the DM can rewrite them (new abilities on level-up,
|
read them; only the DM can rewrite them (new abilities on level-up,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useCampaignNpcs,
|
useCampaignNpcs,
|
||||||
useCampaignWeapons,
|
useCampaignWeapons,
|
||||||
useCampaignItems,
|
useCampaignItems,
|
||||||
|
useCampaignLocations,
|
||||||
useAddParticipant,
|
useAddParticipant,
|
||||||
usePatchParticipant,
|
usePatchParticipant,
|
||||||
useRemoveParticipant,
|
useRemoveParticipant,
|
||||||
@@ -22,8 +23,11 @@ import {
|
|||||||
type NewReward,
|
type NewReward,
|
||||||
} from '../api/queries';
|
} from '../api/queries';
|
||||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
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 { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||||
import type {
|
import type {
|
||||||
Ability,
|
Ability,
|
||||||
@@ -93,6 +97,7 @@ function CombatantCard({
|
|||||||
mode,
|
mode,
|
||||||
isActive,
|
isActive,
|
||||||
onPatch,
|
onPatch,
|
||||||
|
canRollInitiative,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
participant: EncounterParticipant;
|
participant: EncounterParticipant;
|
||||||
@@ -100,6 +105,8 @@ function CombatantCard({
|
|||||||
mode: EncounterMode;
|
mode: EncounterMode;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onPatch: (data: Partial<EncounterParticipant>) => void;
|
onPatch: (data: Partial<EncounterParticipant>) => void;
|
||||||
|
// The DM for anyone; a player for their own character.
|
||||||
|
canRollInitiative: boolean;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [hpDelta, setHpDelta] = useState('');
|
const [hpDelta, setHpDelta] = useState('');
|
||||||
@@ -112,9 +119,13 @@ function CombatantCard({
|
|||||||
const info = p.characterCombatInfo;
|
const info = p.characterCombatInfo;
|
||||||
const monster = p.monster;
|
const monster = p.monster;
|
||||||
const npc = p.npc;
|
const npc = p.npc;
|
||||||
const tracksHp = p.maxHp > 0;
|
// A player may get a percentage in place of a monster's or NPC's numbers.
|
||||||
const isDown = tracksHp && p.currentHp <= 0;
|
const currentHp = p.currentHp ?? 0;
|
||||||
const pct = tracksHp ? Math.max(0, Math.min(100, (p.currentHp / p.maxHp) * 100)) : 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.
|
// 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 monsterStats = monster && monster.ac !== undefined ? monster : null;
|
||||||
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
|
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
|
||||||
@@ -122,7 +133,7 @@ function CombatantCard({
|
|||||||
function applyDelta(sign: 1 | -1) {
|
function applyDelta(sign: 1 | -1) {
|
||||||
const amount = Number(hpDelta);
|
const amount = Number(hpDelta);
|
||||||
if (!amount) return;
|
if (!amount) return;
|
||||||
onPatch({ currentHp: p.currentHp + sign * amount });
|
onPatch({ currentHp: currentHp + sign * amount });
|
||||||
setHpDelta('');
|
setHpDelta('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,26 +163,36 @@ function CombatantCard({
|
|||||||
<div className="combatant-sub">{subline(p)}</div>
|
<div className="combatant-sub">{subline(p)}</div>
|
||||||
</div>
|
</div>
|
||||||
{mode === 'BATTLE' &&
|
{mode === 'BATTLE' &&
|
||||||
(isDM ? (
|
(canRollInitiative ? (
|
||||||
<label className="flex-row gap-sm">
|
<label className="flex-row gap-sm" title="Type what the d20 showed (1 to 20). The initiative bonus is added for you.">
|
||||||
Initiative
|
Initiative roll
|
||||||
<input
|
<NumberInput
|
||||||
key={`${p.id}:${p.initiative}`}
|
key={`${p.id}:${p.initiativeRoll}`}
|
||||||
type="number"
|
min={1}
|
||||||
defaultValue={p.initiative}
|
max={20}
|
||||||
|
placeholder="d20"
|
||||||
|
aria-label={`Initiative roll for ${p.displayName}`}
|
||||||
|
defaultValue={p.initiativeRoll ?? ''}
|
||||||
style={{ width: 64 }}
|
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>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<StatChip label="Initiative">{p.initiative}</StatChip>
|
<StatChip label="Initiative">{p.initiativeRoll == null ? '—' : p.initiative}</StatChip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stat-chips mt-sm">
|
<div className="stat-chips mt-sm">
|
||||||
{tracksHp && (
|
{tracksHp && !barOnly && (
|
||||||
<StatChip label="HP">
|
<StatChip label="HP">
|
||||||
{p.currentHp}/{p.maxHp}
|
{currentHp}/{maxHp}
|
||||||
{info && info.tempHp > 0 && ` +${info.tempHp}`}
|
{info && info.tempHp > 0 && ` +${info.tempHp}`}
|
||||||
</StatChip>
|
</StatChip>
|
||||||
)}
|
)}
|
||||||
@@ -203,14 +224,12 @@ function CombatantCard({
|
|||||||
|
|
||||||
{tracksHp && (
|
{tracksHp && (
|
||||||
<>
|
<>
|
||||||
<div className="hp-bar-track mt-sm">
|
<HpBar current={pct} max={100} temp={info && maxHp > 0 ? (info.tempHp / maxHp) * 100 : 0} />
|
||||||
<div className={hpBarClass(p.currentHp, p.maxHp)} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
{isDM && (
|
{isDM && (
|
||||||
<div className="flex-row gap-sm mt-sm">
|
<div className="flex-row gap-sm mt-sm">
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
|
||||||
min={1}
|
min={1}
|
||||||
|
max={99999}
|
||||||
placeholder="Amount"
|
placeholder="Amount"
|
||||||
aria-label={`HP change for ${p.displayName}`}
|
aria-label={`HP change for ${p.displayName}`}
|
||||||
value={hpDelta}
|
value={hpDelta}
|
||||||
@@ -505,9 +524,9 @@ function RewardsPanel({
|
|||||||
<option value="ITEM">Item</option>
|
<option value="ITEM">Item</option>
|
||||||
</select>
|
</select>
|
||||||
{type === 'GOLD' ? (
|
{type === 'GOLD' ? (
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
|
||||||
min={1}
|
min={1}
|
||||||
|
max={1000000}
|
||||||
placeholder="Amount"
|
placeholder="Amount"
|
||||||
aria-label="Gold amount"
|
aria-label="Gold amount"
|
||||||
value={gold}
|
value={gold}
|
||||||
@@ -542,9 +561,9 @@ function RewardsPanel({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
|
||||||
min={1}
|
min={1}
|
||||||
|
max={999}
|
||||||
aria-label="Quantity"
|
aria-label="Quantity"
|
||||||
title="Quantity"
|
title="Quantity"
|
||||||
value={quantity}
|
value={quantity}
|
||||||
@@ -577,6 +596,7 @@ function RewardsPanel({
|
|||||||
|
|
||||||
export function EncounterPage() {
|
export function EncounterPage() {
|
||||||
const { encounterId = '' } = useParams();
|
const { encounterId = '' } = useParams();
|
||||||
|
const { user } = useAuth();
|
||||||
const { data: encounter, isLoading, error } = useEncounter(encounterId);
|
const { data: encounter, isLoading, error } = useEncounter(encounterId);
|
||||||
useCampaignSocket(encounter?.campaignId);
|
useCampaignSocket(encounter?.campaignId);
|
||||||
|
|
||||||
@@ -587,6 +607,7 @@ export function EncounterPage() {
|
|||||||
const { data: npcs } = useCampaignNpcs(encounter?.campaignId, isDM);
|
const { data: npcs } = useCampaignNpcs(encounter?.campaignId, isDM);
|
||||||
const { data: weapons } = useCampaignWeapons(encounter?.campaignId);
|
const { data: weapons } = useCampaignWeapons(encounter?.campaignId);
|
||||||
const { data: items } = useCampaignItems(encounter?.campaignId);
|
const { data: items } = useCampaignItems(encounter?.campaignId);
|
||||||
|
const { data: locations } = useCampaignLocations(encounter?.campaignId);
|
||||||
|
|
||||||
const addParticipant = useAddParticipant(encounterId);
|
const addParticipant = useAddParticipant(encounterId);
|
||||||
const patchParticipant = usePatchParticipant(encounterId);
|
const patchParticipant = usePatchParticipant(encounterId);
|
||||||
@@ -624,7 +645,7 @@ export function EncounterPage() {
|
|||||||
if (!pickId) return;
|
if (!pickId) return;
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
addParticipant.mutate(
|
addParticipant.mutate(
|
||||||
{ [`${pickType}Id`]: pickId, initiative: Number(initiative) || 0 },
|
{ [`${pickType}Id`]: pickId, initiativeRoll: initiative ? Number(initiative) : undefined },
|
||||||
{ onError, onSuccess: () => setPickId('') }
|
{ onError, onSuccess: () => setPickId('') }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -637,6 +658,7 @@ export function EncounterPage() {
|
|||||||
mode={encounter.mode}
|
mode={encounter.mode}
|
||||||
isActive={p.id === active?.id}
|
isActive={p.id === active?.id}
|
||||||
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
|
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)}
|
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -661,6 +683,7 @@ export function EncounterPage() {
|
|||||||
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
|
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
|
||||||
<span className={`badge ${encounter.status === 'ACTIVE' ? 'badge-live' : ''}`}>{STATUS_LABELS[encounter.status]}</span>
|
<span className={`badge ${encounter.status === 'ACTIVE' ? 'badge-live' : ''}`}>{STATUS_LABELS[encounter.status]}</span>
|
||||||
{isBattle && <span className="badge">Round {encounter.currentRound}</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>}
|
{encounter.archivedAt && <span className="badge">Archived</span>}
|
||||||
{active && <span className="turn-marker">▶ {active.displayName}'s turn</span>}
|
{active && <span className="turn-marker">▶ {active.displayName}'s turn</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -679,6 +702,19 @@ export function EncounterPage() {
|
|||||||
<option value="BATTLE">Battle</option>
|
<option value="BATTLE">Battle</option>
|
||||||
<option value="QUEST">NPC quest</option>
|
<option value="QUEST">NPC quest</option>
|
||||||
</select>
|
</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' && (
|
{encounter.status === 'PLANNING' && (
|
||||||
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
|
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
|
||||||
{isBattle ? 'Begin Battle' : 'Begin Quest'}
|
{isBattle ? 'Begin Battle' : 'Begin Quest'}
|
||||||
@@ -804,10 +840,12 @@ export function EncounterPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-row gap-sm mt-sm">
|
<div className="flex-row gap-sm mt-sm">
|
||||||
{isBattle && (
|
{isBattle && (
|
||||||
<input
|
<NumberInput
|
||||||
type="number"
|
min={1}
|
||||||
placeholder="Initiative"
|
max={20}
|
||||||
aria-label="Initiative"
|
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}
|
value={initiative}
|
||||||
onChange={(e) => setInitiative(e.target.value)}
|
onChange={(e) => setInitiative(e.target.value)}
|
||||||
style={{ flex: 1, minWidth: 0 }}
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
|||||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||||
import { usePagination, Pagination } from '../components/Paginated';
|
import { usePagination, Pagination } from '../components/Paginated';
|
||||||
import { InfoTip } from '../components/InfoTip';
|
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';
|
import { speciesIcon } from '../lib/speciesIcons';
|
||||||
|
|
||||||
// A DM's one-screen view of every character's stats at a glance; a player
|
// A DM's one-screen view of every character's stats at a glance; a player
|
||||||
@@ -77,20 +78,18 @@ export function PartyOverviewPage() {
|
|||||||
<td>
|
<td>
|
||||||
{c.maxHp != null ? (
|
{c.maxHp != null ? (
|
||||||
<div style={{ minWidth: 90 }}>
|
<div style={{ minWidth: 90 }}>
|
||||||
<span className="mono">{c.currentHp}/{c.maxHp}</span>
|
<span className="mono">
|
||||||
<div className="hp-bar-track mt-sm" style={{ height: 6 }}>
|
{c.currentHp}/{c.maxHp}
|
||||||
<div
|
{c.tempHp > 0 && ` +${c.tempHp}`}
|
||||||
className={hpBarClass(c.currentHp, c.maxHp)}
|
</span>
|
||||||
style={{ width: `${Math.max(0, Math.min(100, (c.currentHp / c.maxHp) * 100))}%` }}
|
<HpBar current={c.currentHp} max={c.maxHp} temp={c.tempHp} height={6} />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
'—'
|
'—'
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{c.armorClass ?? '—'}</td>
|
<td>{c.armorClass ?? '—'}</td>
|
||||||
<td className="mono">{c.gold}</td>
|
<td className="mono">{c.gold ?? '—'}</td>
|
||||||
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"backend",
|
"backend",
|
||||||
"frontend"
|
"frontend"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.103.2",
|
"@tanstack/react-query": "^5.103.2",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dnd-campaign-manager",
|
"name": "dnd-campaign-manager",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "D&D 5e campaign manager — monorepo root",
|
"description": "D&D 5e campaign manager — monorepo root",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "npm run dev --workspace backend",
|
"dev:backend": "npm run dev --workspace backend",
|
||||||
"dev:frontend": "npm run dev --workspace frontend",
|
"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