V1.12.0: worlds from a code, shops and cash in the seeds, up-to-date packages

- A DM can start a campaign from a ready-made world by typing its
  six-character code: "+ New Campaign" on the Campaigns page, then "Or
  start from a ready-made world". The whole world is built in one go
  with them as its DM: locations, weapons, items, spells, monsters,
  NPCs, encounters and notes. ALDENF is Aldenfell, HOLLOW is Hollowcrown.
- A world is one file in /backend/seeds. Drop another in and its code
  works straight away, with no restart. "npm run seeds:check" lists
  every world, or what is wrong with one: a misspelt field, a name used
  twice, an encounter pointing at something the file does not have. A
  file with a mistake in it is never offered. docs/SEEDS.md has the
  format.
- A seed can now hold everything a campaign can: gear effects, an NPC's
  wares and exchange rate, things for sale at an encounter, and rewards
  in silver, copper and cash.
- The database seed creates the admin account and nothing else. The
  Aldenfell and Hollowcrown seed commands are gone: worlds come from
  codes.
- Hollowcrown follows the V3 book: 4 starting items and 3 things
  equipped, the book's effects on every weapon and item, armor as items
  (library, modern and magic), worn gear at the book's levels, damage
  types on monsters, ten shop scenes with prices and stock, five money
  changers, Meridian loot as cash and mixed hoards as gold, silver and
  copper.
- Aldenfell: the Shield and Leather Armor change AC, Mireille and the
  Founding Fair stalls sell things, and the book's missing scenes are in
  (E5, E7, E15, E16, E18, E23-24, E26, the Drowned Shrine, Sevrisk's
  Hollow, a wyvern pass). The locket is found with Investigation, as the
  book has it.
- Packages are up to date: Prisma 7 (with the pg driver adapter and a
  prisma.config.js that holds the database URL), Express 5, zod 4,
  dotenv 18, jest 30, bcrypt 6 and TypeScript 7. "npm audit" finds
  nothing. Install scripts are approved for Prisma and bcrypt. On a
  server: "npm install", then "npm run deploy".
- The frontend's five lint warnings are fixed; useAuth and usePagination
  have files of their own.
