V1.0.1: Aldenfell campaign seed, content library, party overview

- Seed now resets the database and seeds the Aldenfell campaign from the
  campaign book: admin DM user, default weapons, locations, bestiary,
  pre-built encounters, and DM/shared notes
- Default weapon/spell/monster library, character profile fields,
  locations and dice mode (new migration)
- Party overview page, pagination, info tips, build preview calc
- Remove stale demo-login hint; notes preserve line breaks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 10:24:48 +02:00
co-authored by Claude Opus 5.5
parent 9b29dc8253
commit 94067f1268
34 changed files with 3158 additions and 391 deletions
@@ -0,0 +1,26 @@
-- CreateEnum
CREATE TYPE "DiceMode" AS ENUM ('DIGITAL', 'PHYSICAL');
-- AlterTable
ALTER TABLE "characters" ADD COLUMN "appearance" TEXT,
ADD COLUMN "background" TEXT,
ADD COLUMN "currentLocationId" TEXT,
ADD COLUMN "diceMode" "DiceMode" NOT NULL DEFAULT 'DIGITAL',
ADD COLUMN "privateNotes" TEXT;
-- CreateTable
CREATE TABLE "locations" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "locations_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "locations" ADD CONSTRAINT "locations_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "characters" ADD CONSTRAINT "characters_currentLocationId_fkey" FOREIGN KEY ("currentLocationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+31 -3
View File
@@ -101,6 +101,11 @@ enum NoteVisibility {
SHARED
}
enum DiceMode {
DIGITAL
PHYSICAL
}
// ---------------------------------------------------------------------------
// Identity & campaigns
// ---------------------------------------------------------------------------
@@ -141,10 +146,24 @@ model Campaign {
monsters Monster[]
encounters Encounter[]
notes Note[]
locations Location[]
@@map("campaigns")
}
model Location {
id String @id @default(cuid())
campaignId String
name String
description String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
characters Character[]
@@map("locations")
}
model CampaignMember {
id String @id @default(cuid())
campaignId String
@@ -195,15 +214,24 @@ model Character {
specialAbilities String? // free-text list shown in the quick-stats panel
gold Int @default(0)
background String? // free-text story background, player-editable
appearance String? // free-text character look/description, player-editable
privateNotes String? // visible to the owning player only — not even the DM
diceMode DiceMode @default(DIGITAL) // chosen once, before the first build roll
currentLocationId String?
buildStage BuildStage @default(NOT_STARTED)
buildState Json? // in-progress dice rolls / pending choices while building
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User? @relation("PlayerCharacters", fields: [userId], references: [id])
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User? @relation("PlayerCharacters", fields: [userId], references: [id])
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
currentLocation Location? @relation(fields: [currentLocationId], references: [id], onDelete: SetNull)
skillProficiencies CharacterSkillProficiency[]
saveProficiencies CharacterSaveProficiency[]
+81 -41
View File
@@ -1,8 +1,12 @@
// Seeds a demo campaign for local dev. Species/classes/skills are NOT seeded
// as rows — they're fixed game-rule tables ported into src/game/data/*.js and
// used directly by the calculation engine. Weapons ARE seeded, but only into
// this demo campaign (weapons are per-campaign in the schema, since a DM can
// add homebrew weapons).
// Resets the database and seeds the Aldenfell campaign (see
// docs/Aldenfell - Full Campaign Book.docx and seed-data/aldenfell.js).
//
// DESTRUCTIVE: every user, campaign, character, note, etc. is deleted first.
//
// Species/classes/skills are NOT seeded as rows — they're fixed game-rule
// tables ported into src/game/data/*.js, as is the default weapon/spell/monster
// library DMs pull from. The default weapons (seed-data/weapons.json) ARE
// seeded, into the campaign, since weapons are per-campaign in the schema.
const path = require('path');
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
@@ -10,54 +14,90 @@ const { generateInviteCode } = require('../src/utils/inviteCode');
const prisma = new PrismaClient();
const weapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
const aldenfell = require(path.join(__dirname, '..', 'seed-data', 'aldenfell.js'));
const ADMIN_EMAIL = 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = '12345678';
async function resetDatabase() {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
}
async function main() {
const passwordHash = await bcrypt.hash('password123', 12);
await resetDatabase();
const dm = await prisma.user.upsert({
where: { email: 'dm@example.com' },
update: {},
create: { email: 'dm@example.com', passwordHash, displayName: 'Dungeon Master' },
const admin = await prisma.user.create({
data: {
email: ADMIN_EMAIL,
passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12),
displayName: 'Joshua',
},
});
const player = await prisma.user.upsert({
where: { email: 'player@example.com' },
update: {},
create: { email: 'player@example.com', passwordHash, displayName: 'Test Player' },
const campaign = await prisma.campaign.create({
data: {
...aldenfell.campaign,
inviteCode: generateInviteCode(),
ownerId: admin.id,
members: { create: { userId: admin.id, role: 'DM' } },
},
});
let campaign = await prisma.campaign.findFirst({ where: { name: 'The Sunken Reach (Demo)' } });
if (!campaign) {
campaign = await prisma.campaign.create({
data: {
name: 'The Sunken Reach (Demo)',
description: 'A demo campaign seeded for local development.',
inviteCode: generateInviteCode(),
startingGold: 15,
startingItemCount: 2,
startingSpellCount: 2,
ownerId: dm.id,
members: {
create: [
{ userId: dm.id, role: 'DM' },
{ userId: player.id, role: 'PLAYER' },
],
},
},
await prisma.weapon.createMany({ data: weapons.map((w) => ({ ...w, campaignId: campaign.id })) });
await prisma.location.createMany({
data: aldenfell.locations.map((l) => ({ ...l, campaignId: campaign.id })),
});
const monsterByName = {};
for (const monster of aldenfell.monsters) {
const created = await prisma.monster.create({ data: { ...monster, campaignId: campaign.id } });
monsterByName[created.name] = created;
}
for (const enc of aldenfell.encounters) {
const participants = [];
for (const [name, count] of enc.monsters) {
const monster = monsterByName[name];
if (!monster) throw new Error(`Encounter "${enc.name}" references unknown monster "${name}"`);
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${monster.name} #${i}` : monster.name,
currentHp: monster.maxHp,
maxHp: monster.maxHp,
isMonster: true,
});
}
}
await prisma.encounter.create({
data: { campaignId: campaign.id, name: enc.name, participants: { create: participants } },
});
}
for (const weapon of weapons) {
const existing = await prisma.weapon.findFirst({ where: { campaignId: campaign.id, name: weapon.name } });
if (!existing) {
await prisma.weapon.create({ data: { ...weapon, campaignId: campaign.id } });
}
}
// Notes list newest-first, so stamp them in reverse to keep book order on screen.
const allNotes = [
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
const now = Date.now();
await prisma.note.createMany({
data: allNotes.map((n, i) => ({
...n,
campaignId: campaign.id,
authorId: admin.id,
createdAt: new Date(now - i * 1000),
})),
});
console.log('Seed complete.');
console.log(` DM login: dm@example.com / password123`);
console.log(` Player login: player@example.com / password123`);
console.log(` Campaign invite code: ${campaign.inviteCode}`);
console.log(` Admin/DM login: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${weapons.length} weapons, ${aldenfell.locations.length} locations, ${aldenfell.monsters.length} monsters, ` +
`${aldenfell.encounters.length} encounters, ${allNotes.length} notes`,
);
}
main()
+417
View File
@@ -0,0 +1,417 @@
// Aldenfell campaign content, transcribed (and condensed where a note would
// otherwise be unreadable) from docs/Aldenfell - Full Campaign Book.docx.
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids.
const campaign = {
name: 'Aldenfell',
description:
'A kingdom of knights and dragons is standing on top of something that’s waking up — and you’re the ones who find out first. ' +
'Heroic adventure first, real consequences, and horror as a spice rather than the whole meal.',
startingGold: 15,
startingItemCount: 2,
startingSpellCount: 2,
};
// ---------------------------------------------------------------------------
// Gazetteer
// ---------------------------------------------------------------------------
const locations = [
// Highcrown
{ name: 'Highcrown — The Founding Chapel', description: 'Site of Session One’s bell mystery. Its cellar connects to the Sunken Vaults. Quiet by day, a good recurring “something’s off” location throughout Act 1.' },
{ name: 'Highcrown — The Palace of Aldenfell', description: 'King Aldric’s seat. Court is held weekly; an audience without a sponsor (a knight, a noble, a Concord representative) takes real effort. Princess Isolde keeps a smaller, more accessible private study here.' },
{ name: 'Highcrown — The Grey Dawn Hall', description: 'Order of the Grey Dawn headquarters. Recruitment, training yards, and Ser Kavan Thorne’s office. The natural starting point for a Fighter/Paladin-leaning party.' },
{ name: 'Highcrown — The Concord Exchange', description: 'The Merchant Concord’s trading hall. Job boards, contracts, and Mireille Costa’s usual haunt. Best source of quick, paying side work.' },
{ name: 'Highcrown — The Lantern Market', description: 'Highcrown’s main marketplace, busiest during the Founding Fair. Gear, rumors, and pickpockets in equal measure. The campaign opens (E1) and closes here.' },
// Greywood
{ name: 'Greywood — The Warden’s Lodge', description: 'Ashwen and the other Wardens’ base, a day’s ride from Highcrown. First place to hear rumors about the old shrines acting strangely.' },
{ name: 'Greywood — The Old Shrine Sites', description: 'Scattered pre-kingdom shrines throughout the forest; several sit almost directly above Hollow Below cracks. Livestock/crop failures (a Stage 1 omen) cluster near these. → Side Quest: The Warden’s Silence.' },
{ name: 'Greywood — The Feywild Grove', description: 'A grove where the boundary with the Feywild is thin. Home to a reclusive, capricious fey court — a “be careful what you promise” side encounter, unrelated to the main plot.' },
{ name: 'Greywood — The Sunken Library', description: 'Ruins of a pre-kingdom archive, half-collapsed and root-choked. Contains fragments of the truth about the Hollow King’s origin — a place for the party to piece the mystery together on their own terms.' },
// Spine
{ name: 'The Spine — Threndir’s Eyrie', description: 'High on the eastern peaks, reachable by a hard climb or careful flight/magic. Threndir Ashclaw grants an audience to anyone who approaches openly and unarmed-in-spirit.' },
{ name: 'The Spine — Sevrisk’s Hollow', description: 'A lower, damper cave system claimed by the young green dragon Sevrisk. Far more dangerous to simply wander into.' },
{ name: 'The Spine — Stonejaw Hold', description: 'Korgath’s clan settlement in a Spine foothill valley. Outsiders are tested, not attacked on sight. → Side Quest: Trial of Stone.' },
{ name: 'The Spine — Ironvein', description: 'A mining town at the Spine’s base, Aldenfell’s main source of iron. Practical resupply stop with its own local rumors (miners going quiet, deeper tunnels avoided).' },
// Salt Coast & Bramble Marches
{ name: 'Salt Coast — Tidewatch', description: 'The Salt Coast’s largest port town, proudly independent-minded. Good base for coastal side content. → Side Quest: What the Tide Brought In.' },
{ name: 'Salt Coast — Smuggler’s Cove', description: 'A hidden inlet south of Tidewatch, used for exactly what the name suggests. → Side Quest: The Smugglers’ Tithe.' },
{ name: 'Bramble Marches — Greyford Keep', description: 'A border fortress garrisoned by Aldenfell troops watching the disputed line. Tense, under-resourced, a pressure-cooker location (E11, E16).' },
{ name: 'Bramble Marches — The Sorrow Camp', description: 'A refugee camp just inside Aldenfell territory, swollen by people fleeing the Marches’ border tension. → Side Quest: Refugees of the Marches.' },
// Hollow Below
{ name: 'Hollow Below — The Sunken Vaults (Layer 1)', description: 'Old crypts beneath Highcrown, reached from the Founding Chapel’s cellar or three other points in the city. Low real danger, mundane-spooky. Session One’s crawl (E3) happens here.' },
{ name: 'Hollow Below — The Root (Layer 2)', description: 'Natural caves beneath the Greywood and the Spine’s lower slopes. Geography stops being reliable. Hollowed, Bellcrawlers, and the Root Stalker live here. Danger is real.' },
{ name: 'Hollow Below — The Weeping Passage', description: 'A Root landmark: a long tunnel where the walls audibly “weep” — a faint, wrong, almost-voice sound. Set-piece location for the party’s first Hollowed.' },
{ name: 'Hollow Below — The Hollow Court (Layer 3)', description: 'The seal itself. Off-limits until Act 4. Gravity, light, and memory all bend near it. The Hollow King’s Shadow operates here.' },
];
// ---------------------------------------------------------------------------
// Bestiary (Aldenfell-specific stat blocks). attackBonus/damageDice hold the
// creature's primary attack; the rest lives in specialTrait/notes.
// ---------------------------------------------------------------------------
const monsters = [
{
name: 'Threndir Ashclaw (old copper dragon)', challengeRating: '8', ac: 17, maxHp: 165, speed: 40,
strScore: 23, dexScore: 12, conScore: 17, intScore: 16, wisScore: 13, chaScore: 17, attackBonus: 11, damageDice: '2d10+6',
specialTrait: 'Legendary talker — always offers to negotiate before fighting unless attacked first. Acid breath (recharge 5–6): 60-ft line, DC 15 Dex, 10d8 acid (half on success).',
notes: 'Huge dragon, 3,900 XP. Climb 40 ft, fly 80 ft. Bite +11 (2d10+6 piercing), Claw +11 (2d6+6 slashing), Tail +11 (2d8+6 bludgeoning). Saves Dex +5, Con +7, Wis +5, Cha +7. Skills Deception +7, Insight +5, Perception +9, Stealth +5. Blindsight 60 ft, darkvision 120 ft, passive Perception 19. Wants proof the crown can still be trusted — the intended “win” is a negotiation.',
},
{
name: 'Sevrisk (young green dragon)', challengeRating: '5', ac: 18, maxHp: 136, speed: 40,
strScore: 19, dexScore: 12, conScore: 17, intScore: 16, wisScore: 13, chaScore: 15, attackBonus: 7, damageDice: '2d6+4',
specialTrait: 'Poison breath (recharge 5–6): 30-ft cone, DC 14 Con, 8d6 poison (half on success).',
notes: 'Large dragon, 1,800 XP. Swim 40 ft, fly 80 ft. Bite +7 (2d6+4 piercing +2d6 poison), Claw +7 (2d4+4 slashing). Territorial and opportunistic — a straightforward combat threat, in contrast to Threndir.',
},
{
name: 'Stonejaw Hill Giant', challengeRating: '5', ac: 13, maxHp: 105, speed: 40,
strScore: 21, dexScore: 8, conScore: 19, intScore: 5, wisScore: 9, chaScore: 6, attackBonus: 8, damageDice: '3d8+5',
specialTrait: 'Honor-bound — fights fairly and expects the same.',
notes: 'Huge giant, 1,800 XP each (Korgath Stonejaw’s Spine Clan warband). Greatclub +8 (3d8+5 bludgeoning), Rock +8, 60/240 ft (3d10+5 bludgeoning). Passive Perception 9, speaks Giant. A trial or contest (Trial of Stone) fits better than an ambush.',
},
{
name: 'Spine Wyvern', challengeRating: '3', ac: 13, maxHp: 68, speed: 20,
strScore: 19, dexScore: 10, conScore: 16, intScore: 5, wisScore: 12, chaScore: 6, attackBonus: 6, damageDice: '2d6+4',
specialTrait: 'Territorial, not evil — attacks anything lingering near a nest, ignores parties that move on.',
notes: 'Large dragon, 700 XP. Fly 80 ft. Bite +6 (2d6+4 piercing), Stinger +6 (2d6+4 piercing, DC 15 Con or poisoned 1 minute). Perception +4, darkvision 60 ft, passive Perception 14.',
},
{
name: 'Reef Terror', challengeRating: '3', ac: 13, maxHp: 68, speed: 20,
strScore: 19, dexScore: 10, conScore: 16, intScore: 5, wisScore: 12, chaScore: 6, attackBonus: 6, damageDice: '2d6+4',
specialTrait: 'Territorial — drags fishers under near its reef.',
notes: 'Reskinned Spine Wyvern for Side Quest: What the Tide Brought In — fins instead of wings, no flight, swim speed instead. Bite +6 (2d6+4 piercing), Stinger +6 (2d6+4 piercing, DC 15 Con or poisoned). NOT connected to the Hollow King.',
},
{
name: 'Greywood Direwolf', challengeRating: '1', ac: 14, maxHp: 37, speed: 50,
strScore: 17, dexScore: 15, conScore: 15, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 5, damageDice: '2d6+3',
specialTrait: 'Pack tactics — advantage if an ally is within 5 ft of the target and not incapacitated.',
notes: 'Large beast, 200 XP each. Bite +5 (2d6+3 piercing, DC 13 Str or prone). Perception +3, Stealth +4. Use 3–4 at once — the clearest Action Economy lesson for a new table.',
},
{
name: 'Highcrown Cutthroat', challengeRating: '1/8', ac: 12, maxHp: 11, speed: 30,
strScore: 11, dexScore: 12, conScore: 12, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 3, damageDice: '1d6+1',
specialTrait: null,
notes: 'Medium humanoid, 25 XP each. Shortsword +3 (1d6+1 piercing), Dagger (thrown) +3, 20/60 (1d4+1 piercing). Ordinary desperate people — kill them, scare them off, or find out why they’re desperate?',
},
{
name: 'Bandit Captain', challengeRating: '2', ac: 15, maxHp: 65, speed: 30,
strScore: 15, dexScore: 16, conScore: 14, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 5, damageDice: '1d6+3',
specialTrait: 'Multiattack (two scimitar attacks).',
notes: 'Medium humanoid, 450 XP. Scimitar +5 (1d6+3 slashing), Dagger (thrown) +5, 20/60 (1d4+3 piercing). Leads a band of 4–6 cutthroats.',
},
{
name: 'Hollowed', challengeRating: '1', ac: 12, maxHp: 27, speed: 30,
strScore: 12, dexScore: 10, conScore: 12, intScore: 10, wisScore: 10, chaScore: 8, attackBonus: 3, damageDice: '1d6+1',
specialTrait: 'Almost right — DC 15 Insight notices the wrongness (no blink, a repeated phrase, no reflection) before it acts. Hollow whisper — 1/day speaks one sentence in Vessa Hollow’s voice to its target.',
notes: 'Medium humanoid, 200 XP. HORROR register. Slam +3 (1d6+1 bludgeoning); on a hit DC 12 Wis save or frightened 1 round. Deception +3 while passing as themselves. The horror is in the roleplay, not the numbers.',
},
{
name: 'Bellcrawler', challengeRating: '2', ac: 14, maxHp: 32, speed: 30,
strScore: 12, dexScore: 15, conScore: 16, intScore: 3, wisScore: 12, chaScore: 4, attackBonus: 5, damageDice: '2d6+2',
specialTrait: 'Mimic bell-toll — lures the curious. Stillness — motionless and silent the instant it’s observed; DC 17 Perception to spot while still.',
notes: 'Small monstrosity, 450 XP. HORROR register. Climb 30 ft. Bite +5 (2d6+2 piercing), Skitter (disengage as a bonus action). Stealth/dread, not a straight fight.',
},
{
name: 'Root Stalker', challengeRating: '4', ac: 15, maxHp: 78, speed: 30,
strScore: 17, dexScore: 14, conScore: 13, intScore: 6, wisScore: 14, chaScore: 6, attackBonus: 6, damageDice: '2d8+3',
specialTrait: 'Silent approach — advantage on its first attack each combat. Grasping roots — on a hit, target restrained (DC 14 Str to escape) until the Stalker is damaged.',
notes: 'Large aberration, 1,100 XP. HORROR register — signature Aldenfell monster. Burrow 20 ft. Multiattack (two Root Lash +6, 2d8+3 bludgeoning). Tremorsense 60 ft (blind beyond), passive Perception 12. Run as a slow, dreadful stalk.',
},
{
name: 'The Hollow King’s Shadow', challengeRating: '7', ac: 14, maxHp: 90, speed: 30,
strScore: 10, dexScore: 14, conScore: 10, intScore: 16, wisScore: 16, chaScore: 18, attackBonus: 8, damageDice: '3d8',
specialTrait: 'Not yet real — at 0 HP it disperses for the scene, never dies. Echoing dread — starting a turn within 30 ft: DC 15 Wis or frightened. Passes through walls.',
notes: 'Large undead (incorporeal), 2,900 XP but uncounted — DO NOT run as a fair fight. Fly 30 ft (hover). Chilling touch +8 (3d8 necrotic). Treat HP as a ticking clock: interrupt, banish, or outlast it (close a ritual circle, reach sunlight/a warded threshold, survive N rounds). Save its true, killable form for Act 4.',
},
{
name: 'Skeleton', challengeRating: '1/4', ac: 13, maxHp: 13, speed: 30,
strScore: 10, dexScore: 14, conScore: 15, intScore: 6, wisScore: 8, chaScore: 5, attackBonus: 4, damageDice: '1d6+2',
specialTrait: 'Undead — vulnerable to bludgeoning',
notes: 'Ordinary restless dead for the Sunken Vaults’ Restless Ossuary (Room 4). Not tied to the Hollow King; DC 12 Religion identifies that proper deconsecration would settle them.',
},
];
// ---------------------------------------------------------------------------
// Encounters — pre-built (PLANNING) for the combat beats of the Campaign Book.
// Each participant is [monster name, count]; instances get "#n" suffixes.
// ---------------------------------------------------------------------------
const encounters = [
{ name: 'E3 — Sunken Vaults: The Restless Ossuary', monsters: [['Skeleton', 2]] },
{ name: 'E6 — The Bell-Ringer’s Debt: Collectors', monsters: [['Highcrown Cutthroat', 3]] },
{ name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]] },
{ name: 'Side Quest — The Warden’s Silence: Direwolf Pack', monsters: [['Greywood Direwolf', 3]] },
{ name: 'Side Quest — The Warden’s Silence: The Shrine', monsters: [['Hollowed', 1]] },
{ name: 'Side Quest — What the Tide Brought In: The Reef', monsters: [['Reef Terror', 1]] },
{ name: 'E9 — The Root: Weeping Passage', monsters: [['Bellcrawler', 1]] },
{ name: 'E9 — The Root: Hollowed Nest', monsters: [['Hollowed', 2]] },
{ name: 'E9 — The Root: Root Stalker’s Warren', monsters: [['Root Stalker', 1]] },
{ name: 'E9 — The Root: Bellcrawler Gallery', monsters: [['Bellcrawler', 3]] },
{ name: 'E12 — A Line Thins', monsters: [['Root Stalker', 1]] },
{ name: 'E14 — The Break', monsters: [['Hollowed', 4]] },
{ name: 'Mid-campaign scare — The Hollow King’s Shadow', monsters: [['The Hollow King’s Shadow', 1]] },
{ name: 'E25 — The Last Approach', monsters: [['Root Stalker', 1], ['Bellcrawler', 2]] },
];
// ---------------------------------------------------------------------------
// Notes. Listed in reading order; the seed timestamps them so the first entry
// shows at the top of the campaign's note list.
// ---------------------------------------------------------------------------
const sharedNotes = [
`Welcome to Aldenfell — Player Primer
You’re a stranger arriving in Highcrown, the capital, right as the yearly Founding Fair kicks off — a good excuse for adventurers from every corner of the kingdom to cross paths in one place.
The setting: Aldenfell is a kingdom of knightly orders, old noble houses, and a royal court that’s held things together for generations. To the west, the ancient Greywood forest keeps its own secrets and its own guardians. To the north and east, the mountains of the Spine are home to giant clans and dragons who are neighbors and political players, not just monsters to slay. The Salt Coast to the south is full of independent-minded fishing towns and rumors of things in the water. A world built for classic adventure — quests, courts, monsters, treasure — with real weight behind the choices you make.
A note on tone: most of this campaign plays like a bright, exciting fantasy adventure. Every so often, especially if you go looking underground, it gets genuinely unsettling. You’ll always get a clear signal before things turn scary, so lean in when it happens.
Building your character: we’ll roll to discover your species and class, then roll your ability scores. Knights, wandering scholars, forest-raised rangers, self-taught spellcasters — everything has a place here.`,
];
const dmNotes = [
`Overview & Tone
Aldenfell looks whole from the road and is rotting from underneath. Knights ride under bright banners, dragons nest in the high peaks, the court still feasts — but something old and hungry is stirring beneath the capital, and every faction is maneuvering around that fact instead of dealing with it. The party arrives as the cracks start showing.
Tone pillars (in priority order):
• Heroic adventure first — the classic fantasy toybox, played straight and fun.
• Real consequences — choices stick; burned bridges stay burned; stalling on the central threat visibly worsens the world. Not punishing, just earned.
• Horror as a spice — surface world plays bright; horror spikes in telegraphed places (the Hollow Below). Always signal before a scene turns scary.
Tone dial (out of 5): Heroism 5 · Intrigue 3 · Combat 4 · Horror 3 (5 in the Underworld) · Comedy 2.
Regions: Highcrown (capital, home base) · The Greywood (old forest, fey, Wardens) · The Spine (dragons & giants) · The Salt Coast (smuggling, sea monsters) · The Bramble Marches (contested border) · The Hollow Below (underneath everything).`,
`The Central Conflict — Campaign Clock
Something sealed beneath the capital centuries ago — the Hollow King — is waking. It doesn’t attack at first; it corrupts. Use this as a VISIBLE clock; it advances only between major story beats.
Stage 1 — Omens: livestock/crops fail near old shrines; shared identical nightmares in Highcrown; minor nobles acting strangely loyal to no one. (Session One sits here.)
Stage 2 — Cracks: the boundary thins in mappable places; monsters surface near cracks; a Bramble Marches faction goes quiet; the corrupted noble house is revealed.
Stage 3 — Open wound: a crack becomes a breach; a town/district is lost or quarantined; the crown publicly acknowledges it and fractures; factions jockey for power.
Stage 4 — The Hollow King wakes: endgame, shaped by what the party did in stages 1–3.
Real consequences: an ally lost in stage 2 stays lost; a faction alienated in stage 1 won’t help in stage 3; a region left to fall stays changed.
Branch points to track: faction(s) sided with · named NPCs alive/allied/dead/estranged · regions with lasting damage · how much the public knows by Act 3.`,
`Factions
• The Crown — King Aldric Veyrmont, aging, cornered, in denial. Heir Princess Isolde Veyrmont: capable, well-liked, quietly building her own circle — a strong ally for the party.
• Order of the Grey Dawn — premier knightly order sworn to the crown. Split between traditionalists and a faction that thinks the court is hiding something. “You’re recruited” is a clean session-one hook.
• The Merchant Concord — trade coalition running Highcrown’s economy. Neutral, pragmatic, pays for side quests, quick to abandon anyone bad for business.
• Wardens of the Greywood — rangers/druids who answer to no crown. Suspicious, not hostile; first to notice the cracks near the old shrines.
• Spine Clans & dragon eyries — several giant clans and named dragons treating Aldenfell as a neighboring power. “Talk your way out of this” and “earn an ally” scenes.
• A corrupted noble house — pick a minor house NOW; don’t reveal until Stage 2. Let their behavior be subtly off from session one.`,
`Notable NPCs
• King Aldric Veyrmont — ruler. Tired, proud, in denial. Pays well for quiet problem-solving; reacts badly to blunt truth.
• Princess Isolde Veyrmont — heir. Sharp, pragmatic. Best long-term ally; recruits capable outsiders on purpose.
• Ser Kavan Thorne — Grey Dawn knight-commander. Rigid, honorable, loyal to a fault. Recruits the party; struggles when evidence points at the crown.
• Mireille Costa — Merchant Concord rep. Charming, transactional, always has a job.
• Ashwen — Warden of the Greywood. Terse, watchful. First to notice the shrines; a slow-burn ally.
• Threndir Ashclaw — old, weary copper dragon of the Spine. Blunt, wants the kingdom to survive. Talks before fighting.
• Korgath Stonejaw — giant clan chieftain. Honor-bound, no patience for politics. Trials, contests, bargains.
• Vessa Hollow — a voice, not yet a face. Calm, almost kind, wrong. Use in dreams/omens long before she appears.
• Corporal Ressa Vane — Grey Dawn warden escort (E9C); reports everything back to Grey Dawn Hall.
• The bell-ringer — rescued in Session One; returns in The Bell-Ringer’s Debt.`,
`DM SECRETS & TWISTS (eyes only)
• What the Hollow King is: the accumulated, imprisoned will of everyone Highcrown’s founders sacrificed to secure their claim to the land. The seal is a suppression of many, not a prison for one. The resolution is about what the party does with that truth (bury it, expose it, make amends) — not just a sword fight.
• Vessa Hollow: one of the sacrificed, the Hollow King’s sympathetic “face.” She believes waking the rest is justice. Unsettling, never mustache-twirling. Negotiating with her is a legitimate outcome.
• The corrupted noble house: choose whichever minor house you introduced most casually in Act 1. Their head bargained with Vessa years ago for prosperity and believes they’re managing the influence rather than being used.
• Ser Kavan Thorne: the Grey Dawn’s founding is tied to the original sacrifice. Thorne doesn’t know. Whether/how the party tells him is theirs to choose.
• Princess Isolde: already suspects something is deeply wrong; most likely to believe the party early.`,
`Session One — The Bell That Wasn’t Ringing
Setup: all PCs are in Highcrown for the Founding Fair. Build characters at the table (Roll-Your-Character System) — can be session one’s first half.
Hook: the old chapel bell, which has rung every hour for as long as anyone remembers, stops. A city guard (or Ser Kavan Thorne) gathers volunteers to check the chapel.
What they find: no break-in. The bell rope is neatly cut and left coiled. The bell-ringer is missing, not dead. A line of unfamiliar script is scratched inside the bell (Hollow King iconography). A frightened local saw “a woman who wasn’t blinking.” The trail leads to the Sunken Vaults beneath the chapel.
Light dungeon crawl: 2–4 easy/medium encounters (feral guard dog, low-level undead) — nothing horror-register yet. The bell-ringer is found alive but shaken, having seen “someone who looked like his neighbor, except wrong.”
Ending: the bell rings again, the fair goes on, nothing is solved. They leave with a taste of combat, one unanswered mystery, and at least one NPC connection.`,
`Act 1: Omens — E1–E8
E1 Founding Fair (Lantern Market): character creation, no combat → E2.
E2 The Bell That Wasn’t Ringing (Founding Chapel): DC 13 Investigation finds the cut rope & script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking.”
• E2A investigate immediately: full clues; skip Vault Room 1.
• E2B distracted first: guard missed the script; DC 13 Investigation to find it; start Room 1.
• E2C talk to the crowd first: 3 witness accounts, no script lead; E10 Insight later at advantage.
E3 The Sunken Vaults: run Vault Rooms 1–6; bell-ringer found in Room 5 → E4.
E4 Aftermath & Faction Hooks (all open doors, can run in parallel):
• E4A Grey Dawn recruitment → Grey Dawn ending track.
• E4B Concord job board → The Smugglers’ Tithe.
• E4C Warden contact → The Warden’s Silence.
• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.
• E4E Investigate independently (DC 15 Investigation) → Independent track.
E5 Omens Montage: 2–3 short scenes across sessions (shrine livestock, shared nightmares — DC 12 Insight, the odd noble).
E6 The Bell-Ringer’s Debt (optional side quest).
E7 A Closer Look: investigating the noble house hits dead ends — unless E2C, then DC 13 Insight catches a servant who flinched.
E8 Turning the Page: after 2+ E4 threads and one E5 omen, advance clock to Stage 2 → E9.`,
`Act 2: Cracks — E9–E13
E9 First Descent (The Root, Weeping Passage). Signal the tone shift. DC 14 Survival notices the dripping is a Hollowed’s footsteps.
• E9A retreat: keep what they learned, full rest before re-entry.
• E9B push through alone: Root Rooms 1–4; Weeping Passage marking matches the bell script.
• E9C (took E4A): Corporal Ressa Vane escorts, skips to Room 2 — but reports back (matters at E10B, E15).
• E9D (won Trial of Stone): Spine guide enters via Room 4; hears the legend of “the sleeper under the hollow crown”; DC 13 Survival or wake the Root Stalker at the entrance.
E10 The House Unmasked (Highcrown). Ledger: every shrine “donation” falls three days after a livestock death.
• E10A confront directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.
• E10B report to crown/Isolde/Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).
• E10C (E2C + servant thread): private DC 13 check at advantage → confession + sealed letter; the house never learns it’s suspected. Best route into Act 3.
E11 Silence on the Border (Greyford Keep): gate open, no bodies. DC 13 Perception: drag marks — the defenders left. Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): clans already there, share Root Stalker migration intel. Treasure: 2d6×10 gp trade goods + masterwork weapon.
E12 A Line Thins: a Root Stalker surfaces near a location the party didn’t secure. DC 15 Arcana/Religion: deliberate erosion from below. Key item: root-fiber scrap (needed for E14/E18).
E13 The Clock Turns → Stage 3.`,
`Act 3: Open Wound — E14–E20
E14 The Break: the E12 crack becomes a breach; 2–4 Hollowed pour out. DC 15 Arcana: shaped for something to come through.
• E14A seal it (DC 17 Arcana or chapel ritual): closes, but a debt comes due at E17.
• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.
Key item: seal-stone fragment (needed at E19).
E15 The House Falls (or Doesn’t):
• E15A (after E10A): house prepared — DC 18 to break the cover story, or fight guards + a Hollowed retainer.
• E15B (after E10B): public tribunal — DC 16 Persuasion/Insight for exile over execution.
• E15C (after E10C): strike the cult infrastructure directly → E15C-1 capture a cult lieutenant who reveals the Hollow Court’s surface entrance.
E16 What the Marches Cost (Greyford Keep):
• E16A reinforced (E11C): the keep holds; Spine standing rises.
• E16B under-supplied: the keep falls; a named refugee NPC dies unless DC 16 Athletics/Survival rescue.
E17 The Debt Comes Due: pay off E14A, unresolved side quests, or ignored omens — or a quiet breather.
E18 Into the Hollow Court: Court Rooms 1–3. E18A standard entry via the Root’s Descent; E18B (lieutenant captured) skip to Room 2.
E19 Vessa’s Seat (Court Room 4): the party learns the Hollow King’s true nature from Vessa. Root-fiber + seal-stone (DC 15 Arcana, auto with a Religion-proficient caster) → advantage on the first check at E22.
E20 The Clock Turns Again → Stage 4.`,
`Act 4: The Hollow King Wakes — E21–E27
E21 Rally Point (Highcrown or Greyford): who shows up depends on the branch list.
• E21A Grey Dawn track: Kavan + wardens, by-the-book.
• E21B Spine Clans track: war-band; advantage on the first Survival check at E22.
• E21C Independent: no reinforcements; every choice stays theirs.
• Multiple tracks: groups arrive and disagree — the party mediates.
Treasure: a minor magic item as a going-away gift.
E22 The Seal Chamber (Court Room 5) — the key branch:
• E22A reinforce (DC 18 Arcana/Religion): Sealed Ending; the seal-stone becomes a unique magic item.
• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.
• E22C do nothing: seal fails anyway; E24–E25 are harder.
E23 The Waking Begins (Court Room 6): E22B = no surprise, full resources; E22C = apply one escalation stage first.
E24 What the Court Remembers: vision encounter; DC 14 Insight recognizes manipulation vs truth.
E25 The Last Approach: gauntlet of 2–3 horror-register creatures; allied faction fights alongside.
E26 The Hollow King: build the stat block from DM Secrets, scaled to party. Grey Dawn = disciplined line; Spine Clans = terrain & ritual (collapse passages, the Memory Pool); Independent = hardest, entirely theirs.
E27 Endings: Sealed (bought time) · Grey Dawn (institution absorbs the heroes) · Spine Clans (credit finally given) · Independent (their own story). Close back at the Lantern Market, a year later.`,
`Side Quest — The Smugglers’ Tithe
Salt Coast (Tidewatch, Smuggler’s Cove) · Lv 1–3 · Highcrown Cutthroats, Bandit Captain
Hook: Mireille Costa hires the party to learn why a cut of Tidewatch’s smuggled goods is vanishing before reaching buyers.
Truth: a Bandit Captain is extorting the smugglers and funneling the “tithe” to arm one side of the Bramble Marches conflict.
Beats: investigate Tidewatch (smugglers distrust outsiders) → track a shipment to Smuggler’s Cove → confront the crew (fight, negotiate, or both).
Outcomes: end it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward). Decides whether Greyford is supplied at E11/E16.`,
`Side Quest — The Warden’s Silence
The Greywood (Warden’s Lodge, an Old Shrine) · Lv 1–2 · Greywood Direwolves, one Hollowed
Hook: Ashwen asks the party to check on a Warden who stopped reporting in from a shrine site.
Truth: the Warden was Hollowed after investigating the shrine’s crack alone — the party’s first, sympathetic Hollowed.
Beats: track the Warden → direwolf pack on the way (agitated, not evil) → the Hollowed Warden at the shrine; DC 15 Insight reveals the wrongness.
Outcomes: fight, or restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced something is deeply wrong.`,
`Side Quest — Trial of Stone
The Spine (Stonejaw Hold) · Lv 2–4 · Korgath Stonejaw’s Hill Giants
Hook: the party needs Korgath’s clan as an ally; he offers a trial instead of agreement.
Truth: nothing sinister — a genuine test of character.
Trials (per PC or shared): non-lethal wrestling with a giant; a test of honesty; proving their word is good (retrieve something, deliver a message honestly).
Beats: arrive → formal challenge → trial → feast on success.
Outcomes: success = standing alliance (Spine Clans track). Failure isn’t fatal — Korgath offers a second, harder trial.`,
`Side Quest — The Bell-Ringer’s Debt
Highcrown · Lv 1–2 · Highcrown Cutthroats · Follows Session One
Hook: the rescued bell-ringer owes a Highcrown underworld lender; interest is compounding fast.
Truth: the lender operates under the corrupted noble house’s protection (not knowable yet).
Beats: hear him out → track the collectors → pay the debt, intimidate the lender, or dig into who backs the lender (harder, more rewarding).
Outcomes: a loyal low-key ally either way. Digging plants a connect-the-dots clue toward the corrupted house before Act 2.`,
`Side Quest — Refugees of the Marches
Bramble Marches (Greyford Keep, the Sorrow Camp) · Lv 2–4 · Cutthroats or a Bandit Captain · The clearest moral-choice quest
Hook: the Sorrow Camp is overwhelmed and preyed on by aid-skimmers; Greyford’s commander wants the camp “moved along.”
Truth: no clean villain — the commander isn’t wrong about the strategic liability; the refugees have nowhere else to go.
Beats: tension between camp and garrison → find who’s skimming aid → confront them → broker (or fail to broker) an arrangement.
Outcomes: genuinely branching — lasting goodwill, or a hardened Marches conflict. Introduce a named refugee NPC here (at risk at E16B).`,
`Side Quest — What the Tide Brought In
Salt Coast (Tidewatch) · Lv 2–3 · Reef Terror (reskinned Spine Wyvern) · Horror-lite
Hook: a fishing boat washes up empty — sails intact, catch in the hold, crew gone.
Truth: NOT connected to the Hollow King (a deliberate palate-cleanser). A territorial sea creature drags fishers under near a reef.
Beats: DC 13 Investigation finds claw marks, not violence → spooked fishers give the reef’s location → confront at sea or lure to shallows.
Outcomes: safe fishing reopens and real goodwill in Tidewatch — an uncomplicated “you helped real people” win.`,
`Dungeon — Sunken Vaults (Layer 1)
1. The Collapsed Stair (entry) — spiral stair, rusted gate on one hinge. No monster. DC 12 Perception: fresh footprints (the bell-ringer’s). → Room 2.
2. The Old Crypt Hall — hub; sealed niches, some open. DC 13 Investigation: just old theft. Good spot for a feral dog or low undead. Treasure: silver locket 15 gp (DC 12). → 3, 4, stair to 6.
3. Smugglers’ Cache — crates with an unknown merchant sigil. DC 14 Investigation ties it to a lender linked to the corrupted house. Treasure: ~40 gp trade goods + ledger page with partial name. Dead end.
4. The Restless Ossuary — 1–2 skeletons rise halfway through (ordinary dead). DC 12 Religion: deconsecration would settle them. Treasure: offering box (DC 13) with 25 gp. Dead end.
5. The Bell-Ringer’s Refuge — bedroll, cold lantern. The bell-ringer is found here in E3; otherwise a squatter’s camp. Branch off Room 2 or 6.
6. The Descent — ragged hole, wrong cold air. Transition to the Root; signal the tone shift. The party can always turn back. → Root Room 1.`,
`Dungeon — The Root (Layer 2)
1. The Threshold — almost-regular dripping. Nothing attacks. DC 15 Perception: the rhythm changes when someone speaks. Retreat always safe. → 2.
2. The Weeping Passage — walls “weep.” E9B: marking matches the bell script (DC 16 Arcana/Religion). Maybe one Bellcrawler using Stillness (DC 17 Perception). → 3 (east), 4 (south).
3. Hollowed Nest — tidy cavern, bedrolls. 1–2 Hollowed; DC 15 Insight before combat. Maybe a named missing person. Treasure: ~20 gp effects + (DC 14) half-burned letter hinting at the corrupted house. → 2, 5.
4. Root Stalker’s Warren — moving roots. Root Stalker lair; DC 14 Perception or its first attack has advantage. Treasure: 60 gp + masterwork dagger (50 gp). Dead end (optional secret passage to 6).
5. The Drowned Shrine — pre-kingdom altar in black water. DC 16 History/Religion: older than Aldenfell. Treasure: silver relic 75 gp (DC 13 Athletics/Investigation). → 2, crawl to 6.
6. Bellcrawler Gallery — ledges over a drop; bell-toll lure. 2–3 Bellcrawlers; DC 15 Perception; DC 13 Dex or fall 2d6. Treasure: 90 gp + potion of healing. → 2, 7.
7. The Descent to the Hollow Court — a stair too regular to exist. Off-limits before Act 4; let them see it and turn back. → Court Room 1.`,
`Dungeon — The Hollow Court (Layer 3)
1. The Bent Stair — the stair disagrees with itself. DC 15 Wis save or lose track of time (optionally lose 10 minutes). No monster. → 2.
2. The Memory Pool — each PC sees a personal vision tied to their backstory/choices (payoff for the branch list). No damage; can choose not to look. DC 17 Insight to understand it. → 3.
3. The Court of Whispers — overlapping voices; Vessa may speak plainly here. DC 16 Insight: she believes what she says. Any combat uses Hollowed guardians, not Vessa. → 4.
4. Vessa’s Seat — a single worn stone seat. Meet or confront Vessa; strong candidate for a negotiated outcome. → 5.
5. The Seal Chamber — a wound stitched shut, coming undone. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation. Treasure: the campaign’s single most significant magic item. → 6.
6. The Waking — the end of the dungeon. Don’t decide its contents until Act 3 is complete.`,
`DM Tables — Skill DCs, Treasure, Downtime
DCs: Very Easy 5 · Easy 10 · Medium 15 · Hard 20 · Very Hard 25 · Nearly Impossible 30.
Recurring: spot a Hollowed (Insight 15) · Hollow Below wrongness (Perception 14–17) · Hollow King iconography (Religion/Arcana 16) · read a political room (Insight 13) · talk down a dragon/giant (Persuasion 15–18) · track (Survival 13) · simple lock/trap (12) · recall lore (History 14) · haggle (Persuasion 12) · resist horror fear (Wis save 14–15).
Treasure Lv 1–4 (d10): 1 2d10×5 sp · 2 1d6×10 gp · 3 trade good 10–30 gp · 4 masterwork weapon/tool 50 gp · 5 2d6×10 gp · 6 NPC keepsake · 7 potion of healing · 8 1d4×25 gp gems · 9 1st-level scroll · 10 minor magic item.
Treasure Lv 5–8 (d10): 1 2d6×25 gp · 2 art object 50–150 gp · 3 masterwork armor · 4 1d6×50 gp gems · 5 greater healing · 6 2nd/3rd-level scroll · 7 uncommon magic item · 8 3d6×25 gp · 9 faction reward · 10 rare magic item.
Treasure Lv 9+ (d8): 1 3d6×50 gp · 2 rare item · 3 very rare item · 4 relic 500+ gp · 5 4th/5th-level scroll · 6 faction favor · 7 supreme healing · 8 DM’s choice.
Downtime (~1 week each): Carousing (1d6×10 gp, DC 12 Persuasion for a rumor/contact) · Crafting (half price in materials) · Training (~25 gp/week) · Faction work (standing, not gold) · Research (Sunken Library or faction records, DC 15 Investigation/History for one clue) · Recuperation (roleplay closure).`,
`DM Tables — Rumors
Act 1 (d8): 1 “The bell stopped once before, generations back.” (Misleading — never happened) · 2 “Old man Farrow’s cows went dry past the shrine road.” (True) · 3 “Princess Isolde’s been asking sharp questions of the temple scholars.” (True) · 4 “Ser Kavan turned away a recruit for asking about the crown.” (False) · 5 “There’s gold in the old crypts under the city.” (Misleading — modest and dangerous) · 6 “A noble house has been paying off debts all over the city.” (True — the corrupted house) · 7 “The Wardens haven’t been seen at the eastern shrine in weeks.” (True) · 8 “Something’s wrong with the water past Tidewatch.” (True — What the Tide Brought In)
Act 2 (d8): 1 “Salt Coast smugglers work for someone bigger now.” (True) · 2 “A Warden went missing near the shrines.” (True/resolved) · 3 “Something down there talked back.” (True) · 4 “That noble house is buying property near the docks.” (Misleading — the reason is worse than greed) · 5 “The King’s been ill.” (False — cornered, in denial) · 6 “The Spine dragons are stirring — war’s coming.” (False) · 7 “Refugees are pouring into the Marches.” (True) · 8 “A missing person was seen acting strange, then vanished.” (True — Hollowed)
Act 3–4 (d6): 1 “The crown’s lost control of [district].” (True) · 2 “Isolde will move against her father.” (Misleading) · 3 “The old families always knew what’s under the city.” (True) · 4 “A faction says the party caused all this.” (Depends on reputation) · 5 “The sky over Highcrown is different at night.” (True) · 6 “It’s almost over, one way or another.” (True)`,
`DM Tables — Random Encounters by Region (d6)
Highcrown: 1 Cutthroat pickpocket (DC 13 Perception) · 2 Grey Dawn patrol · 3 Concord merchant with a small job · 4 street preacher of omens (free rumor) · 5 quiet day · 6 a noble retinue — DC 12 Insight notices something off if it’s the corrupted house.
The Greywood: 1 1d3 Greywood Direwolves · 2 Warden patrol · 3 unmarked old shrine · 4 harmless fey mischief · 5 traveler in trouble · 6 quiet — chance for a long rest.
The Spine: 1 Spine Wyvern (avoidable) · 2 Stonejaw trading party · 3 signs of dragons · 4 treacherous pass (DC 14 Athletics or detour) · 5 a curious lone giant · 6 empty country.
The Salt Coast: 1 smugglers wary of witnesses · 2 short-handed fishing crew · 3 storm (DC 12 Survival for shelter) · 4 rumor of something in the water · 5 petty lord’s toll collector · 6 calm seas.
The Bramble Marches: 1 Bandit Captain’s crew · 2 refugees on the road · 3 tense border patrol · 4 signs of the neighboring power · 5 a skirmish in progress · 6 watchful quiet.`,
];
module.exports = { campaign, locations, monsters, encounters, sharedNotes, dmNotes };
+12 -12
View File
@@ -1,14 +1,14 @@
[
{ "key": "FIGHTER", "name": "Fighter", "hitDie": 10, "primaryAbility": "Str/Dex", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null },
{ "key": "BARBARIAN", "name": "Barbarian", "hitDie": 12, "primaryAbility": "Str", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null },
{ "key": "ROGUE", "name": "Rogue", "hitDie": 8, "primaryAbility": "Dex", "saveProficiencies": ["DEX", "INT"], "spellcastingAbility": null },
{ "key": "RANGER", "name": "Ranger", "hitDie": 10, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": "WIS" },
{ "key": "WIZARD", "name": "Wizard", "hitDie": 6, "primaryAbility": "Int", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "INT" },
{ "key": "SORCERER", "name": "Sorcerer", "hitDie": 6, "primaryAbility": "Cha", "saveProficiencies": ["CON", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "CLERIC", "name": "Cleric", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "WIS" },
{ "key": "PALADIN", "name": "Paladin", "hitDie": 10, "primaryAbility": "Str/Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "BARD", "name": "Bard", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["DEX", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "WARLOCK", "name": "Warlock", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "DRUID", "name": "Druid", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "WIS" },
{ "key": "MONK", "name": "Monk", "hitDie": 8, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": null }
{ "key": "FIGHTER", "name": "Fighter", "hitDie": 10, "primaryAbility": "Str/Dex", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null, "skillChoices": { "count": 2, "options": ["ACROBATICS", "ANIMAL_HANDLING", "ATHLETICS", "HISTORY", "INSIGHT", "INTIMIDATION", "PERCEPTION", "SURVIVAL"] } },
{ "key": "BARBARIAN", "name": "Barbarian", "hitDie": 12, "primaryAbility": "Str", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null, "skillChoices": { "count": 2, "options": ["ANIMAL_HANDLING", "ATHLETICS", "INTIMIDATION", "NATURE", "PERCEPTION", "SURVIVAL"] } },
{ "key": "ROGUE", "name": "Rogue", "hitDie": 8, "primaryAbility": "Dex", "saveProficiencies": ["DEX", "INT"], "spellcastingAbility": null, "skillChoices": { "count": 4, "options": ["ACROBATICS", "ATHLETICS", "DECEPTION", "INSIGHT", "INTIMIDATION", "INVESTIGATION", "PERCEPTION", "PERFORMANCE", "PERSUASION", "SLEIGHT_OF_HAND", "STEALTH"] } },
{ "key": "RANGER", "name": "Ranger", "hitDie": 10, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": "WIS", "skillChoices": { "count": 3, "options": ["ANIMAL_HANDLING", "ATHLETICS", "INSIGHT", "INVESTIGATION", "NATURE", "PERCEPTION", "STEALTH", "SURVIVAL"] } },
{ "key": "WIZARD", "name": "Wizard", "hitDie": 6, "primaryAbility": "Int", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "INT", "skillChoices": { "count": 2, "options": ["ARCANA", "HISTORY", "INSIGHT", "INVESTIGATION", "MEDICINE", "RELIGION"] } },
{ "key": "SORCERER", "name": "Sorcerer", "hitDie": 6, "primaryAbility": "Cha", "saveProficiencies": ["CON", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 2, "options": ["ARCANA", "DECEPTION", "INSIGHT", "INTIMIDATION", "PERSUASION", "RELIGION"] } },
{ "key": "CLERIC", "name": "Cleric", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "WIS", "skillChoices": { "count": 2, "options": ["HISTORY", "INSIGHT", "MEDICINE", "PERSUASION", "RELIGION"] } },
{ "key": "PALADIN", "name": "Paladin", "hitDie": 10, "primaryAbility": "Str/Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 2, "options": ["ATHLETICS", "INSIGHT", "INTIMIDATION", "MEDICINE", "PERSUASION", "RELIGION"] } },
{ "key": "BARD", "name": "Bard", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["DEX", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 3, "options": ["ACROBATICS", "ANIMAL_HANDLING", "ARCANA", "ATHLETICS", "DECEPTION", "HISTORY", "INSIGHT", "INTIMIDATION", "INVESTIGATION", "MEDICINE", "NATURE", "PERCEPTION", "PERFORMANCE", "PERSUASION", "RELIGION", "SLEIGHT_OF_HAND", "STEALTH", "SURVIVAL"] } },
{ "key": "WARLOCK", "name": "Warlock", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 2, "options": ["ARCANA", "DECEPTION", "HISTORY", "INTIMIDATION", "INVESTIGATION", "NATURE", "RELIGION"] } },
{ "key": "DRUID", "name": "Druid", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "WIS", "skillChoices": { "count": 2, "options": ["ARCANA", "ANIMAL_HANDLING", "INSIGHT", "MEDICINE", "NATURE", "PERCEPTION", "RELIGION", "SURVIVAL"] } },
{ "key": "MONK", "name": "Monk", "hitDie": 8, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": null, "skillChoices": { "count": 2, "options": ["ACROBATICS", "ATHLETICS", "HISTORY", "INSIGHT", "RELIGION", "STEALTH"] } }
]
+44 -3
View File
@@ -41,15 +41,52 @@ function rollDie(sides) {
return crypto.randomInt(1, sides + 1);
}
// Standard method: 4d6, drop the lowest, sum the rest.
function rollAbilityScore() {
const dice = Array.from({ length: 4 }, () => rollDie(6));
// Standard method: 4d6, drop the lowest, sum the rest. Shared by both the
// digital path (dice rolled here) and the physical path (dice typed in by
// the roller, validated by validateManualRoll and passed in already-rolled).
function abilityScoreFromDice(dice) {
const sorted = [...dice].sort((a, b) => a - b);
const dropped = sorted[0];
const total = sorted.slice(1).reduce((a, b) => a + b, 0);
return { dice, dropped, total };
}
function rollAbilityScore() {
const dice = Array.from({ length: 4 }, () => rollDie(6));
return abilityScoreFromDice(dice);
}
// Fisher-Yates-ish random pick of `count` distinct items from `options`,
// using the same crypto RNG as dice rolls (never Math.random for anything
// that ends up on a character sheet).
function randomSample(options, count) {
const pool = [...options];
const picked = [];
for (let i = 0; i < count && pool.length > 0; i++) {
const idx = crypto.randomInt(0, pool.length);
picked.push(pool.splice(idx, 1)[0]);
}
return picked;
}
// Auto-generates skill proficiencies for a class per its SRD "choose N from
// this list" rule (classes.js `skillChoices`) — replaces the manual
// checkbox pick the builder used to require.
function autoSelectSkills(characterClass) {
const data = characterClass ? CLASSES[characterClass] : null;
const choices = data ? data.skillChoices : null;
if (!choices) return [];
const picked = randomSample(choices.options, choices.count);
return picked.map((skill) => ({ skill, proficient: true, expertise: false }));
}
// Validates a physically-rolled die value a player types in against the
// step's real die range — physical-dice-mode's equivalent of trusting
// crypto.randomInt's output range for the digital path.
function validateManualRoll(sides, value) {
return Number.isInteger(value) && value >= 1 && value <= sides;
}
function speciesChoicesForRoll(roll) {
const entry = SPECIES_ROLL_TABLE[roll];
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_SPECIES };
@@ -71,6 +108,10 @@ module.exports = {
ALL_CLASSES,
rollDie,
rollAbilityScore,
abilityScoreFromDice,
speciesChoicesForRoll,
classChoicesForRoll,
randomSample,
autoSelectSkills,
validateManualRoll,
};
+69 -12
View File
@@ -1,19 +1,76 @@
// Generated from /seed-data/classes.json — keep in sync by hand if that file changes.
// See docs/GAME_RULES_REFERENCE.md "Class Quick Facts".
// skillChoices is the SRD "choose N from this list" skill-proficiency rule per
// class, used by buildFlow.js's autoSelectSkills() to auto-generate skill
// proficiencies during the character builder instead of a manual checkbox pick.
const ALL_SKILLS = [
'ACROBATICS', 'ANIMAL_HANDLING', 'ARCANA', 'ATHLETICS', 'DECEPTION', 'HISTORY',
'INSIGHT', 'INTIMIDATION', 'INVESTIGATION', 'MEDICINE', 'NATURE', 'PERCEPTION',
'PERFORMANCE', 'PERSUASION', 'RELIGION', 'SLEIGHT_OF_HAND', 'STEALTH', 'SURVIVAL',
];
const CLASSES = {
FIGHTER: { key: 'FIGHTER', name: 'Fighter', hitDie: 10, primaryAbility: 'Str/Dex', saveProficiencies: ['STR', 'CON'], spellcastingAbility: null },
BARBARIAN: { key: 'BARBARIAN', name: 'Barbarian', hitDie: 12, primaryAbility: 'Str', saveProficiencies: ['STR', 'CON'], spellcastingAbility: null },
ROGUE: { key: 'ROGUE', name: 'Rogue', hitDie: 8, primaryAbility: 'Dex', saveProficiencies: ['DEX', 'INT'], spellcastingAbility: null },
RANGER: { key: 'RANGER', name: 'Ranger', hitDie: 10, primaryAbility: 'Dex/Wis', saveProficiencies: ['STR', 'DEX'], spellcastingAbility: 'WIS' },
WIZARD: { key: 'WIZARD', name: 'Wizard', hitDie: 6, primaryAbility: 'Int', saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'INT' },
SORCERER: { key: 'SORCERER', name: 'Sorcerer', hitDie: 6, primaryAbility: 'Cha', saveProficiencies: ['CON', 'CHA'], spellcastingAbility: 'CHA' },
CLERIC: { key: 'CLERIC', name: 'Cleric', hitDie: 8, primaryAbility: 'Wis', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'WIS' },
PALADIN: { key: 'PALADIN', name: 'Paladin', hitDie: 10, primaryAbility: 'Str/Cha', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA' },
BARD: { key: 'BARD', name: 'Bard', hitDie: 8, primaryAbility: 'Cha', saveProficiencies: ['DEX', 'CHA'], spellcastingAbility: 'CHA' },
WARLOCK: { key: 'WARLOCK', name: 'Warlock', hitDie: 8, primaryAbility: 'Cha', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA' },
DRUID: { key: 'DRUID', name: 'Druid', hitDie: 8, primaryAbility: 'Wis', saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'WIS' },
MONK: { key: 'MONK', name: 'Monk', hitDie: 8, primaryAbility: 'Dex/Wis', saveProficiencies: ['STR', 'DEX'], spellcastingAbility: null },
FIGHTER: {
key: 'FIGHTER', name: 'Fighter', hitDie: 10, primaryAbility: 'Str/Dex',
saveProficiencies: ['STR', 'CON'], spellcastingAbility: null,
skillChoices: { count: 2, options: ['ACROBATICS', 'ANIMAL_HANDLING', 'ATHLETICS', 'HISTORY', 'INSIGHT', 'INTIMIDATION', 'PERCEPTION', 'SURVIVAL'] },
},
BARBARIAN: {
key: 'BARBARIAN', name: 'Barbarian', hitDie: 12, primaryAbility: 'Str',
saveProficiencies: ['STR', 'CON'], spellcastingAbility: null,
skillChoices: { count: 2, options: ['ANIMAL_HANDLING', 'ATHLETICS', 'INTIMIDATION', 'NATURE', 'PERCEPTION', 'SURVIVAL'] },
},
ROGUE: {
key: 'ROGUE', name: 'Rogue', hitDie: 8, primaryAbility: 'Dex',
saveProficiencies: ['DEX', 'INT'], spellcastingAbility: null,
skillChoices: { count: 4, options: ['ACROBATICS', 'ATHLETICS', 'DECEPTION', 'INSIGHT', 'INTIMIDATION', 'INVESTIGATION', 'PERCEPTION', 'PERFORMANCE', 'PERSUASION', 'SLEIGHT_OF_HAND', 'STEALTH'] },
},
RANGER: {
key: 'RANGER', name: 'Ranger', hitDie: 10, primaryAbility: 'Dex/Wis',
saveProficiencies: ['STR', 'DEX'], spellcastingAbility: 'WIS',
skillChoices: { count: 3, options: ['ANIMAL_HANDLING', 'ATHLETICS', 'INSIGHT', 'INVESTIGATION', 'NATURE', 'PERCEPTION', 'STEALTH', 'SURVIVAL'] },
},
WIZARD: {
key: 'WIZARD', name: 'Wizard', hitDie: 6, primaryAbility: 'Int',
saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'INT',
skillChoices: { count: 2, options: ['ARCANA', 'HISTORY', 'INSIGHT', 'INVESTIGATION', 'MEDICINE', 'RELIGION'] },
},
SORCERER: {
key: 'SORCERER', name: 'Sorcerer', hitDie: 6, primaryAbility: 'Cha',
saveProficiencies: ['CON', 'CHA'], spellcastingAbility: 'CHA',
skillChoices: { count: 2, options: ['ARCANA', 'DECEPTION', 'INSIGHT', 'INTIMIDATION', 'PERSUASION', 'RELIGION'] },
},
CLERIC: {
key: 'CLERIC', name: 'Cleric', hitDie: 8, primaryAbility: 'Wis',
saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'WIS',
skillChoices: { count: 2, options: ['HISTORY', 'INSIGHT', 'MEDICINE', 'PERSUASION', 'RELIGION'] },
},
PALADIN: {
key: 'PALADIN', name: 'Paladin', hitDie: 10, primaryAbility: 'Str/Cha',
saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA',
skillChoices: { count: 2, options: ['ATHLETICS', 'INSIGHT', 'INTIMIDATION', 'MEDICINE', 'PERSUASION', 'RELIGION'] },
},
BARD: {
key: 'BARD', name: 'Bard', hitDie: 8, primaryAbility: 'Cha',
saveProficiencies: ['DEX', 'CHA'], spellcastingAbility: 'CHA',
skillChoices: { count: 3, options: ALL_SKILLS },
},
WARLOCK: {
key: 'WARLOCK', name: 'Warlock', hitDie: 8, primaryAbility: 'Cha',
saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA',
skillChoices: { count: 2, options: ['ARCANA', 'DECEPTION', 'HISTORY', 'INTIMIDATION', 'INVESTIGATION', 'NATURE', 'RELIGION'] },
},
DRUID: {
key: 'DRUID', name: 'Druid', hitDie: 8, primaryAbility: 'Wis',
saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'WIS',
skillChoices: { count: 2, options: ['ARCANA', 'ANIMAL_HANDLING', 'INSIGHT', 'MEDICINE', 'NATURE', 'PERCEPTION', 'RELIGION', 'SURVIVAL'] },
},
MONK: {
key: 'MONK', name: 'Monk', hitDie: 8, primaryAbility: 'Dex/Wis',
saveProficiencies: ['STR', 'DEX'], spellcastingAbility: null,
skillChoices: { count: 2, options: ['ACROBATICS', 'ATHLETICS', 'HISTORY', 'INSIGHT', 'RELIGION', 'STEALTH'] },
},
};
module.exports = { CLASSES };
+7
View File
@@ -0,0 +1,7 @@
// Combined default-content library — see weaponsLibrary.js / spellsLibrary.js
// / monstersLibrary.js for the individual sets and their shape.
const { WEAPON_LIBRARY } = require('./weaponsLibrary');
const { SPELL_LIBRARY } = require('./spellsLibrary');
const { MONSTER_LIBRARY } = require('./monstersLibrary');
module.exports = { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY };
+47
View File
@@ -0,0 +1,47 @@
// Default monster library — a starter bestiary spanning CR 0 to CR 5,
// offered to any campaign so a DM isn't hand-typing common enemies from
// scratch. Shape matches the `Monster` Prisma model minus `id`/`campaignId`,
// plus a stable `key` for "add to campaign" lookups.
const MONSTER_LIBRARY = [
{ key: 'commoner', name: 'Commoner', challengeRating: '0', ac: 10, maxHp: 4, speed: 30, strScore: 10, dexScore: 10, conScore: 10, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 2, damageDice: '1d4', specialTrait: null, notes: 'An ordinary villager, armed with a club.' },
{ key: 'giant-rat', name: 'Giant Rat', challengeRating: '1/8', ac: 12, maxHp: 7, speed: 30, strScore: 7, dexScore: 15, conScore: 11, intScore: 2, wisScore: 10, chaScore: 4, attackBonus: 4, damageDice: '1d4+2', specialTrait: 'Pack Tactics', notes: 'Bite, piercing damage.' },
{ key: 'kobold', name: 'Kobold', challengeRating: '1/8', ac: 12, maxHp: 5, speed: 30, strScore: 7, dexScore: 15, conScore: 9, intScore: 8, wisScore: 7, chaScore: 8, attackBonus: 4, damageDice: '1d4+2', specialTrait: 'Pack Tactics, Sunlight Sensitivity', notes: 'Dagger, piercing damage.' },
{ key: 'bandit', name: 'Bandit', challengeRating: '1/8', ac: 12, maxHp: 11, speed: 30, strScore: 11, dexScore: 12, conScore: 12, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 3, damageDice: '1d6+1', specialTrait: null, notes: 'Scimitar, slashing damage.' },
{ key: 'cultist', name: 'Cultist', challengeRating: '1/8', ac: 12, maxHp: 9, speed: 30, strScore: 11, dexScore: 12, conScore: 10, intScore: 10, wisScore: 11, chaScore: 10, attackBonus: 3, damageDice: '1d6+1', specialTrait: 'Dark Devotion', notes: 'Scimitar, slashing damage.' },
{ key: 'stirge', name: 'Stirge', challengeRating: '1/8', ac: 12, maxHp: 2, speed: 10, strScore: 4, dexScore: 16, conScore: 11, intScore: 2, wisScore: 8, chaScore: 6, attackBonus: 5, damageDice: '1d4', specialTrait: 'Blood Drain', notes: 'Proboscis; attaches and drains hp on a hit.' },
{ key: 'goblin', name: 'Goblin', challengeRating: '1/4', ac: 15, maxHp: 7, speed: 30, strScore: 8, dexScore: 14, conScore: 10, intScore: 10, wisScore: 8, chaScore: 8, attackBonus: 4, damageDice: '1d6+2', specialTrait: 'Nimble Escape', notes: 'Scimitar, slashing damage.' },
{ key: 'skeleton', name: 'Skeleton', challengeRating: '1/4', ac: 13, maxHp: 13, speed: 30, strScore: 10, dexScore: 14, conScore: 15, intScore: 6, wisScore: 8, chaScore: 5, attackBonus: 4, damageDice: '1d6+2', specialTrait: 'Undead — vulnerable to bludgeoning', notes: 'Shortsword, piercing damage.' },
{ key: 'zombie', name: 'Zombie', challengeRating: '1/4', ac: 8, maxHp: 22, speed: 20, strScore: 13, dexScore: 6, conScore: 16, intScore: 3, wisScore: 6, chaScore: 5, attackBonus: 3, damageDice: '1d6+1', specialTrait: 'Undead Fortitude', notes: 'Slam, bludgeoning damage.' },
{ key: 'wolf', name: 'Wolf', challengeRating: '1/4', ac: 13, maxHp: 11, speed: 40, strScore: 12, dexScore: 15, conScore: 12, intScore: 3, wisScore: 12, chaScore: 6, attackBonus: 4, damageDice: '2d4+2', specialTrait: 'Pack Tactics', notes: 'Bite; can knock prone on a hit.' },
{ key: 'giant-wolf-spider', name: 'Giant Wolf Spider', challengeRating: '1/4', ac: 13, maxHp: 11, speed: 40, strScore: 12, dexScore: 16, conScore: 13, intScore: 3, wisScore: 12, chaScore: 4, attackBonus: 3, damageDice: '1d6+1', specialTrait: 'Poison bite', notes: 'Spider climb; bite deals poison damage.' },
{ key: 'scout', name: 'Scout', challengeRating: '1/2', ac: 13, maxHp: 16, speed: 30, strScore: 11, dexScore: 14, conScore: 12, intScore: 11, wisScore: 13, chaScore: 11, attackBonus: 4, damageDice: '1d6+2', specialTrait: 'Keen Hearing and Sight', notes: 'Shortsword or longbow.' },
{ key: 'orc', name: 'Orc', challengeRating: '1/2', ac: 13, maxHp: 15, speed: 30, strScore: 16, dexScore: 12, conScore: 16, intScore: 7, wisScore: 11, chaScore: 10, attackBonus: 5, damageDice: '1d12+3', specialTrait: 'Aggressive', notes: 'Greataxe, slashing damage.' },
{ key: 'hobgoblin', name: 'Hobgoblin', challengeRating: '1/2', ac: 18, maxHp: 11, speed: 30, strScore: 13, dexScore: 12, conScore: 12, intScore: 10, wisScore: 10, chaScore: 9, attackBonus: 3, damageDice: '1d8+1', specialTrait: 'Martial Advantage', notes: 'Longsword, slashing damage.' },
{ key: 'thug', name: 'Thug', challengeRating: '1/2', ac: 11, maxHp: 32, speed: 30, strScore: 15, dexScore: 11, conScore: 14, intScore: 10, wisScore: 10, chaScore: 11, attackBonus: 4, damageDice: '1d6+2', specialTrait: 'Pack Tactics', notes: 'Mace, bludgeoning damage.' },
{ key: 'gnoll', name: 'Gnoll', challengeRating: '1/2', ac: 15, maxHp: 22, speed: 30, strScore: 14, dexScore: 12, conScore: 11, intScore: 6, wisScore: 10, chaScore: 7, attackBonus: 3, damageDice: '1d8+1', specialTrait: 'Rampage', notes: 'Spear, piercing damage.' },
{ key: 'lizardfolk', name: 'Lizardfolk', challengeRating: '1/2', ac: 15, maxHp: 22, speed: 30, strScore: 15, dexScore: 10, conScore: 13, intScore: 7, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+1', specialTrait: 'Hold Breath', notes: 'Club or spear, piercing damage.' },
{ key: 'black-bear', name: 'Black Bear', challengeRating: '1/2', ac: 11, maxHp: 19, speed: 40, strScore: 15, dexScore: 10, conScore: 14, intScore: 2, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+2', specialTrait: 'Keen Smell', notes: 'Bite and claw attacks.' },
{ key: 'giant-spider', name: 'Giant Spider', challengeRating: '1', ac: 14, maxHp: 26, speed: 30, strScore: 14, dexScore: 16, conScore: 12, intScore: 2, wisScore: 11, chaScore: 4, attackBonus: 5, damageDice: '1d8+3', specialTrait: 'Web, poison bite', notes: 'Bite deals piercing plus poison damage.' },
{ key: 'ghoul', name: 'Ghoul', challengeRating: '1', ac: 12, maxHp: 22, speed: 30, strScore: 13, dexScore: 15, conScore: 10, intScore: 7, wisScore: 10, chaScore: 6, attackBonus: 2, damageDice: '2d6+2', specialTrait: 'Claws paralyze on a hit', notes: 'Undead.' },
{ key: 'dire-wolf', name: 'Dire Wolf', challengeRating: '1', ac: 14, maxHp: 37, speed: 50, strScore: 17, dexScore: 15, conScore: 15, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 5, damageDice: '2d6+3', specialTrait: 'Pack Tactics', notes: 'Bite; can knock prone on a hit.' },
{ key: 'brown-bear', name: 'Brown Bear', challengeRating: '1', ac: 11, maxHp: 34, speed: 40, strScore: 19, dexScore: 10, conScore: 16, intScore: 2, wisScore: 13, chaScore: 7, attackBonus: 5, damageDice: '1d8+5', specialTrait: 'Keen Smell', notes: 'Multiattack: bite and claws.' },
{ key: 'harpy', name: 'Harpy', challengeRating: '1', ac: 11, maxHp: 38, speed: 20, strScore: 12, dexScore: 13, conScore: 12, intScore: 7, wisScore: 10, chaScore: 13, attackBonus: 4, damageDice: '2d4+2', specialTrait: 'Luring Song', notes: 'Flying speed 40 ft; claws deal slashing damage.' },
{ key: 'ochre-jelly', name: 'Ochre Jelly', challengeRating: '2', ac: 8, maxHp: 45, speed: 10, strScore: 15, dexScore: 6, conScore: 14, intScore: 2, wisScore: 6, chaScore: 1, attackBonus: 5, damageDice: '2d6+3', specialTrait: 'Amorphous, splits when damaged by slashing', notes: 'Pseudopod, bludgeoning plus acid damage.' },
{ key: 'ogre', name: 'Ogre', challengeRating: '2', ac: 11, maxHp: 59, speed: 40, strScore: 19, dexScore: 8, conScore: 16, intScore: 5, wisScore: 7, chaScore: 7, attackBonus: 6, damageDice: '2d8+4', specialTrait: null, notes: 'Greatclub, bludgeoning damage.' },
{ key: 'griffon', name: 'Griffon', challengeRating: '2', ac: 12, maxHp: 59, speed: 30, strScore: 18, dexScore: 15, conScore: 16, intScore: 2, wisScore: 13, chaScore: 8, attackBonus: 6, damageDice: '2d6+4', specialTrait: 'Keen Sight', notes: 'Flying speed 80 ft; multiattack beak and claws.' },
{ key: 'ankheg', name: 'Ankheg', challengeRating: '2', ac: 14, maxHp: 39, speed: 30, strScore: 17, dexScore: 11, conScore: 13, intScore: 1, wisScore: 13, chaScore: 6, attackBonus: 6, damageDice: '2d6+4', specialTrait: 'Burrow speed 10 ft; can spit acid', notes: 'Bite, piercing plus acid damage.' },
{ key: 'manticore', name: 'Manticore', challengeRating: '3', ac: 14, maxHp: 68, speed: 30, strScore: 17, dexScore: 16, conScore: 17, intScore: 7, wisScore: 12, chaScore: 8, attackBonus: 5, damageDice: '1d8+3', specialTrait: 'Tail spikes (ranged)', notes: 'Flying speed 50 ft; multiattack claws or tail spikes.' },
{ key: 'basilisk', name: 'Basilisk', challengeRating: '3', ac: 15, maxHp: 52, speed: 20, strScore: 16, dexScore: 8, conScore: 15, intScore: 2, wisScore: 8, chaScore: 7, attackBonus: 5, damageDice: '2d6+3', specialTrait: 'Petrifying Gaze', notes: 'Bite, piercing damage.' },
{ key: 'owlbear', name: 'Owlbear', challengeRating: '3', ac: 13, maxHp: 59, speed: 40, strScore: 20, dexScore: 12, conScore: 17, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 7, damageDice: '1d10+5', specialTrait: 'Keen Sight and Smell', notes: 'Multiattack: beak and claws.' },
{ key: 'wight', name: 'Wight', challengeRating: '3', ac: 14, maxHp: 45, speed: 30, strScore: 15, dexScore: 14, conScore: 16, intScore: 10, wisScore: 13, chaScore: 15, attackBonus: 4, damageDice: '1d8+2', specialTrait: 'Life Drain, Sunlight Sensitivity', notes: 'Longsword or life-draining touch.' },
{ key: 'werewolf', name: 'Werewolf', challengeRating: '3', ac: 12, maxHp: 58, speed: 30, strScore: 15, dexScore: 13, conScore: 14, intScore: 10, wisScore: 11, chaScore: 10, attackBonus: 4, damageDice: '1d8+2', specialTrait: 'Shapechanger, regeneration', notes: 'Bite (hybrid/wolf form) or multiattack claws + bite.' },
{ key: 'doppelganger', name: 'Doppelganger', challengeRating: '3', ac: 14, maxHp: 52, speed: 30, strScore: 11, dexScore: 18, conScore: 14, intScore: 11, wisScore: 12, chaScore: 14, attackBonus: 4, damageDice: '2d4+2', specialTrait: 'Shapechanger, Ambusher', notes: 'Multiattack slam.' },
{ key: 'displacer-beast', name: 'Displacer Beast', challengeRating: '3', ac: 13, maxHp: 85, speed: 40, strScore: 18, dexScore: 15, conScore: 16, intScore: 6, wisScore: 12, chaScore: 8, attackBonus: 7, damageDice: '2d6+4', specialTrait: 'Displacement (attacks vs. it have disadvantage)', notes: 'Multiattack: two tentacles.' },
{ key: 'ettin', name: 'Ettin', challengeRating: '4', ac: 12, maxHp: 85, speed: 40, strScore: 21, dexScore: 8, conScore: 17, intScore: 6, wisScore: 10, chaScore: 8, attackBonus: 7, damageDice: '2d8+4', specialTrait: 'Two heads — advantage on Perception, can’t be surprised easily', notes: 'Multiattack: battleaxe and morningstar.' },
{ key: 'hill-giant', name: 'Hill Giant', challengeRating: '5', ac: 13, maxHp: 105, speed: 40, strScore: 21, dexScore: 8, conScore: 19, intScore: 5, wisScore: 9, chaScore: 6, attackBonus: 8, damageDice: '3d8+5', specialTrait: null, notes: 'Greatclub, bludgeoning damage.' },
{ key: 'troll', name: 'Troll', challengeRating: '5', ac: 15, maxHp: 84, speed: 30, strScore: 18, dexScore: 13, conScore: 20, intScore: 7, wisScore: 9, chaScore: 7, attackBonus: 7, damageDice: '2d6+4', specialTrait: 'Regeneration (vulnerable to fire/acid)', notes: 'Multiattack: bite and two claws.' },
{ key: 'wraith', name: 'Wraith', challengeRating: '5', ac: 13, maxHp: 67, speed: 0, strScore: 6, dexScore: 16, conScore: 16, intScore: 12, wisScore: 14, chaScore: 15, attackBonus: 6, damageDice: '4d8+4', specialTrait: 'Incorporeal, life drain, flying 60 ft', notes: 'Undead; life drain touch attack.' },
];
module.exports = { MONSTER_LIBRARY };
+152
View File
@@ -0,0 +1,152 @@
// Default spell library — a broad SRD-style spell list (cantrips through 5th
// level) spanning every spellcasting class, offered to any campaign so a DM
// isn't hand-typing spells from scratch. Shape matches the `Spell` Prisma
// model minus `id`/`campaignId`, plus a stable `key` for "add to campaign"
// lookups and a `classes` tag array the library picker can filter by.
const SPELL_LIBRARY = [
// ---- Cantrips (level 0) ----
{ key: 'acid-splash', name: 'Acid Splash', level: 0, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Hurl a bubble of acid at one or two creatures within 5 feet of each other.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'blade-ward', name: 'Blade Ward', level: 0, school: 'Abjuration', castingTime: '1 action', range: 'Self', duration: '1 round', description: 'Resist bludgeoning, piercing, and slashing damage until your next turn.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'chill-touch', name: 'Chill Touch', level: 0, school: 'Necromancy', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'A ghostly hand deals necrotic damage and prevents healing.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'dancing-lights', name: 'Dancing Lights', level: 0, school: 'Illusion', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Create up to four torch-sized lights that you can move.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'druidcraft', name: 'Druidcraft', level: 0, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'A small, harmless nature-flavored effect: predict weather, sprout a seedling, and the like.', classes: ['DRUID'] },
{ key: 'eldritch-blast', name: 'Eldritch Blast', level: 0, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'A beam of crackling energy strikes one creature for force damage.', classes: ['WARLOCK'] },
{ key: 'fire-bolt', name: 'Fire Bolt', level: 0, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Hurl a mote of fire that deals fire damage on a hit.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'friends', name: 'Friends', level: 0, school: 'Enchantment', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 minute', description: 'Advantage on Charisma checks against one creature for the duration.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'guidance', name: 'Guidance', level: 0, school: 'Divination', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 minute', description: 'Add 1d4 to one ability check of your choice.', classes: ['CLERIC', 'DRUID'] },
{ key: 'light', name: 'Light', level: 0, school: 'Evocation', castingTime: '1 action', range: 'Touch', duration: '1 hour', description: 'An object sheds bright light in a 20-foot radius.', classes: ['BARD', 'CLERIC', 'SORCERER', 'WIZARD'] },
{ key: 'mage-hand', name: 'Mage Hand', level: 0, school: 'Conjuration', castingTime: '1 action', range: '30 feet', duration: '1 minute', description: 'A spectral hand manipulates objects, opens doors, and carries small items.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'mending', name: 'Mending', level: 0, school: 'Transmutation', castingTime: '1 minute', range: 'Touch', duration: 'Instantaneous', description: 'Repairs a single break or tear in an object.', classes: ['BARD', 'CLERIC', 'DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'message', name: 'Message', level: 0, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'Whisper a message to a target that only they can hear, with a whispered reply.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'minor-illusion', name: 'Minor Illusion', level: 0, school: 'Illusion', castingTime: '1 action', range: '30 feet', duration: '1 minute', description: 'Create a sound or an image of an object for the duration.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'poison-spray', name: 'Poison Spray', level: 0, school: 'Conjuration', castingTime: '1 action', range: '10 feet', duration: 'Instantaneous', description: 'Project a puff of noxious gas that deals poison damage.', classes: ['DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'prestidigitation', name: 'Prestidigitation', level: 0, school: 'Transmutation', castingTime: '1 action', range: '10 feet', duration: 'Up to 1 hour', description: 'A minor magical trick — light a candle, clean an object, chill or warm food, and the like.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'produce-flame', name: 'Produce Flame', level: 0, school: 'Conjuration', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'A flickering flame in your hand sheds light and can be hurled for fire damage.', classes: ['DRUID'] },
{ key: 'ray-of-frost', name: 'Ray of Frost', level: 0, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A ray of freezing energy deals cold damage and slows the target.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'resistance', name: 'Resistance', level: 0, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 minute', description: 'The target adds 1d4 to one saving throw of its choice.', classes: ['CLERIC', 'DRUID'] },
{ key: 'sacred-flame', name: 'Sacred Flame', level: 0, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Radiant flame descends on a creature that must succeed a Dexterity save or take damage.', classes: ['CLERIC'] },
{ key: 'shillelagh', name: 'Shillelagh', level: 0, school: 'Transmutation', castingTime: '1 bonus action', range: 'Touch', duration: '1 minute', description: 'Imbue a club or quarterstaff with magic, using Wisdom for its attack and damage.', classes: ['DRUID'] },
{ key: 'shocking-grasp', name: 'Shocking Grasp', level: 0, school: 'Evocation', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Lightning springs from your hand, dealing damage and preventing reactions.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'spare-the-dying', name: 'Spare the Dying', level: 0, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature you touch that has 0 hit points becomes stable.', classes: ['CLERIC'] },
{ key: 'thorn-whip', name: 'Thorn Whip', level: 0, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'A vine-like whip deals damage and drags the target closer.', classes: ['DRUID'] },
{ key: 'true-strike', name: 'True Strike', level: 0, school: 'Divination', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 round', description: 'Gain advantage on your next attack roll against the target.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'vicious-mockery', name: 'Vicious Mockery', level: 0, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A string of insults deals psychic damage and imposes disadvantage on the target’s next attack.', classes: ['BARD'] },
// ---- Level 1 ----
{ key: 'alarm', name: 'Alarm', level: 1, school: 'Abjuration', castingTime: '1 minute', range: '30 feet', duration: '8 hours', description: 'Set an audible or mental alarm against intruders.', classes: ['RANGER', 'WIZARD'] },
{ key: 'animal-friendship', name: 'Animal Friendship', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: '24 hours', description: 'Convince a beast that you mean it no harm.', classes: ['BARD', 'DRUID', 'RANGER'] },
{ key: 'bane', name: 'Bane', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Up to three creatures subtract 1d4 from attack rolls and saves.', classes: ['BARD', 'CLERIC'] },
{ key: 'bless', name: 'Bless', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Up to three creatures add 1d4 to attack rolls and saves.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'burning-hands', name: 'Burning Hands', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Self (15-foot cone)', duration: 'Instantaneous', description: 'A thin sheet of flame sweeps out from your hands.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'charm-person', name: 'Charm Person', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: '1 hour', description: 'A humanoid you can see regards you as a friendly acquaintance.', classes: ['BARD', 'DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'color-spray', name: 'Color Spray', level: 1, school: 'Illusion', castingTime: '1 action', range: 'Self (15-foot cone)', duration: '1 round', description: 'A dazzling array of flashing colors can blind weak creatures.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'command', name: 'Command', level: 1, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: '1 round', description: 'Speak a one-word command a creature must obey.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'comprehend-languages', name: 'Comprehend Languages', level: 1, school: 'Divination', castingTime: '1 action', range: 'Self', duration: '1 hour', description: 'Understand any spoken or written language.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'cure-wounds', name: 'Cure Wounds', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature you touch regains hit points.', classes: ['BARD', 'CLERIC', 'DRUID', 'PALADIN', 'RANGER'] },
{ key: 'detect-magic', name: 'Detect Magic', level: 1, school: 'Divination', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 10 minutes', description: 'Sense the presence of magic within 30 feet.', classes: ['BARD', 'CLERIC', 'DRUID', 'PALADIN', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'disguise-self', name: 'Disguise Self', level: 1, school: 'Illusion', castingTime: '1 action', range: 'Self', duration: '1 hour', description: 'Change your appearance, including clothing and gear.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'entangle', name: 'Entangle', level: 1, school: 'Conjuration', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'Grasping weeds and vines sprout in a 20-foot square.', classes: ['DRUID', 'RANGER'] },
{ key: 'faerie-fire', name: 'Faerie Fire', level: 1, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Objects in a 20-foot cube are outlined in light, granting attackers advantage.', classes: ['DRUID'] },
{ key: 'feather-fall', name: 'Feather Fall', level: 1, school: 'Transmutation', castingTime: '1 reaction', range: '60 feet', duration: '1 minute', description: 'Falling creatures slow to a gentle descent.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'fog-cloud', name: 'Fog Cloud', level: 1, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 hour', description: 'A 20-foot-radius sphere of fog heavily obscures the area.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'guiding-bolt', name: 'Guiding Bolt', level: 1, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'A flash of light streaks to a target, dealing radiant damage.', classes: ['CLERIC'] },
{ key: 'healing-word', name: 'Healing Word', level: 1, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: 'Instantaneous', description: 'A creature you can see regains hit points.', classes: ['BARD', 'CLERIC', 'DRUID'] },
{ key: 'hunters-mark', name: "Hunter's Mark", level: 1, school: 'Divination', castingTime: '1 bonus action', range: '90 feet', duration: 'Concentration, up to 1 hour', description: 'Deal extra damage to a marked target and track it magically.', classes: ['RANGER'] },
{ key: 'identify', name: 'Identify', level: 1, school: 'Divination', castingTime: '1 minute', range: 'Touch', duration: 'Instantaneous', description: 'Learn the properties of a magic item or the nature of a spell affecting a creature.', classes: ['BARD', 'WIZARD'] },
{ key: 'jump', name: 'Jump', level: 1, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: '1 minute', description: "A creature's jump distance triples.", classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'longstrider', name: 'Longstrider', level: 1, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: '1 hour', description: "A creature's speed increases by 10 feet.", classes: ['BARD', 'DRUID', 'RANGER', 'WIZARD'] },
{ key: 'mage-armor', name: 'Mage Armor', level: 1, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: "A protective magical force gives the target a base AC of 13 + Dex modifier.", classes: ['SORCERER', 'WIZARD'] },
{ key: 'magic-missile', name: 'Magic Missile', level: 1, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Three darts of magical force each strike a target unerringly.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'protection-from-evil-and-good', name: 'Protection from Evil and Good', level: 1, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 10 minutes', description: 'Protect a creature from aberrations, celestials, elementals, fey, fiends, and undead.', classes: ['CLERIC', 'PALADIN', 'WARLOCK', 'WIZARD'] },
{ key: 'shield', name: 'Shield', level: 1, school: 'Abjuration', castingTime: '1 reaction', range: 'Self', duration: '1 round', description: 'An invisible barrier grants +5 AC until the start of your next turn.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'sleep', name: 'Sleep', level: 1, school: 'Enchantment', castingTime: '1 action', range: '90 feet', duration: '1 minute', description: 'Creatures in a 20-foot radius fall unconscious, weakest hit points first.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'speak-with-animals', name: 'Speak with Animals', level: 1, school: 'Divination', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'Comprehend and verbally communicate with beasts.', classes: ['BARD', 'DRUID', 'RANGER'] },
{ key: 'thunderwave', name: 'Thunderwave', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Self (15-foot cube)', duration: 'Instantaneous', description: 'A wave of thunderous force sweeps out, damaging and pushing creatures.', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'witch-bolt', name: 'Witch Bolt', level: 1, school: 'Evocation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'A beam of lightning forms a sustained arc that damages the target each turn.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
// ---- Level 2 ----
{ key: 'aid', name: 'Aid', level: 2, school: 'Abjuration', castingTime: '1 action', range: '30 feet', duration: '8 hours', description: 'Bolster allies with additional hit points.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'alter-self', name: 'Alter Self', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 hour', description: 'Assume a variety of minor physical changes.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'animal-messenger', name: 'Animal Messenger', level: 2, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: '24 hours', description: 'Send a tiny animal to deliver a short message.', classes: ['BARD', 'DRUID', 'RANGER'] },
{ key: 'blindness-deafness', name: 'Blindness/Deafness', level: 2, school: 'Necromancy', castingTime: '1 action', range: '30 feet', duration: '1 minute', description: 'Blind or deafen a creature that fails a Constitution save.', classes: ['BARD', 'CLERIC', 'SORCERER', 'WIZARD'] },
{ key: 'blur', name: 'Blur', level: 2, school: 'Illusion', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 minute', description: 'Your outline blurs, giving attackers disadvantage.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'darkvision', name: 'Darkvision', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: 'Grant a creature darkvision out to 60 feet.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'enhance-ability', name: 'Enhance Ability', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'Grant advantage on checks with one chosen ability.', classes: ['BARD', 'CLERIC', 'DRUID', 'SORCERER'] },
{ key: 'enlarge-reduce', name: 'Enlarge/Reduce', level: 2, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'A creature or object doubles or halves in size.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'flaming-sphere', name: 'Flaming Sphere', level: 2, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'A 5-foot-diameter sphere of fire rolls where you direct it.', classes: ['DRUID', 'WIZARD'] },
{ key: 'gust-of-wind', name: 'Gust of Wind', level: 2, school: 'Evocation', castingTime: '1 action', range: 'Self (60-foot line)', duration: 'Concentration, up to 1 minute', description: 'A line of strong wind pushes creatures and extinguishes flames.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'hold-person', name: 'Hold Person', level: 2, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Paralyze a humanoid that fails a Wisdom save.', classes: ['BARD', 'CLERIC', 'DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'invisibility', name: 'Invisibility', level: 2, school: 'Illusion', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'A creature you touch becomes invisible.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'lesser-restoration', name: 'Lesser Restoration', level: 2, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'End one disease or condition afflicting the target.', classes: ['BARD', 'CLERIC', 'DRUID', 'PALADIN', 'RANGER'] },
{ key: 'levitate', name: 'Levitate', level: 2, school: 'Transmutation', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 10 minutes', description: 'A creature or object rises vertically and can be moved.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'mirror-image', name: 'Mirror Image', level: 2, school: 'Illusion', castingTime: '1 action', range: 'Self', duration: '1 minute', description: 'Three illusory duplicates of yourself spring into being.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'misty-step', name: 'Misty Step', level: 2, school: 'Conjuration', castingTime: '1 bonus action', range: 'Self', duration: 'Instantaneous', description: 'Teleport up to 30 feet in a burst of silvery mist.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'moonbeam', name: 'Moonbeam', level: 2, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'A silvery beam of pale light shines down in a 5-foot-radius cylinder.', classes: ['DRUID'] },
{ key: 'pass-without-trace', name: 'Pass without Trace', level: 2, school: 'Abjuration', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 hour', description: 'The party becomes exceptionally difficult to track.', classes: ['DRUID', 'RANGER'] },
{ key: 'scorching-ray', name: 'Scorching Ray', level: 2, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Three rays of fire streak toward targets within range.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'shatter', name: 'Shatter', level: 2, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A sudden loud ringing noise deals thunder damage in a 10-foot-radius sphere.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'silence', name: 'Silence', level: 2, school: 'Illusion', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'No sound can be created within or pass through a 20-foot-radius sphere.', classes: ['BARD', 'CLERIC', 'RANGER'] },
{ key: 'spider-climb', name: 'Spider Climb', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'A creature gains the ability to move on vertical surfaces and ceilings.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'spiritual-weapon', name: 'Spiritual Weapon', level: 2, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: '1 minute', description: 'A spectral weapon attacks a target and can be moved each turn.', classes: ['CLERIC'] },
{ key: 'suggestion', name: 'Suggestion', level: 2, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 8 hours', description: 'Plant a reasonable-sounding suggestion in a creature’s mind.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'web', name: 'Web', level: 2, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 hour', description: 'Thick, sticky webs fill a 20-foot cube.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'zone-of-truth', name: 'Zone of Truth', level: 2, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: '10 minutes', description: 'Creatures in a 15-foot-radius sphere can’t speak a deliberate lie.', classes: ['BARD', 'CLERIC', 'PALADIN'] },
// ---- Level 3 ----
{ key: 'animate-dead', name: 'Animate Dead', level: 3, school: 'Necromancy', castingTime: '1 minute', range: '10 feet', duration: 'Instantaneous', description: 'Raise a skeleton or zombie from a corpse to fight for you.', classes: ['CLERIC', 'WIZARD'] },
{ key: 'beacon-of-hope', name: 'Beacon of Hope', level: 3, school: 'Abjuration', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Bolster creatures against despair, granting advantage on saves and max healing.', classes: ['CLERIC'] },
{ key: 'call-lightning', name: 'Call Lightning', level: 3, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A storm cloud forms and you can call down bolts of lightning.', classes: ['DRUID'] },
{ key: 'counterspell', name: 'Counterspell', level: 3, school: 'Abjuration', castingTime: '1 reaction', range: '60 feet', duration: 'Instantaneous', description: 'Interrupt a creature casting a spell.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'daylight', name: 'Daylight', level: 3, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: '1 hour', description: 'A 60-foot-radius sphere of bright light spreads out.', classes: ['CLERIC', 'DRUID', 'PALADIN', 'RANGER', 'SORCERER'] },
{ key: 'dispel-magic', name: 'Dispel Magic', level: 3, school: 'Abjuration', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'End one active spell affecting a creature, object, or effect.', classes: ['BARD', 'CLERIC', 'DRUID', 'PALADIN', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'fear', name: 'Fear', level: 3, school: 'Illusion', castingTime: '1 action', range: 'Self (30-foot cone)', duration: 'Concentration, up to 1 minute', description: 'Creatures in the cone become frightened and drop what they’re holding.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'fireball', name: 'Fireball', level: 3, school: 'Evocation', castingTime: '1 action', range: '150 feet', duration: 'Instantaneous', description: 'A bright streak explodes into flame, dealing fire damage in a 20-foot radius.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'fly', name: 'Fly', level: 3, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 10 minutes', description: 'The target gains a flying speed of 60 feet.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'haste', name: 'Haste', level: 3, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Double the target’s speed and grant an extra action.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'lightning-bolt', name: 'Lightning Bolt', level: 3, school: 'Evocation', castingTime: '1 action', range: 'Self (100-foot line)', duration: 'Instantaneous', description: 'A stroke of lightning blasts out in a line, dealing lightning damage.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'mass-healing-word', name: 'Mass Healing Word', level: 3, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: 'Instantaneous', description: 'Up to six creatures you can see regain hit points.', classes: ['CLERIC'] },
{ key: 'nondetection', name: 'Nondetection', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: 'Hide a target from divination magic.', classes: ['BARD', 'RANGER', 'WIZARD'] },
{ key: 'protection-from-energy', name: 'Protection from Energy', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'Resistance to one damage type of your choice.', classes: ['CLERIC', 'DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'remove-curse', name: 'Remove Curse', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Lift a curse from a creature or object.', classes: ['CLERIC', 'PALADIN', 'WARLOCK', 'WIZARD'] },
{ key: 'revivify', name: 'Revivify', level: 3, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature that died within the last minute returns to life with 1 hit point.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'sleet-storm', name: 'Sleet Storm', level: 3, school: 'Conjuration', castingTime: '1 action', range: '150 feet', duration: 'Concentration, up to 1 minute', description: 'Freezing rain falls in a 40-foot-radius cylinder, icing the ground.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'slow', name: 'Slow', level: 3, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Up to six creatures are hindered, halving their speed and worse.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'spirit-guardians', name: 'Spirit Guardians', level: 3, school: 'Conjuration', castingTime: '1 action', range: 'Self (15-foot radius)', duration: 'Concentration, up to 10 minutes', description: 'Protective spirits swirl around you, damaging enemies that enter.', classes: ['CLERIC'] },
{ key: 'stinking-cloud', name: 'Stinking Cloud', level: 3, school: 'Conjuration', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'A 20-foot-radius sphere of yellow, nauseating gas spreads out.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'water-breathing', name: 'Water Breathing', level: 3, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: '24 hours', description: 'Up to ten creatures can breathe underwater.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
// ---- Level 4 ----
{ key: 'banishment', name: 'Banishment', level: 4, school: 'Abjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Send a creature to a harmless demiplane.', classes: ['CLERIC', 'PALADIN', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'blight', name: 'Blight', level: 4, school: 'Necromancy', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'Necromantic energy withers a creature, dealing necrotic damage.', classes: ['DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'confusion', name: 'Confusion', level: 4, school: 'Enchantment', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'Creatures in a 10-foot-radius sphere act randomly.', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'dimension-door', name: 'Dimension Door', level: 4, school: 'Conjuration', castingTime: '1 action', range: '500 feet', duration: 'Instantaneous', description: 'Teleport yourself (and possibly a companion) to a spot you can visualize.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'fire-shield', name: 'Fire Shield', level: 4, school: 'Evocation', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'Wreathe yourself in flame that damages attackers and grants resistance.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'freedom-of-movement', name: 'Freedom of Movement', level: 4, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '1 hour', description: 'The target’s movement is unaffected by difficult terrain and restraints.', classes: ['BARD', 'CLERIC', 'DRUID', 'RANGER'] },
{ key: 'greater-invisibility', name: 'Greater Invisibility', level: 4, school: 'Illusion', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 minute', description: 'The target becomes invisible even while attacking.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'guardian-of-faith', name: 'Guardian of Faith', level: 4, school: 'Conjuration', castingTime: '1 action', range: '30 feet', duration: '8 hours', description: 'A Large spectral guardian appears and attacks enemies near it.', classes: ['CLERIC'] },
{ key: 'ice-storm', name: 'Ice Storm', level: 4, school: 'Evocation', castingTime: '1 action', range: '300 feet', duration: 'Instantaneous', description: 'Hail hammers a 20-foot-radius cylinder, dealing bludgeoning and cold damage.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'polymorph', name: 'Polymorph', level: 4, school: 'Transmutation', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 hour', description: 'Transform a creature into a new form.', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'stone-shape', name: 'Stone Shape', level: 4, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Reshape a chunk of stone into a form you desire.', classes: ['CLERIC', 'DRUID', 'WIZARD'] },
{ key: 'stoneskin', name: 'Stoneskin', level: 4, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'The target gains resistance to nonmagical bludgeoning, piercing, and slashing damage.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'wall-of-fire', name: 'Wall of Fire', level: 4, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Conjure a wall of fire that damages creatures passing through it.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
// ---- Level 5 ----
{ key: 'animate-objects', name: 'Animate Objects', level: 5, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Up to ten nonmagical objects animate and fight for you.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'cloudkill', name: 'Cloudkill', level: 5, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A 20-foot-radius sphere of poisonous, yellow-green fog billows out.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'cone-of-cold', name: 'Cone of Cold', level: 5, school: 'Evocation', castingTime: '1 action', range: 'Self (60-foot cone)', duration: 'Instantaneous', description: 'A blast of cold air deals cold damage to everything in the cone.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'contagion', name: 'Contagion', level: 5, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: '7 days', description: 'Infect a creature with a magical disease.', classes: ['CLERIC', 'DRUID'] },
{ key: 'dispel-evil-and-good', name: 'Dispel Evil and Good', level: 5, school: 'Abjuration', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 minute', description: 'Protection and the ability to banish otherworldly creatures.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'dominate-person', name: 'Dominate Person', level: 5, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Attempt to beguile a humanoid and control its actions.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'flame-strike', name: 'Flame Strike', level: 5, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A column of divine fire roars down, dealing fire and radiant damage.', classes: ['CLERIC'] },
{ key: 'greater-restoration', name: 'Greater Restoration', level: 5, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Undo a debilitating effect: reduced ability score, exhaustion, or curse.', classes: ['BARD', 'CLERIC', 'DRUID'] },
{ key: 'hold-monster', name: 'Hold Monster', level: 5, school: 'Enchantment', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'Paralyze one creature that fails a Wisdom save.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'insect-plague', name: 'Insect Plague', level: 5, school: 'Conjuration', castingTime: '1 action', range: '300 feet', duration: 'Concentration, up to 10 minutes', description: 'A swarm of biting, stinging insects fills a 20-foot-radius sphere.', classes: ['CLERIC', 'DRUID', 'SORCERER'] },
{ key: 'legend-lore', name: 'Legend Lore', level: 5, school: 'Divination', castingTime: '10 minutes', range: 'Self', duration: 'Instantaneous', description: 'Learn what you know, or have heard, about a name, place, or object.', classes: ['BARD', 'CLERIC', 'WIZARD'] },
{ key: 'mass-cure-wounds', name: 'Mass Cure Wounds', level: 5, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Up to six creatures in a 30-foot-radius sphere regain hit points.', classes: ['BARD', 'CLERIC', 'DRUID'] },
{ key: 'raise-dead', name: 'Raise Dead', level: 5, school: 'Necromancy', castingTime: '1 hour', range: 'Touch', duration: 'Instantaneous', description: 'Return a dead creature to life, as long as its body is mostly intact.', classes: ['BARD', 'CLERIC', 'PALADIN'] },
{ key: 'wall-of-force', name: 'Wall of Force', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'An invisible wall of force springs into existence, impenetrable to physical force.', classes: ['WIZARD'] },
{ key: 'wall-of-stone', name: 'Wall of Stone', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A nonmagical wall of solid stone springs into existence.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
];
module.exports = { SPELL_LIBRARY };
+54
View File
@@ -0,0 +1,54 @@
// Default weapon library — the SRD simple + martial weapon list, offered to
// any campaign so a DM isn't hand-typing common weapons from scratch. Shape
// matches the `Weapon` Prisma model minus `id`/`campaignId`, plus a stable
// `key` for "add to campaign" lookups and a `category` for the library
// picker's filter. attackAbility follows the schema's STR|DEX|FINESSE.
const WEAPON_LIBRARY = [
// Simple Melee
{ key: 'club', name: 'Club', category: 'Simple Melee', damageDice: '1d4', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['light'], rangeNormal: null, rangeLong: null },
{ key: 'dagger', name: 'Dagger', category: 'Simple Melee', damageDice: '1d4', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'thrown'], rangeNormal: 20, rangeLong: 60 },
{ key: 'greatclub', name: 'Greatclub', category: 'Simple Melee', damageDice: '1d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'handaxe', name: 'Handaxe', category: 'Simple Melee', damageDice: '1d6', damageType: 'slashing', attackAbility: 'STR', properties: ['light', 'thrown'], rangeNormal: 20, rangeLong: 60 },
{ key: 'javelin', name: 'Javelin', category: 'Simple Melee', damageDice: '1d6', damageType: 'piercing', attackAbility: 'STR', properties: ['thrown'], rangeNormal: 30, rangeLong: 120 },
{ key: 'light-hammer', name: 'Light Hammer', category: 'Simple Melee', damageDice: '1d4', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['light', 'thrown'], rangeNormal: 20, rangeLong: 60 },
{ key: 'mace', name: 'Mace', category: 'Simple Melee', damageDice: '1d6', damageType: 'bludgeoning', attackAbility: 'STR', properties: [], rangeNormal: null, rangeLong: null },
{ key: 'quarterstaff', name: 'Quarterstaff', category: 'Simple Melee', damageDice: '1d6', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['versatile (1d8)'], rangeNormal: null, rangeLong: null },
{ key: 'sickle', name: 'Sickle', category: 'Simple Melee', damageDice: '1d4', damageType: 'slashing', attackAbility: 'STR', properties: ['light'], rangeNormal: null, rangeLong: null },
{ key: 'spear', name: 'Spear', category: 'Simple Melee', damageDice: '1d6', damageType: 'piercing', attackAbility: 'STR', properties: ['thrown', 'versatile (1d8)'], rangeNormal: 20, rangeLong: 60 },
// Simple Ranged
{ key: 'light-crossbow', name: 'Light Crossbow', category: 'Simple Ranged', damageDice: '1d8', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'loading', 'two-handed'], rangeNormal: 80, rangeLong: 320 },
{ key: 'dart', name: 'Dart', category: 'Simple Ranged', damageDice: '1d4', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'thrown'], rangeNormal: 20, rangeLong: 60 },
{ key: 'shortbow', name: 'Shortbow', category: 'Simple Ranged', damageDice: '1d6', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'two-handed'], rangeNormal: 80, rangeLong: 320 },
{ key: 'sling', name: 'Sling', category: 'Simple Ranged', damageDice: '1d4', damageType: 'bludgeoning', attackAbility: 'DEX', properties: ['ammunition'], rangeNormal: 30, rangeLong: 120 },
// Martial Melee
{ key: 'battleaxe', name: 'Battleaxe', category: 'Martial Melee', damageDice: '1d8', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d10)'], rangeNormal: null, rangeLong: null },
{ key: 'flail', name: 'Flail', category: 'Martial Melee', damageDice: '1d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: [], rangeNormal: null, rangeLong: null },
{ key: 'glaive', name: 'Glaive', category: 'Martial Melee', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'greataxe', name: 'Greataxe', category: 'Martial Melee', damageDice: '1d12', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'greatsword', name: 'Greatsword', category: 'Martial Melee', damageDice: '2d6', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'halberd', name: 'Halberd', category: 'Martial Melee', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'lance', name: 'Lance', category: 'Martial Melee', damageDice: '1d12', damageType: 'piercing', attackAbility: 'STR', properties: ['reach', 'special'], rangeNormal: null, rangeLong: null },
{ key: 'longsword', name: 'Longsword', category: 'Martial Melee', damageDice: '1d8', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d10)'], rangeNormal: null, rangeLong: null },
{ key: 'maul', name: 'Maul', category: 'Martial Melee', damageDice: '2d6', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'morningstar', name: 'Morningstar', category: 'Martial Melee', damageDice: '1d8', damageType: 'piercing', attackAbility: 'STR', properties: [], rangeNormal: null, rangeLong: null },
{ key: 'pike', name: 'Pike', category: 'Martial Melee', damageDice: '1d10', damageType: 'piercing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'rapier', name: 'Rapier', category: 'Martial Melee', damageDice: '1d8', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse'], rangeNormal: null, rangeLong: null },
{ key: 'scimitar', name: 'Scimitar', category: 'Martial Melee', damageDice: '1d6', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null },
{ key: 'shortsword', name: 'Shortsword', category: 'Martial Melee', damageDice: '1d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null },
{ key: 'trident', name: 'Trident', category: 'Martial Melee', damageDice: '1d6', damageType: 'piercing', attackAbility: 'STR', properties: ['thrown', 'versatile (1d8)'], rangeNormal: 20, rangeLong: 60 },
{ key: 'war-pick', name: 'War Pick', category: 'Martial Melee', damageDice: '1d8', damageType: 'piercing', attackAbility: 'STR', properties: [], rangeNormal: null, rangeLong: null },
{ key: 'warhammer', name: 'Warhammer', category: 'Martial Melee', damageDice: '1d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['versatile (1d10)'], rangeNormal: null, rangeLong: null },
{ key: 'whip', name: 'Whip', category: 'Martial Melee', damageDice: '1d4', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse', 'reach'], rangeNormal: null, rangeLong: null },
// Martial Ranged
{ key: 'blowgun', name: 'Blowgun', category: 'Martial Ranged', damageDice: '1', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'loading'], rangeNormal: 25, rangeLong: 100 },
{ key: 'hand-crossbow', name: 'Hand Crossbow', category: 'Martial Ranged', damageDice: '1d6', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'light', 'loading'], rangeNormal: 30, rangeLong: 120 },
{ key: 'heavy-crossbow', name: 'Heavy Crossbow', category: 'Martial Ranged', damageDice: '1d10', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'loading', 'two-handed'], rangeNormal: 100, rangeLong: 400 },
{ key: 'longbow', name: 'Longbow', category: 'Martial Ranged', damageDice: '1d8', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'two-handed'], rangeNormal: 150, rangeLong: 600 },
{ key: 'net', name: 'Net', category: 'Martial Ranged', damageDice: '-', damageType: 'special', attackAbility: 'DEX', properties: ['special', 'thrown'], rangeNormal: 5, rangeLong: 15 },
];
module.exports = { WEAPON_LIBRARY };
+255
View File
@@ -197,3 +197,258 @@ describe('campaigns + characters + build flow', () => {
expect(forbiddenRes.status).toBe(403);
});
});
async function setupCampaign(namePrefix) {
const dmRegister = await request(app)
.post('/auth/register')
.send({ email: uniqueEmail(`${namePrefix}-dm`), password: 'password123', displayName: 'DM' });
const dmToken = dmRegister.body.token;
cleanupUserIds.push(dmRegister.body.user.id);
const playerRegister = await request(app)
.post('/auth/register')
.send({ email: uniqueEmail(`${namePrefix}-player`), password: 'password123', displayName: 'Player' });
const playerToken = playerRegister.body.token;
cleanupUserIds.push(playerRegister.body.user.id);
const campaignRes = await request(app)
.post('/campaigns')
.set('Authorization', `Bearer ${dmToken}`)
.send({ name: `${namePrefix} Campaign`, startingItemCount: 0, startingSpellCount: 0, skipMotivationRoll: true });
const campaign = campaignRes.body.campaign;
cleanupCampaignIds.push(campaign.id);
await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set('Authorization', `Bearer ${playerToken}`);
return {
dmAuth: { Authorization: `Bearer ${dmToken}` },
playerAuth: { Authorization: `Bearer ${playerToken}` },
campaign,
};
}
describe('dice mode', () => {
itIfDb('rejects an out-of-range physical roll and accepts a valid one, matching the digital shape', async () => {
const { dmAuth, campaign } = await setupCampaign('dicemode');
const characterRes = await request(app)
.post(`/campaigns/${campaign.id}/characters`)
.set(dmAuth)
.send({ name: 'Physical Roller' });
const characterId = characterRes.body.character.id;
const modeRes = await request(app).patch(`/characters/${characterId}/build/dice-mode`).set(dmAuth).send({ diceMode: 'PHYSICAL' });
expect(modeRes.status).toBe(200);
expect(modeRes.body.diceMode).toBe('PHYSICAL');
const tooHigh = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 7 });
expect(tooHigh.status).toBe(400);
const validRoll = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 3 });
expect(validRoll.status).toBe(200);
expect(validRoll.body.buildState.speciesRoll).toBe(3);
// Dice mode can no longer change once rolling has started.
const lateSwitch = await request(app).patch(`/characters/${characterId}/build/dice-mode`).set(dmAuth).send({ diceMode: 'DIGITAL' });
expect(lateSwitch.status).toBe(400);
});
});
describe('auto-generated skill proficiencies', () => {
itIfDb('assigns the class\'s SRD skill count automatically once ability scores are locked in', async () => {
const { dmAuth, campaign } = await setupCampaign('autoskill');
const characterRes = await request(app)
.post(`/campaigns/${campaign.id}/characters`)
.set(dmAuth)
.send({ name: 'Auto Skills' });
const characterId = characterRes.body.character.id;
let rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
const species = rollRes.body.buildState.speciesChoices.options[0];
await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(dmAuth)
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
while (rollRes.body.buildState.classChoices.type === 'reroll') {
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
}
const characterClass = rollRes.body.buildState.classChoices.options[0];
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ characterClass });
await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
const scoresRes = await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(dmAuth)
.send({ assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
expect(scoresRes.body.buildStage).toBe('ASSIGNING_PROFICIENCIES');
expect(scoresRes.body.buildState.skillProficiencies.length).toBeGreaterThan(0);
expect(new Set(scoresRes.body.buildState.skillProficiencies.map((s) => s.skill)).size).toBe(
scoresRes.body.buildState.skillProficiencies.length
); // no duplicates
});
});
describe('locations', () => {
itIfDb('DM manages locations; a player cannot', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('locations');
const createRes = await request(app).post(`/campaigns/${campaign.id}/locations`).set(dmAuth).send({ name: 'The Rusty Tankard' });
expect(createRes.status).toBe(201);
const locationId = createRes.body.location.id;
const playerCreateRes = await request(app).post(`/campaigns/${campaign.id}/locations`).set(playerAuth).send({ name: 'Nope' });
expect(playerCreateRes.status).toBe(403);
const listRes = await request(app).get(`/campaigns/${campaign.id}/locations`).set(playerAuth);
expect(listRes.status).toBe(200);
expect(listRes.body.locations.map((l) => l.id)).toContain(locationId);
const charRes = await request(app)
.post(`/campaigns/${campaign.id}/characters`)
.set(playerAuth)
.send({ name: 'Wanderer' });
const patchRes = await request(app)
.patch(`/characters/${charRes.body.character.id}`)
.set(playerAuth)
.send({ currentLocationId: locationId });
expect(patchRes.status).toBe(200);
expect(patchRes.body.character.currentLocationId).toBe(locationId);
});
});
describe('default content library', () => {
itIfDb('lists library entries and copies one into the campaign', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('library');
const listRes = await request(app).get(`/campaigns/${campaign.id}/library`).set(playerAuth);
expect(listRes.status).toBe(200);
expect(listRes.body.weapons.length).toBeGreaterThan(20);
expect(listRes.body.spells.length).toBeGreaterThan(50);
expect(listRes.body.monsters.length).toBeGreaterThan(20);
const addRes = await request(app)
.post(`/campaigns/${campaign.id}/weapons/from-library`)
.set(dmAuth)
.send({ key: 'longsword' });
expect(addRes.status).toBe(201);
expect(addRes.body.weapon.name).toBe('Longsword');
expect(addRes.body.weapon.campaignId).toBe(campaign.id);
const playerAddRes = await request(app)
.post(`/campaigns/${campaign.id}/monsters/from-library`)
.set(playerAuth)
.send({ key: 'goblin' });
expect(playerAddRes.status).toBe(403);
});
});
describe('private notes', () => {
itIfDb('is visible to the owning player but hidden from the DM and other players', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('privatenotes');
const charRes = await request(app)
.post(`/campaigns/${campaign.id}/characters`)
.set(playerAuth)
.send({ name: 'Secretive' });
const characterId = charRes.body.character.id;
const patchRes = await request(app)
.patch(`/characters/${characterId}`)
.set(playerAuth)
.send({ privateNotes: 'The amulet is cursed.' });
expect(patchRes.status).toBe(200);
expect(patchRes.body.character.privateNotes).toBe('The amulet is cursed.');
const ownerReadRes = await request(app).get(`/characters/${characterId}`).set(playerAuth);
expect(ownerReadRes.body.character.privateNotes).toBe('The amulet is cursed.');
const dmReadRes = await request(app).get(`/characters/${characterId}`).set(dmAuth);
expect(dmReadRes.body.character.privateNotes).toBeUndefined();
const dmSetAttempt = await request(app)
.patch(`/characters/${characterId}`)
.set(dmAuth)
.send({ privateNotes: 'DM trying to snoop' });
expect(dmSetAttempt.status).toBe(403);
});
});
describe('DM-only campaign notes', () => {
itIfDb('only the DM can post one, and only the DM can read it back', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('dmnotes');
const playerAttempt = await request(app)
.post('/notes')
.set(playerAuth)
.send({ campaignId: campaign.id, body: 'Sneaky player note', visibility: 'DM_ONLY' });
expect(playerAttempt.status).toBe(403);
const dmNoteRes = await request(app)
.post('/notes')
.set(dmAuth)
.send({ campaignId: campaign.id, body: 'The mayor is the villain.', visibility: 'DM_ONLY' });
expect(dmNoteRes.status).toBe(201);
const dmListRes = await request(app).get(`/campaigns/${campaign.id}/notes`).set(dmAuth);
expect(dmListRes.body.notes.some((n) => n.body === 'The mayor is the villain.')).toBe(true);
const playerListRes = await request(app).get(`/campaigns/${campaign.id}/notes`).set(playerAuth);
expect(playerListRes.body.notes.some((n) => n.body === 'The mayor is the villain.')).toBe(false);
});
});
describe('encounter combat info', () => {
itIfDb('shows a character combatant\'s weapons to every campaign member, not just the DM', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('encountercombat');
const weaponRes = await request(app)
.post(`/campaigns/${campaign.id}/weapons/from-library`)
.set(dmAuth)
.send({ key: 'dagger' });
const weaponId = weaponRes.body.weapon.id;
const characterRes = await request(app)
.post(`/campaigns/${campaign.id}/characters`)
.set(dmAuth)
.send({ name: 'Combatant' });
const characterId = characterRes.body.character.id;
// Fast-path the build with skipMotivationRoll + a single wildcard-free walk.
let rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
const species = rollRes.body.buildState.speciesChoices.options[0];
await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(dmAuth)
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
while (rollRes.body.buildState.classChoices.type === 'reroll') {
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
}
const characterClass = rollRes.body.buildState.classChoices.options[0];
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ characterClass });
await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(dmAuth)
.send({ assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({});
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ weaponIds: [], spellIds: [] });
await request(app).post(`/characters/${characterId}/build/finalize`).set(dmAuth);
await request(app).post(`/characters/${characterId}/weapons`).set(dmAuth).send({ weaponId });
await request(app).patch(`/characters/${characterId}/weapons/${weaponId}`).set(dmAuth).send({ equipped: true });
const encounterRes = await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Ambush' });
const encounterId = encounterRes.body.encounter.id;
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId, initiative: 10 });
const playerViewRes = await request(app).get(`/encounters/${encounterId}`).set(playerAuth);
expect(playerViewRes.status).toBe(200);
const participant = playerViewRes.body.encounter.participants[0];
expect(participant.characterCombatInfo.weapons).toHaveLength(1);
expect(participant.characterCombatInfo.weapons[0].name).toBe('Dagger');
expect(typeof participant.characterCombatInfo.weapons[0].attackBonus).toBe('number');
});
});
+109 -1
View File
@@ -5,6 +5,7 @@ const { requireAuth } = require('../middleware/auth');
const { requireCampaignMember, requireDM } = require('../middleware/campaignAccess');
const { generateInviteCode } = require('../utils/inviteCode');
const { computeCharacterSheet } = require('../game/calculations');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const router = express.Router();
@@ -168,6 +169,8 @@ function characterSummary(character) {
tempHp: character.tempHp,
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
currentLocationId: character.currentLocationId,
currentLocationName: character.currentLocation ? character.currentLocation.name : null,
};
}
@@ -175,7 +178,7 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
try {
const characters = await prisma.character.findMany({
where: { campaignId: req.campaign.id },
include: { skillProficiencies: true, saveProficiencies: true },
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
orderBy: { createdAt: 'asc' },
});
res.json({ characters: characters.map(characterSummary) });
@@ -426,4 +429,109 @@ router.get('/:id/notes', requireCampaignMember(), async (req, res, next) => {
}
});
// ---------------------------------------------------------------------------
// Locations — DM-managed, used to track where each character currently is
// ---------------------------------------------------------------------------
router.get('/:id/locations', requireCampaignMember(), async (req, res, next) => {
try {
const locations = await prisma.location.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
res.json({ locations });
} catch (err) {
next(err);
}
});
const locationSchema = z.object({
name: z.string().min(1).max(80),
description: z.string().max(2000).optional().nullable(),
});
router.post('/:id/locations', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = locationSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const location = await prisma.location.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
res.status(201).json({ location });
} catch (err) {
next(err);
}
});
router.patch('/:id/locations/:locationId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = locationSchema.partial().safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const location = await prisma.location.update({ where: { id: req.params.locationId }, data: parsed.data });
res.json({ location });
} catch (err) {
next(err);
}
});
router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
await prisma.location.delete({ where: { id: req.params.locationId } });
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Default content library — static SRD-style weapons/spells/monsters any
// campaign can pull from, instead of the DM hand-typing everything. See
// backend/src/game/data/{weapons,spells,monsters}Library.js for the sets.
// ---------------------------------------------------------------------------
router.get('/:id/library', requireCampaignMember(), async (req, res, next) => {
try {
res.json({ weapons: WEAPON_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
} catch (err) {
next(err);
}
});
router.post('/:id/weapons/from-library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ key: z.string() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const template = WEAPON_LIBRARY.find((w) => w.key === parsed.data.key);
if (!template) return res.status(404).json({ error: 'Unknown library weapon.' });
const { key, category, ...data } = template;
const weapon = await prisma.weapon.create({ data: { ...data, campaignId: req.campaign.id } });
res.status(201).json({ weapon });
} catch (err) {
next(err);
}
});
router.post('/:id/spells/from-library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ key: z.string() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const template = SPELL_LIBRARY.find((s) => s.key === parsed.data.key);
if (!template) return res.status(404).json({ error: 'Unknown library spell.' });
const { key, classes, ...data } = template;
const spell = await prisma.spell.create({ data: { ...data, campaignId: req.campaign.id } });
res.status(201).json({ spell });
} catch (err) {
next(err);
}
});
router.post('/:id/monsters/from-library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ key: z.string() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const template = MONSTER_LIBRARY.find((m) => m.key === parsed.data.key);
if (!template) return res.status(404).json({ error: 'Unknown library monster.' });
const { key, ...data } = template;
const monster = await prisma.monster.create({ data: { ...data, campaignId: req.campaign.id } });
res.status(201).json({ monster });
} catch (err) {
next(err);
}
});
module.exports = router;
+136 -41
View File
@@ -15,6 +15,8 @@ const {
rollAbilityScore,
speciesChoicesForRoll,
classChoicesForRoll,
autoSelectSkills,
validateManualRoll,
MOTIVATION_TABLE,
} = require('../game/buildFlow');
const { SKILLS } = require('../game/data/skills');
@@ -32,11 +34,14 @@ async function loadFullCharacter(id) {
inventoryItems: true,
weapons: { include: { weapon: true } },
spells: { include: { spell: true } },
currentLocation: true,
},
});
}
function serializeCharacter(character) {
// privateNotes is a real privacy boundary, not a UI hide — it's only ever
// included in the payload for the character's own owning player.
function serializeCharacter(character, isOwner) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
return {
id: character.id,
@@ -63,6 +68,12 @@ function serializeCharacter(character) {
tempHp: character.tempHp,
armorBonus: character.armorBonus,
specialAbilities: character.specialAbilities,
background: character.background,
appearance: character.appearance,
privateNotes: isOwner ? character.privateNotes : undefined,
diceMode: character.diceMode,
currentLocationId: character.currentLocationId,
currentLocation: character.currentLocation || null,
gold: character.gold,
buildStage: character.buildStage,
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
@@ -78,7 +89,7 @@ function serializeCharacter(character) {
router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
try {
const character = await loadFullCharacter(req.character.id);
res.json({ character: serializeCharacter(character) });
res.json({ character: serializeCharacter(character, req.isOwner) });
} catch (err) {
next(err);
}
@@ -91,6 +102,10 @@ const patchCharacterSchema = z.object({
armorBonus: z.number().int().min(0).optional(),
speedOverride: z.number().int().min(0).nullable().optional(),
specialAbilities: z.string().max(4000).nullable().optional(),
background: z.string().max(4000).nullable().optional(),
appearance: z.string().max(4000).nullable().optional(),
privateNotes: z.string().max(4000).nullable().optional(),
currentLocationId: z.string().nullable().optional(),
gold: z.number().int().min(0).optional(),
level: z.number().int().min(1).max(20).optional(),
xp: z.number().int().min(0).optional(),
@@ -101,12 +116,28 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
const parsed = patchCharacterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
// privateNotes is for the owning player only — a DM editing someone
// else's character (or another player, though write access already
// blocks that) should never be able to set it through this route.
if (parsed.data.privateNotes !== undefined && !req.isOwner) {
return res.status(403).json({ error: 'Only the owning player can edit private notes.' });
}
if (parsed.data.currentLocationId) {
const location = await prisma.location.findUnique({ where: { id: parsed.data.currentLocationId } });
if (!location || location.campaignId !== req.character.campaignId) {
return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
}
const updated = await prisma.character.update({ where: { id: req.character.id }, data: parsed.data });
const full = await loadFullCharacter(updated.id);
const payload = serializeCharacter(full);
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { character: payload });
res.json({ character: payload });
// Broadcast payload never includes privateNotes — a socket room has
// every campaign member in it, not just the owner. Only the direct HTTP
// response (below) is allowed to include it.
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { character: serializeCharacter(full, false) });
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
}
@@ -126,38 +157,90 @@ router.delete('/:id', requireCharacterAccess(), async (req, res, next) => {
// Roll-Your-Character build flow — docs/ROLL_YOUR_CHARACTER.md
// ---------------------------------------------------------------------------
// Digital mode rolls server-side (crypto.randomInt) as before. Physical mode
// requires the value(s) the roller typed in from their real dice — this
// helper validates them against the step's real die range/count and throws
// a user-facing error string on failure, otherwise returns the value(s) in
// the exact same shape the digital path would have produced.
function resolveSingleRoll(character, body, sides) {
if (character.diceMode !== 'PHYSICAL') return rollDie(sides);
const value = Number(body?.value);
if (!validateManualRoll(sides, value)) {
throw new Error(`Enter the d${sides} you rolled — a whole number from 1 to ${sides}.`);
}
return value;
}
function resolveAbilityRolls(character, body) {
if (character.diceMode !== 'PHYSICAL') {
return Array.from({ length: 6 }, () => rollAbilityScore());
}
const sets = body?.abilitySets;
if (!Array.isArray(sets) || sets.length !== 6) {
throw new Error('Enter all six 4d6 sets you rolled at the table.');
}
return sets.map((dice, i) => {
if (!Array.isArray(dice) || dice.length !== 4 || !dice.every((d) => validateManualRoll(6, d))) {
throw new Error(`Ability set ${i + 1}: enter four d6 results, each a whole number from 1 to 6.`);
}
return abilityScoreFromDice(dice);
});
}
router.patch('/:id/build/dice-mode', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const character = req.character;
const parsed = z.object({ diceMode: z.enum(['DIGITAL', 'PHYSICAL']) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const alreadyRolling = character.buildStage !== 'ROLLING_SPECIES' || Boolean((character.buildState || {}).speciesRoll);
if (alreadyRolling) {
return res.status(400).json({ error: 'Dice mode can only be chosen before the first roll of the build.' });
}
const updated = await prisma.character.update({ where: { id: character.id }, data: { diceMode: parsed.data.diceMode } });
res.json({ diceMode: updated.diceMode });
} catch (err) {
next(err);
}
});
router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const character = req.character;
const stage = character.buildStage;
const buildState = { ...(character.buildState || {}) };
if (stage === 'ROLLING_SPECIES') {
const roll = rollDie(6);
buildState.speciesRoll = roll;
buildState.speciesChoices = speciesChoicesForRoll(roll);
delete buildState.species;
delete buildState.halfElfBonusAbilities;
} else if (stage === 'ROLLING_CLASS') {
const alreadyRolled = buildState.classChoices && buildState.classChoices.type !== 'reroll';
if (alreadyRolled) {
return res.status(400).json({ error: 'Class die already rolled for this character — submit a choice, or reroll if the current roll allows it.' });
try {
if (stage === 'ROLLING_SPECIES') {
const roll = resolveSingleRoll(character, req.body, 6);
buildState.speciesRoll = roll;
buildState.speciesChoices = speciesChoicesForRoll(roll);
delete buildState.species;
delete buildState.halfElfBonusAbilities;
} else if (stage === 'ROLLING_CLASS') {
const alreadyRolled = buildState.classChoices && buildState.classChoices.type !== 'reroll';
if (alreadyRolled) {
return res.status(400).json({ error: 'Class die already rolled for this character — submit a choice, or reroll if the current roll allows it.' });
}
const roll = resolveSingleRoll(character, req.body, 8);
buildState.classRoll = roll;
buildState.classChoices = classChoicesForRoll(roll);
delete buildState.characterClass;
} else if (stage === 'ROLLING_MOTIVATION') {
const roll = resolveSingleRoll(character, req.body, 6);
buildState.motivationRoll = roll;
buildState.motivationText = MOTIVATION_TABLE[roll];
} else if (stage === 'ASSIGNING_SCORES') {
if (buildState.abilityRolls) {
return res.status(400).json({ error: 'Ability scores were already rolled for this character.' });
}
buildState.abilityRolls = resolveAbilityRolls(character, req.body);
} else {
return res.status(400).json({ error: `No die roll is required at the "${stage}" step.` });
}
const roll = rollDie(8);
buildState.classRoll = roll;
buildState.classChoices = classChoicesForRoll(roll);
delete buildState.characterClass;
} else if (stage === 'ROLLING_MOTIVATION') {
const roll = rollDie(6);
buildState.motivationRoll = roll;
buildState.motivationText = MOTIVATION_TABLE[roll];
} else if (stage === 'ASSIGNING_SCORES') {
if (buildState.abilityRolls) {
return res.status(400).json({ error: 'Ability scores were already rolled for this character.' });
}
buildState.abilityRolls = Array.from({ length: 6 }, () => rollAbilityScore());
} else {
return res.status(400).json({ error: `No die roll is required at the "${stage}" step.` });
} catch (rollErr) {
return res.status(400).json({ error: rollErr.message });
}
const updated = await prisma.character.update({ where: { id: character.id }, data: { buildState } });
@@ -186,14 +269,19 @@ const chooseSchemas = {
CHA: z.number().int().min(0).max(5),
}),
}),
// Skill proficiencies are now auto-generated (see autoSelectSkills, applied
// when this stage is entered from ASSIGNING_SCORES below) — this payload is
// an optional DM correction, not the normal path.
ASSIGNING_PROFICIENCIES: z.object({
skillProficiencies: z.array(
z.object({
skill: z.enum(Object.keys(SKILLS)),
proficient: z.boolean().default(true),
expertise: z.boolean().default(false),
})
),
skillProficiencies: z
.array(
z.object({
skill: z.enum(Object.keys(SKILLS)),
proficient: z.boolean().default(true),
expertise: z.boolean().default(false),
})
)
.optional(),
}),
CHOOSING_EQUIPMENT: z.object({
weaponIds: z.array(z.string()).default([]),
@@ -274,8 +362,16 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
return res.status(400).json({ error: 'Each of the 6 rolled scores must be assigned to exactly one ability.' });
}
buildState.abilityAssignments = assignments;
// Skill proficiencies are auto-generated per the class's SRD "choose N"
// rule the moment ability scores are locked in, so the next step has
// something to show immediately rather than an empty picker.
buildState.skillProficiencies = autoSelectSkills(buildState.characterClass);
} else if (stage === 'ASSIGNING_PROFICIENCIES') {
buildState.skillProficiencies = parsed.data.skillProficiencies;
// Optional DM override of the auto-generated picks; if omitted, the
// auto-generated set from the previous step stands as-is.
if (parsed.data.skillProficiencies) {
buildState.skillProficiencies = parsed.data.skillProficiencies;
}
} else if (stage === 'CHOOSING_EQUIPMENT') {
const { weaponIds, spellIds } = parsed.data;
const maxItems = req.campaign.startingItemCount;
@@ -373,9 +469,8 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
});
const full = await loadFullCharacter(result.id);
const payload = serializeCharacter(full);
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { character: payload });
res.json({ character: payload });
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { character: serializeCharacter(full, false) });
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
}
+44 -3
View File
@@ -3,7 +3,7 @@ const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet } = require('../game/calculations');
const { computeCharacterSheet, computeWeaponStats } = require('../game/calculations');
const router = express.Router();
@@ -26,19 +26,60 @@ async function requireEncounterAccess(req, res, next) {
}
}
// Every campaign member looking at an encounter can see a character
// combatant's equipped weapons and known spells (attack/damage bonus, save
// DC) — not just HP/AC — so the party can plan around each other mid-fight,
// not only the DM. Uses the same calculation-engine functions the
// quick-stats panel already uses, never re-deriving the formulas.
function characterCombatInfo(character) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
const weapons = character.weapons.map((cw) => ({
characterWeaponId: cw.id,
name: cw.weapon.name,
equipped: cw.equipped,
quantity: cw.quantity,
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true),
}));
const spells = character.spells.map((cs) => ({
characterSpellId: cs.id,
name: cs.spell.name,
level: cs.spell.level,
prepared: cs.prepared,
}));
return { armorClass: sheet.armorClass, weapons, spells, spellcasting: sheet.spellcasting };
}
async function serializeEncounter(encounterId) {
return prisma.encounter.findUnique({
const encounter = await prisma.encounter.findUnique({
where: { id: encounterId },
include: {
participants: {
include: {
character: { select: { id: true, name: true } },
character: {
include: {
skillProficiencies: true,
saveProficiencies: true,
weapons: { include: { weapon: true } },
spells: { include: { spell: true } },
},
},
monster: { select: { id: true, name: true } },
},
orderBy: { initiative: 'desc' },
},
},
});
if (!encounter) return encounter;
encounter.participants = encounter.participants.map((p) => {
if (!p.character) return p;
return {
...p,
character: { id: p.character.id, name: p.character.name },
characterCombatInfo: characterCombatInfo(p.character),
};
});
return encounter;
}
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
+6
View File
@@ -34,6 +34,12 @@ router.post('/', async (req, res, next) => {
if (resolvedCampaignId) {
const membership = await loadCampaignMembership(req.user.id, resolvedCampaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
// A campaign-level (not character-level) DM_ONLY note is the DM's
// private scratchpad — only the DM may create one of those.
if (visibility === 'DM_ONLY' && !characterId && membership.role !== 'DM') {
return res.status(403).json({ error: 'Only the DM can post a DM-only campaign note.' });
}
}
const note = await prisma.note.create({