- Hollowcrown: The Rift Between Worlds as its own seed (npm run seed:hollowcrown), built from the campaign book: pools, 58 monsters, 57 NPCs, 28 locations, 72 encounters with rewards, and DM notes. Shared seeding logic moved to prisma/seedCampaign.js. - What players can see: only encounters their own character is in; only the pool entries their character holds (plus starting options while building); only locations their character has been to; no invite code; no gold, inventory or money log of other players' characters. Socket events carry ids only and campaign rooms are members-only. - Encounters have a location; while one is in progress its characters are there, and visits are remembered. - Campaign setting to show players a health bar only for monsters and NPCs. - Initiative is entered as the d20 roll (1-20) and the bonus is added; players can enter their own character's roll. - Number fields enforce their limits when typing, and the API enforces the same limits. - Temp HP shows on HP bars and absorbs damage before real HP. - All packages at version 1.3.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211 lines
8.7 KiB
JavaScript
211 lines
8.7 KiB
JavaScript
// Shared by the campaign seeds (seed.js, seed-hollowcrown.js): makes sure the
|
|
// admin account exists, then adds one fully-built campaign for it.
|
|
//
|
|
// A seed only ever ADDS, so it is safe against a live database. The admin
|
|
// account is created if it is missing (an existing one, and its password, is
|
|
// left alone), and the campaign is added unless that account already owns an
|
|
// active campaign of the same name. To get a fresh copy, archive or delete the
|
|
// old one in the app and run the seed again.
|
|
//
|
|
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
|
|
// account that is created.
|
|
//
|
|
// `content` is one campaign, with every pool as rows shaped like the Prisma
|
|
// models minus ids:
|
|
// campaign, locations, weapons, items, spells, monsters, npcs,
|
|
// sharedNotes, dmNotes (note bodies, in reading order), and
|
|
// encounters, in the order they should list:
|
|
// { name, mode, description, dmNotes,
|
|
// location: a location's name (optional) — where the scene is,
|
|
// monsters: [[monster name, count, display name?, max HP?], ...],
|
|
// npcs: [NPC name | { name, hp }, ...],
|
|
// rewards: [{ gold } | { weapon } | { item }, each with optional
|
|
// quantity, level (defaults to the entry's starting level)
|
|
// and note] }
|
|
const bcrypt = require('bcrypt');
|
|
const { PrismaClient } = require('@prisma/client');
|
|
const { generateInviteCode } = require('../src/utils/inviteCode');
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
|
|
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
|
|
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
|
|
|
|
// Notes list newest-first on screen, so they are stamped a second apart,
|
|
// backwards: the first entry in the seed data is the one at the top.
|
|
function bookOrder(index) {
|
|
return new Date(Date.now() - index * 1000);
|
|
}
|
|
|
|
function byName(rows) {
|
|
return new Map(rows.map((row) => [row.name, row]));
|
|
}
|
|
|
|
function lookup(map, name, what, encounterName) {
|
|
const row = map.get(name);
|
|
if (!row) throw new Error(`Encounter "${encounterName}" references unknown ${what} "${name}"`);
|
|
return row;
|
|
}
|
|
|
|
async function createCampaign(db, admin, content) {
|
|
const campaign = await db.campaign.create({
|
|
data: {
|
|
...content.campaign,
|
|
inviteCode: generateInviteCode(),
|
|
ownerId: admin.id,
|
|
members: { create: { userId: admin.id, role: 'DM' } },
|
|
},
|
|
});
|
|
const campaignId = campaign.id;
|
|
const inCampaign = (rows) => rows.map((row) => ({ ...row, campaignId }));
|
|
|
|
await db.location.createMany({ data: inCampaign(content.locations) });
|
|
await db.weapon.createMany({ data: inCampaign(content.weapons) });
|
|
await db.item.createMany({ data: inCampaign(content.items) });
|
|
await db.spell.createMany({ data: inCampaign(content.spells) });
|
|
await db.monster.createMany({ data: inCampaign(content.monsters) });
|
|
await db.npc.createMany({ data: inCampaign(content.npcs) });
|
|
|
|
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
|
|
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
|
|
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
|
|
const items = byName(await db.item.findMany({ where: { campaignId } }));
|
|
const locations = byName(await db.location.findMany({ where: { campaignId } }));
|
|
|
|
let rewardCount = 0;
|
|
for (const [index, enc] of content.encounters.entries()) {
|
|
const participants = [];
|
|
for (const [name, count, displayName, hp] of enc.monsters || []) {
|
|
const monster = lookup(monsters, name, 'monster', enc.name);
|
|
const label = displayName || monster.name;
|
|
const maxHp = hp ?? monster.maxHp;
|
|
for (let i = 1; i <= count; i++) {
|
|
participants.push({
|
|
monsterId: monster.id,
|
|
displayName: count > 1 ? `${label} #${i}` : label,
|
|
currentHp: maxHp,
|
|
maxHp,
|
|
isMonster: true,
|
|
});
|
|
}
|
|
}
|
|
for (const entry of enc.npcs || []) {
|
|
const { name, hp } = typeof entry === 'string' ? { name: entry } : entry;
|
|
const npc = lookup(npcs, name, 'NPC', enc.name);
|
|
// An NPC with no combat stats takes part with no hit points to track.
|
|
const maxHp = hp ?? npc.maxHp ?? 0;
|
|
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: maxHp, maxHp, isMonster: false });
|
|
}
|
|
|
|
// Rewards list oldest-first, so these are stamped forwards.
|
|
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity, level, note }, i) => {
|
|
const createdAt = new Date(Date.now() + i);
|
|
if (gold) return { type: 'GOLD', gold, note, createdAt };
|
|
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
|
|
return {
|
|
type: weapon ? 'WEAPON' : 'ITEM',
|
|
[weapon ? 'weaponId' : 'itemId']: entry.id,
|
|
quantity: quantity ?? 1,
|
|
level: Math.max(level ?? entry.minLevel, entry.minLevel),
|
|
note,
|
|
createdAt,
|
|
};
|
|
});
|
|
rewardCount += rewards.length;
|
|
|
|
await db.encounter.create({
|
|
data: {
|
|
campaignId,
|
|
name: enc.name,
|
|
mode: enc.mode,
|
|
description: enc.description,
|
|
dmNotes: enc.dmNotes,
|
|
locationId: enc.location ? lookup(locations, enc.location, 'location', enc.name).id : null,
|
|
sortOrder: index,
|
|
participants: { create: participants },
|
|
rewards: { create: rewards },
|
|
},
|
|
});
|
|
}
|
|
|
|
const notes = [
|
|
...content.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
|
|
...content.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
|
|
];
|
|
await db.note.createMany({
|
|
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
|
|
});
|
|
|
|
const starting = (rows) => rows.filter((row) => row.isStartingOption).length;
|
|
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
|
|
console.log(
|
|
` ${content.weapons.length} weapons (${starting(content.weapons)} starting), ${content.items.length} items ` +
|
|
`(${starting(content.items)} starting), ${content.spells.length} spells (${starting(content.spells)} starting), ` +
|
|
`${content.locations.length} locations`,
|
|
);
|
|
console.log(
|
|
` ${content.monsters.length} monsters, ${content.npcs.length} NPCs, ${content.encounters.length} encounters ` +
|
|
`(${rewardCount} rewards lined up), ${notes.length} notes`,
|
|
);
|
|
}
|
|
|
|
async function resetDatabase() {
|
|
// Truncating the two roots cascades through every table that references them.
|
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
|
|
}
|
|
|
|
// `reset` is DESTRUCTIVE: every user, campaign, character, note, etc. is
|
|
// deleted before anything is added.
|
|
async function seed(content, { reset = false } = {}) {
|
|
if (reset) {
|
|
await resetDatabase();
|
|
console.log('Database reset: every user and campaign was deleted.');
|
|
}
|
|
|
|
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
|
|
if (admin) {
|
|
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
|
|
} else {
|
|
admin = await prisma.user.create({
|
|
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
|
|
});
|
|
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
|
|
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
|
|
}
|
|
|
|
const existing = await prisma.campaign.findFirst({
|
|
where: { ownerId: admin.id, name: content.campaign.name, archivedAt: null },
|
|
});
|
|
if (existing) {
|
|
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
|
|
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
|
|
return;
|
|
}
|
|
|
|
// One transaction, so a failed seed doesn't leave half a campaign behind.
|
|
console.log('Seeding campaign...');
|
|
await prisma.$transaction((tx) => createCampaign(tx, admin, content), { timeout: 120000, maxWait: 20000 });
|
|
console.log('Seed complete.');
|
|
}
|
|
|
|
// Runs a seed script to completion: exit code 1 on failure, connection closed either way.
|
|
function run(content, options) {
|
|
return seed(content, options)
|
|
.catch((err) => {
|
|
console.error(err);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(() => prisma.$disconnect());
|
|
}
|
|
|
|
// A copy of a library entry as a pool row: its `key` and picker-only tags dropped.
|
|
function fromLibrary(library, key, extra = {}) {
|
|
const template = library.find((entry) => entry.key === key);
|
|
if (!template) throw new Error(`Unknown library entry "${key}"`);
|
|
const { key: _key, category, classes, ...row } = template;
|
|
return { ...row, ...extra };
|
|
}
|
|
|
|
module.exports = { run, fromLibrary };
|