- All packages at version 1.12.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 16:03:16 +02:00
co-authored by Claude Opus 5.5
parent b82084276a
commit 9c97d61ce5
40 changed files with 7442 additions and 2718 deletions
+38 -25
View File
@@ -18,6 +18,7 @@ monsters, and a real-time encounter/combat tracker.
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, trade, what players see |
| `docs/MONSTERS.md` | The monster list and built-in bestiary |
| `docs/NPCS.md` | Non-player characters, what they sell and the rates they change money at |
| `docs/SEEDS.md` | World seeds: ready-made campaigns a DM starts from a code, and how to add one |
`CLAUDE_BUILD_PROMPT.md`, `docs/GAME_RULES_REFERENCE.md` and
`docs/ROLL_YOUR_CHARACTER.md` are the original specifications the first
@@ -48,12 +49,12 @@ including a character sheet or an encounter) and **Account**.
## Stack
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
- **Backend:** Node.js + Express, JavaScript, Prisma ORM (with the `pg` driver adapter), PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
- **Frontend:** React + Vite + TypeScript, React Router, TanStack Query, socket.io-client.
## Prerequisites
- Node.js 18+ (tested on Node 22)
- Node.js 20.19+ (tested on Node 24)
- A running PostgreSQL instance (local install, Docker, or a hosted service like Neon/Supabase)
## First-time setup
@@ -65,7 +66,8 @@ 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 # adds the admin account + the Aldenfell campaign if missing (see output for login/invite code)
npx prisma generate # migrate no longer does this itself
npx prisma db seed # adds the admin account if it is missing (see output for the login and the world codes)
# Frontend
cd ../frontend
@@ -82,26 +84,33 @@ 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 admin
account — see the seed script's console output for credentials and invite
code), and start playing.
account — see the seed script's console output for the credentials), 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.
The database seed creates the admin account and nothing else. It only adds: an
existing account, and its password, is left alone, so it is safe to run on a
server with real data. `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` and
`SEED_ADMIN_NAME` set the account it creates. `npx prisma db seed -- --reset`
wipes every user and campaign first.
A second campaign, Hollowcrown: The Rift Between Worlds, has its own seed and
is not part of the default one. It follows the same rules (it only adds, and
skips if the admin already has an active Hollowcrown):
## Worlds
```bash
cd backend
npm run seed:hollowcrown
```
Campaigns are created in the app: blank, or from a **world seed** by its
code. On the Campaigns page click "+ New Campaign" and enter a code under "Or
start from a ready-made world"; the whole world is built for you as its DM.
On a server, after pulling a new version, run `npm run deploy` in `backend`
| Code | World |
|---|---|
| `ALDENF` | Aldenfell |
| `HOLLOW` | Hollowcrown: The Rift Between Worlds |
Each world is one file in `/backend/seeds`. Drop another file in and its code
works straight away, with no restart; `npm run seeds:check` (from the repo
root or `backend`) checks every file and says what is wrong with one.
`docs/SEEDS.md` has the format.
On a server, after pulling a new version, run `npm install` in the repo root
(when dependencies changed) and then `npm run deploy` in `backend`
and then restart the backend. It applies new migrations **and** regenerates
the Prisma client; without the second half the server keeps running with the
old data model and pages that use anything new fail to load (for example
@@ -115,8 +124,9 @@ cd backend
npm test
```
The calculation engine (`src/game/calculations.js`) is fully unit tested and
runs with no external dependencies. The route-level integration tests in
The calculation engine (`src/game/calculations.js`) and the world seeds
(`src/seeds`, which also checks every file in `/backend/seeds`) are unit
tested and run with no external dependencies. The route-level integration tests in
`src/routes/api.integration.test.js` need a real, migrated database reachable
via `DATABASE_URL` — they skip themselves gracefully (with a console warning)
if the DB isn't reachable, so `npm test` still passes in an environment with
@@ -126,14 +136,17 @@ no database configured.
```
/backend — Express + Prisma API, Socket.io server
/prisma — schema.prisma, seed.js
/seed-data — reference JSON (weapons seeded per-campaign; species/classes/skills
are ported into src/game/data/*.js and used directly by the
calculation engine, not stored as DB rows)
prisma.config.js — where the Prisma CLI finds the schema, the database and the seed
/prisma — schema.prisma, migrations, seed.js (the admin account only)
/seeds — world seeds: one file per ready-made campaign, created by code
/seed-data — reference JSON (species/classes/skills/weapons, ported into
src/game/data/*.js and used directly by the calculation engine,
not stored as DB rows)
/src
/game — calculations.js (the ONLY place D&D formulas live) + buildFlow.js
/middleware — auth + campaign/character authorization
/routes — auth, campaigns, characters, encounters, notes
/seeds — finds, checks and creates campaigns from the world seeds
/sockets — Socket.io room wiring
/frontend — React + Vite + TypeScript app
/docs — game rules & character-builder specs (reference material)
+10 -12
View File
@@ -1,12 +1,9 @@
{
"name": "backend",
"version": "1.11.1",
"version": "1.12.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
"prisma": {
"seed": "node prisma/seed.js"
},
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
@@ -15,23 +12,24 @@
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:seed": "prisma db seed",
"seeds:check": "node scripts/check-seeds.js",
"deploy": "prisma migrate deploy && prisma generate",
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"dotenv": "^18.0.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.2",
"socket.io": "^4.8.0",
"zod": "^3.23.8"
"zod": "^4.6.5"
},
"devDependencies": {
"jest": "^29.7.0",
"prisma": "^5.20.0",
"jest": "^30.5.2",
"prisma": "^7.10.0",
"supertest": "^7.0.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
// Prisma CLI configuration: where the schema and migrations live, which
// database to run them against, and the command behind `prisma db seed`.
require('dotenv').config({ quiet: true });
const { defineConfig, env } = require('prisma/config');
module.exports = defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'node prisma/seed.js',
},
datasource: {
url: env('DATABASE_URL'),
},
});
+4 -4
View File
@@ -1,7 +1,8 @@
// D&D Campaign Manager — Prisma schema
// Postgres. Run `npx prisma migrate dev --name init` after `DATABASE_URL` is set,
// then `npx prisma db seed` using the seed script built from /seed-data/*.json
// (see CLAUDE_BUILD_PROMPT.md, "Database" section).
// Postgres. The connection (DATABASE_URL) is set in prisma.config.js, not here.
// Run `npx prisma migrate dev` then `npx prisma generate`; `npx prisma db seed`
// adds the admin account (prisma/seed.js). Worlds are created in the app from
// the seeds in /backend/seeds.
generator client {
provider = "prisma-client-js"
@@ -9,7 +10,6 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ---------------------------------------------------------------------------
-32
View File
@@ -1,32 +0,0 @@
// Adds the Hollowcrown: The Rift Between Worlds campaign
// (seed-data/hollowcrown.js) for the admin account. Not part of the default
// seed — run it on its own:
//
// npm run seed:hollowcrown
//
// Like every seed it only adds (see seedCampaign.js): it never touches other
// campaigns, and does nothing if the admin already has an active Hollowcrown.
const { run, fromLibrary } = require('./seedCampaign');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const hollowcrown = require('../seed-data/hollowcrown.js');
const startingSpells = new Set(hollowcrown.startingSpells);
for (const key of startingSpells) fromLibrary(SPELL_LIBRARY, key); // fail early on a misspelt key
run({
...hollowcrown,
// The book's starting picks come from the site library; its modern relics
// and magic weapons are never starting options.
weapons: [
...hollowcrown.libraryWeapons.map((key) => fromLibrary(WEAPON_LIBRARY, key, { isStartingOption: true })),
...hollowcrown.weapons,
],
// The whole spell library goes in, so a spell is there to hand out the
// moment a character unlocks it; only the book's Session 0 list is offered
// in the builder.
spells: [
...SPELL_LIBRARY.map(({ key }) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: startingSpells.has(key) })),
...hollowcrown.spells,
],
monsters: [...hollowcrown.libraryMonsters.map((key) => fromLibrary(MONSTER_LIBRARY, key)), ...hollowcrown.monsters],
});
+48 -26
View File
@@ -1,32 +1,54 @@
// The default seed: the Aldenfell campaign (seed-data/aldenfell.js) for the
// admin account. See seedCampaign.js for what a seed does and why it is safe
// to run against a live database.
// The database seed: makes sure the admin account exists, and nothing else.
//
// npx prisma db seed add what is missing
// npx prisma db seed create the admin account if it is missing
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
// character, note, etc. first
//
// Other campaigns have their own command (npm run seed:hollowcrown).
// It only ever adds, so it is safe against a live database: an existing
// account, and its password, is left alone. 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
// library DMs pull from. The default weapons (seed-data/weapons.json) ARE
// seeded, into the campaign, since weapons are per-campaign in the schema.
const { run, fromLibrary } = require('./seedCampaign');
const { SPELL_LIBRARY } = require('../src/game/data/library');
const defaultWeapons = require('../seed-data/weapons.json');
const aldenfell = require('../seed-data/aldenfell.js');
// Worlds are not seeded here. A campaign is created in the app, blank or from
// one of the world seeds in /backend/seeds by its code (docs/SEEDS.md).
//
// Species/classes/skills are not rows either: they are fixed game-rule tables
// in src/game/data/*.js, as is the library DMs pull weapons, items, spells
// and monsters from.
const bcrypt = require('bcrypt');
const prisma = require('../src/db');
const { loadSeeds } = require('../src/seeds');
run(
{
...aldenfell,
// Everyday weapons are what a new character picks from; the rest are earned.
weapons: [...defaultWeapons.map((w) => ({ ...w, isStartingOption: true })), ...aldenfell.weapons],
items: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting) })),
spells: [
...aldenfell.spells.starting.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: true })),
...aldenfell.spells.earned.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: false })),
],
},
{ reset: process.argv.includes('--reset') },
);
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';
async function seed({ reset = false } = {}) {
if (reset) {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
console.log('Database reset: every user and campaign was deleted.');
}
if (await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } })) {
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
} else {
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 ("Account" in the navigation) once you are logged in.');
}
const worlds = loadSeeds().filter((world) => !world.error);
if (worlds.length) {
console.log('Worlds to start a campaign from (Campaigns → New Campaign → world code):');
for (const { code, seed: world } of worlds) console.log(` ${code} ${world.campaign.name}`);
}
}
seed({ reset: process.argv.includes('--reset') })
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
-210
View File
@@ -1,210 +0,0 @@
// Shared by the campaign seeds (seed.js, seed-hollowcrown.js): makes sure the
// admin account exists, then adds one fully-built campaign for it.
//
// A seed only ever ADDS, so it is safe against a live database. The admin
// account is created if it is missing (an existing one, and its password, is
// left alone), and the campaign is added unless that account already owns an
// active campaign of the same name. To get a fresh copy, archive or delete the
// old one in the app and run the seed again.
//
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
// account that is created.
//
// `content` is one campaign, with every pool as rows shaped like the Prisma
// models minus ids:
// campaign, locations, weapons, items, spells, monsters, npcs,
// sharedNotes, dmNotes (note bodies, in reading order), and
// encounters, in the order they should list:
// { name, mode, description, dmNotes,
// location: a location's name (optional) — where the scene is,
// monsters: [[monster name, count, display name?, max HP?], ...],
// npcs: [NPC name | { name, hp }, ...],
// rewards: [{ gold } | { weapon } | { item }, each with optional
// quantity, level (defaults to the entry's starting level)
// and note] }
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { generateInviteCode } = require('../src/utils/inviteCode');
const prisma = new PrismaClient();
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
// Notes list newest-first on screen, so they are stamped a second apart,
// backwards: the first entry in the seed data is the one at the top.
function bookOrder(index) {
return new Date(Date.now() - index * 1000);
}
function byName(rows) {
return new Map(rows.map((row) => [row.name, row]));
}
function lookup(map, name, what, encounterName) {
const row = map.get(name);
if (!row) throw new Error(`Encounter "${encounterName}" references unknown ${what} "${name}"`);
return row;
}
async function createCampaign(db, admin, content) {
const campaign = await db.campaign.create({
data: {
...content.campaign,
inviteCode: generateInviteCode(),
ownerId: admin.id,
members: { create: { userId: admin.id, role: 'DM' } },
},
});
const campaignId = campaign.id;
const inCampaign = (rows) => rows.map((row) => ({ ...row, campaignId }));
await db.location.createMany({ data: inCampaign(content.locations) });
await db.weapon.createMany({ data: inCampaign(content.weapons) });
await db.item.createMany({ data: inCampaign(content.items) });
await db.spell.createMany({ data: inCampaign(content.spells) });
await db.monster.createMany({ data: inCampaign(content.monsters) });
await db.npc.createMany({ data: inCampaign(content.npcs) });
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
const items = byName(await db.item.findMany({ where: { campaignId } }));
const locations = byName(await db.location.findMany({ where: { campaignId } }));
let rewardCount = 0;
for (const [index, enc] of content.encounters.entries()) {
const participants = [];
for (const [name, count, displayName, hp] of enc.monsters || []) {
const monster = lookup(monsters, name, 'monster', enc.name);
const label = displayName || monster.name;
const maxHp = hp ?? monster.maxHp;
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${label} #${i}` : label,
currentHp: maxHp,
maxHp,
isMonster: true,
});
}
}
for (const entry of enc.npcs || []) {
const { name, hp } = typeof entry === 'string' ? { name: entry } : entry;
const npc = lookup(npcs, name, 'NPC', enc.name);
// An NPC with no combat stats takes part with no hit points to track.
const maxHp = hp ?? npc.maxHp ?? 0;
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: maxHp, maxHp, isMonster: false });
}
// Rewards list oldest-first, so these are stamped forwards.
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity, level, note }, i) => {
const createdAt = new Date(Date.now() + i);
if (gold) return { type: 'GOLD', gold, note, createdAt };
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
return {
type: weapon ? 'WEAPON' : 'ITEM',
[weapon ? 'weaponId' : 'itemId']: entry.id,
quantity: quantity ?? 1,
level: Math.max(level ?? entry.minLevel, entry.minLevel),
note,
createdAt,
};
});
rewardCount += rewards.length;
await db.encounter.create({
data: {
campaignId,
name: enc.name,
mode: enc.mode,
description: enc.description,
dmNotes: enc.dmNotes,
locationId: enc.location ? lookup(locations, enc.location, 'location', enc.name).id : null,
sortOrder: index,
participants: { create: participants },
rewards: { create: rewards },
},
});
}
const notes = [
...content.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...content.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
await db.note.createMany({
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
});
const starting = (rows) => rows.filter((row) => row.isStartingOption).length;
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${content.weapons.length} weapons (${starting(content.weapons)} starting), ${content.items.length} items ` +
`(${starting(content.items)} starting), ${content.spells.length} spells (${starting(content.spells)} starting), ` +
`${content.locations.length} locations`,
);
console.log(
` ${content.monsters.length} monsters, ${content.npcs.length} NPCs, ${content.encounters.length} encounters ` +
`(${rewardCount} rewards lined up), ${notes.length} notes`,
);
}
async function resetDatabase() {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
}
// `reset` is DESTRUCTIVE: every user, campaign, character, note, etc. is
// deleted before anything is added.
async function seed(content, { reset = false } = {}) {
if (reset) {
await resetDatabase();
console.log('Database reset: every user and campaign was deleted.');
}
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
if (admin) {
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
} else {
admin = await prisma.user.create({
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
});
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
}
const existing = await prisma.campaign.findFirst({
where: { ownerId: admin.id, name: content.campaign.name, archivedAt: null },
});
if (existing) {
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
return;
}
// One transaction, so a failed seed doesn't leave half a campaign behind.
console.log('Seeding campaign...');
await prisma.$transaction((tx) => createCampaign(tx, admin, content), { timeout: 120000, maxWait: 20000 });
console.log('Seed complete.');
}
// Runs a seed script to completion: exit code 1 on failure, connection closed either way.
function run(content, options) {
return seed(content, options)
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
// A copy of a library entry as a pool row: its `key` and picker-only tags dropped.
function fromLibrary(library, key, extra = {}) {
const template = library.find((entry) => entry.key === key);
if (!template) throw new Error(`Unknown library entry "${key}"`);
const { key: _key, category, classes, ...row } = template;
return { ...row, ...extra };
}
module.exports = { run, fromLibrary };
+27
View File
@@ -0,0 +1,27 @@
// Checks every world seed in /backend/seeds and says what each one holds, or
// what is wrong with it. Run it after dropping in or editing a seed file:
//
// npm run seeds:check
const { loadSeeds, SEEDS_DIR } = require('../src/seeds');
const results = loadSeeds();
if (results.length === 0) console.log(`No seed files in ${SEEDS_DIR}.`);
for (const { file, code, seed, error } of results) {
if (error) {
console.error(`✗ ${file}\n${error.replace(/^/gm, ' ')}`);
process.exitCode = 1;
continue;
}
const starting = (rows) => rows.filter((row) => row.isStartingOption).length;
const count = (rows, of) => rows.reduce((sum, row) => sum + (row[of] || []).length, 0);
console.log(`✓ ${file} — code ${code} — ${seed.campaign.name}`);
console.log(
` ${seed.weapons.length} weapons (${starting(seed.weapons)} starting), ${seed.items.length} items (${starting(seed.items)} starting), ` +
`${seed.spells.length} spells (${starting(seed.spells)} starting), ${seed.locations.length} locations`,
);
console.log(
` ${seed.monsters.length} monsters, ${seed.npcs.length} NPCs (${count(seed.npcs, 'wares')} wares), ${seed.encounters.length} encounters ` +
`(${count(seed.encounters, 'rewards')} rewards, ${count(seed.encounters, 'wares')} wares), ${seed.sharedNotes.length + seed.dmNotes.length} notes`,
);
}
@@ -1,8 +1,9 @@
// 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.
// Aldenfell: a world seed (docs/SEEDS.md). A DM starts a campaign from it by
// entering its code.
//
// 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.
const campaign = {
name: 'Aldenfell',
@@ -137,17 +138,30 @@ const monsters = [
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.',
},
{
name: 'The Hollow King', challengeRating: '10', ac: 16, maxHp: 190, speed: 30,
strScore: 18, dexScore: 14, conScore: 18, intScore: 18, wisScore: 18, chaScore: 20, attackBonus: 9, damageDice: '4d8',
specialTrait: 'Many, not one — it is the will of everyone the founders sacrificed, so it cannot be charmed or frightened, and it speaks in several voices. Echoing dread — starting a turn within 30 ft: DC 16 Wis or frightened. What the Court remembers (recharge 5–6): each creature within 30 ft, DC 16 Wis or 6d6 psychic and lose its reaction (half on success).',
notes: 'A STARTING POINT, NOT THE BOOK’S: the Campaign Book gives no stat block for E26 and tells you to build one to your party’s level and the resources they brought. This one suits four level 7–8 characters; change the numbers freely. Fly 30 ft (hover). Multiattack (two Chilling Touch +9, 4d8 necrotic). Three legendary actions a round: move without provoking, one Chilling Touch, or (2 actions) pull a creature 20 ft toward the seal (DC 16 Str). The fight should never be only a sword fight — what the party does with the truth (E19, E22) matters more than its hit points.',
},
];
// ---------------------------------------------------------------------------
// 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.
// Weapons. The everyday ones are copies from the site library and are what a
// new character picks from. The rest are not starting options, so they only
// reach a character as a reward, a gift or a purchase. 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 everydayWeapons = [
'dagger', 'shortsword', 'rapier', 'longsword', 'greatsword', 'battleaxe', 'handaxe', 'warhammer', 'mace',
'quarterstaff', 'spear', 'shortbow', 'longbow', 'light-crossbow', 'dart',
];
const weapons = [
...everydayWeapons.map((library) => ({ library, isStartingOption: true })),
{ name: 'Unarmed Strike', damageDice: '1', damageType: 'bludgeoning', attackAbility: 'STR', properties: [], isStartingOption: true },
{ 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 },
@@ -159,38 +173,47 @@ const weapons = [
];
// ---------------------------------------------------------------------------
// Item pool. Items are descriptive: holding one never changes a number on the
// sheet. `starting: true` offers the item in the character builder.
// Item pool. Most items are descriptive. The ones with `effects` change the
// sheet of whoever has them equipped (armor is an item with an AC effect), and
// `effectNote` says what a number cannot. `isStartingOption: 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 },
{ 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', isStartingOption: true },
{ name: 'Traveller’s Pack', description: 'Bedroll, mess kit, tinderbox, waterskin and five days of rations.', isStartingOption: true },
{ name: 'Rope (50 ft)', description: 'Hempen rope. Holds a climber; DC 17 Strength to burst.', isStartingOption: true },
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', isStartingOption: true },
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', isStartingOption: true },
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', isStartingOption: true },
{ name: 'Shield', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
{ name: 'Leather Armor', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', isStartingOption: 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.', isStartingOption: 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 },
{ library: 'studded-leather' },
{ library: 'chain-shirt' },
{ library: 'chain-mail' },
{ 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: '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.', effects: [{ stat: 'SKILL_SURVIVAL', amount: 1 }] },
{ 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.' },
{ name: 'Seized House Documents', description: 'Ledgers and letters taken from the noble house after it was confronted. No value in coin; a great deal of value to anyone building a case.' },
{ name: 'House Records', description: 'The fallen house’s private records: names, payments and meeting places for its contacts across Aldenfell.' },
{ name: 'Masterwork Chain Mail', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
{ name: 'The Seal-Stone', description: 'The stone at the heart of the seal, whole again and quiet. It is heavier than it looks, and it is never quite cold.', minLevel: 7, effects: [{ stat: 'SAVE_WIS', amount: 2 }], effectNote: 'Grows warm near anything of the Hollow Below. Its other properties are the DM’s to decide: something tied to binding, warding or detecting.' },
];
// ---------------------------------------------------------------------------
@@ -200,7 +223,7 @@ const items = [
// DM to teach later.
// ---------------------------------------------------------------------------
const spells = {
const spellKeys = {
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',
@@ -216,6 +239,11 @@ const spells = {
],
};
const spells = [
...spellKeys.starting.map((library) => ({ library, isStartingOption: true })),
...spellKeys.earned,
];
// ---------------------------------------------------------------------------
// 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
@@ -242,7 +270,17 @@ const npcs = [
{
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.',
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. She also sells: Concord goods at Concord prices, in any scene she is in (DC 12 Persuasion to haggle, per the DM Tables).',
wares: [
{ item: 'Potion of Healing', price: 50 },
{ item: 'Potion of Greater Healing', price: 150 },
{ item: 'Alchemist’s Fire', price: 50 },
{ item: 'Healer’s Kit', price: 5 },
{ item: 'Thieves’ Tools', price: 25 },
{ item: 'Studded Leather', price: 45 },
{ item: 'Chain Shirt', price: 50 },
{ item: 'Chain Mail', price: 75 },
],
},
{
name: 'Ashwen', role: 'Warden of the Greywood', disposition: 'NEUTRAL',
@@ -311,6 +349,8 @@ const npcs = [
// handed out. { gold } | { weapon } | { item }, with optional
// quantity, level (defaults to the entry's starting level) and
// note.
// wares — for sale at the scene itself (a stall), as on an NPC:
// { weapon | item, price, currency?, level?, stock? }.
// ---------------------------------------------------------------------------
const encounters = [
@@ -319,6 +359,18 @@ const encounters = [
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'],
// The market stalls: everyday kit at everyday prices.
wares: [
{ item: 'Traveller’s Pack', price: 2 },
{ item: 'Rope (50 ft)', price: 1 },
{ item: 'Torches (bundle of 5)', price: 5, currency: 'COPPER' },
{ item: 'Spellcasting Focus', price: 10 },
{ item: 'Shield', price: 10 },
{ item: 'Leather Armor', price: 10 },
{ weapon: 'Dagger', price: 2 },
{ weapon: 'Shortbow', price: 25 },
{ weapon: 'Longsword', price: 15 },
],
},
{
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST', location: 'Highcrown — The Founding Chapel',
@@ -329,9 +381,9 @@ const encounters = [
{
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
description: 'A long hall of burial niches beneath the chapel, some sealed, some broken open. Something is growling in the dark between the pillars.',
dmNotes: 'Vault Room 2, the hub (→ Rooms 3, 4, stair to 6). The dog is starving, not vicious: DC 12 Animal Handling or a thrown ration ends it. DC 13 Investigation: the open niches are just old theft. DC 12 Perception finds the locket.',
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 Investigation spots the locket.',
monsters: [['Feral Guard Dog', 1]],
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Perception' }],
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Investigation' }],
},
{
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
@@ -360,6 +412,17 @@ const encounters = [
{ item: 'Concord Writ of Passage', note: 'E4B — on taking Mireille’s contract' },
],
},
{
name: 'E5 — Omens Montage', mode: 'QUEST', location: 'Greywood — The Old Shrine Sites',
description: 'Small things, weeks apart. A farmer’s cattle gone dry overnight out past the shrine road. A stranger at the next table describing, detail for detail, the dream you had last night. None of it is an attack. All of it is wrong.',
dmNotes: 'Pure atmosphere, no treasure. Run two or three as short scenes spread across sessions, never back to back:\n• Livestock and crops failing near an old shrine.\n• Shared nightmares around Highcrown — DC 12 Insight for a PC to realise their own dream matched a stranger’s account.\n• A minor noble behaving with odd, unearned loyalty to no one in particular (the corrupted house — a background detail, not a spotlight).\nThe Drowned Shrine (Root Room 5) is a good place to point an omen at. → E6 whenever it feels earned.',
},
{
name: 'E7 — A Closer Look', mode: 'QUEST',
description: 'One noble house keeps turning up at the edge of things: generous, quiet, a little too pleased with itself. Asking after it gets polite answers and closed doors.',
dmNotes: 'If the party actively investigates the odd house from E5 they find only dead ends at this stage — by design. Stage 1 omens accumulate; they do not resolve.\nException: a party that took E2C makes a DC 13 Insight check (not 15) to catch one real thread — a servant who flinched at the mention of the house’s name. Note what they learn for E10.\n→ E8: once they have engaged with two or more E4 threads and one full E5 omen, advance the clock to Stage 2 and move to Act 2.',
npcs: ['King Aldric Veyrmont', 'Princess Isolde Veyrmont'],
},
{
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.',
@@ -440,6 +503,23 @@ const encounters = [
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: 'The Spine — Wyvern Pass', mode: 'BATTLE', location: 'The Spine — Ironvein',
description: 'The pass above Ironvein narrows to a ledge, and the ledge runs under a shelf of rock streaked white with droppings. Something large shifts up there and looks down at you.',
dmNotes: 'Optional travel encounter (Random Encounters, the Spine). Territorial, not evil: it attacks anything that lingers near the nest and ignores a party that keeps moving — DC 13 Stealth or Survival to pass without a fight. Stinger: DC 15 Con or poisoned for a minute. Ironvein below is the resupply stop, with its own rumours (miners going quiet, deeper tunnels avoided).',
monsters: [['Spine Wyvern', 1]],
},
{
name: 'The Spine — Sevrisk’s Hollow', mode: 'BATTLE', location: 'The Spine — Sevrisk’s Hollow',
description: 'A low, wet cave mouth breathing green-tinged mist. The bones outside it have been arranged, not dropped. From inside comes a voice like a blade being drawn: slow, amused, and very sure of itself.',
dmNotes: 'Optional, and dangerous: Sevrisk is the straightforward dragon fight, in contrast to Threndir. Territorial and opportunistic — she will bargain only to gain an edge, and breaks a bargain the moment it stops paying. Poison breath (recharge 5–6): 30-ft cone, DC 14 Con, 8d6. CR 5: too much for a party below level 4 without help or a plan. Her hoard is the treasure.',
monsters: [['Sevrisk (young green dragon)', 1, 'Sevrisk']],
rewards: [
{ gold: 400, note: 'Sevrisk’s hoard — coin and plate' },
{ item: 'Potion of Greater Healing', quantity: 2, note: 'In the hoard' },
{ weapon: 'Grey Dawn Longsword', note: 'In the hoard — a knight came here once and did not leave' },
],
},
{
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE', location: 'Hollow Below — The Weeping Passage',
description: 'A long tunnel beneath the Greywood where the walls weep, and the sound they make is almost a voice. Somewhere ahead, very faintly, a bell tolls.',
@@ -467,6 +547,12 @@ const encounters = [
{ weapon: 'Masterwork Dagger', note: 'Still in a dead hand' },
],
},
{
name: 'E9 — The Root: The Drowned Shrine', mode: 'QUEST', location: 'Hollow Below — The Root (Layer 2)',
description: 'A shrine older than anything in the vaults above, half sunk in still black water. The altar is whole. The symbol carved into it belongs to no faith anyone above has practised in a very long time.',
dmNotes: 'Root Room 5. A pre-kingdom holy site, connected to the Sunken Library in the Greywood. DC 16 History or Religion: the symbol is older than Aldenfell itself — a thread toward the Hollow King’s origin, without the full truth. A good place for a vision or omen (E5 can point here).\nThe relic: DC 13 Athletics or Investigation to lift it without disturbing the water. Disturbing it summons nothing, only ripples that spread wrong. Exits: back to Room 2, or a narrow crawl to Room 6.',
rewards: [{ item: 'Silver Shrine Relic', note: 'In the water — DC 13 Athletics or Investigation' }],
},
{
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'Narrow ledges over a drop your torch cannot find the bottom of. From three different directions at once, a bell begins to toll.',
@@ -474,7 +560,7 @@ const encounters = [
monsters: [['Bellcrawler', 3]],
rewards: [
{ gold: 90, note: 'Scattered along the ledges' },
{ item: 'Potion of Healing', note: 'In a fallen pack' },
{ item: 'Potion of Healing', note: 'In a fallen pack — the book has the coin or the potion, DM’s choice' },
],
},
{
@@ -482,7 +568,10 @@ const encounters = [
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' }],
rewards: [
{ item: 'Sealed Letter', note: 'E10C only — the servant’s evidence' },
{ item: 'Seized House Documents', note: 'E10A only — taken at the confrontation' },
],
},
{
name: 'E11 — Silence on the Border', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
@@ -508,6 +597,27 @@ const encounters = [
monsters: [['Hollowed', 4]],
rewards: [{ item: 'Seal-Stone Fragment', note: 'Key item — from the breach’s edge' }],
},
{
name: 'E15 — The House Falls (or Doesn’t)', mode: 'BATTLE',
description: 'Word travels faster than truth in Highcrown. By the time you reach the house, half the city already has an opinion about what happened there — and most of them are wrong.',
dmNotes: 'Plays very differently by Act 2 route.\n• E15A (took E10A): the house has spent Act 2 preparing — hired guards, an alibi, perhaps a scapegoat servant. DC 18 to break the cover story, or this fight: house guards and one Hollowed-touched retainer.\n• E15B (took E10B): a tense public tribunal, no fight. DC 16 Persuasion or Insight steers it toward exile rather than execution (matters for a returning villain).\n• E15C (took E10C): the house suspects nothing. Move on its masters — the cult in Highcrown. Only this route reaches E15C-1: a cult lieutenant taken alive, who gives up the Hollow Court’s surface entrance (→ E18B).\nRemove the monsters for E15B and E15C. → E16.',
monsters: [['Bandit Captain', 2, 'House Guard Sergeant'], ['Highcrown Cutthroat', 4, 'House Guard'], ['Hollowed', 1, 'The Retainer']],
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
rewards: [{ item: 'House Records', note: 'Names other cult contacts across Aldenfell — seeds for a side quest, or safely ignored' }],
},
{
name: 'E16 — What the Marches Cost', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
description: 'Smoke on the horizon means something different out here than it does in Highcrown. Out here, it usually means someone made a choice that can’t be unmade.',
dmNotes: 'The consequences of E11. No loot: this is about consequence.\n• E16A — Greyford was reinforced (E11C, Spine Clans allied): the keep holds. A rare unambiguous good outcome; let them feel it. Spine Clan standing rises.\n• E16B — Greyford was under-supplied (Smugglers’ Tithe unresolved): the keep falls or is abandoned for good. The named refugee from Refugees of the Marches is lost unless the party steps in — DC 16 Athletics or Survival chase and rescue.\n→ E17 (pay off any debt the party has run up: the E14A weakening, an unresolved side quest, unchased omens — or a quiet breather with the bell-ringer) → E18.',
npcs: ['Greyford’s Commander'],
},
{
name: 'E18 — Into the Hollow Court', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'The stair doesn’t so much end as stop pretending to be a stair. What’s beneath Aldenfell was never built — it grew, the way a wound grows scar tissue, except the scar tissue is watching you.',
dmNotes: 'Hollow Court Rooms 1–3. E18A enters by the Bent Stair; E18B (cult lieutenant from E15C-1) skips straight to the Memory Pool.\n• Room 1, The Bent Stair: DC 15 Wis save or lose track of how long the descent took (atmosphere, or 10 minutes lost). No monster.\n• Room 2, The Memory Pool: each PC who looks sees a vision tied to a choice they made — the payoff for the branch points. No damage; DC 17 Insight to understand it.\n• Room 3, The Court of Whispers: Vessa may speak plainly here. DC 16 Insight: she believes what she says. If it comes to a fight it is with these Hollowed guardians, never with her — remove them if the party talks.\n→ E19.',
monsters: [['Hollowed', 4, 'Hollowed Guardian']],
npcs: ['Vessa Hollow'],
},
{
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.',
@@ -525,14 +635,20 @@ const encounters = [
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' }],
rewards: [
{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' },
{ item: 'Masterwork Chain Mail', note: 'E21A — from the Grey Dawn armoury, if the Order stands with them' },
],
},
{
name: 'E22 — The Seal Chamber', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'A wound in the world, stitched shut long ago, and coming undone one thread at a time. Whatever you came here to decide, this is where you decide it.',
dmNotes: 'Court Room 5 — the key branch. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation.\n• E22A reinforce (DC 18 Arcana/Religion): the Sealed Ending — time bought.\n• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.\n• E22C do nothing: the seal fails anyway; E24–E25 are harder.\nHollowbane is the campaign’s single most significant magic item: legendary growth, starts at level 7.',
npcs: ['Vessa Hollow'],
rewards: [{ weapon: 'Hollowbane', note: 'Drawn from the seal itself' }],
rewards: [
{ weapon: 'Hollowbane', note: 'Drawn from the seal itself — the campaign’s one significant magic item' },
{ item: 'The Seal-Stone', note: 'E22A only — the seal, reinforced rather than broken' },
],
},
{
name: 'E25 — The Last Approach', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
@@ -540,6 +656,19 @@ const encounters = [
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]],
},
{
name: 'E23–E24 — The Waking', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'The Waking isn’t loud. That’s the wrong word for it. It’s the sound of something enormous remembering it has a name.',
dmNotes: 'Hollow Court Room 6. The seal gives way, one way or another.\n• Broke it deliberately (E22B): the party arrives prepared, on their own timing — full resources, no surprise round against them.\n• It failed on its own (E22C): the Hollow King is already partly awake — apply one stage of escalation before the party acts, and E25–E26 are noticeably harder.\nE24, What the Court Remembers: not combat. Its memories bleed into the room, fragments of who it was before. DC 14 Insight recognises a fragment as manipulation rather than truth — it is recruiting sympathy, not confessing. → E25.',
npcs: ['Vessa Hollow'],
},
{
name: 'E26 — The Hollow King', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'There is no throne and no crown. There is a shape made of everyone who was put down here, and it has finally turned to look at you.',
dmNotes: 'The final confrontation. The book gives no stat block — it asks you to build one to the party’s level and what they brought. The “The Hollow King” monster here is a starting point for four level 7–8 characters: change it.\nThree shapes:\n• Grey Dawn track — disciplined tactics: Kavan’s wardens hold a line while the party lands the killing blow. Add Ser Kavan Thorne.\n• Spine Clans track — terrain and ritual: collapsing passages, the Memory Pool as a weapon. Add Korgath Stonejaw.\n• Independent — hardest: no reinforcements, and every choice in the last round is theirs.\nNegotiating through Vessa is a legitimate ending. → E27, Endings: Sealed, Grey Dawn, Spine Clans or Independent — and close where it opened, at the Lantern Market a year later.',
monsters: [['The Hollow King', 1]],
npcs: ['Vessa Hollow'],
},
];
// ---------------------------------------------------------------------------
@@ -803,4 +932,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, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
module.exports = { code: 'ALDENF', campaign, locations, monsters, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -1,5 +1,10 @@
// Prisma no longer reads .env itself, and this is the first thing to need it.
require('dotenv').config({ quiet: true });
const { PrismaClient } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const prisma = new PrismaClient();
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
module.exports = prisma;
+1 -1
View File
@@ -1,4 +1,4 @@
require('dotenv').config();
require('dotenv').config({ quiet: true });
const http = require('http');
const { Server } = require('socket.io');
const { createApp } = require('./app');
@@ -2720,3 +2720,89 @@ describe('equipping', () => {
expect((await wear('weapons', sword.id, true)).status).toBe(400);
});
});
describe('a campaign from a world seed', () => {
async function registerDm() {
const res = await request(app).post('/auth/register').send({ email: uniqueEmail('seed-dm'), password: 'password123', displayName: 'Seed DM' });
cleanupUserIds.push(res.body.user.id);
return { Authorization: `Bearer ${res.body.token}` };
}
const fromSeed = (auth, code) => request(app).post('/campaigns/from-seed').set(auth).send({ code });
itIfDb('builds the whole world for whoever enters its code, as its DM', async () => {
const auth = await registerDm();
const res = await fromSeed(auth, ' aldenf ');
expect(res.status).toBe(201);
const campaign = res.body.campaign;
cleanupCampaignIds.push(campaign.id);
expect(campaign).toMatchObject({ name: 'Aldenfell', role: 'DM', startingGold: 15 });
expect(campaign.inviteCode).toMatch(/^[A-Z0-9]{6}$/);
const get = async (path) => (await request(app).get(`/campaigns/${campaign.id}/${path}`).set(auth)).body;
const { weapons } = await get('weapons');
const { items } = await get('items');
const { npcs } = await get('npcs');
const { encounters } = await get('encounters');
expect((await get('locations')).locations.length).toBeGreaterThan(10);
expect((await get('monsters')).monsters.length).toBeGreaterThan(10);
expect((await get('notes')).notes.length).toBeGreaterThan(10);
// Library copies and the seed's own rows, with what the seed set on them.
expect(weapons.find((w) => w.name === 'Longsword')).toMatchObject({ isStartingOption: true });
expect(weapons.find((w) => w.name === 'Hollowbane')).toMatchObject({ isStartingOption: false, minLevel: 7 });
expect(items.find((i) => i.name === 'Shield').effects).toEqual([{ stat: 'AC', amount: 2 }]);
// An NPC's wares, and a stall's, point at the campaign's own pool entries.
const mireille = npcs.find((n) => n.name === 'Mireille Costa');
const potion = mireille.wares.find((w) => w.item && w.item.name === 'Potion of Healing');
expect(potion).toMatchObject({ price: 50, currency: 'GOLD', itemId: items.find((i) => i.name === 'Potion of Healing').id });
const greater = mireille.wares.find((w) => w.item && w.item.name === 'Potion of Greater Healing');
expect(greater.level).toBe(3); // sold at the item's own starting level
// Encounters keep the seed's order, with their people, creatures and loot.
expect(encounters[0].name).toBe('E1 — The Founding Fair');
const fair = (await request(app).get(`/encounters/${encounters[0].id}`).set(auth)).body.encounter;
expect(fair.wares.find((w) => w.item && w.item.name === 'Torches (bundle of 5)')).toMatchObject({ price: 5, currency: 'COPPER' });
expect(fair.participants.map((p) => p.displayName)).toEqual(expect.arrayContaining(['Mireille Costa', 'Ser Kavan Thorne']));
const ossuary = encounters.find((e) => e.name === 'E3 — Sunken Vaults: The Restless Ossuary');
const fight = (await request(app).get(`/encounters/${ossuary.id}`).set(auth)).body.encounter;
expect(fight.participants.map((p) => p.displayName).sort()).toEqual(['Skeleton #1', 'Skeleton #2']);
expect(fight.rewards[0]).toMatchObject({ type: 'GOLD', gold: 25, currency: 'GOLD' });
});
itIfDb('seeds cash, the other coins and money changers', async () => {
const auth = await registerDm();
const res = await fromSeed(auth, 'HOLLOW');
expect(res.status).toBe(201);
const campaign = res.body.campaign;
cleanupCampaignIds.push(campaign.id);
expect(campaign).toMatchObject({ startingItemCount: 4, maxEquipped: 3, maxEquippedWeapons: 1 });
const npcs = (await request(app).get(`/campaigns/${campaign.id}/npcs`).set(auth)).body.npcs;
expect(npcs.find((n) => n.name === 'Ray')).toMatchObject({ exchangeGold: 1, exchangeCash: 50 });
const encounters = (await request(app).get(`/campaigns/${campaign.id}/encounters`).set(auth)).body.encounters;
const detail = async (name) => (await request(app).get(`/encounters/${encounters.find((e) => e.name === name).id}`).set(auth)).body.encounter;
expect((await detail('Security Response')).rewards).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'GOLD', gold: 300, currency: 'CASH' })]));
expect((await detail('The Goblin Den')).rewards.filter((r) => r.type === 'GOLD').map((r) => `${r.gold} ${r.currency}`)).toEqual(['12 GOLD', '50 SILVER', '100 COPPER']);
const shops = await detail('Port Meridian: Ray’s Cash for Gold and the Shops');
expect(shops.wares.find((w) => w.weapon && w.weapon.name === 'Chainsaw')).toMatchObject({ price: 300, currency: 'CASH', level: 5, stock: 1 });
});
itIfDb('the same code can be used again, by anyone, for a copy of their own', async () => {
const first = await fromSeed(await registerDm(), 'ALDENF');
const second = await fromSeed(await registerDm(), 'ALDENF');
cleanupCampaignIds.push(first.body.campaign.id, second.body.campaign.id);
expect(second.status).toBe(201);
expect(second.body.campaign.id).not.toBe(first.body.campaign.id);
expect(second.body.campaign.inviteCode).not.toBe(first.body.campaign.inviteCode);
});
itIfDb('refuses a code that is no world, and needs a login', async () => {
const auth = await registerDm();
expect((await fromSeed(auth, 'ZZZZZZ')).status).toBe(404);
expect((await fromSeed(auth, 'abc')).status).toBe(400);
expect((await request(app).post('/campaigns/from-seed').set(auth).send({})).status).toBe(400);
expect((await request(app).post('/campaigns/from-seed').send({ code: 'ALDENF' })).status).toBe(401);
expect((await request(app).get('/campaigns').set(auth)).body.campaigns).toEqual([]);
});
});
+24
View File
@@ -10,6 +10,8 @@ const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require
const { visitedLocationIds } = require('../utils/locations');
const { deathSaveInfo } = require('../utils/hp');
const { CURRENCIES } = require('../utils/grants');
const { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
const { createCampaignFromSeed } = require('../seeds/createCampaign');
const router = express.Router();
@@ -96,6 +98,28 @@ router.post('/', async (req, res, next) => {
}
});
// POST /campaigns/from-seed — a ready-made world (a file in /backend/seeds)
// as a new campaign, by its code. As with a blank campaign, whoever creates
// it is its DM and owner; the same code can be used again for another copy.
router.post('/from-seed', async (req, res, next) => {
try {
const parsed = z.object({ code: z.string().max(40) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (normalizeCode(parsed.data.code).length !== CODE_LENGTH) {
return res.status(400).json({ error: `A world code is ${CODE_LENGTH} letters or numbers` });
}
const seed = findSeed(parsed.data.code);
if (!seed) return res.status(404).json({ error: 'There is no world with that code' });
// One transaction, so a failure doesn't leave half a world behind. A big
// world is thousands of rows, which is more than the default 5 seconds.
const campaign = await prisma.$transaction((tx) => createCampaignFromSeed(tx, req.user, seed), { timeout: 120000, maxWait: 20000 });
res.status(201).json({ campaign: campaignSummary(campaign, 'DM') });
} catch (err) {
next(err);
}
});
// POST /campaigns/join/:inviteCode — must be before requireCampaignMember-gated routes below
router.post('/join/:inviteCode', async (req, res, next) => {
try {
+2 -2
View File
@@ -603,8 +603,8 @@ const chooseSchemas = {
.optional(),
}),
CHOOSING_ABILITIES: z.object({ talents: z.array(z.string()) }),
CHOOSING_BACKGROUND: z.object({ picks: z.record(z.array(z.string())) }),
CHOOSING_APPEARANCE: z.object({ picks: z.record(z.array(z.string())) }),
CHOOSING_BACKGROUND: z.object({ picks: z.record(z.string(), z.array(z.string())) }),
CHOOSING_APPEARANCE: z.object({ picks: z.record(z.string(), z.array(z.string())) }),
CHOOSING_EQUIPMENT: z.object({
weaponIds: z.array(z.string()).default([]),
itemIds: z.array(z.string()).default([]),
+121
View File
@@ -0,0 +1,121 @@
// Creates one campaign, fully built, from a checked seed (seeds/index.js,
// buildSeed), with `owner` as its DM. `db` is a transaction client, so a
// failure leaves nothing behind.
const { generateInviteCode } = require('../utils/inviteCode');
// Notes list newest-first on screen, so they are stamped a second apart,
// backwards: the first entry in the seed is the one at the top.
function bookOrder(index) {
return new Date(Date.now() - index * 1000);
}
function byName(rows) {
return new Map(rows.map((row) => [row.name, row]));
}
const MONEY = { gold: 'GOLD', silver: 'SILVER', copper: 'COPPER', cash: 'CASH' };
async function createCampaignFromSeed(db, owner, seed) {
let inviteCode = generateInviteCode();
while (await db.campaign.findUnique({ where: { inviteCode } })) inviteCode = generateInviteCode();
const campaign = await db.campaign.create({
data: { ...seed.campaign, inviteCode, ownerId: owner.id, members: { create: { userId: owner.id, role: 'DM' } } },
});
const campaignId = campaign.id;
const inCampaign = (rows) => rows.map((row) => ({ ...row, campaignId }));
await db.location.createMany({ data: inCampaign(seed.locations) });
await db.weapon.createMany({ data: inCampaign(seed.weapons) });
await db.item.createMany({ data: inCampaign(seed.items) });
await db.spell.createMany({ data: inCampaign(seed.spells) });
await db.monster.createMany({ data: inCampaign(seed.monsters) });
await db.npc.createMany({ data: inCampaign(seed.npcs.map(({ wares, ...npc }) => npc)) });
const where = { where: { campaignId } };
const monsters = byName(await db.monster.findMany(where));
const npcs = byName(await db.npc.findMany(where));
const weapons = byName(await db.weapon.findMany(where));
const items = byName(await db.item.findMany(where));
const locations = byName(await db.location.findMany(where));
// A weapon or item named in a reward or a ware, with the level it is handed
// over at: its own starting level unless the seed asks for higher.
const gear = ({ weapon, item, level }) => {
const entry = weapon ? weapons.get(weapon) : items.get(item);
return { [weapon ? 'weaponId' : 'itemId']: entry.id, level: level ?? entry.minLevel };
};
const wares = (list, seller) =>
(list || []).map(({ price, currency, stock, ...ware }, i) => ({
...seller,
...gear(ware),
campaignId,
price,
currency: currency || 'GOLD',
stock: stock ?? null,
createdAt: new Date(Date.now() + i), // wares list in the order written
}));
for (const npc of seed.npcs) {
if (npc.wares) await db.saleOffer.createMany({ data: wares(npc.wares, { npcId: npcs.get(npc.name).id }) });
}
for (const [index, enc] of seed.encounters.entries()) {
const participants = [];
for (const [name, count, displayName, hp] of enc.monsters || []) {
const monster = monsters.get(name);
const label = displayName || monster.name;
const maxHp = hp ?? monster.maxHp;
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${label} #${i}` : label,
currentHp: maxHp,
maxHp,
isMonster: true,
});
}
}
for (const entry of enc.npcs || []) {
const { name, hp } = typeof entry === 'string' ? { name: entry } : entry;
const npc = npcs.get(name);
// An NPC with no combat stats takes part with no hit points to track.
const maxHp = hp ?? npc.maxHp ?? 0;
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: maxHp, maxHp, isMonster: false });
}
// Rewards list oldest-first, so these are stamped forwards.
const rewards = (enc.rewards || []).map(({ quantity, note, ...reward }, i) => {
const createdAt = new Date(Date.now() + i);
const coin = Object.keys(MONEY).find((key) => reward[key]);
if (coin) return { type: 'GOLD', gold: reward[coin], currency: MONEY[coin], note, createdAt };
return { type: reward.weapon ? 'WEAPON' : 'ITEM', ...gear(reward), quantity: quantity ?? 1, note, createdAt };
});
const encounter = await db.encounter.create({
data: {
campaignId,
name: enc.name,
mode: enc.mode,
description: enc.description,
dmNotes: enc.dmNotes,
locationId: enc.location ? locations.get(enc.location).id : null,
sortOrder: index,
participants: { create: participants },
rewards: { create: rewards },
},
});
if (enc.wares) await db.saleOffer.createMany({ data: wares(enc.wares, { encounterId: encounter.id }) });
}
await db.note.createMany({
data: [
...seed.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...seed.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
].map((note, i) => ({ ...note, campaignId, authorId: owner.id, createdAt: bookOrder(i) })),
});
return campaign;
}
module.exports = { createCampaignFromSeed };
+317
View File
@@ -0,0 +1,317 @@
// World seeds: ready-made campaigns a DM starts from by typing a code.
//
// Every .js file in /backend/seeds is one world (docs/SEEDS.md has the full
// format). It exports its `code` and the campaign's content; this module
// finds the files, checks them, and turns one into rows for
// createCampaign.js. The folder is read again on every lookup, so a file
// dropped in is live without a restart.
const fs = require('fs');
const path = require('path');
const { z } = require('zod');
const { GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { CURRENCIES } = require('../utils/grants');
const SEEDS_DIR = path.join(__dirname, '..', '..', 'seeds');
const CODE_LENGTH = 6;
const CODE_PATTERN = /^[A-Z0-9]{6}$/;
// What someone types, as a seed file would have it.
function normalizeCode(code) {
return String(code || '').replace(/[\s-]/g, '').toUpperCase();
}
// ---------------------------------------------------------------------------
// The shape of a seed file. Objects are strict, so a misspelt field is an
// error rather than something silently left out of the campaign. The limits
// are the ones the app itself enforces (routes/*.js), so whatever a seed
// creates can still be edited on screen.
// ---------------------------------------------------------------------------
const text = (max) => z.string().trim().min(1).max(max);
const optionalText = (max) => z.string().max(max).nullish();
const dice = z.string().min(1).max(20).nullish();
// A creature's damage may name its type: "1d6+2 necrotic".
const damage = z.string().min(1).max(60).nullish();
const score = z.number().int().min(1).max(30).optional();
const gearLevel = z.number().int().min(1).max(MAX_GEAR_LEVEL);
const gear = {
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
minLevel: gearLevel.optional(),
isStartingOption: z.boolean().optional(),
effects: z.array(z.strictObject({ stat: z.enum(GEAR_STATS), amount: z.number().int().min(-100).max(100) })).max(12).optional(),
effectNote: optionalText(300),
};
const POOLS = {
weapons: {
library: WEAPON_LIBRARY,
row: z.strictObject({
name: text(80),
damageDice: z.string().min(1).max(20),
damageType: text(40),
attackAbility: z.enum(['STR', 'DEX', 'FINESSE']),
properties: z.array(z.string()).optional(),
rangeNormal: z.number().int().min(0).nullish(),
rangeLong: z.number().int().min(0).nullish(),
...gear,
}),
},
items: {
library: ITEM_LIBRARY,
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, ...gear }),
},
spells: {
library: SPELL_LIBRARY,
row: z.strictObject({
name: text(80),
level: z.number().int().min(0).max(9),
damageDice: dice,
school: optionalText(40),
castingTime: optionalText(80),
range: optionalText(80),
duration: optionalText(80),
description: optionalText(4000),
isStartingOption: z.boolean().optional(),
revives: z.boolean().optional(),
}),
},
monsters: {
library: MONSTER_LIBRARY,
row: z.strictObject({
name: text(80),
ac: z.number().int().min(0).max(40),
maxHp: z.number().int().min(1).max(10000),
speed: z.number().int().min(0).max(500).optional(),
strScore: score,
dexScore: score,
conScore: score,
intScore: score,
wisScore: score,
chaScore: score,
attackBonus: z.number().int().min(-10).max(30).optional(),
damageDice: damage,
challengeRating: optionalText(10),
specialTrait: optionalText(500),
notes: optionalText(2000),
}),
},
};
const moneyAmount = z.number().int().min(1).max(1000000);
// Something for sale: one weapon or one item from this seed's pools, by name.
const wareSchema = z
.strictObject({
weapon: z.string().optional(),
item: z.string().optional(),
price: moneyAmount,
currency: z.enum(CURRENCIES).optional(),
level: gearLevel.nullish(),
stock: z.number().int().min(0).max(9999).nullish(),
})
.refine((ware) => Boolean(ware.weapon) !== Boolean(ware.item), { message: 'A ware sells one weapon or one item' });
// Loot: money ({ gold: 25 }, or silver / copper / cash), a weapon or an item.
const rewardSchema = z
.strictObject({
gold: moneyAmount.nullish(),
silver: moneyAmount.nullish(),
copper: moneyAmount.nullish(),
cash: moneyAmount.nullish(),
weapon: z.string().nullish(),
item: z.string().nullish(),
quantity: z.number().int().min(1).max(999).nullish(),
level: gearLevel.nullish(),
note: optionalText(200),
})
.refine((reward) => ['gold', 'silver', 'copper', 'cash', 'weapon', 'item'].filter((key) => reward[key] != null).length === 1, {
message: 'A reward is one thing: gold, silver, copper, cash, a weapon or an item',
});
const npcSchema = z.strictObject({
name: text(80),
role: optionalText(80),
disposition: z.enum(['FRIENDLY', 'NEUTRAL', 'HOSTILE']).optional(),
description: optionalText(2000),
dmNotes: optionalText(4000),
ac: z.number().int().min(0).max(40).nullish(),
maxHp: z.number().int().min(1).max(10000).nullish(),
attackBonus: z.number().int().min(-10).max(30).nullish(),
damageDice: damage,
// A money changer: exchangeGold gold for exchangeCash cash, either way.
exchangeGold: moneyAmount.nullish(),
exchangeCash: moneyAmount.nullish(),
wares: z.array(wareSchema).optional(),
});
const encounterSchema = z.strictObject({
name: text(120),
mode: z.enum(['BATTLE', 'QUEST']).optional(),
description: optionalText(4000),
dmNotes: optionalText(4000),
location: z.string().nullish(),
// [monster name, how many, display name?, max HP?]
monsters: z
.array(z.tuple([z.string(), z.number().int().min(1).max(50)]).rest(z.union([z.string(), z.number().int().min(1), z.null(), z.undefined()])))
.optional(),
npcs: z.array(z.union([z.string(), z.strictObject({ name: z.string(), hp: z.number().int().min(0).optional() })])).optional(),
rewards: z.array(rewardSchema).optional(),
wares: z.array(wareSchema).optional(),
});
// A pool entry is a full row, or one copied from the site library: its key
// alone, or { library: key } with fields to change on the copy. The rows
// themselves are checked once the copies are made (resolvePool).
const poolSchema = z.array(z.union([z.string(), z.record(z.string(), z.any())])).default([]);
const seedSchema = z.strictObject({
code: z.string().regex(CODE_PATTERN, `The code is ${CODE_LENGTH} capital letters or digits`),
campaign: z.strictObject({
name: text(120),
description: optionalText(2000),
startingGold: z.number().int().min(0).max(1000000).optional(),
startingItemCount: z.number().int().min(0).max(50).optional(),
startingSpellCount: z.number().int().min(0).max(50).optional(),
skipMotivationRoll: z.boolean().optional(),
showMonsterHp: z.boolean().optional(),
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
maxEquipped: z.number().int().min(1).max(20).optional(),
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
}),
locations: z.array(z.strictObject({ name: text(80), description: optionalText(2000) })).default([]),
weapons: poolSchema,
items: poolSchema,
spells: poolSchema,
monsters: poolSchema,
npcs: z.array(npcSchema).default([]),
encounters: z.array(encounterSchema).default([]),
// Note bodies, in reading order.
sharedNotes: z.array(text(10000)).default([]),
dmNotes: z.array(text(10000)).default([]),
});
class SeedError extends Error {}
function issueList(error) {
return error.issues.map((issue) => `${issue.path.join('.') || '(file)'}: ${issue.message}`);
}
// Swaps library references for copies of the library's rows (less the key and
// the picker-only tags) and checks every row. Problems are pushed to `problems`.
function resolvePool(pool, entries, problems) {
const { library, row } = POOLS[pool];
const rows = [];
entries.forEach((entry, index) => {
let candidate = entry;
if (typeof entry === 'string' || (entry && entry.library)) {
const { library: key, ...changes } = typeof entry === 'string' ? { library: entry } : entry;
const template = library.find((item) => item.key === key);
if (!template) return problems.push(`${pool}.${index}: there is no "${key}" in the ${pool} library`);
const { key: _key, category, classes, ...copy } = template;
candidate = { ...copy, ...changes };
}
const parsed = row.safeParse(candidate);
if (!parsed.success) return problems.push(...issueList(parsed.error).map((issue) => `${pool}.${index} (${candidate.name || '?'}): ${issue}`));
rows.push(parsed.data);
});
return rows;
}
// Checks a seed file's export and returns it ready to create: library
// references filled in, defaults applied. Throws a SeedError listing
// everything wrong with it.
function buildSeed(raw) {
const parsed = seedSchema.safeParse(raw);
if (!parsed.success) throw new SeedError(issueList(parsed.error).join('\n'));
const seed = parsed.data;
const problems = [];
for (const pool of Object.keys(POOLS)) seed[pool] = resolvePool(pool, seed[pool], problems);
// Encounters, rewards and wares point at things by name, so within a pool a
// name can only be used once.
const names = {};
for (const pool of ['locations', 'weapons', 'items', 'spells', 'monsters', 'npcs']) {
names[pool] = new Set();
for (const { name } of seed[pool]) {
if (names[pool].has(name)) problems.push(`${pool}: "${name}" is there twice`);
names[pool].add(name);
}
}
const gearByName = { weapon: new Map(seed.weapons.map((w) => [w.name, w])), item: new Map(seed.items.map((i) => [i.name, i])) };
const checkGear = (where, { weapon, item, level }) => {
const kind = weapon ? 'weapon' : 'item';
const entry = gearByName[kind].get(weapon || item);
if (!entry) return problems.push(`${where}: there is no ${kind} called "${weapon || item}"`);
if (level && level < (entry.minLevel || 1)) problems.push(`${where}: "${entry.name}" starts at level ${entry.minLevel}, so it can't be level ${level}`);
};
for (const npc of seed.npcs) {
if (Boolean(npc.exchangeGold) !== Boolean(npc.exchangeCash)) problems.push(`NPC "${npc.name}": an exchange rate needs both exchangeGold and exchangeCash`);
(npc.wares || []).forEach((ware) => checkGear(`NPC "${npc.name}" wares`, ware));
}
for (const encounter of seed.encounters) {
const where = `Encounter "${encounter.name}"`;
if (encounter.location && !names.locations.has(encounter.location)) problems.push(`${where}: there is no location called "${encounter.location}"`);
for (const [monster] of encounter.monsters || []) {
if (!names.monsters.has(monster)) problems.push(`${where}: there is no monster called "${monster}"`);
}
for (const entry of encounter.npcs || []) {
const name = typeof entry === 'string' ? entry : entry.name;
if (!names.npcs.has(name)) problems.push(`${where}: there is no NPC called "${name}"`);
}
for (const reward of encounter.rewards || []) if (reward.weapon || reward.item) checkGear(`${where} rewards`, reward);
(encounter.wares || []).forEach((ware) => checkGear(`${where} wares`, ware));
}
if (problems.length) throw new SeedError(problems.join('\n'));
return seed;
}
// ---------------------------------------------------------------------------
// The folder
// ---------------------------------------------------------------------------
// Every seed file, good or bad: [{ file, code, seed } | { file, error }].
// A file is only built again when it has changed on disk.
const built = new Map();
function loadSeeds(dir = SEEDS_DIR) {
if (!fs.existsSync(dir)) return [];
const results = [];
const codes = new Map();
for (const file of fs.readdirSync(dir).filter((name) => name.endsWith('.js')).sort()) {
const fullPath = path.join(dir, file);
const { mtimeMs } = fs.statSync(fullPath);
let entry = built.get(fullPath);
if (!entry || entry.mtimeMs !== mtimeMs) {
try {
delete require.cache[require.resolve(fullPath)];
const seed = buildSeed(require(fullPath));
entry = { mtimeMs, result: { file, code: seed.code, seed } };
} catch (err) {
entry = { mtimeMs, result: { file, error: err.message } };
}
built.set(fullPath, entry);
}
let { result } = entry;
if (result.code && codes.has(result.code)) {
result = { file, error: `Its code ${result.code} is already used by ${codes.get(result.code)}` };
} else if (result.code) {
codes.set(result.code, file);
}
results.push(result);
}
return results;
}
// The world behind a code, or null. A file with something wrong in it is
// never offered; `npm run seeds:check` says what is wrong.
function findSeed(code, dir = SEEDS_DIR) {
const wanted = normalizeCode(code);
const match = loadSeeds(dir).find((result) => result.code === wanted && !result.error);
return match ? match.seed : null;
}
module.exports = { SEEDS_DIR, CODE_LENGTH, SeedError, normalizeCode, buildSeed, loadSeeds, findSeed };
+138
View File
@@ -0,0 +1,138 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { buildSeed, loadSeeds, findSeed, normalizeCode, SeedError } = require('./index');
// The smallest seed there is, with whatever a test adds or changes.
function seedWith(extra = {}) {
return { code: 'TEST01', campaign: { name: 'Test World' }, ...extra };
}
describe('the seeds in /backend/seeds', () => {
const results = loadSeeds();
test('every file is a valid world', () => {
expect(results.length).toBeGreaterThan(0);
for (const { file, error } of results) expect({ file, error }).toEqual({ file, error: undefined });
});
test('each has its own code', () => {
const codes = results.map((result) => result.code);
expect(new Set(codes).size).toBe(codes.length);
});
});
describe('buildSeed', () => {
test('fills in what is left out', () => {
const seed = buildSeed(seedWith());
expect(seed.weapons).toEqual([]);
expect(seed.encounters).toEqual([]);
expect(seed.sharedNotes).toEqual([]);
});
test('copies library entries, with changes, and drops the library-only fields', () => {
const seed = buildSeed(seedWith({ weapons: ['dagger', { library: 'longsword', isStartingOption: true, minLevel: 2 }], monsters: ['goblin'] }));
expect(seed.weapons[0]).toMatchObject({ name: 'Dagger', damageDice: '1d4' });
expect(seed.weapons[1]).toMatchObject({ name: 'Longsword', isStartingOption: true, minLevel: 2 });
expect(seed.weapons[0]).not.toHaveProperty('key');
expect(seed.weapons[0]).not.toHaveProperty('category');
expect(seed.monsters[0].name).toBe('Goblin');
});
test('refuses a code that is not six capital letters or digits', () => {
for (const code of ['abc123', 'ABC12', 'ABC1234', 'ABC-12', undefined]) {
expect(() => buildSeed(seedWith({ code }))).toThrow(SeedError);
}
});
test('refuses a misspelt field rather than leaving it out', () => {
expect(() => buildSeed(seedWith({ npcs: [{ name: 'Mira', desciption: 'A baker' }] }))).toThrow(/desciption/);
expect(() => buildSeed(seedWith({ items: [{ name: 'Rope', starting: true }] }))).toThrow(/starting/);
});
test('refuses an unknown library key', () => {
expect(() => buildSeed(seedWith({ spells: ['fire-bolt', 'not-a-spell'] }))).toThrow(/no "not-a-spell" in the spells library/);
});
test('refuses the same name twice in a pool', () => {
expect(() => buildSeed(seedWith({ weapons: ['dagger', 'dagger'] }))).toThrow(/"Dagger" is there twice/);
});
test('refuses an encounter that points at something the seed does not have', () => {
const encounter = (extra) => seedWith({ encounters: [{ name: 'The Bridge', ...extra }] });
expect(() => buildSeed(encounter({ location: 'Nowhere' }))).toThrow(/no location called "Nowhere"/);
expect(() => buildSeed(encounter({ monsters: [['Troll', 1]] }))).toThrow(/no monster called "Troll"/);
expect(() => buildSeed(encounter({ npcs: ['Mira'] }))).toThrow(/no NPC called "Mira"/);
expect(() => buildSeed(encounter({ rewards: [{ item: 'Rope' }] }))).toThrow(/no item called "Rope"/);
expect(() => buildSeed(encounter({ wares: [{ weapon: 'Dagger', price: 2 }] }))).toThrow(/no weapon called "Dagger"/);
});
test('a reward is exactly one thing', () => {
const rewards = (list) => seedWith({ items: [{ name: 'Rope' }], encounters: [{ name: 'The Bridge', rewards: list }] });
expect(buildSeed(rewards([{ gold: 5 }, { cash: 20 }, { item: 'Rope', quantity: 2 }])).encounters[0].rewards).toHaveLength(3);
expect(() => buildSeed(rewards([{ gold: 5, item: 'Rope' }]))).toThrow(SeedError);
expect(() => buildSeed(rewards([{ note: 'nothing' }]))).toThrow(SeedError);
});
test('gear cannot be sold or handed out below its starting level', () => {
const world = (ware) => seedWith({ items: [{ name: 'Old Crown', minLevel: 4 }], npcs: [{ name: 'Mira', wares: [ware] }] });
expect(buildSeed(world({ item: 'Old Crown', price: 100, level: 5 })).npcs[0].wares).toHaveLength(1);
expect(() => buildSeed(world({ item: 'Old Crown', price: 100, level: 2 }))).toThrow(/starts at level 4/);
});
test('an exchange rate needs both halves', () => {
expect(() => buildSeed(seedWith({ npcs: [{ name: 'Mira', exchangeGold: 1 }] }))).toThrow(/exchange rate/);
expect(buildSeed(seedWith({ npcs: [{ name: 'Mira', exchangeGold: 1, exchangeCash: 20 }] })).npcs[0].exchangeCash).toBe(20);
});
test('an effect has to be on a number the sheet has', () => {
const item = (effects) => seedWith({ items: [{ name: 'Shield', effects }] });
expect(buildSeed(item([{ stat: 'AC', amount: 2 }])).items[0].effects).toEqual([{ stat: 'AC', amount: 2 }]);
expect(() => buildSeed(item([{ stat: 'LUCK', amount: 2 }]))).toThrow(SeedError);
});
});
describe('the seeds folder', () => {
let dir;
const drop = (file, seed) => fs.writeFileSync(path.join(dir, file), `module.exports = ${JSON.stringify(seed)};`);
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'seeds-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
test('a code finds its world however it is typed', () => {
drop('one.js', seedWith({ code: 'AB12CD' }));
expect(normalizeCode(' ab1-2cd ')).toBe('AB12CD');
expect(findSeed(' ab1-2cd ', dir).campaign.name).toBe('Test World');
expect(findSeed('ZZZZZZ', dir)).toBeNull();
expect(findSeed('', dir)).toBeNull();
});
test('a file dropped in later is picked up', () => {
expect(findSeed('LATER1', dir)).toBeNull();
drop('later.js', seedWith({ code: 'LATER1' }));
expect(findSeed('LATER1', dir)).not.toBeNull();
});
test('a broken file is reported and never offered, without hiding the good ones', () => {
drop('bad.js', seedWith({ code: 'BAD001', encounters: [{ name: 'X', monsters: [['Nobody', 1]] }] }));
drop('good.js', seedWith({ code: 'GOOD01' }));
fs.writeFileSync(path.join(dir, 'syntax.js'), 'module.exports = {');
const byFile = Object.fromEntries(loadSeeds(dir).map((result) => [result.file, result]));
expect(byFile['bad.js'].error).toMatch(/no monster called "Nobody"/);
expect(byFile['syntax.js'].error).toBeTruthy();
expect(byFile['good.js'].error).toBeUndefined();
expect(findSeed('BAD001', dir)).toBeNull();
expect(findSeed('GOOD01', dir)).not.toBeNull();
});
test('two files cannot share a code: the first keeps it', () => {
drop('a.js', seedWith({ code: 'SAME01', campaign: { name: 'First' } }));
drop('b.js', seedWith({ code: 'SAME01', campaign: { name: 'Second' } }));
expect(loadSeeds(dir)[1].error).toMatch(/already used by a\.js/);
expect(findSeed('SAME01', dir).campaign.name).toBe('First');
});
});
+233
View File
@@ -0,0 +1,233 @@
# World Seeds
A world seed is a ready-made campaign: locations, weapons, items, spells,
monsters, NPCs with their wares, encounters with their rewards, and notes. A
DM starts a campaign from one by typing its code.
This describes the code as it stands. Related: [ITEMS.md](ITEMS.md),
[WEAPONS.md](WEAPONS.md), [SPELLS.md](SPELLS.md), [MONSTERS.md](MONSTERS.md),
[NPCS.md](NPCS.md), [ENCOUNTERS.md](ENCOUNTERS.md).
## Contents
1. [The basics](#1-the-basics)
2. [Starting a campaign from a code](#2-starting-a-campaign-from-a-code)
3. [Adding a world](#3-adding-a-world)
4. [What a seed file holds](#4-what-a-seed-file-holds)
5. [Checking a seed](#5-checking-a-seed)
6. [The database seed](#6-the-database-seed)
7. [The worlds that come with the app](#7-the-worlds-that-come-with-the-app)
---
## 1. The basics
- Every `.js` file in `/backend/seeds` is one world. The file says what its
**code** is: six capital letters or digits.
- Anyone who is logged in and knows a code can create that world as a new
campaign. They become its DM and owner, exactly as with a blank campaign.
- The same code can be used any number of times. Each use makes a separate
campaign with its own invite code; nothing is shared between copies.
- A seed only says what a campaign **starts** as. Once created, the campaign
is the DM's to change, and changing the seed file later does not touch
campaigns already made from it.
- The folder is read each time a code is entered, so a new file is live as
soon as it is on the server. No restart, no migration.
## 2. Starting a campaign from a code
On the **Campaigns** page, click **+ New Campaign**. Under the form for a
blank campaign is **Or start from a ready-made world**: type the code and
click **Create From Code**. Capitals and spaces do not matter (`aldenf`,
`ALDENF` and `ald-enf` are the same code).
The whole world is created in one go, and you land on its campaign page. If
anything in it fails, nothing is created.
| Reply | Meaning |
|---|---|
| "A world code is 6 letters or numbers" | What was typed is not six characters long |
| "There is no world with that code" | No valid seed file has that code |
The API is `POST /campaigns/from-seed` with `{ "code": "ALDENF" }`. It
answers with the new campaign, as `POST /campaigns` does.
## 3. Adding a world
1. Write the world as a file in `/backend/seeds`, for example
`/backend/seeds/my-world.js` (section 4).
2. Run `npm run seeds:check` (section 5) and fix whatever it reports.
3. Put the file on the server. The code works straight away.
A file with a mistake in it is never offered: its code simply answers "There
is no world with that code" until the file is fixed. Two files cannot share a
code; the one whose file name comes first keeps it.
To retire a world, delete its file. Campaigns already made from it stay.
## 4. What a seed file holds
A seed file is a JavaScript module that exports one object. Only `code` and
`campaign.name` are required; every list can be left out.
```js
module.exports = {
code: 'MYWRLD',
campaign: { name: 'My World', description: '...', startingGold: 20, startingItemCount: 2, startingSpellCount: 2 },
locations: [{ name: 'The Old Mill', description: '...' }],
weapons: [
'dagger', // a copy from the site library
{ library: 'longsword', isStartingOption: true }, // a copy, with changes
{ name: 'Millstone Hammer', damageDice: '2d6', damageType: 'bludgeoning', attackAbility: 'STR', minLevel: 3 },
],
items: [
{ library: 'shield', isStartingOption: true },
{ name: 'Miller’s Charm', description: '...', effects: [{ stat: 'SKILL_PERCEPTION', amount: 1 }] },
],
spells: [{ library: 'fire-bolt', isStartingOption: true }, 'fireball'],
monsters: ['goblin', { name: 'Mill Wraith', ac: 13, maxHp: 30, attackBonus: 4, damageDice: '1d8+2 necrotic' }],
npcs: [
{
name: 'Old Hob', role: 'Miller', disposition: 'FRIENDLY', description: '...', dmNotes: '...',
wares: [{ item: 'Shield', price: 10 }, { weapon: 'Dagger', price: 2, stock: 3 }],
},
],
encounters: [
{
name: 'The Mill at Night', mode: 'BATTLE', location: 'The Old Mill', description: '...', dmNotes: '...',
monsters: [['Goblin', 3], ['Mill Wraith', 1, 'The Miller’s Wife', 40]],
npcs: ['Old Hob'],
rewards: [{ gold: 25 }, { silver: 40 }, { weapon: 'Millstone Hammer', note: 'Behind the wheel' }],
},
],
sharedNotes: ['Welcome to My World...'],
dmNotes: ['The secret of the mill...'],
};
```
The file is ordinary JavaScript, so it can build its lists however it likes
(the Hollowcrown seed adds the whole spell library with a `map`).
### Top level
| Field | What it is |
|---|---|
| `code` | Six capital letters or digits. What a DM types |
| `campaign` | The campaign's settings: `name`, and optionally `description`, `startingGold`, `startingItemCount`, `startingSpellCount`, `skipMotivationRoll`, `showMonsterHp`, `areaAttackAcPenalty`, `maxEquipped`, `maxEquippedWeapons`. Anything left out takes the app's default |
| `locations` | `{ name, description }` |
| `weapons`, `items`, `spells`, `monsters` | The pools. Each entry is a row or a library copy (below) |
| `npcs` | NPC rows, each optionally with `wares` |
| `encounters` | In the order they should list |
| `sharedNotes`, `dmNotes` | Note bodies, in reading order: the first is shown at the top. Shared notes are for everyone, DM notes for the DM |
### Pool entries
An entry in `weapons`, `items`, `spells` or `monsters` is one of:
- **A library key** such as `'longsword'`: a copy of that entry from the
site's built-in library (`src/game/data/*Library.js`).
- **A library copy with changes**: `{ library: 'longsword', isStartingOption: true, minLevel: 2 }`.
Any field of the row can be changed, including its name.
- **A full row**, with the same fields the app's own forms have:
| Pool | Fields (required in bold) |
|---|---|
| Weapon | **`name`**, **`damageDice`**, **`damageType`**, **`attackAbility`** (`STR`, `DEX` or `FINESSE`), `properties`, `rangeNormal`, `rangeLong`, `damageScaling` (1 to 3), `minLevel` (1 to 9), `isStartingOption`, `effects`, `effectNote` |
| Item | **`name`**, `description`, `damageDice`, `damageScaling`, `minLevel`, `isStartingOption`, `effects`, `effectNote` |
| Spell | **`name`**, **`level`** (0 to 9), `damageDice`, `school`, `castingTime`, `range`, `duration`, `description`, `isStartingOption`, `revives` |
| Monster | **`name`**, **`ac`**, **`maxHp`**, `speed`, `strScore` ... `chaScore`, `attackBonus`, `damageDice` (may name its type: `"1d6+2 necrotic"`), `challengeRating`, `specialTrait`, `notes` |
`effects` is a list of `{ stat, amount }`, as described in
[ITEMS.md](ITEMS.md): armor is an item with an `AC` effect.
Within a pool a name can only be used once, because everything else in the
file points at pool entries **by name**.
### NPCs
**`name`**, `role`, `disposition` (`FRIENDLY`, `NEUTRAL` or `HOSTILE`),
`description` (what players see), `dmNotes`, and for those who might fight
`ac`, `maxHp`, `attackBonus`, `damageDice`.
- **A money changer** has `exchangeGold` and `exchangeCash` together:
`exchangeGold: 1, exchangeCash: 50` is 1 gold for $50, either way.
- **A shopkeeper** has `wares`, a list of things for sale (below).
### Encounters
| Field | What it is |
|---|---|
| **`name`** | |
| `mode` | `BATTLE` (the default) or `QUEST`. Buying and money changing only happen in a quest |
| `description` | The scene, shown to the players in it |
| `dmNotes` | DM only |
| `location` | A location's name |
| `monsters` | `[monster name, how many, display name, max HP]`. The last two are optional; more than one gets "#1", "#2"... |
| `npcs` | NPC names, or `{ name, hp }` to set the hit points for this scene |
| `rewards` | Lined up unawarded (below) |
| `wares` | For sale at the scene itself, with nobody behind it: a market, a vending machine |
A **reward** is exactly one of `gold`, `silver`, `copper`, `cash` (an amount),
`weapon` or `item` (a name), with an optional `note`. A weapon or item can also
have `quantity` and `level`.
A **ware** is `{ weapon | item, price }` with optional `currency` (`GOLD`, the
default, `SILVER`, `COPPER` or `CASH`), `level` and `stock` (left out: no
limit).
A reward's or ware's `level` defaults to the entry's own starting level, and
cannot be below it.
### Limits
Text fields have the same length limits as the app's forms (for example 4,000
characters for an encounter's DM notes and 10,000 for a note), so that
everything a seed creates can still be edited on screen. The checker says
which field is over.
## 5. Checking a seed
```bash
npm run seeds:check
```
lists every file in `/backend/seeds` with its code and what it holds, or
everything wrong with it:
```
✓ aldenfell.js — code ALDENF — Aldenfell
24 weapons (16 starting), 31 items (10 starting), 45 spells (30 starting), 21 locations
15 monsters, 13 NPCs (8 wares), 38 encounters (40 rewards, 9 wares), 23 notes
✗ my-world.js
npcs.0.desciption: Unrecognized key: "desciption"
Encounter "The Mill at Night": there is no monster called "Gobblin"
```
It checks the things that would otherwise only show up as a half-working
campaign:
- A field name that is not one of the fields above (a typo).
- A library key that is not in the library.
- The same name twice in one pool.
- An encounter, reward or ware that names a location, monster, NPC, weapon or
item the file does not have.
- A reward that is not exactly one thing, or gear handed out below its
starting level.
- An exchange rate with only one half.
`npm test` runs the same check on every seed in the folder.
## 6. The database seed
`npx prisma db seed` creates the admin account if it is missing, and nothing
else. It never adds a campaign: worlds come from codes, in the app. See the
README for the account it creates and `--reset`.
## 7. The worlds that come with the app
| Code | World | From |
|---|---|---|
| `ALDENF` | Aldenfell | The Aldenfell Campaign Book |
| `HOLLOW` | Hollowcrown: The Rift Between Worlds | The Hollowcrown Dungeon Master's Book (V3) |
Change a world's code by editing `code` in its file.
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.11.1",
"version": "1.12.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -23,7 +23,7 @@
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"typescript": "~6.0.2",
"typescript": "~7.0.2",
"vite": "^8.3.0"
}
}
+10
View File
@@ -62,6 +62,16 @@ export function useCreateCampaign() {
});
}
// A ready-made world (a seed file on the server) as a new campaign, by its code.
export function useCreateCampaignFromSeed() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (code: string) =>
(await apiClient.post<{ campaign: Campaign }>('/campaigns/from-seed', { code })).data.campaign,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
});
}
export function useUpdateCampaign(campaignId: string) {
const qc = useQueryClient();
return useMutation({
+5 -22
View File
@@ -1,28 +1,17 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { apiClient, getStoredToken, setAuthToken } from '../api/client';
import type { User } from '../api/types';
interface AuthContextValue {
user: User | null;
token: string | null;
loading: boolean;
login: (token: string, user: User) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
import { AuthContext } from './useAuth';
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(getStoredToken());
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// With no stored token there is nothing to check, so nothing to wait for.
const [loading, setLoading] = useState(() => getStoredToken() !== null);
useEffect(() => {
const stored = getStoredToken();
if (!stored) {
setLoading(false);
return;
}
if (!stored) return;
setAuthToken(stored);
apiClient
.get<{ user: User }>('/auth/me')
@@ -53,9 +42,3 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+18
View File
@@ -0,0 +1,18 @@
import { createContext, useContext } from 'react';
import type { User } from '../api/types';
export interface AuthContextValue {
user: User | null;
token: string | null;
loading: boolean;
login: (token: string, user: User) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Link, useMatch } from 'react-router-dom';
import { useCampaign, useCampaignCharacters, usePatchCharacterWeapon, usePatchInventoryItem, useQuickStats } from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId } from '../lib/lastCampaign';
import { closeCharacterPanel, openCharacterPanel, useCharacterPanelId } from '../lib/characterPanel';
import { CLASS_LABELS, SPECIES_LABELS, fmtMod, formatPurse, spellLevelLabel } from '../lib/format';
+1 -1
View File
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
// Short explanations for the stat labels sprinkled across the sheet, quick
// stats, party table, and builder — "what is this, and how is it worked
// out" for someone who doesn't already know D&D 5e by heart.
export const STAT_HELP: Record<string, string> = {
const STAT_HELP: Record<string, string> = {
AC: 'Armor Class — how hard you are to hit. 10 + your Dexterity modifier + any armor bonus.',
HP: 'Hit Points — damage you can take before falling unconscious. Max HP is set by your class hit die, Constitution modifier, and level.',
TEMP_HP: 'Temporary Hit Points — an extra buffer that absorbs damage first and disappears when it runs out (it does not stack with more temp HP).',
+1 -1
View File
@@ -1,5 +1,5 @@
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
import { DiceRoller } from './DiceRoller';
import { CharacterPanel } from './CharacterPanel';
+1 -24
View File
@@ -1,27 +1,4 @@
import { useMemo, useState } from 'react';
// Small generic pager shared by every list-turned-table in the app, so page
// state isn't hand-rolled per screen. Resets to page 1 whenever the
// underlying item count shrinks below the current page (e.g. after a delete
// or a filter change).
export function usePagination<T>(items: T[] | undefined, pageSize = 10) {
const [page, setPage] = useState(1);
const all = items || [];
const pageCount = Math.max(1, Math.ceil(all.length / pageSize));
const safePage = Math.min(page, pageCount);
const pageItems = useMemo(() => all.slice((safePage - 1) * pageSize, safePage * pageSize), [all, safePage, pageSize]);
return {
page: safePage,
pageCount,
pageItems,
total: all.length,
setPage,
nextPage: () => setPage((p) => Math.min(pageCount, p + 1)),
prevPage: () => setPage((p) => Math.max(1, p - 1)),
};
}
// The controls for a paged list; the page state itself is hooks/usePagination.
export function Pagination({
page,
pageCount,
+1 -1
View File
@@ -1,5 +1,5 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import { Spinner } from './Spinner';
export function ProtectedRoute() {
+1 -1
View File
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { io, type Socket } from 'socket.io-client';
import { API_URL } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
let sharedSocket: Socket | null = null;
+26
View File
@@ -0,0 +1,26 @@
import { useMemo, useState } from 'react';
// Small generic pager shared by every list-turned-table in the app, so page
// state isn't hand-rolled per screen. Resets to page 1 whenever the
// underlying item count shrinks below the current page (e.g. after a delete
// or a filter change).
export function usePagination<T>(items: T[] | undefined, pageSize = 10) {
const [page, setPage] = useState(1);
const total = items?.length ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, pageCount);
const pageItems = useMemo(
() => (items || []).slice((safePage - 1) * pageSize, safePage * pageSize),
[items, safePage, pageSize],
);
return {
page: safePage,
pageCount,
pageItems,
total,
setPage,
nextPage: () => setPage((p) => Math.min(pageCount, p + 1)),
prevPage: () => setPage((p) => Math.max(1, p - 1)),
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react';
import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import { extractErrorMessage } from '../lib/format';
import { ErrorBanner } from '../components/Spinner';
+3 -2
View File
@@ -39,13 +39,14 @@ import {
useAddSpellFromLibrary,
useAddMonsterFromLibrary,
} from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { JumpNav } from '../components/JumpNav';
import { LifeBadge } from '../components/DeathSaves';
import { usePagination, Pagination } from '../components/Paginated';
import { Pagination } from '../components/Paginated';
import { usePagination } from '../hooks/usePagination';
import { WaresEditor } from '../components/Wares';
import { EffectsEditor } from '../components/EffectsEditor';
import { effectsSummary } from '../lib/gear';
+35 -1
View File
@@ -1,12 +1,13 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useCampaigns, useCreateCampaign, useJoinCampaign, useSetCampaignArchived } from '../api/queries';
import { useCampaigns, useCreateCampaign, useCreateCampaignFromSeed, useJoinCampaign, useSetCampaignArchived } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { extractErrorMessage } from '../lib/format';
export function CampaignListPage() {
const { data: campaigns, isLoading, error } = useCampaigns();
const createCampaign = useCreateCampaign();
const createFromSeed = useCreateCampaignFromSeed();
const joinCampaign = useJoinCampaign();
const setArchived = useSetCampaignArchived();
const navigate = useNavigate();
@@ -15,6 +16,8 @@ export function CampaignListPage() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [worldCode, setWorldCode] = useState('');
const [worldError, setWorldError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
@@ -32,6 +35,17 @@ export function CampaignListPage() {
}
}
async function handleCreateFromSeed(e: FormEvent) {
e.preventDefault();
setWorldError(null);
try {
const campaign = await createFromSeed.mutateAsync(worldCode.trim().toUpperCase());
navigate(`/campaigns/${campaign.id}`);
} catch (err) {
setWorldError(extractErrorMessage(err));
}
}
async function handleJoin(e: FormEvent) {
e.preventDefault();
setFormError(null);
@@ -72,6 +86,26 @@ export function CampaignListPage() {
{createCampaign.isPending ? 'Founding…' : 'Found Campaign'}
</button>
</form>
<h2 className="section-label mt-lg">Or start from a ready-made world</h2>
<p style={{ color: 'var(--text-muted)', marginTop: 0 }}>
Have a world code? Enter it and the whole world is set up for you as its DM: locations, monsters, NPCs,
gear, spells and encounters.
</p>
<form onSubmit={handleCreateFromSeed} className="flex-row flex-wrap">
<input
aria-label="World code"
placeholder="e.g. ALDENF"
maxLength={12}
value={worldCode}
onChange={(e) => setWorldCode(e.target.value)}
style={{ flex: 1, minWidth: 160, textTransform: 'uppercase' }}
/>
<button type="submit" className="btn" disabled={createFromSeed.isPending || !worldCode.trim()}>
{createFromSeed.isPending ? 'Building the world…' : 'Create From Code'}
</button>
</form>
{worldError && <div className="mt-sm"><ErrorBanner message={worldError} /></div>}
</div>
)}
+3 -2
View File
@@ -28,7 +28,7 @@ import {
useCopyCharacter,
useCampaignLocations,
} from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
@@ -39,7 +39,8 @@ import { DeathSaves } from '../components/DeathSaves';
import { GearInForce } from '../components/CharacterPanel';
import { effectsSummary } from '../lib/gear';
import { InfoTip } from '../components/InfoTip';
import { usePagination, Pagination } from '../components/Paginated';
import { Pagination } from '../components/Paginated';
import { usePagination } from '../hooks/usePagination';
import {
ABILITY_LABELS,
CLASS_LABELS,
+1 -1
View File
@@ -25,7 +25,7 @@ import {
type NewReward,
} from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import type { User } from '../api/types';
import { extractErrorMessage } from '../lib/format';
import { ErrorBanner } from '../components/Spinner';
+3 -2
View File
@@ -1,9 +1,10 @@
import { Link, useParams } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import { useCampaign, useCampaignCharacters } from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { usePagination, Pagination } from '../components/Paginated';
import { Pagination } from '../components/Paginated';
import { usePagination } from '../hooks/usePagination';
import { InfoTip } from '../components/InfoTip';
import { CLASS_LABELS, SPECIES_LABELS, formatPurse } from '../lib/format';
import { HpBar } from '../components/HpBar';
+1 -1
View File
@@ -1,7 +1,7 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import type { User } from '../api/types';
import { extractErrorMessage } from '../lib/format';
import { ErrorBanner } from '../components/Spinner';
+4660 -2118
View File
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.11.1",
"version": "1.12.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [
@@ -11,6 +11,21 @@
"dev:backend": "npm run dev --workspace backend",
"dev:frontend": "npm run dev --workspace frontend",
"test:backend": "npm run test --workspace backend",
"seed:hollowcrown": "npm run seed:hollowcrown --workspace backend"
"seeds:check": "npm run seeds:check --workspace backend"
},
"allowScripts": {
"prisma": true,
"@prisma/client": true,
"@prisma/engines": true,
"bcrypt": true,
"@parcel/watcher": false,
"unrs-resolver": false
},
"overrides": {
"mysql2": "^3.24.5",
"deepmerge-ts": "^8.0.2",
"@istanbuljs/load-nyc-config": {
"js-yaml": "^4.1.0"
}
}
}