V1.2.0: archive/delete campaigns and encounters, encounter sorting, change password, fuller seed

- Campaigns: DM can archive/restore; owner can delete. Archived campaigns
  move off the main list and stop accepting invite codes.
- Encounters: DM can archive/restore and delete, and arrange the campaign's
  own order; the list can also be sorted by name, status, type or date.
  Archived encounters are hidden from players' lists.
- Account page with change password (POST /auth/password).
- Seed is now additive (safe on a live database; --reset for the old wipe)
  and builds a fuller Aldenfell: earned weapons, item pool, spells, NPCs,
  and 28 battle/quest scenes with descriptions, DM notes and rewards.
- Vite preview host config for the deployed site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 08:58:54 +02:00
co-authored by Claude Opus 5.5
parent 4a6a09bae8
commit 28e1a84b64
22 changed files with 1340 additions and 94 deletions
+13 -3
View File
@@ -44,7 +44,7 @@ npm install # installs both workspaces (backend + frontend)
cd backend
cp .env.example .env # edit DATABASE_URL and JWT_SECRET
npx prisma migrate dev --name init
npx prisma db seed # creates a demo campaign + demo users (see output for login/invite code)
npx prisma db seed # adds the admin account + the Aldenfell campaign if missing (see output for login/invite code)
# Frontend
cd ../frontend
@@ -60,10 +60,20 @@ npm run dev:backend # Express + Socket.io on http://localhost:4000
npm run dev:frontend # Vite dev server, prints its own port (5173 by default)
```
Open the frontend URL, register an account (or log in with the seeded demo
users — see the seed script's console output for credentials and invite
Open the frontend URL, register an account (or log in with the seeded admin
account — see the seed script's console output for credentials and invite
code), and start playing.
The seed only adds: it never touches an existing account or campaign, so it is
safe to run on a server with real data. It skips the campaign if the admin
already has an active "Aldenfell"; archive or delete that one in the app
(Campaign Settings) and run it again for a fresh copy. `SEED_ADMIN_EMAIL`,
`SEED_ADMIN_PASSWORD` and `SEED_ADMIN_NAME` set the account it creates.
`npx prisma db seed -- --reset` wipes every user and campaign first.
On a server, apply new migrations with `npx prisma migrate deploy`. Change
your password from the account page (click your name in the top bar).
## Tests
```bash
@@ -0,0 +1,2 @@
-- AlterTable: a campaign can be put away without deleting it
ALTER TABLE "campaigns" ADD COLUMN "archivedAt" TIMESTAMP(3);
@@ -0,0 +1,12 @@
-- AlterTable: an encounter can be put away, and has a place in the campaign's own order
ALTER TABLE "encounters" ADD COLUMN "archivedAt" TIMESTAMP(3);
ALTER TABLE "encounters" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
-- Existing encounters keep the order the list has always shown: newest first.
UPDATE "encounters" AS e
SET "sortOrder" = r.pos
FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY "campaignId" ORDER BY "createdAt" DESC, id) - 1 AS pos
FROM "encounters"
) AS r
WHERE e.id = r.id;
+8
View File
@@ -159,6 +159,9 @@ model Campaign {
startingSpellCount Int @default(0)
skipMotivationRoll Boolean @default(false)
ownerId String
// Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it.
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -494,6 +497,11 @@ model Encounter {
activeParticipantId String?
description String? // the scene / objective, shown to everyone
dmNotes String? // never sent to players
// Set while archived: kept in full, but off the campaign's list — players
// no longer see it there at all.
archivedAt DateTime?
// Place in the campaign's own order, lowest first. The DM rearranges it.
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+142 -45
View File
@@ -1,7 +1,17 @@
// Resets the database and seeds the Aldenfell campaign (see
// docs/Aldenfell - Full Campaign Book.docx and seed-data/aldenfell.js).
// Seeds the Aldenfell campaign (seed-data/aldenfell.js) for the admin account.
//
// DESTRUCTIVE: every user, campaign, character, note, etc. is deleted first.
// Safe to run against a live database: it only ever ADDS. The admin account is
// created if it is missing (an existing one, and its password, is left alone),
// and the campaign is added unless that account already owns an active
// campaign of the same name. To get a fresh copy, archive or delete the old
// one in the app and run the seed again.
//
// npx prisma db seed add what is missing
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
// character, note, etc. first
//
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
// account that is created.
//
// 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
@@ -11,31 +21,40 @@ const path = require('path');
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { generateInviteCode } = require('../src/utils/inviteCode');
const { SPELL_LIBRARY } = require('../src/game/data/library');
const prisma = new PrismaClient();
const weapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
const defaultWeapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
const aldenfell = require(path.join(__dirname, '..', 'seed-data', 'aldenfell.js'));
const ADMIN_EMAIL = 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = '12345678';
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
const RESET = process.argv.includes('--reset');
async function resetDatabase() {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
}
async function main() {
await resetDatabase();
// 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);
}
const admin = await prisma.user.create({
data: {
email: ADMIN_EMAIL,
passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12),
displayName: 'Joshua',
},
});
function byName(rows) {
return new Map(rows.map((row) => [row.name, row]));
}
const campaign = await prisma.campaign.create({
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(),
@@ -43,61 +62,139 @@ async function main() {
members: { create: { userId: admin.id, role: 'DM' } },
},
});
const campaignId = campaign.id;
await prisma.weapon.createMany({ data: weapons.map((w) => ({ ...w, isStartingOption: true, campaignId: campaign.id })) });
await db.location.createMany({ data: aldenfell.locations.map((l) => ({ ...l, campaignId })) });
await prisma.location.createMany({
data: aldenfell.locations.map((l) => ({ ...l, campaignId: campaign.id })),
// 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({
data: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting), campaignId })),
});
const monsterByName = {};
for (const monster of aldenfell.monsters) {
const created = await prisma.monster.create({ data: { ...monster, campaignId: campaign.id } });
monsterByName[created.name] = created;
}
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 });
for (const enc of aldenfell.encounters) {
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] of enc.monsters) {
const monster = monsterByName[name];
if (!monster) throw new Error(`Encounter "${enc.name}" references unknown monster "${name}"`);
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 ? `${monster.name} #${i}` : monster.name,
displayName: count > 1 ? `${label} #${i}` : label,
currentHp: monster.maxHp,
maxHp: monster.maxHp,
isMonster: true,
});
}
}
await prisma.encounter.create({
data: { campaignId: campaign.id, name: enc.name, participants: { create: participants } },
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 },
},
});
}
// Notes list newest-first, so stamp them in reverse to keep book order on screen.
const allNotes = [
const notes = [
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
const now = Date.now();
await prisma.note.createMany({
data: allNotes.map((n, i) => ({
...n,
campaignId: campaign.id,
authorId: admin.id,
createdAt: new Date(now - i * 1000),
})),
await db.note.createMany({
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
});
console.log('Seed complete.');
console.log(` Admin/DM login: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${weapons.length} weapons, ${aldenfell.locations.length} locations, ${aldenfell.monsters.length} monsters, ` +
`${aldenfell.encounters.length} encounters, ${allNotes.length} notes`,
` ${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()
+407 -20
View File
@@ -1,6 +1,8 @@
// Aldenfell campaign content, transcribed (and condensed where a note would
// otherwise be unreadable) from docs/Aldenfell - Full Campaign Book.docx.
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids.
// Aldenfell campaign content: the gazetteer, bestiary and notes are condensed
// from the Aldenfell Campaign Book; the gear, item pool, NPC list and scenes
// are built out from it to use everything a campaign can hold.
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids, except
// where a section's comment says otherwise.
const campaign = {
name: 'Aldenfell',
@@ -129,28 +131,413 @@ const monsters = [
specialTrait: 'Undead — vulnerable to bludgeoning',
notes: 'Ordinary restless dead for the Sunken Vaults’ Restless Ossuary (Room 4). Not tied to the Hollow King; DC 12 Religion identifies that proper deconsecration would settle them.',
},
{
name: 'Feral Guard Dog', challengeRating: '1/8', ac: 12, maxHp: 5, speed: 40,
strScore: 13, dexScore: 14, conScore: 12, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+1',
specialTrait: 'Keen hearing and smell — advantage on Perception checks that rely on them.',
notes: 'Medium beast, 25 XP. Bite +3 (1d6+1 piercing, DC 11 Str or prone). A chapel mastiff gone half-wild in the Sunken Vaults’ Old Crypt Hall (Room 2). Starving, not vicious — DC 12 Animal Handling or a thrown ration ends the fight.',
},
];
// ---------------------------------------------------------------------------
// Encounters — pre-built (PLANNING) for the combat beats of the Campaign Book.
// Each participant is [monster name, count]; instances get "#n" suffixes.
// Weapons the party earns. The everyday weapons (seed-data/weapons.json) are
// seeded as starting options; these are not, so they only reach a character as
// a reward or a gift. damageDice is the damage at minLevel, and a character
// must be at least a held copy's level to use it. damageScaling: 1 standard,
// 2 powerful, 3 legendary.
// ---------------------------------------------------------------------------
const weapons = [
{ name: 'Masterwork Dagger', damageDice: '1d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'thrown'], rangeNormal: 20, rangeLong: 60, minLevel: 2, damageScaling: 1 },
{ name: 'Smuggler’s Cutlass', damageDice: '1d8', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse'], rangeNormal: null, rangeLong: null, minLevel: 2, damageScaling: 1 },
{ name: 'Warden’s Longbow', damageDice: '1d10', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'two-handed'], rangeNormal: 150, rangeLong: 600, minLevel: 2, damageScaling: 2 },
{ name: 'Grey Dawn Longsword', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d12)'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 2 },
{ name: 'Greyford Halberd', damageDice: '1d12', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 1 },
{ name: 'Stonejaw Maul', damageDice: '2d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 4, damageScaling: 2 },
{ name: 'Threndir’s Fang', damageDice: '1d8', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null, minLevel: 5, damageScaling: 2 },
{ name: 'Hollowbane', damageDice: '2d8', damageType: 'radiant', attackAbility: 'FINESSE', properties: ['finesse', 'versatile (2d10)'], rangeNormal: null, rangeLong: null, minLevel: 7, damageScaling: 3 },
];
// ---------------------------------------------------------------------------
// Item pool. Items are descriptive: holding one never changes a number on the
// sheet. `starting: true` offers the item in the character builder.
// ---------------------------------------------------------------------------
const items = [
// Starting kit
{ name: 'Potion of Healing', description: 'A red liquid that glimmers when shaken. Drink it (an action) to regain the hit points shown.', damageDice: '2d4+2', starting: true },
{ name: 'Traveller’s Pack', description: 'Bedroll, mess kit, tinderbox, waterskin and five days of rations.', starting: true },
{ name: 'Rope (50 ft)', description: 'Hempen rope. Holds a climber; DC 17 Strength to burst.', starting: true },
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', starting: true },
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', starting: true },
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', starting: true },
{ name: 'Shield', description: 'A wooden or steel shield, +2 Armor Class while carried. Ask the DM to add it to your armor bonus.', starting: true },
{ name: 'Leather Armor', description: 'Boiled leather, Armor Class 11 + Dexterity. Ask the DM to set your armor bonus.', starting: true },
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', starting: true },
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', starting: true },
// Found and earned
{ name: 'Potion of Greater Healing', description: 'A deeper red than the common potion. Drink it (an action) to regain the hit points shown.', damageDice: '4d4+4', minLevel: 3 },
{ name: 'Alchemist’s Fire', description: 'A sticky flask that ignites in air. Throw it up to 20 feet: the target burns for the damage shown at the start of each of its turns until someone spends an action putting it out.', damageDice: '1d4' },
{ name: 'Silver Locket', description: 'A tarnished locket from the Old Crypt Hall, worth about 15 gp. The portrait inside has been scratched out.' },
{ name: 'Smugglers’ Ledger Page', description: 'A torn page from a crate in the Sunken Vaults, stamped with a merchant sigil nobody at the Concord Exchange recognises. Half a name survives in the margin.' },
{ name: 'Bell-Ringer’s Keepsake', description: 'A worn brass bell-clapper charm, pressed into your hand with more gratitude than its owner could put into words.' },
{ name: 'Grey Dawn Signet', description: 'An iron ring bearing the Order’s rising sun. Shown at Grey Dawn Hall or to any patrol, it marks you as someone the Order vouches for.' },
{ name: 'Concord Writ of Passage', description: 'A sealed Merchant Concord contract. Waives tolls and gate fees across Aldenfell while the Concord still considers you good for business.' },
{ name: 'Warden’s Charm', description: 'A knot of greywood bark and red thread. The Wardens will know you by it, and the forest’s paths seem a little easier to find while you carry it.' },
{ name: 'Stonejaw Oath-Token', description: 'A river stone the size of a fist, carved with the Stonejaw clan mark. Proof that your word was tested and held.' },
{ name: 'Half-Burned Letter', description: 'Found among a missing person’s belongings in the Root. Most of it is ash. What is left mentions payments, a shrine, and a family crest.' },
{ name: 'Silver Shrine Relic', description: 'A small silver figure lifted from black water beneath the Greywood, worth about 75 gp. Its style is older than the kingdom.' },
{ name: 'Sealed Letter', description: 'Heavy paper, an unbroken wax seal, and a servant’s whispered warning about who must never know you have it.' },
{ name: 'Root-Fiber Scrap', description: 'A length of pale root that is still, very slightly, moving. It is cold, and it does not rot.' },
{ name: 'Seal-Stone Fragment', description: 'A shard of worked stone from the edge of the breach. Script runs along one face. It is warm, and holding it makes the bell script easier to look at.' },
];
// ---------------------------------------------------------------------------
// Spells, pulled from the default library (src/game/data/spellsLibrary.js) by
// key. A new character can only pick starting spells of their own level or
// lower, so the starting set is cantrips and 1st level; the rest are for the
// DM to teach later.
// ---------------------------------------------------------------------------
const spells = {
starting: [
'chill-touch', 'eldritch-blast', 'fire-bolt', 'guidance', 'light', 'mage-hand', 'minor-illusion',
'prestidigitation', 'produce-flame', 'ray-of-frost', 'sacred-flame', 'shocking-grasp', 'spare-the-dying',
'thorn-whip', 'vicious-mockery',
'bless', 'burning-hands', 'charm-person', 'command', 'cure-wounds', 'detect-magic', 'entangle', 'faerie-fire',
'guiding-bolt', 'healing-word', 'mage-armor', 'magic-missile', 'shield', 'sleep', 'thunderwave',
],
earned: [
'hold-person', 'invisibility', 'lesser-restoration', 'misty-step', 'moonbeam', 'scorching-ray', 'shatter',
'spiritual-weapon',
'counterspell', 'daylight', 'dispel-magic', 'fireball', 'remove-curse', 'revivify',
'banishment',
],
};
// ---------------------------------------------------------------------------
// NPCs. `description` is what the party sees once the NPC is in a scene;
// `dmNotes` never leaves the DM's screen. Combat stats only where a fight is
// plausible.
// ---------------------------------------------------------------------------
const npcs = [
{
name: 'King Aldric Veyrmont', role: 'King of Aldenfell', disposition: 'NEUTRAL',
description: 'An aging king with a soldier’s posture and tired eyes. Courteous, proud, and quick to steer talk away from anything that sounds like bad news.',
dmNotes: 'Tired, proud, in denial — cornered, not ill. Pays well for quiet problem-solving and reacts badly to blunt truth. An audience needs a sponsor (a knight, a noble, a Concord representative). The crown fractures publicly at Stage 3.',
},
{
name: 'Princess Isolde Veyrmont', role: 'Heir to the throne', disposition: 'FRIENDLY',
description: 'Sharp, pragmatic and well liked. She keeps a small private study at the palace and has a habit of asking capable outsiders exactly the right question.',
dmNotes: 'The best long-term ally, and she recruits outsiders on purpose. Already suspects something is deeply wrong, so she is the most likely to believe the party early. Reporting to her at E10B sets up Act 3’s politics. Rumour says she will move against her father — misleading.',
},
{
name: 'Ser Kavan Thorne', role: 'Knight-Commander of the Grey Dawn', disposition: 'FRIENDLY',
description: 'Rigid, honorable and loyal to a fault. He runs Grey Dawn Hall by the book and judges people by whether they do what they said they would.',
dmNotes: 'Recruits the party (E4A → Grey Dawn track). SECRET: the Order’s founding is tied to the original sacrifice, and Thorne does not know. He struggles when evidence points at the crown; whether and how the party tells him is theirs to choose. If Ressa Vane escorted them (E9C) he already suspects by E10B.',
ac: 18, maxHp: 65, attackBonus: 6, damageDice: '1d8+4',
},
{
name: 'Mireille Costa', role: 'Merchant Concord representative', disposition: 'NEUTRAL',
description: 'Charming and transactional, usually found at the Concord Exchange with a contract already drafted. She always has a job, and she always pays what she promised.',
dmNotes: 'Source of paying side work (E4B → The Smugglers’ Tithe). The Concord is neutral and pragmatic, and drops anyone who becomes bad for business — including the party.',
},
{
name: 'Ashwen', role: 'Warden of the Greywood', disposition: 'NEUTRAL',
description: 'Terse and watchful, with a longbow that never seems to be out of reach. She answers to the forest, not to the crown.',
dmNotes: 'Suspicious, not hostile — a slow-burn ally. The first to notice the old shrines acting strangely (E4C → The Warden’s Silence). After that quest she is convinced something is deeply wrong, whatever happened to her Warden.',
ac: 15, maxHp: 44, attackBonus: 6, damageDice: '1d8+3',
},
{
name: 'Threndir Ashclaw', role: 'Old copper dragon of the Spine', disposition: 'NEUTRAL',
description: 'Vast, scarred and weary, coiled in an eyrie high on the eastern peaks. He grants an audience to anyone who approaches openly, and he prefers words to fire.',
dmNotes: 'Blunt, and wants the kingdom to survive. He wants proof the crown can still be trusted — the intended “win” is a negotiation (Persuasion DC 15–18). Always offers to talk unless attacked first. If it does come to a fight, use the “Threndir Ashclaw (old copper dragon)” monster stat block.',
},
{
name: 'Korgath Stonejaw', role: 'Chieftain of the Stonejaw clan', disposition: 'NEUTRAL',
description: 'A hill giant chieftain with a voice like a rockslide and no patience for politics. Outsiders in his hold are tested, not attacked.',
dmNotes: 'Honor-bound: trials, contests and bargains, never ambushes. Offers the Trial of Stone instead of an agreement; success is a standing alliance (Spine Clans track), failure earns a second, harder trial. Nothing sinister here — it is a genuine test of character.',
ac: 13, maxHp: 105, attackBonus: 8, damageDice: '3d8+5',
},
{
name: 'Vessa Hollow', role: 'A voice in the dark', disposition: 'NEUTRAL',
description: 'A woman’s voice — calm, patient, almost kind. She does not blink. She speaks as though she has been waiting a very long time and can wait a little longer.',
dmNotes: 'SECRET: one of the people Highcrown’s founders sacrificed, and the Hollow King’s sympathetic “face”. She believes waking the rest is justice. Unsettling, never mustache-twirling; DC 16 Insight confirms she believes what she says. Use her in dreams and omens long before she appears. Negotiating with her is a legitimate outcome. She does not fight — any combat near her uses Hollowed guardians.',
},
{
name: 'Corporal Ressa Vane', role: 'Grey Dawn warden escort', disposition: 'FRIENDLY',
description: 'A steady, plain-spoken corporal of the Grey Dawn, assigned to see you safely through the tunnels. She writes things down.',
dmNotes: 'Only appears if the party took Grey Dawn recruitment (E4A → E9C). She lets them skip to Root Room 2, and she reports everything back to Grey Dawn Hall — which matters at E10B and E15.',
ac: 16, maxHp: 27, attackBonus: 4, damageDice: '1d8+2',
},
{
name: 'The Bell-Ringer', role: 'Bell-ringer of the Founding Chapel', disposition: 'FRIENDLY',
description: 'A stooped, soft-spoken man who has rung the chapel bell every hour for most of his life. Badly shaken, and very grateful to see a friendly face.',
dmNotes: 'Found alive in Vault Room 5 (E3). He saw “someone who looked like his neighbor, except wrong”. Returns in The Bell-Ringer’s Debt: he owes a Highcrown lender who operates under the corrupted house’s protection (not knowable yet). A loyal low-key ally either way.',
},
{
name: 'A Frightened Local', role: 'Witness outside the chapel', disposition: 'NEUTRAL',
description: 'A market porter who was passing the chapel when the bell stopped. Pale, talking too fast, and not keen to go back inside.',
dmNotes: 'DC 12 Persuasion or Insight gets the account of “a woman who wasn’t blinking”. If the party talks to the crowd first (E2C) they get three witness accounts but no script lead, and their E10 Insight check is at advantage.',
},
{
name: 'The Lender', role: 'Highcrown moneylender', disposition: 'HOSTILE',
description: 'A neat, unhurried man who keeps his ledgers tidier than his conscience. He never raises his voice; he has collectors for that.',
dmNotes: 'Holds the bell-ringer’s debt. Operates under the corrupted noble house’s protection — digging into who backs him is harder but plants a clue toward the house before Act 2. Can be paid off, intimidated, or exposed.',
ac: 12, maxHp: 22, attackBonus: 3, damageDice: '1d4+1',
},
{
name: 'Greyford’s Commander', role: 'Commander of Greyford Keep', disposition: 'NEUTRAL',
description: 'An exhausted officer holding a border fortress with half the troops and a third of the supplies it needs. Blunt, overworked, and out of patience.',
dmNotes: 'Wants the Sorrow Camp “moved along” — and is not wrong about the strategic liability. No clean villain in Refugees of the Marches. Whether Greyford is supplied (The Smugglers’ Tithe outcome) decides rescue vs recovery at E11 and whether the keep holds at E16.',
ac: 17, maxHp: 52, attackBonus: 5, damageDice: '1d8+3',
},
];
// ---------------------------------------------------------------------------
// Encounters — pre-built (PLANNING) scenes for the beats of the Campaign Book,
// in book order. BATTLE scenes run in initiative order; QUEST scenes are built
// around NPCs and an objective.
// description — the scene, shown to every player who opens it: no spoilers.
// dmNotes — DCs, branches and secrets, DM only.
// monsters — [monster name, count, optional display name]; a count above
// one gets "#n" suffixes.
// npcs — NPC names.
// rewards — lined up unawarded, so they stay the DM's secret until
// handed out. { gold } | { weapon } | { item }, with optional
// quantity, level (defaults to the entry's starting level) and
// note.
// ---------------------------------------------------------------------------
const encounters = [
{ name: 'E3 — Sunken Vaults: The Restless Ossuary', monsters: [['Skeleton', 2]] },
{ name: 'E6 — The Bell-Ringer’s Debt: Collectors', monsters: [['Highcrown Cutthroat', 3]] },
{ name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]] },
{ name: 'Side Quest — The Warden’s Silence: Direwolf Pack', monsters: [['Greywood Direwolf', 3]] },
{ name: 'Side Quest — The Warden’s Silence: The Shrine', monsters: [['Hollowed', 1]] },
{ name: 'Side Quest — What the Tide Brought In: The Reef', monsters: [['Reef Terror', 1]] },
{ name: 'E9 — The Root: Weeping Passage', monsters: [['Bellcrawler', 1]] },
{ name: 'E9 — The Root: Hollowed Nest', monsters: [['Hollowed', 2]] },
{ name: 'E9 — The Root: Root Stalker’s Warren', monsters: [['Root Stalker', 1]] },
{ name: 'E9 — The Root: Bellcrawler Gallery', monsters: [['Bellcrawler', 3]] },
{ name: 'E12 — A Line Thins', monsters: [['Root Stalker', 1]] },
{ name: 'E14 — The Break', monsters: [['Hollowed', 4]] },
{ name: 'Mid-campaign scare — The Hollow King’s Shadow', monsters: [['The Hollow King’s Shadow', 1]] },
{ name: 'E25 — The Last Approach', monsters: [['Root Stalker', 1], ['Bellcrawler', 2]] },
{
name: 'E1 — The Founding Fair', mode: 'QUEST',
description: 'Highcrown’s Lantern Market at the height of the Founding Fair: lantern-strung stalls, contests of strength and skill, minstrels, and strangers from every corner of the kingdom. This is where you meet.',
dmNotes: 'Character creation happens here (Roll-Your-Character System) — it can be the whole first half of session one. No combat. Let each PC win or lose something small at a stall, and introduce Mireille and Ser Kavan in passing so they are familiar faces by E4. Ends when the chapel bell fails to ring → E2.',
npcs: ['Mireille Costa', 'Ser Kavan Thorne'],
},
{
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST',
description: 'The Founding Chapel’s bell has rung every hour for as long as anyone remembers. It has stopped. A crowd is gathering outside, and someone is asking for volunteers to go in and look.',
dmNotes: 'No break-in. The bell rope is neatly cut and left coiled; the bell-ringer is missing, not dead; a line of unfamiliar script is scratched inside the bell (Hollow King iconography). DC 13 Investigation finds the rope and script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking”.\n• E2A investigate immediately: full clues; skip Vault Room 1.\n• E2B distracted first: the guard missed the script; DC 13 Investigation to find it; start at Room 1.\n• E2C talk to the crowd first: three witness accounts, no script lead; E10 Insight later at advantage.\nThe trail leads to the cellar → E3.',
npcs: ['Ser Kavan Thorne', 'A Frightened Local'],
},
{
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE',
description: 'A long hall of burial niches beneath the chapel, some sealed, some broken open. Something is growling in the dark between the pillars.',
dmNotes: 'Vault Room 2, the hub (→ Rooms 3, 4, stair to 6). The dog is starving, not vicious: DC 12 Animal Handling or a thrown ration ends it. DC 13 Investigation: the open niches are just old theft. DC 12 Perception finds the locket.',
monsters: [['Feral Guard Dog', 1]],
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Perception' }],
},
{
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE',
description: 'Bones stacked floor to ceiling, and an offering box furred with dust. The air is still. Then it isn’t.',
dmNotes: 'Vault Room 4, a dead end. One or two skeletons rise halfway through the room — ordinary restless dead, not tied to the Hollow King. DC 12 Religion: proper deconsecration would settle them without a fight. DC 13 to open the offering box.',
monsters: [['Skeleton', 2]],
rewards: [{ gold: 25, note: 'Offering box — DC 13 to open' }],
},
{
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST',
description: 'A side chamber with a bedroll, a cold lantern, and a frightened old man who very much hopes you are who you look like.',
dmNotes: 'Vault Room 5. The bell-ringer is alive but shaken: he saw “someone who looked like his neighbor, except wrong”. Getting him out is the win — the bell rings again, the fair goes on, nothing is solved.\nRoom 3 (Smugglers’ Cache) is on the way: DC 14 Investigation ties the crates’ sigil to a lender linked to the corrupted house.\nRoom 6 (The Descent) leads to the Root: wrong, cold air. Signal the tone shift; the party can always turn back. → E4.',
npcs: ['The Bell-Ringer'],
rewards: [
{ gold: 40, note: 'Room 3 — trade goods from the smugglers’ cache' },
{ item: 'Smugglers’ Ledger Page', note: 'Room 3 — DC 14 Investigation' },
],
},
{
name: 'E4 — Aftermath & Faction Hooks', mode: 'QUEST',
description: 'Word travels fast in Highcrown. By the next morning three different people have sent for you, and each of them has work.',
dmNotes: 'All open doors; they can run in parallel.\n• E4A Grey Dawn recruitment (Kavan) → Grey Dawn ending track; unlocks Ressa Vane at E9C.\n• E4B Concord job board (Mireille) → The Smugglers’ Tithe.\n• E4C Warden contact (Ashwen) → The Warden’s Silence.\n• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.\n• E4E Investigate independently (DC 15 Investigation) → Independent track.\nAfter two or more threads and one E5 omen, advance the clock to Stage 2 (E8).',
npcs: ['Ser Kavan Thorne', 'Mireille Costa', 'Ashwen'],
rewards: [
{ item: 'Grey Dawn Signet', note: 'E4A — if they accept recruitment' },
{ item: 'Concord Writ of Passage', note: 'E4B — on taking Mireille’s contract' },
],
},
{
name: 'E6 — The Bell-Ringer’s Debt: Collectors', mode: 'BATTLE',
description: 'Three hard-faced men are waiting outside the bell-ringer’s door when you arrive. They are not here to hear him ring.',
dmNotes: 'Optional side quest, Lv 1–2. The collectors are ordinary desperate people: kill them, scare them off, or find out why they are desperate. Beats: hear the bell-ringer out → track the collectors → pay the debt, intimidate the lender, or dig into who backs him (harder, more rewarding — a clue toward the corrupted house before Act 2).',
monsters: [['Highcrown Cutthroat', 3]],
npcs: ['The Bell-Ringer', 'The Lender'],
rewards: [
{ gold: 30, note: 'The collectors’ take for the week' },
{ item: 'Bell-Ringer’s Keepsake', note: 'Given once the debt is dealt with' },
],
},
{
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST',
description: 'Tidewatch, the Salt Coast’s largest port: proud, salt-stained, and suspicious of anyone asking questions. Someone is skimming the town’s quiet trade, and the Concord wants to know who.',
dmNotes: 'Lv 1–3. Mireille hires the party to learn why a cut of Tidewatch’s smuggled goods vanishes before reaching buyers. TRUTH: a Bandit Captain is extorting the smugglers and funnelling the “tithe” to arm one side of the Bramble Marches conflict. Smugglers distrust outsiders. Track a shipment to Smuggler’s Cove → next scene.\nOutcome decides whether Greyford is supplied at E11/E16.',
npcs: ['Mireille Costa'],
rewards: [{ gold: 100, note: 'Mireille’s fee — paid in full if it ends quietly' }],
},
{
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE',
description: 'A hidden inlet south of Tidewatch. Lanterns hooded, a longboat drawn up on the shingle, and a crew that was not expecting company.',
dmNotes: 'Fight, negotiate, or both. The Captain leads from the front (multiattack) and bolts for the boat below half HP. End it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward).',
monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]],
rewards: [
{ gold: 60, note: 'The tithe chest' },
{ weapon: 'Smuggler’s Cutlass', note: 'The Captain’s own blade' },
],
},
{
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE',
description: 'The trail to the shrine runs through old growth where the light barely reaches the ground. The forest has gone quiet, and there are eyes between the trees.',
dmNotes: 'Lv 1–2. The pack is agitated by the shrine, not evil — DC 13 Animal Handling or Nature to back away without a fight. Pack tactics make three wolves the clearest action-economy lesson for a new table.',
monsters: [['Greywood Direwolf', 3]],
},
{
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE',
description: 'A mossed-over shrine older than the kingdom, split by a crack you could lose an arm in. The missing Warden is standing in front of it. She turns and greets you by name.',
dmNotes: 'HORROR register — signal it. The Warden was Hollowed after investigating the crack alone: the party’s first, sympathetic Hollowed. DC 15 Insight notices the wrongness (no blink, a repeated phrase) before it acts. Fight, restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced.',
monsters: [['Hollowed', 1, 'The Missing Warden']],
rewards: [
{ item: 'Warden’s Charm', note: 'From Ashwen, whatever the outcome' },
{ weapon: 'Warden’s Longbow', note: 'The missing Warden’s bow — Ashwen says it should be used' },
],
},
{
name: 'Side Quest — Trial of Stone', mode: 'QUEST',
description: 'Stonejaw Hold, a giant clan’s valley in the Spine foothills. The chieftain has heard you out. He does not offer agreement. He offers a trial.',
dmNotes: 'Lv 2–4. A genuine test of character, nothing sinister. Trials (per PC or shared): non-lethal wrestling with a giant (the Hill Giant here — HP is a “pin” clock, nobody dies); a test of honesty; proving their word is good (retrieve something, deliver a message honestly). Beats: arrive → formal challenge → trial → feast.\nSuccess = standing alliance (Spine Clans track, unlocks E9D and E11C). Failure is not fatal: Korgath offers a second, harder trial.',
monsters: [['Stonejaw Hill Giant', 1, 'Stonejaw Champion']],
npcs: ['Korgath Stonejaw'],
rewards: [
{ item: 'Stonejaw Oath-Token', note: 'On passing the trial' },
{ weapon: 'Stonejaw Maul', note: 'Feast-gift for whoever wrestled the champion' },
],
},
{
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST',
description: 'The Sorrow Camp: hundreds of people under canvas just inside the border, and a garrison at Greyford Keep that wants them gone. Food sent for the camp is not all arriving.',
dmNotes: 'Lv 2–4, the clearest moral-choice quest. No clean villain: the commander is not wrong about the strategic liability, and the refugees have nowhere else to go. Beats: tension between camp and garrison → find who is skimming aid (the Captain’s crew) → confront them → broker, or fail to broker, an arrangement.\nIntroduce a named refugee NPC here — they are at risk at E16B.',
monsters: [['Bandit Captain', 1, 'Aid-Skimmer Boss'], ['Highcrown Cutthroat', 2]],
npcs: ['Greyford’s Commander'],
rewards: [{ gold: 50, note: 'Recovered aid money — keeping it is a choice the camp will notice' }],
},
{
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE',
description: 'A fishing boat washed up empty this morning: sails intact, catch in the hold, crew gone. The fishers will point out the reef. None of them will row you there.',
dmNotes: 'Lv 2–3, horror-lite. NOT connected to the Hollow King — a deliberate palate-cleanser. DC 13 Investigation on the boat finds claw marks, not violence. Confront it at sea (it swims, the party does not) or lure it to the shallows. Outcome: safe fishing reopens and real goodwill in Tidewatch.',
monsters: [['Reef Terror', 1]],
rewards: [
{ gold: 80, note: 'Tidewatch’s collection for whoever cleared the reef' },
{ item: 'Potion of Healing', quantity: 2, note: 'From the harbour apothecary' },
],
},
{
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST',
description: 'A hard climb above the snow line, and a cave mouth wide enough to sail a ship into. The dragon inside already knows you are here. He has not moved, and he has not told you to leave.',
dmNotes: 'A negotiation, not a fight: Threndir wants proof the crown can still be trusted (Persuasion DC 15–18; real evidence from E10 lowers it). He talks first unless attacked. If it goes wrong, add the “Threndir Ashclaw (old copper dragon)” monster to this scene — and remember he is far beyond a low-level party.',
npcs: ['Threndir Ashclaw'],
rewards: [{ weapon: 'Threndir’s Fang', note: 'A shed tooth, set in a hilt — given only if he comes to respect them' }],
},
{
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE',
description: 'A long tunnel beneath the Greywood where the walls weep, and the sound they make is almost a voice. Somewhere ahead, very faintly, a bell tolls.',
dmNotes: 'HORROR register — signal the tone shift. Root Room 2. DC 14 Survival: the dripping is a Hollowed’s footsteps. The Bellcrawler uses Stillness (DC 17 Perception to spot) and mimics the bell to lure the curious.\n• E9A retreat: keep what they learned, full rest before re-entry.\n• E9B push through: the marking here matches the bell script (DC 16 Arcana/Religion).\n• E9C (took E4A): Ressa Vane escorts and reports back — remove her from the scene otherwise.\n• E9D (won Trial of Stone): a Spine guide enters via Room 4.',
monsters: [['Bellcrawler', 1]],
npcs: ['Corporal Ressa Vane'],
},
{
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE',
description: 'A cavern kept unnervingly tidy: bedrolls squared away, boots in a row. Two people sit facing the wall. They stand when you enter, and smile at exactly the same moment.',
dmNotes: 'Root Room 3. DC 15 Insight before combat. Consider making one a named missing person. Slam hits force DC 12 Wis or frightened for a round; each can whisper one sentence in Vessa’s voice. The letter (DC 14) hints at the corrupted house.',
monsters: [['Hollowed', 2]],
rewards: [
{ gold: 20, note: 'Personal effects' },
{ item: 'Half-Burned Letter', note: 'DC 14 Investigation' },
],
},
{
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE',
description: 'The roots in this chamber are as thick as a man’s waist, and they are not growing from anything. One of them shifts when your light touches it.',
dmNotes: 'Root Room 4, a dead end (optional secret passage to Room 6). DC 14 Perception or its first attack has advantage. Run it as a slow, dreadful stalk: tremorsense, burrow, grasping roots (restrained, DC 14 Str). E9D: DC 13 Survival or it wakes at the entrance.',
monsters: [['Root Stalker', 1]],
rewards: [
{ gold: 60, note: 'From what the roots grew around' },
{ weapon: 'Masterwork Dagger', note: 'Still in a dead hand' },
],
},
{
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE',
description: 'Narrow ledges over a drop your torch cannot find the bottom of. From three different directions at once, a bell begins to toll.',
dmNotes: 'Root Room 6. DC 15 Perception to place them; DC 13 Dex on the ledges or fall 2d6. They skitter (bonus-action disengage) and freeze when watched — stealth and dread, not a straight fight. → Room 7, the stair to the Hollow Court: let them see it and turn back.',
monsters: [['Bellcrawler', 3]],
rewards: [
{ gold: 90, note: 'Scattered along the ledges' },
{ item: 'Potion of Healing', note: 'In a fallen pack' },
],
},
{
name: 'E10 — The House Unmasked', mode: 'QUEST',
description: 'Back in Highcrown with a name, a ledger, and a question nobody at court wants asked out loud: what do you do with it?',
dmNotes: 'The ledger: every shrine “donation” falls three days after a livestock death.\n• E10A confront the house directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.\n• E10B report to the crown, Isolde or Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).\n• E10C (E2C + the servant thread): private DC 13 check at advantage → confession and the sealed letter; the house never learns it is suspected. Best route into Act 3.',
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
rewards: [{ item: 'Sealed Letter', note: 'E10C only' }],
},
{
name: 'E11 — Silence on the Border', mode: 'QUEST',
description: 'Greyford Keep. The gate stands open. No smoke from the chimneys, no one on the walls, no bodies. A kettle is still warm on a guardroom stove.',
dmNotes: 'DC 13 Perception: drag marks — the defenders left. The Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): the clans are already here and share Root Stalker migration intel. Remove the Commander from the scene if this is a recovery.',
npcs: ['Greyford’s Commander'],
rewards: [
{ gold: 70, note: 'Trade goods in the keep’s stores (2d6×10 gp)' },
{ weapon: 'Greyford Halberd', note: 'From the armoury' },
],
},
{
name: 'E12 — A Line Thins', mode: 'BATTLE',
description: 'The ground splits somewhere you thought was safe, and something comes up through it that should have stayed far below.',
dmNotes: 'A Root Stalker surfaces near a location the party did not secure — pick it from what they left undone. DC 15 Arcana/Religion: this is deliberate erosion from below. The root-fiber scrap is a key item (needed at E14/E18/E19). Then E13: the clock turns to Stage 3.',
monsters: [['Root Stalker', 1]],
rewards: [{ item: 'Root-Fiber Scrap', note: 'Key item — cut from the Stalker' }],
},
{
name: 'E14 — The Break', mode: 'BATTLE',
description: 'The crack has become a breach. People are coming out of it — neighbours, guards, people you have met — walking calmly, smiling, and not blinking.',
dmNotes: 'Two to four Hollowed (scale to the party). DC 15 Arcana: the breach is shaped for something to come through.\n• E14A seal it (DC 17 Arcana or the chapel ritual): closes, but a debt comes due at E17.\n• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.\nThe seal-stone fragment is a key item (needed at E19).',
monsters: [['Hollowed', 4]],
rewards: [{ item: 'Seal-Stone Fragment', note: 'Key item — from the breach’s edge' }],
},
{
name: 'Mid-campaign scare — The Hollow King’s Shadow', mode: 'BATTLE',
description: 'The light goes thin and the air goes cold. Something tall is standing where the wall should be, and the wall does not seem to mind.',
dmNotes: 'DO NOT run as a fair fight. At 0 HP it disperses for the scene and never dies; treat its HP as a ticking clock. Give the party a way out: close a ritual circle, reach sunlight or a warded threshold, or survive N rounds. Echoing dread: DC 15 Wis or frightened within 30 ft. Save its true, killable form for Act 4.',
monsters: [['The Hollow King’s Shadow', 1]],
},
{
name: 'E19 — Vessa’s Seat', mode: 'QUEST',
description: 'A single worn stone seat at the heart of the Hollow Court, and a woman who has been expecting you. She asks you to sit. She says she would like to explain.',
dmNotes: 'Court Room 4. The party learns the Hollow King’s true nature from Vessa: the imprisoned will of everyone the founders sacrificed — a suppression of many, not a prison for one. DC 16 Insight: she believes what she says. A strong candidate for a negotiated outcome.\nRoot-fiber + seal-stone (DC 15 Arcana, automatic with a Religion-proficient caster) → advantage on the first check at E22. Then E20: the clock turns to Stage 4.',
npcs: ['Vessa Hollow'],
},
{
name: 'E21 — Rally Point', mode: 'QUEST',
description: 'One last gathering before the descent. Whoever is going to stand with you is here now — and they do not all agree about what happens next.',
dmNotes: 'Who shows up depends on the branch list — remove anyone the party lost or alienated.\n• E21A Grey Dawn track: Kavan and his wardens, by the book.\n• E21B Spine Clans track: Korgath’s war-band; advantage on the first Survival check at E22.\n• E21C Independent: no reinforcements; every choice stays theirs.\n• Several tracks: the groups arrive and disagree — the party mediates.',
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne', 'Korgath Stonejaw', 'Ashwen'],
rewards: [{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' }],
},
{
name: 'E22 — The Seal Chamber', mode: 'QUEST',
description: 'A wound in the world, stitched shut long ago, and coming undone one thread at a time. Whatever you came here to decide, this is where you decide it.',
dmNotes: 'Court Room 5 — the key branch. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation.\n• E22A reinforce (DC 18 Arcana/Religion): the Sealed Ending — time bought.\n• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.\n• E22C do nothing: the seal fails anyway; E24–E25 are harder.\nHollowbane is the campaign’s single most significant magic item: legendary growth, starts at level 7.',
npcs: ['Vessa Hollow'],
rewards: [{ weapon: 'Hollowbane', note: 'Drawn from the seal itself' }],
},
{
name: 'E25 — The Last Approach', mode: 'BATTLE',
description: 'The final stretch of the Hollow Court, where gravity, light and memory have all stopped agreeing with one another. The things that live here know you are coming.',
dmNotes: 'A gauntlet of horror-register creatures; whichever faction the party rallied fights alongside them (add those NPCs). Grey Dawn = a disciplined line; Spine Clans = terrain and ritual (collapse passages); Independent = hardest, and entirely theirs. → E26, the Hollow King: build that stat block from DM Secrets, scaled to the party.',
monsters: [['Root Stalker', 1], ['Bellcrawler', 2]],
},
];
// ---------------------------------------------------------------------------
@@ -414,4 +801,4 @@ The Salt Coast: 1 smugglers wary of witnesses · 2 short-handed fishing crew ·
The Bramble Marches: 1 Bandit Captain’s crew · 2 refugees on the road · 3 tense border patrol · 4 signs of the neighboring power · 5 a skirmish in progress · 6 watchful quiet.`,
];
module.exports = { campaign, locations, monsters, encounters, sharedNotes, dmNotes };
module.exports = { campaign, locations, monsters, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
+130
View File
@@ -1521,3 +1521,133 @@ describe('encounter combat info', () => {
expect(typeof participant.characterCombatInfo.weapons[0].attackBonus).toBe('number');
});
});
describe('changing a password', () => {
itIfDb('needs the current password, then only the new one logs in', async () => {
const email = uniqueEmail('pw');
const registerRes = await request(app).post('/auth/register').send({ email, password: 'password123', displayName: 'Pw' });
cleanupUserIds.push(registerRes.body.user.id);
const auth = { Authorization: `Bearer ${registerRes.body.token}` };
expect((await request(app).post('/auth/password').send({ currentPassword: 'password123', newPassword: 'new-password1' })).status).toBe(401);
const wrong = await request(app).post('/auth/password').set(auth).send({ currentPassword: 'nope', newPassword: 'new-password1' });
expect(wrong.status).toBe(400);
const short = await request(app).post('/auth/password').set(auth).send({ currentPassword: 'password123', newPassword: 'short' });
expect(short.status).toBe(400);
const same = await request(app).post('/auth/password').set(auth).send({ currentPassword: 'password123', newPassword: 'password123' });
expect(same.status).toBe(400);
const changed = await request(app).post('/auth/password').set(auth).send({ currentPassword: 'password123', newPassword: 'new-password1' });
expect(changed.status).toBe(204);
expect((await request(app).post('/auth/login').send({ email, password: 'password123' })).status).toBe(401);
expect((await request(app).post('/auth/login').send({ email, password: 'new-password1' })).status).toBe(200);
});
});
describe('archiving and deleting a campaign', () => {
itIfDb('the DM archives and restores; an archived campaign keeps its contents but takes no new members', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('archive');
const c = `/campaigns/${campaign.id}`;
expect(campaign.archivedAt).toBeNull();
expect((await request(app).patch(c).set(playerAuth).send({ archived: true })).status).toBe(403);
const archived = await request(app).patch(c).set(dmAuth).send({ archived: true });
expect(archived.status).toBe(200);
expect(archived.body.campaign.archivedAt).toBeTruthy();
// Still there for its members, and flagged in their lists.
const playerList = await request(app).get('/campaigns').set(playerAuth);
expect(playerList.body.campaigns.find((x) => x.id === campaign.id).archivedAt).toBeTruthy();
expect((await request(app).get(c).set(playerAuth)).status).toBe(200);
const outsider = await request(app)
.post('/auth/register')
.send({ email: uniqueEmail('archive-outsider'), password: 'password123', displayName: 'Outsider' });
cleanupUserIds.push(outsider.body.user.id);
const outsiderAuth = { Authorization: `Bearer ${outsider.body.token}` };
expect((await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(outsiderAuth)).status).toBe(403);
// Other settings can change without un-archiving it.
const renamed = await request(app).patch(c).set(dmAuth).send({ startingGold: 7 });
expect(renamed.body.campaign.archivedAt).toBeTruthy();
const restored = await request(app).patch(c).set(dmAuth).send({ archived: false });
expect(restored.body.campaign.archivedAt).toBeNull();
expect(restored.body.campaign.startingGold).toBe(7);
expect((await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(outsiderAuth)).status).toBe(201);
});
itIfDb('only the owner deletes, and everything in the campaign goes with it', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('delete');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Doomed');
const encounterRes = await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Last Stand' });
const encounterId = encounterRes.body.encounter.id;
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId: heroId });
await request(app).post('/notes').set(dmAuth).send({ campaignId: campaign.id, body: 'Secret', visibility: 'DM_ONLY' });
expect((await request(app).delete(c).set(playerAuth)).status).toBe(403);
expect((await request(app).get(c).set(dmAuth)).status).toBe(200);
expect((await request(app).delete(c).set(dmAuth)).status).toBe(204);
expect((await request(app).get(c).set(dmAuth)).status).toBe(404);
expect((await request(app).get(`/characters/${heroId}`).set(playerAuth)).status).toBe(404);
expect((await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).status).toBe(404);
expect(await prisma.note.count({ where: { campaignId: campaign.id } })).toBe(0);
const dmList = await request(app).get('/campaigns').set(dmAuth);
expect(dmList.body.campaigns.find((x) => x.id === campaign.id)).toBeUndefined();
});
});
describe('ordering, archiving and deleting encounters', () => {
itIfDb('the DM arranges the list, archives out of players\' sight, and deletes without undoing rewards', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('encorder');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Keeper');
const ids = {};
for (const name of ['One', 'Two', 'Three']) {
ids[name] = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name })).body.encounter.id;
}
const names = async (auth) => (await request(app).get(`${c}/encounters`).set(auth)).body.encounters.map((e) => e.name);
// New encounters go to the top.
expect(await names(dmAuth)).toEqual(['Three', 'Two', 'One']);
const order = (encounterIds, auth = dmAuth) => request(app).put(`${c}/encounters/order`).set(auth).send({ encounterIds });
expect((await order([ids.One, ids.Two, ids.Three], playerAuth)).status).toBe(403);
expect((await order([ids.One, ids.One])).status).toBe(400);
expect((await order([ids.One, 'not-an-encounter'])).status).toBe(400);
expect((await order([ids.One, ids.Two, ids.Three])).status).toBe(204);
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
// Archived: the DM still lists it, players don't, and it can still be opened.
expect((await request(app).patch(`/encounters/${ids.Two}`).set(playerAuth).send({ archived: true })).status).toBe(403);
const archived = await request(app).patch(`/encounters/${ids.Two}`).set(dmAuth).send({ archived: true });
expect(archived.body.encounter.archivedAt).toBeTruthy();
expect(await names(playerAuth)).toEqual(['One', 'Three']);
expect(await names(dmAuth)).toEqual(['One', 'Two', 'Three']);
expect((await request(app).get(`/encounters/${ids.Two}`).set(playerAuth)).status).toBe(200);
const restored = await request(app).patch(`/encounters/${ids.Two}`).set(dmAuth).send({ archived: false });
expect(restored.body.encounter.archivedAt).toBeNull();
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
// Deleting takes the scene but leaves what it already gave out.
await request(app).post(`/encounters/${ids.One}/participants`).set(dmAuth).send({ characterId: heroId });
const rewardRes = await request(app).post(`/encounters/${ids.One}/rewards`).set(dmAuth).send({ type: 'GOLD', gold: 12 });
await request(app).post(`/encounters/${ids.One}/rewards/${rewardRes.body.reward.id}/award`).set(dmAuth).send({ characterId: heroId });
const goldBefore = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.gold;
expect((await request(app).delete(`/encounters/${ids.One}`).set(playerAuth)).status).toBe(403);
expect((await request(app).delete(`/encounters/${ids.One}`).set(dmAuth)).status).toBe(204);
expect((await request(app).get(`/encounters/${ids.One}`).set(dmAuth)).status).toBe(404);
expect(await names(dmAuth)).toEqual(['Two', 'Three']);
const after = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
expect(after.gold).toBe(goldBefore);
expect(goldBefore).toBeGreaterThanOrEqual(12);
});
});
+29
View File
@@ -18,6 +18,11 @@ const loginSchema = z.object({
password: z.string().min(1),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
});
function issueToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, displayName: user.displayName },
@@ -76,4 +81,28 @@ router.get('/me', requireAuth, async (req, res, next) => {
}
});
// Tokens already issued stay valid until they expire; this only changes what
// the next login needs.
router.post('/password', requireAuth, async (req, res, next) => {
try {
const parsed = changePasswordSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { currentPassword, newPassword } = parsed.data;
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) return res.status(404).json({ error: 'User not found' });
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) return res.status(400).json({ error: 'Your current password is incorrect' });
if (newPassword === currentPassword) {
return res.status(400).json({ error: 'Your new password must be different from the current one' });
}
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: await bcrypt.hash(newPassword, 12) } });
return res.status(204).end();
} catch (err) {
return next(err);
}
});
module.exports = router;
+57 -5
View File
@@ -22,6 +22,7 @@ function campaignSummary(campaign, role) {
startingSpellCount: campaign.startingSpellCount,
skipMotivationRoll: campaign.skipMotivationRoll,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
createdAt: campaign.createdAt,
};
@@ -84,6 +85,7 @@ router.post('/join/:inviteCode', async (req, res, next) => {
try {
const campaign = await prisma.campaign.findUnique({ where: { inviteCode: req.params.inviteCode } });
if (!campaign) return res.status(404).json({ error: 'Invalid invite code' });
if (campaign.archivedAt) return res.status(403).json({ error: 'That campaign is archived and is not taking new members' });
const existing = await prisma.campaignMember.findUnique({
where: { campaignId_userId: { campaignId: campaign.id, userId: req.user.id } },
@@ -112,20 +114,41 @@ router.get('/:id', requireCampaignMember(), async (req, res, next) => {
}
});
const updateCampaignSchema = createCampaignSchema.partial();
// `archived` puts the campaign away (true) or brings it back (false).
const updateCampaignSchema = createCampaignSchema.partial().extend({ archived: z.boolean().optional() });
router.patch('/:id', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = updateCampaignSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data: parsed.data });
const { archived, ...data } = parsed.data;
// Re-archiving keeps the original date.
if (archived === true && !req.campaign.archivedAt) data.archivedAt = new Date();
if (archived === false) data.archivedAt = null;
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data });
res.json({ campaign: campaignSummary(campaign, 'DM') });
} catch (err) {
next(err);
}
});
// Deleting takes everything in the campaign with it — characters, pools,
// encounters, notes — so it is the owner's call alone, not any DM's.
router.delete('/:id', requireCampaignMember(), async (req, res, next) => {
try {
if (req.campaign.ownerId !== req.user.id) {
return res.status(403).json({ error: 'Only the campaign owner can delete it' });
}
await prisma.campaign.delete({ where: { id: req.campaign.id } });
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('campaign:deleted', { campaignId: req.campaign.id });
res.status(204).end();
} catch (err) {
next(err);
}
});
router.post('/:id/invite', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const regenerate = req.body && req.body.regenerate === true;
@@ -510,9 +533,10 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
try {
const isDM = req.membership.role === 'DM';
// Archived encounters are the DM's to see; players only get the live list.
const encounters = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id },
orderBy: { createdAt: 'desc' },
where: { campaignId: req.campaign.id, ...(isDM ? {} : { archivedAt: null }) },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
// dmNotes never leaves the server for a player.
res.json({ encounters: isDM ? encounters : encounters.map(({ dmNotes, ...encounter }) => encounter) });
@@ -531,13 +555,41 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
try {
const parsed = createEncounterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const encounter = await prisma.encounter.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
// A new encounter goes to the top of the campaign's order.
const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
const encounter = await prisma.encounter.create({
data: { ...parsed.data, campaignId: req.campaign.id, sortOrder: (first._min.sortOrder ?? 1) - 1 },
});
res.status(201).json({ encounter });
} catch (err) {
next(err);
}
});
const encounterOrderSchema = z.object({ encounterIds: z.array(z.string()).min(1).max(1000) });
// Sets the campaign's own encounter order: the ids given take positions
// 0, 1, 2... in the order sent.
router.put('/:id/encounters/order', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = encounterOrderSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { encounterIds } = parsed.data;
const owned = await prisma.encounter.count({ where: { id: { in: encounterIds }, campaignId: req.campaign.id } });
if (new Set(encounterIds).size !== encounterIds.length || owned !== encounterIds.length) {
return res.status(400).json({ error: 'Send each encounter of this campaign once.' });
}
await prisma.$transaction(
encounterIds.map((id, sortOrder) => prisma.encounter.update({ where: { id }, data: { sortOrder } }))
);
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// NPCs (DM only — players meet them through encounters, without the DM's
// notes or stats)
+16 -1
View File
@@ -174,13 +174,16 @@ const patchEncounterSchema = z.object({
name: z.string().min(1).max(120).optional(),
description: z.string().max(4000).nullable().optional(),
dmNotes: z.string().max(4000).nullable().optional(),
archived: z.boolean().optional(), // put away (true) or bring back (false)
});
router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const parsed = patchEncounterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const data = { ...parsed.data };
const { archived, ...data } = parsed.data;
if (archived === true && !req.encounter.archivedAt) data.archivedAt = new Date();
if (archived === false) data.archivedAt = null;
// Starting a battle puts the first combatant in the initiative order on turn.
const mode = data.mode ?? req.encounter.mode;
@@ -196,6 +199,18 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
}
});
// Deleting removes the scene, its combatant rows and its reward list. What
// was already handed out stays with the characters, as does their HP.
router.delete('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
await prisma.encounter.delete({ where: { id: req.encounter.id } });
emitEncounterUpdated(req);
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Combatants
// ---------------------------------------------------------------------------
+22 -2
View File
@@ -61,7 +61,25 @@ notes) works the same in both.
Status is a label. Nothing is locked when an encounter is complete: the DM
can still change HP, add people and award rewards. Encounters cannot be
deleted or renamed from the screen.
renamed from the screen.
### Order, archive and delete
**Order.** The campaign page lists encounters in the **campaign order**, which
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter
goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
date instead; that choice is remembered per browser and changes nothing for
anyone else. ▲ and ▼ only show while sorting by campaign order.
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
away without changing it. It moves to the DM's "Archived" section and
disappears from the players' list; anyone who already has its page open or
bookmarked can still open it. "Restore" puts it back on the list.
**Delete.** "Delete" (on the encounter page, or on an archived encounter in
the list) removes the scene, who was in it and its reward list, after a
confirmation. There is no undo. Rewards already handed out stay with the
characters, and characters keep their current HP.
## 4. The scene and DM notes
@@ -194,6 +212,8 @@ information cannot be dug out of the page.
| Create an encounter | Yes | No |
| Open an encounter | Yes | Yes |
| Change mode, status, scene or DM notes | Yes | No |
| Rearrange, archive, restore or delete encounters | Yes | No |
| See archived encounters in the list | Yes | No |
| Add or remove anyone | Yes | No |
| Change HP, initiative or conditions | Yes | No |
| Advance turns and rounds | Yes | No |
@@ -211,5 +231,5 @@ People outside the campaign cannot open its encounters.
- Maps, positions, distances and areas of effect
- Experience points and encounter difficulty
- Spells as rewards
- Deleting or renaming an encounter from the screen
- Renaming an encounter from the screen
- A log of what happened during the encounter
+2
View File
@@ -9,6 +9,7 @@ import { PartyOverviewPage } from './pages/PartyOverviewPage';
import { CharacterSheetPage } from './pages/CharacterSheetPage';
import { CharacterBuilderPage } from './pages/CharacterBuilderPage';
import { EncounterPage } from './pages/EncounterPage';
import { AccountPage } from './pages/AccountPage';
function App() {
return (
@@ -24,6 +25,7 @@ function App() {
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
<Route path="/account" element={<AccountPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+71 -1
View File
@@ -62,7 +62,7 @@ export function useCreateCampaign() {
export function useUpdateCampaign(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<Campaign>) =>
mutationFn: async (data: Partial<Campaign> & { archived?: boolean }) =>
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, data)).data.campaign,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
@@ -71,6 +71,31 @@ export function useUpdateCampaign(campaignId: string) {
});
}
// Archived campaigns stay whole; they only move off the main list.
export function useSetCampaignArchived() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ campaignId, archived }: { campaignId: string; archived: boolean }) =>
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, { archived })).data.campaign,
onSuccess: (campaign) => {
qc.invalidateQueries({ queryKey: ['campaign', campaign.id] });
qc.invalidateQueries({ queryKey: ['campaigns'] });
},
});
}
// Permanent: takes every character, encounter and note in the campaign with it.
export function useDeleteCampaign() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (campaignId: string) => apiClient.delete(`/campaigns/${campaignId}`),
onSuccess: (_res, campaignId) => {
qc.removeQueries({ queryKey: ['campaign', campaignId] });
qc.invalidateQueries({ queryKey: ['campaigns'] });
},
});
}
export function useJoinCampaign() {
const qc = useQueryClient();
return useMutation({
@@ -579,6 +604,51 @@ export function useCreateEncounter(campaignId: string) {
});
}
// Sets the campaign's own encounter order to the ids given, first to last.
export function useReorderEncounters(campaignId: string) {
const qc = useQueryClient();
const key = ['campaign', campaignId, 'encounters'];
return useMutation({
mutationFn: async (encounterIds: string[]) => apiClient.put(`/campaigns/${campaignId}/encounters/order`, { encounterIds }),
// Show the move straight away; the refetch below confirms or undoes it.
onMutate: (encounterIds) => {
qc.setQueryData<Encounter[]>(key, (old) => {
if (!old) return old;
const byId = new Map(old.map((e) => [e.id, e]));
return encounterIds.flatMap((id, sortOrder) => {
const encounter = byId.get(id);
return encounter ? [{ ...encounter, sortOrder }] : [];
});
});
},
onSettled: () => qc.invalidateQueries({ queryKey: key }),
});
}
export function useSetEncounterArchived(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ encounterId, archived }: { encounterId: string; archived: boolean }) =>
(await apiClient.patch(`/encounters/${encounterId}`, { archived })).data,
onSuccess: (_data, { encounterId }) => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
},
});
}
// Permanent. Rewards already handed out stay with the characters.
export function useDeleteEncounter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (encounterId: string) => apiClient.delete(`/encounters/${encounterId}`),
onSuccess: (_res, encounterId) => {
qc.removeQueries({ queryKey: ['encounter', encounterId] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
},
});
}
export function useEncounter(encounterId: string | undefined) {
return useQuery({
queryKey: ['encounter', encounterId],
+3
View File
@@ -93,6 +93,7 @@ export interface Campaign {
startingSpellCount: number;
skipMotivationRoll: boolean;
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
createdAt: string;
}
@@ -413,6 +414,8 @@ export interface Encounter {
activeParticipantId: string | null;
description: string | null;
dmNotes?: string | null; // DM only
archivedAt: string | null; // archived encounters only reach the DM's list
sortOrder: number; // place in the campaign's own order, lowest first
createdAt: string;
participants?: EncounterParticipant[];
rewards?: EncounterReward[]; // players only receive the awarded ones
+3 -1
View File
@@ -17,7 +17,9 @@ export function Layout() {
<nav className="topbar-nav">
{user && (
<div className="topbar-user">
<span>{user.displayName}</span>
<Link to="/account" title="Account settings">
{user.displayName}
</Link>
<button className="btn btn-ghost" onClick={logout}>
Log out
</button>
+6
View File
@@ -39,11 +39,16 @@ export function useCampaignSocket(campaignId: string | undefined) {
const onRoundAdvanced = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
};
const onCampaignDeleted = () => {
qc.invalidateQueries({ queryKey: ['campaigns'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
};
socket.on('character:updated', onCharacterUpdated);
socket.on('character:created', onCharacterCreated);
socket.on('encounter:participant-updated', onParticipantUpdated);
socket.on('encounter:round-advanced', onRoundAdvanced);
socket.on('campaign:deleted', onCampaignDeleted);
return () => {
socket.emit('campaign:leave', campaignId);
@@ -51,6 +56,7 @@ export function useCampaignSocket(campaignId: string | undefined) {
socket.off('character:created', onCharacterCreated);
socket.off('encounter:participant-updated', onParticipantUpdated);
socket.off('encounter:round-advanced', onRoundAdvanced);
socket.off('campaign:deleted', onCampaignDeleted);
};
}, [campaignId, token, qc]);
}
+96
View File
@@ -0,0 +1,96 @@
import { useState, type FormEvent } from 'react';
import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import { extractErrorMessage } from '../lib/format';
import { ErrorBanner } from '../components/Spinner';
export function AccountPage() {
const { user } = useAuth();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSaved(false);
if (newPassword !== confirmPassword) {
setError('The two new passwords do not match.');
return;
}
setSubmitting(true);
try {
await apiClient.post('/auth/password', { currentPassword, newPassword });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setSaved(true);
} catch (err) {
setError(extractErrorMessage(err));
} finally {
setSubmitting(false);
}
}
return (
<div style={{ maxWidth: 480 }}>
<div className="page-header">
<div>
<h1>Your Account</h1>
<p className="subtitle">
{user?.displayName} · {user?.email}
</p>
</div>
</div>
<div className="card">
<div className="card-title">Change Password</div>
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="currentPassword">Current password</label>
<input
id="currentPassword"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="newPassword">New password (at least 8 characters)</label>
<input
id="newPassword"
type="password"
autoComplete="new-password"
required
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="confirmPassword">New password again</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
required
minLength={8}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{error && <ErrorBanner message={error} />}
{saved && <p className="subtitle">Password changed. Use the new one next time you log in.</p>}
<button type="submit" className="btn btn-primary mt-md" disabled={submitting}>
{submitting ? 'Saving…' : 'Change Password'}
</button>
</form>
</div>
</div>
);
}
+243 -10
View File
@@ -7,6 +7,9 @@ import {
useDeleteCharacter,
useCampaignEncounters,
useCreateEncounter,
useReorderEncounters,
useSetEncounterArchived,
useDeleteEncounter,
useCampaignMonsters,
useCreateMonster,
useDeleteMonster,
@@ -23,6 +26,8 @@ import {
useCampaignNotes,
useCreateNote,
useUpdateCampaign,
useSetCampaignArchived,
useDeleteCampaign,
useRegenerateInvite,
useCampaignLocations,
useCreateLocation,
@@ -39,7 +44,41 @@ import { usePagination, Pagination } from '../components/Paginated';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type { CharacterSummary, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
// How the encounter list can be ordered. "custom" is the campaign's own order,
// which the DM arranges; the rest only change what this browser shows.
type EncounterSort = 'custom' | 'name' | 'status' | 'type' | 'newest' | 'oldest';
const ENCOUNTER_SORT_LABELS: Record<EncounterSort, string> = {
custom: 'Campaign order',
name: 'Name (A–Z)',
status: 'Status (in progress first)',
type: 'Type (battles first)',
newest: 'Newest first',
oldest: 'Oldest first',
};
const ENCOUNTER_SORT_KEY = 'dnd_encounter_sort';
const STATUS_RANK = { ACTIVE: 0, PLANNING: 1, COMPLETE: 2 };
function storedEncounterSort(): EncounterSort {
const stored = localStorage.getItem(ENCOUNTER_SORT_KEY);
return stored && stored in ENCOUNTER_SORT_LABELS ? (stored as EncounterSort) : 'custom';
}
// `encounters` arrives in campaign order, and the sort is stable, so ties
// (same status, same type) stay in that order.
function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter[] {
const time = (e: Encounter) => new Date(e.createdAt).getTime();
const compare: Record<EncounterSort, (a: Encounter, b: Encounter) => number> = {
custom: () => 0,
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
status: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status],
type: (a, b) => Number(a.mode === 'QUEST') - Number(b.mode === 'QUEST'),
newest: (a, b) => time(b) - time(a),
oldest: (a, b) => time(a) - time(b),
};
return [...encounters].sort(compare[sort]);
}
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
const inProgress = character.buildStage !== 'COMPLETE';
@@ -259,6 +298,9 @@ export function CampaignHomePage() {
const createCharacter = useCreateCharacter(campaignId);
const deleteCharacter = useDeleteCharacter(campaignId);
const createEncounter = useCreateEncounter(campaignId);
const reorderEncounters = useReorderEncounters(campaignId);
const setEncounterArchived = useSetEncounterArchived(campaignId);
const deleteEncounter = useDeleteEncounter(campaignId);
const createMonster = useCreateMonster(campaignId);
const deleteMonster = useDeleteMonster(campaignId);
const createWeapon = useCreateWeapon(campaignId);
@@ -275,6 +317,8 @@ export function CampaignHomePage() {
const createNote = useCreateNote();
const updateCampaign = useUpdateCampaign(campaignId);
const regenerateInvite = useRegenerateInvite(campaignId);
const setArchived = useSetCampaignArchived();
const deleteCampaign = useDeleteCampaign();
const createLocation = useCreateLocation(campaignId);
const deleteLocation = useDeleteLocation(campaignId);
const addWeaponFromLibrary = useAddWeaponFromLibrary(campaignId);
@@ -284,12 +328,16 @@ export function CampaignHomePage() {
const [newCharName, setNewCharName] = useState('');
const [newEncounterName, setNewEncounterName] = useState('');
const [newEncounterMode, setNewEncounterMode] = useState<EncounterMode>('BATTLE');
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
const [showNpcForm, setShowNpcForm] = useState(false);
const [noteBody, setNoteBody] = useState('');
const [dmNoteBody, setDmNoteBody] = useState('');
const [newLocationName, setNewLocationName] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const [showMonsterForm, setShowMonsterForm] = useState(false);
const [showWeaponForm, setShowWeaponForm] = useState(false);
const [showSpellForm, setShowSpellForm] = useState(false);
@@ -303,6 +351,9 @@ export function CampaignHomePage() {
const itemsPage = usePagination(items, 8);
const npcsPage = usePagination(npcs, 8);
const monstersPage = usePagination(monsters, 8);
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
const sharedNotesPage = usePagination(sharedNotes, 6);
@@ -341,6 +392,17 @@ export function CampaignHomePage() {
}
}
// Swaps an encounter with its neighbour in the campaign's own order.
function moveEncounter(index: number, step: -1 | 1) {
const ids = liveEncounters.map((e) => e.id);
const target = index + step;
if (target < 0 || target >= ids.length) return;
[ids[index], ids[target]] = [ids[target], ids[index]];
reorderEncounters.mutate([...ids, ...archivedEncounters.map((e) => e.id)], {
onError: (err) => setFormError(extractErrorMessage(err)),
});
}
async function handleCreateNote(e: FormEvent) {
e.preventDefault();
if (!noteBody.trim()) return;
@@ -355,6 +417,26 @@ export function CampaignHomePage() {
setDmNoteBody('');
}
async function handleSetArchived(archived: boolean) {
setFormError(null);
try {
await setArchived.mutateAsync({ campaignId, archived });
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleDeleteCampaign(e: FormEvent) {
e.preventDefault();
setFormError(null);
try {
await deleteCampaign.mutateAsync(campaignId);
navigate('/campaigns');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleCreateLocation(e: FormEvent) {
e.preventDefault();
if (!newLocationName.trim()) return;
@@ -373,6 +455,7 @@ export function CampaignHomePage() {
<Link to={`/campaigns/${campaignId}/party`} className="btn">
📊 Party Overview
</Link>
{campaign.archivedAt && <span className="badge">Archived</span>}
<span className={`badge ${isDM ? 'badge-dm' : 'badge-player'}`}>{isDM ? 'Dungeon Master' : 'Player'}</span>
</div>
</div>
@@ -439,6 +522,53 @@ export function CampaignHomePage() {
))}
</div>
</div>
<div className="mt-md">
<div className="card-title" style={{ marginBottom: '0.4rem' }}>Archive or Delete</div>
<p className="subtitle" style={{ marginTop: 0 }}>
{campaign.archivedAt
? 'This campaign is archived: it sits under “Archived” on your campaign list and its invite code is switched off. Nothing in it has been removed.'
: 'Archiving moves the campaign off everyone’s main list and switches off its invite code. Nothing is removed, and you can restore it at any time.'}
</p>
<div className="flex-row flex-wrap">
<button className="btn" disabled={setArchived.isPending} onClick={() => handleSetArchived(!campaign.archivedAt)}>
{campaign.archivedAt ? 'Restore Campaign' : 'Archive Campaign'}
</button>
{campaign.ownerId === user?.id && (
<button
className="btn btn-danger"
onClick={() => {
setShowDelete((s) => !s);
setDeleteConfirmName('');
}}
>
{showDelete ? 'Cancel' : 'Delete Campaign…'}
</button>
)}
</div>
{showDelete && (
<form className="mt-md" onSubmit={handleDeleteCampaign}>
<div className="field">
<label htmlFor="deleteConfirmName">
This permanently deletes every character, encounter, note and pool entry in the campaign, for
every player. There is no undo. Type <strong>{campaign.name}</strong> to confirm.
</label>
<input
id="deleteConfirmName"
autoComplete="off"
value={deleteConfirmName}
onChange={(e) => setDeleteConfirmName(e.target.value)}
/>
</div>
<button
type="submit"
className="btn btn-danger"
disabled={deleteCampaign.isPending || deleteConfirmName.trim() !== campaign.name.trim()}
>
{deleteCampaign.isPending ? 'Deleting…' : 'Delete Forever'}
</button>
</form>
)}
</div>
</div>
)}
@@ -484,6 +614,25 @@ export function CampaignHomePage() {
<div className="mt-lg">
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
{liveEncounters.length > 1 && (
<label className="flex-row subtitle">
Sort by
<select
value={encounterSort}
onChange={(e) => {
const sort = e.target.value as EncounterSort;
setEncounterSort(sort);
localStorage.setItem(ENCOUNTER_SORT_KEY, sort);
}}
>
{(Object.keys(ENCOUNTER_SORT_LABELS) as EncounterSort[]).map((sort) => (
<option key={sort} value={sort}>
{ENCOUNTER_SORT_LABELS[sort]}
</option>
))}
</select>
</label>
)}
</div>
{isDM && (
<form onSubmit={handleCreateEncounter} className="flex-row flex-wrap" style={{ marginBottom: '0.75rem' }}>
@@ -502,19 +651,103 @@ export function CampaignHomePage() {
</button>
</form>
)}
{encounters?.length === 0 && <div className="empty-state">No encounters yet.</div>}
{encounters?.map((enc) => (
<Link key={enc.id} to={`/encounters/${enc.id}`} className="card flex-between" style={{ display: 'flex' }}>
<span>
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
<p className="subtitle" style={{ marginTop: 0 }}>
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it.
</p>
)}
{encounters && liveEncounters.length === 0 && (
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
)}
{shownEncounters.map((enc, index) => (
<div key={enc.id} className="card flex-between flex-wrap">
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
{enc.name}
</Link>
<span className="flex-row">
<span className="badge">
{enc.status}
{enc.mode === 'BATTLE' && ` · Round ${enc.currentRound}`}
</span>
{isDM && encounterSort === 'custom' && (
<>
<button
className="btn btn-ghost"
aria-label={`Move ${enc.name} up`}
title="Move up"
disabled={index === 0 || reorderEncounters.isPending}
onClick={() => moveEncounter(index, -1)}
>
▲
</button>
<button
className="btn btn-ghost"
aria-label={`Move ${enc.name} down`}
title="Move down"
disabled={index === shownEncounters.length - 1 || reorderEncounters.isPending}
onClick={() => moveEncounter(index, 1)}
>
▼
</button>
</>
)}
{isDM && (
<button
className="btn btn-ghost"
title="Archive: take it off the list without deleting it"
disabled={setEncounterArchived.isPending}
onClick={() => setEncounterArchived.mutate({ encounterId: enc.id, archived: true })}
>
Archive
</button>
)}
</span>
<span className="badge">
{enc.status}
{enc.mode === 'BATTLE' && ` · Round ${enc.currentRound}`}
</span>
</Link>
</div>
))}
{isDM && archivedEncounters.length > 0 && (
<div className="mt-md">
<div className="flex-between">
<h3 className="section-label" style={{ margin: 0 }}>Archived ({archivedEncounters.length})</h3>
<button className="btn btn-ghost" onClick={() => setShowArchivedEncounters((s) => !s)}>
{showArchivedEncounters ? 'Hide' : 'Show'}
</button>
</div>
{showArchivedEncounters && (
<>
<p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p>
{archivedEncounters.map((enc) => (
<div key={enc.id} className="card flex-between flex-wrap">
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
{enc.name}
</Link>
<span className="flex-row">
<button
className="btn"
disabled={setEncounterArchived.isPending}
onClick={() => setEncounterArchived.mutate({ encounterId: enc.id, archived: false })}
>
Restore
</button>
<button
className="btn btn-danger"
disabled={deleteEncounter.isPending}
onClick={() =>
window.confirm(`Delete "${enc.name}" for good? Rewards already handed out stay with the characters.`) &&
deleteEncounter.mutate(enc.id)
}
>
Delete
</button>
</span>
</div>
))}
</>
)}
</div>
)}
</div>
{isDM && (
+46 -4
View File
@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useCampaigns, useCreateCampaign, useJoinCampaign } from '../api/queries';
import { useCampaigns, useCreateCampaign, useJoinCampaign, useSetCampaignArchived } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { extractErrorMessage } from '../lib/format';
@@ -8,6 +8,7 @@ export function CampaignListPage() {
const { data: campaigns, isLoading, error } = useCampaigns();
const createCampaign = useCreateCampaign();
const joinCampaign = useJoinCampaign();
const setArchived = useSetCampaignArchived();
const navigate = useNavigate();
const [showCreate, setShowCreate] = useState(false);
@@ -15,6 +16,10 @@ export function CampaignListPage() {
const [description, setDescription] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const activeCampaigns = campaigns?.filter((c) => !c.archivedAt);
const archivedCampaigns = campaigns?.filter((c) => c.archivedAt) || [];
async function handleCreate(e: FormEvent) {
e.preventDefault();
@@ -89,11 +94,15 @@ export function CampaignListPage() {
<div className="mt-lg">
{isLoading && <Spinner label="Consulting the tavern board…" />}
{error && <ErrorBanner message="Could not load your campaigns." />}
{campaigns && campaigns.length === 0 && (
<div className="empty-state">No campaigns yet — found one, or join with an invite code above.</div>
{activeCampaigns && activeCampaigns.length === 0 && (
<div className="empty-state">
{archivedCampaigns.length > 0
? 'No active campaigns — found one, join with an invite code above, or restore one from the archive below.'
: 'No campaigns yet — found one, or join with an invite code above.'}
</div>
)}
<div className="grid grid-cols-2">
{campaigns?.map((c) => (
{activeCampaigns?.map((c) => (
<Link key={c.id} to={`/campaigns/${c.id}`} className="card" style={{ display: 'block' }}>
<div className="card-title">
<span>{c.name}</span>
@@ -104,6 +113,39 @@ export function CampaignListPage() {
))}
</div>
</div>
{archivedCampaigns.length > 0 && (
<div className="mt-lg">
<div className="flex-between">
<h2 className="section-label" style={{ margin: 0 }}>Archived ({archivedCampaigns.length})</h2>
<button className="btn btn-ghost" onClick={() => setShowArchived((s) => !s)}>
{showArchived ? 'Hide' : 'Show'}
</button>
</div>
{showArchived && (
<div className="grid grid-cols-2 mt-sm">
{archivedCampaigns.map((c) => (
<div key={c.id} className="card">
<div className="card-title">
<Link to={`/campaigns/${c.id}`}>{c.name}</Link>
<span className={`badge ${c.role === 'DM' ? 'badge-dm' : 'badge-player'}`}>{c.role}</span>
</div>
<p style={{ color: 'var(--text-muted)', marginTop: 0 }}>{c.description || 'No description yet.'}</p>
{c.role === 'DM' && (
<button
className="btn"
disabled={setArchived.isPending}
onClick={() => setArchived.mutate({ campaignId: c.id, archived: false })}
>
Restore
</button>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -344,7 +344,7 @@ export function CharacterSheetPage() {
}
}
const otherCampaigns = campaigns?.filter((c) => c.id !== character.campaignId) ?? [];
const otherCampaigns = campaigns?.filter((c) => c.id !== character.campaignId && !c.archivedAt) ?? [];
const assignedWeaponIds = new Set(character.weapons.map((w) => w.weaponId));
const assignedSpellIds = new Set(character.spells.map((s) => s.spellId));
+25 -1
View File
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
useEncounter,
useCampaign,
@@ -14,6 +14,8 @@ import {
useAdvanceRound,
useNextTurn,
usePatchEncounter,
useSetEncounterArchived,
useDeleteEncounter,
useAddReward,
useDeleteReward,
useAwardReward,
@@ -592,6 +594,9 @@ export function EncounterPage() {
const advanceRound = useAdvanceRound(encounterId);
const nextTurn = useNextTurn(encounterId);
const patchEncounter = usePatchEncounter(encounterId);
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
const navigate = useNavigate();
const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
const [pickId, setPickId] = useState('');
@@ -656,6 +661,7 @@ export function EncounterPage() {
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
<span className={`badge ${encounter.status === 'ACTIVE' ? 'badge-live' : ''}`}>{STATUS_LABELS[encounter.status]}</span>
{isBattle && <span className="badge">Round {encounter.currentRound}</span>}
{encounter.archivedAt && <span className="badge">Archived</span>}
{active && <span className="turn-marker">▶ {active.displayName}'s turn</span>}
</div>
</div>
@@ -698,6 +704,24 @@ export function EncounterPage() {
Reopen
</button>
)}
<button
className="btn btn-ghost"
title={encounter.archivedAt ? 'Put it back on the campaign list' : 'Take it off the campaign list without deleting it'}
disabled={setArchived.isPending}
onClick={() => setArchived.mutate({ encounterId, archived: !encounter.archivedAt }, fail)}
>
{encounter.archivedAt ? 'Restore' : 'Archive'}
</button>
<button
className="btn btn-ghost"
disabled={deleteEncounter.isPending}
onClick={() =>
window.confirm(`Delete "${encounter.name}" for good? Rewards already handed out stay with the characters.`) &&
deleteEncounter.mutate(encounterId, { ...fail, onSuccess: () => navigate(`/campaigns/${encounter.campaignId}`) })
}
>
Delete
</button>
</>
)}
</div>
+6
View File
@@ -4,4 +4,10 @@ import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
preview: {
host: true,
port: 5173,
allowedHosts: ['the-sunken-reach.crosscode.co.za'],
},
})