Drawn by tools/bellhouse-plans.mjs from declared rooms: lobby, control room, registry, sick bay, creche, vigil hall and plant, coal roads and the Sump. 2800x2000 at 5 ft to the square, added to the Adventure and keyed in the Locations table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1337 lines
62 KiB
JavaScript
1337 lines
62 KiB
JavaScript
/**
|
||
* Builds the RingBRP LevelDB compendium packs from tools/content.mjs.
|
||
* Run: node tools/build-packs.mjs
|
||
*
|
||
* IDs are derived deterministically from a key string, so rebuilding a pack
|
||
* keeps stable references and does not duplicate documents in existing worlds.
|
||
*/
|
||
|
||
import { ClassicLevel } from "classic-level";
|
||
import { createHash } from "node:crypto";
|
||
import { ICON_MAP, SKILL_ICON } from "./icons.mjs";
|
||
import { rm, mkdir } from "node:fs/promises";
|
||
import { existsSync } from "node:fs";
|
||
import path from "node:path";
|
||
import { fileURLToPath } from "node:url";
|
||
import {
|
||
SKILL_CATALOGUE, WEAPONS, ARMOURS, GEAR, VEHICLES, PREGENS, NPCS, DEVICES, TALENTS,
|
||
SITE_TABLE, HAZARD_TABLE, hitPoints, majorWound, damageModifier, styleFor
|
||
} from "./content.mjs";
|
||
import { SHIELD } from "../rules.mjs";
|
||
|
||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||
const PACKS = path.join(ROOT, "packs");
|
||
|
||
const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||
function idFor(key) {
|
||
const h = createHash("sha256").update(`ringbrp:${key}`).digest();
|
||
let out = "";
|
||
for (let i = 0; i < 16; i++) out += ALPHABET[h[i] % ALPHABET.length];
|
||
return out;
|
||
}
|
||
|
||
const BASE = () => ({ folder: null, sort: 0, flags: {}, ownership: { default: 0 } });
|
||
const IC = k => ICON_MAP[k] ? `systems/ringbrp/icons/${k}.svg` : null;
|
||
const ICON = {
|
||
skill: IC("skill_mental"),
|
||
weapon: IC("pistol_med"),
|
||
melee: IC("dagger"),
|
||
armour: IC("stab_vest"),
|
||
gear: IC("pack") ?? IC("medkit"),
|
||
device: IC("crossing_regulator"),
|
||
actor: IC("actor_pc"),
|
||
table: IC("table"),
|
||
journal: IC("journal"),
|
||
talent: IC("talent_generic"),
|
||
npc: IC("actor_npc"),
|
||
caseFile: IC("actor_case")
|
||
};
|
||
|
||
/* ---------------- item factories ---------------- */
|
||
|
||
function skillItem({ fam, spec, cat, base, name, extra = {} }) {
|
||
return {
|
||
_id: idFor(`skill:${fam}:${spec}`),
|
||
name, type: "skill", img: IC(SKILL_ICON[cat]) ?? ICON.skill,
|
||
system: {
|
||
description: "",
|
||
familyId: fam, specialisationId: spec, categoryId: cat,
|
||
base: { mode: "flat", value: base, formula: "" },
|
||
value: base,
|
||
trainingCap: null, transferGroup: "",
|
||
experienceTicked: false, experienceEligible: true, canBeTaught: true,
|
||
...extra
|
||
},
|
||
effects: [], ...BASE()
|
||
};
|
||
}
|
||
|
||
function weaponItem(w) {
|
||
const isMelee = w.cls === "melee";
|
||
return {
|
||
_id: idFor(`weapon:${w.key}`),
|
||
name: w.name, type: "weapon", img: IC(w.key) ?? (isMelee ? ICON.melee : ICON.weapon),
|
||
system: {
|
||
description: "", massKg: w.mass ?? 0, quantity: 1, equipped: false, carried: true,
|
||
traditionIds: [w.trad ?? "tradition_local"],
|
||
weaponClass: w.cls,
|
||
skillFamilyId: w.fam, skillSpecialisationId: w.spec,
|
||
damage: w.byBand
|
||
? { mode: "byBand", formula: "", short: w.byBand.short, medium: w.byBand.medium, long: w.byBand.long }
|
||
: { mode: "single", formula: w.dmg, short: "", medium: "", long: "" },
|
||
appliesDamageModifier: !!w.mod,
|
||
woundType: w.wound,
|
||
monofilament: !!w.mono,
|
||
fireMode: w.fire ?? "",
|
||
ranges: { short: w.ranges[0], medium: w.ranges[1], long: w.ranges[2] },
|
||
ammo: { capacity: w.cap ?? 0, loaded: w.cap ?? 0, reserve: 0, reloadRounds: w.fire === "slow" ? 2 : 1 },
|
||
energyPerShot: w.energy ?? 0,
|
||
damageTags: w.mono ? ["monofilament"] : (w.cls === "energy" ? ["energy"] : ["kinetic"]),
|
||
coherenceCost: w.coherence ?? 0
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: w.key, era: w.era ?? "modern" } }
|
||
};
|
||
}
|
||
|
||
function armourItem(a) {
|
||
return {
|
||
_id: idFor(`armour:${a.key}`),
|
||
name: a.name, type: "armour", img: IC(a.key) ?? ICON.armour,
|
||
system: {
|
||
coherenceCost: a.coherence ?? 0,
|
||
description: "", massKg: a.mass, quantity: 1, equipped: false, carried: true,
|
||
traditionIds: [a.trad ?? "tradition_local"],
|
||
points: a.points, coverage: a.covers ?? (a.shield ? SHIELD.coverage : "all"),
|
||
shield: !!a.shield, raised: false,
|
||
ignoredBy: [], degradation: { current: 0, max: a.tough ?? 4 }
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: a.key, era: a.era ?? "modern" } }
|
||
};
|
||
}
|
||
|
||
function gearItem(g) {
|
||
return {
|
||
_id: idFor(`gear:${g.key}`),
|
||
name: g.name, type: "gear", img: IC(g.key) ?? ICON.gear,
|
||
system: {
|
||
description: g.desc ? `<p>${g.desc}</p>` : "",
|
||
massKg: g.mass, quantity: 1, equipped: false, carried: true,
|
||
traditionIds: [g.trad ?? "tradition_home"],
|
||
powerDraw: 0, skillBonus: { skillFamilyId: g.bonus?.fam ?? "", value: g.bonus?.value ?? 0 },
|
||
researchFacility: g.facility ?? "",
|
||
coherenceCost: g.coherence ?? 0,
|
||
// A sight applies itself off the range band, and a lamp off the dark. Both are
|
||
// facts about what is in the pack rather than choices made at the trigger.
|
||
optic: { kind: g.optic ?? "", fits: g.fits ?? "any" },
|
||
lightSource: !!g.lit,
|
||
lightReachM: g.lit ? (g.reach ?? 10) : 0,
|
||
isAnchor: /anchor/i.test(g.name)
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: g.key, era: g.era ?? "modern" } }
|
||
};
|
||
}
|
||
|
||
function vehicleItem(v) {
|
||
return {
|
||
_id: idFor(`vehicle:${v.key}`),
|
||
name: v.name, type: "vehicle", img: IC(v.key) ?? ICON.gear,
|
||
system: {
|
||
description: v.desc ? `<p>${v.desc}</p>` : "",
|
||
// A vehicle is carried by nobody: massKg is what it weighs, not what it costs
|
||
// an agent to have. `carried` false keeps it out of the encumbrance sum.
|
||
massKg: v.mass ?? 0, quantity: 1, equipped: false, carried: false,
|
||
traditionIds: [v.trad ?? "tradition_home"],
|
||
skillFamilyId: v.fam ?? "pilot", skillSpecialisationId: v.spec ?? "ground_car",
|
||
crew: v.crew ?? 1, seats: v.seats ?? 0, cargoKg: v.cargo ?? 0,
|
||
speedBand: v.speed ?? "road", rangeKm: v.range ?? 0,
|
||
hull: { value: v.hull ?? 0, max: v.hull ?? 0 },
|
||
hullMax: v.hullMax ?? v.hull ?? 0,
|
||
spacecraft: !!v.spacecraft, heat: 0, signature: 0, band: "close", stations: [],
|
||
armourPoints: v.armour ?? 0, terrain: v.terrain ?? [],
|
||
powerDraw: 0, coherenceCost: v.coherence ?? 0,
|
||
era: v.era ?? "modern", notes: ""
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: v.key, era: v.era ?? "modern" } }
|
||
};
|
||
}
|
||
|
||
function deviceItem(d) {
|
||
return {
|
||
_id: idFor(`device:${d.key}`),
|
||
name: d.name, type: "device", img: ICON.device,
|
||
system: {
|
||
description: d.desc ?? "", massKg: d.mass, quantity: 1, equipped: false, carried: true,
|
||
traditionIds: d.traditions,
|
||
documentId: d.documentId, materialId: "standard",
|
||
integrity: d.integrity, functions: d.functions
|
||
},
|
||
effects: [], ...BASE()
|
||
};
|
||
}
|
||
|
||
/* ---------------- register expansion ---------------- */
|
||
|
||
/**
|
||
* A posting or trade expanded into the shape buildActor wants.
|
||
*
|
||
* Shared by the starter scenario and the duty roster, because two copies of this is
|
||
* two places for the induction rule to drift. Ratings are FIXED at the middle of each
|
||
* band rather than rolled: a pregen that varies between installs is not a pregen.
|
||
*
|
||
* `civilian` builds the person BEFORE the department found them: their trade and the
|
||
* tools of it, and nothing the agency ever handed them. No induction skills, no
|
||
* standing issue, no tier issue, no trade cant — that last one matters, because trade
|
||
* cant is the department's own argot and a civilian has never heard it spoken.
|
||
*/
|
||
const REG = await import("../postings.mjs");
|
||
/* expandFromRegister moved to ./expand-spec.mjs so the lethality harness can use the
|
||
SAME expansion the build uses. A simulated roster agent expanded by a second copy
|
||
of this logic is not the agent that gets packed. */
|
||
import { expandFromRegister } from "./expand-spec.mjs";
|
||
|
||
/* ---------------- actor factory ---------------- */
|
||
|
||
const SKILL_LOOKUP = new Map(
|
||
SKILL_CATALOGUE.map(([fam, spec, cat, base, name, extra]) => [`${fam}:${spec}`, { fam, spec, cat, base, name, extra }])
|
||
);
|
||
const WEAPON_LOOKUP = new Map(WEAPONS.map(w => [w.key, w]));
|
||
const ARMOUR_LOOKUP = new Map(ARMOURS.map(a => [a.key, a]));
|
||
const GEAR_LOOKUP = new Map(GEAR.map(g => [g.key, g]));
|
||
const DEVICE_LOOKUP = new Map(DEVICES.map(d => [d.key, d]));
|
||
|
||
function buildActor(spec, type) {
|
||
const ch = spec.ch;
|
||
const hp = hitPoints(ch.con, ch.siz);
|
||
const mw = majorWound(hp);
|
||
const style = styleFor(spec.chosenStyle ?? "reflex");
|
||
const actorId = idFor(`actor:${spec.key}`);
|
||
const stableKey = spec.key;
|
||
|
||
const items = [];
|
||
const push = (doc, suffix) => {
|
||
const copy = foundryClone(doc);
|
||
copy._id = idFor(`owned:${spec.key}:${suffix}`);
|
||
items.push(copy);
|
||
};
|
||
|
||
for (const s of spec.skills ?? []) {
|
||
const meta = SKILL_LOOKUP.get(`${s.fam}:${s.spec}`);
|
||
if (!meta) throw new Error(`Unknown skill ${s.fam}:${s.spec} on ${spec.name}`);
|
||
const doc = skillItem({ ...meta, cat: meta.cat, base: meta.base, name: meta.name, extra: meta.extra ?? {} });
|
||
doc.system.value = s.val;
|
||
push(doc, `skill:${s.fam}:${s.spec}`);
|
||
}
|
||
// Every skill, so the sheet reads like a BRP sheet rather than eleven entries
|
||
// scattered over seven categories. Trained ones were pushed above; the rest
|
||
// come in at printed base.
|
||
const trained = new Set((spec.skills ?? []).map(x => `${x.fam}:${x.spec}`));
|
||
for (const [fam, sp, cat, base, name, extra] of SKILL_CATALOGUE) {
|
||
if (trained.has(`${fam}:${sp}`)) continue;
|
||
const doc = skillItem({ fam, spec: sp, cat, base, name, extra: extra ?? {} });
|
||
doc.system.value = base;
|
||
push(doc, `skill:${fam}:${sp}`);
|
||
}
|
||
for (const key of spec.talents ?? []) {
|
||
const t = TALENTS.find(x => x.key === key);
|
||
if (!t) throw new Error(`Unknown talent ${key} on ${spec.name}`);
|
||
push(talentItem(t), `talent:${key}`);
|
||
}
|
||
// Everyone has hands. "Punch / kick" was in the catalogue and issued to nobody,
|
||
// so every agent had Brawl and no item to roll damage with — an unarmed attack
|
||
// was unrollable for every character in the game.
|
||
for (const key of [...new Set(["punch", ...(spec.weapons ?? [])])]) {
|
||
const w = WEAPON_LOOKUP.get(key);
|
||
if (!w) throw new Error(`Unknown weapon ${key} on ${spec.name}`);
|
||
const doc = weaponItem(w);
|
||
doc.system.equipped = true;
|
||
push(doc, `weapon:${key}`);
|
||
}
|
||
for (const key of spec.armour ?? []) {
|
||
const a = ARMOUR_LOOKUP.get(key);
|
||
if (!a) throw new Error(`Unknown armour ${key} on ${spec.name}`);
|
||
const doc = armourItem(a);
|
||
doc.system.equipped = true;
|
||
push(doc, `armour:${key}`);
|
||
}
|
||
for (const entry of spec.gear ?? []) {
|
||
const [key, qty] = Array.isArray(entry) ? entry : [entry, 1];
|
||
const g = GEAR_LOOKUP.get(key);
|
||
if (!g) throw new Error(`Unknown gear ${key} on ${spec.name}`);
|
||
const doc = gearItem(g);
|
||
doc.system.quantity = qty;
|
||
push(doc, `gear:${key}`);
|
||
}
|
||
for (const key of spec.devices ?? []) {
|
||
const d = DEVICE_LOOKUP.get(key);
|
||
if (!d) throw new Error(`Unknown device ${key} on ${spec.name}`);
|
||
push(deviceItem(d), `device:${key}`);
|
||
}
|
||
|
||
const system = {
|
||
characteristics: Object.fromEntries(
|
||
["str", "siz", "con", "int", "pow", "dex", "cha", "edu"].map(k => [k, { value: ch[k] ?? 10 }])
|
||
),
|
||
marksmanship: { style, chosenStyle: spec.chosenStyle ?? style },
|
||
hp: { value: hp, max: hp },
|
||
majorWound: { value: mw },
|
||
wounds: [],
|
||
familiarities: {
|
||
traditions: ["tradition_home"],
|
||
environments: spec.environments ?? []
|
||
},
|
||
combat: { defencesThisRound: 0, defenceTypeLock: "", stance: "normal", secondAttackUsed: false, aiming: { active: false, targetUuid: "", bonus: 0 } },
|
||
// Written explicitly rather than left to the template default. Every packed
|
||
// actor shipped as "hominid", a name from the Ringworld setting that is not a
|
||
// species this game defines — it only ever worked because the sheet falls back
|
||
// to baseline, so most of the cast was drawn and hit-located as something the
|
||
// rules do not contain.
|
||
// bodyPlan wins where it is given: a creature is hit-located as the BODY it has,
|
||
// which for anything on four legs is not the species it belongs to.
|
||
speciesProfile: spec.bodyPlan ?? spec.species ?? "baseline",
|
||
notes: ""
|
||
};
|
||
|
||
if (type === "character") {
|
||
system.age = spec.age ?? { chronological: 30, physiological: 30 };
|
||
if (spec.rank) system.rank = spec.rank;
|
||
system.pursuits = [];
|
||
system.biography = spec.bio ?? "";
|
||
} else {
|
||
system.role = spec.role ?? "";
|
||
system.morale = { downThreshold: 2, note: "" };
|
||
system.naturalArmour = { value: Number(spec.naturalArmour) || 0 };
|
||
system.tactics = spec.tactics ?? "";
|
||
}
|
||
|
||
// A generated portrait if the spec names one, the generic silhouette otherwise.
|
||
// The token gets the same face: a pregen you can recognise on the map is worth
|
||
// more at a convention table than a correct one you cannot tell apart.
|
||
/* Resolved by stem and extension rather than by an exact filename.
|
||
*
|
||
* Two things changed. Portraits are no longer only .webp — tools/make-portraits.mjs
|
||
* writes .svg for every creature, and tools/mj-queue.mjs files .png when one is
|
||
* illustrated by hand — and a creature no longer has to NAME its portrait to get one:
|
||
* art found under its own key is used. That is what lets sixty creatures gain art
|
||
* without sixty edits to content.mjs.
|
||
*
|
||
* Order matters. A hand-made raster always beats the generated diagram, so upgrading a
|
||
* creature is a matter of dropping the file in; nothing has to be un-said in source. */
|
||
const PORTRAIT_EXT = ["webp", "png", "jpg", "svg"];
|
||
const findPortrait = stem => {
|
||
for (const ext of PORTRAIT_EXT) {
|
||
const rel = `art/portraits/${stem}.${ext}`;
|
||
if (existsSync(path.join(ROOT, rel))) return rel;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
let face = ICON.actor;
|
||
const found = findPortrait(spec.portrait ?? spec.key);
|
||
// A spec that NAMES a portrait and has none is still a hard failure: that is a broken
|
||
// image on a pregen sheet, and nobody notices until it is on a projector at a table.
|
||
// A spec that names none and has none simply keeps the generic silhouette.
|
||
if (spec.portrait && !found) {
|
||
throw new Error(`${spec.name}: no portrait for "${spec.portrait}" `
|
||
+ `(looked for ${PORTRAIT_EXT.map(e => `.${e}`).join(", ")} in art/portraits/)`);
|
||
}
|
||
if (found) face = `systems/ringbrp/${found}`;
|
||
|
||
const actorDoc = {
|
||
_id: actorId,
|
||
name: spec.name, type, img: face,
|
||
system,
|
||
items: items.map(i => i._id), // Foundry LevelDB packs store an ID LIST here
|
||
effects: [],
|
||
prototypeToken: {
|
||
name: spec.name, displayName: 20, actorLink: type === "character",
|
||
width: 1, height: 1, texture: { src: face },
|
||
sight: { enabled: true }, disposition: type === "character" ? 1 : -1,
|
||
bar1: { attribute: "hp" }, bar2: { attribute: null },
|
||
flags: {}
|
||
},
|
||
...BASE(), flags: { ringbrp: { key: stableKey } }
|
||
};
|
||
return { actorDoc, itemDocs: items };
|
||
}
|
||
|
||
function foundryClone(o) { return JSON.parse(JSON.stringify(o)); }
|
||
|
||
|
||
function talentItem(t) {
|
||
const iconKey = t.cat === "species" ? "talent_species"
|
||
: t.cat === "agency" ? "talent_agency"
|
||
: t.cat === "field" ? "talent_field" : "talent_generic";
|
||
return {
|
||
_id: idFor(`talent:${t.key}`),
|
||
name: t.name, type: "talent", img: IC(iconKey) ?? ICON.talent,
|
||
system: {
|
||
description: `<p>${t.summary}</p><p><strong>${t.rule}</strong></p>`,
|
||
talentId: t.key, category: t.cat, summary: t.summary, rule: t.rule,
|
||
skillBonus: { skillFamilyId: t.bonus?.fam ?? "", value: t.bonus?.value ?? 0 },
|
||
grantsAction: t.action ?? "", coherenceCost: t.coherence ?? 0,
|
||
usesPerCase: t.uses ?? 0, usedThisCase: 0,
|
||
speciesId: t.species ?? ""
|
||
},
|
||
effects: [], ...BASE()
|
||
};
|
||
}
|
||
|
||
/* ---------------- table + journal factories ---------------- */
|
||
|
||
function tableDoc(spec, key) {
|
||
const id = idFor(`table:${key}`);
|
||
const results = spec.results.map((text, i) => ({
|
||
_id: idFor(`table:${key}:${i}`),
|
||
type: "text",
|
||
name: `${i + 1}`,
|
||
description: `<p>${text}</p>`,
|
||
img: ICON.table,
|
||
weight: 1,
|
||
range: [i + 1, i + 1],
|
||
drawn: false,
|
||
flags: {}
|
||
}));
|
||
return {
|
||
doc: {
|
||
_id: id, name: spec.name, img: ICON.table,
|
||
description: "", formula: spec.formula, replacement: true, displayRoll: true,
|
||
results: results.map(r => r._id), ...BASE()
|
||
},
|
||
results, id
|
||
};
|
||
}
|
||
|
||
function journalDoc(name, key, pages) {
|
||
const id = idFor(`journal:${key}`);
|
||
const pageDocs = pages.map((p, i) => ({
|
||
_id: idFor(`journal:${key}:${i}`),
|
||
name: p.name, type: "text",
|
||
title: { show: true, level: 1 },
|
||
text: { format: 1, content: p.content, markdown: "" },
|
||
image: {}, video: { controls: true, volume: 0.5 }, src: null,
|
||
system: {}, sort: (i + 1) * 100, ownership: { default: -1 }, flags: {},
|
||
category: null
|
||
}));
|
||
return { doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [], ...BASE() }, pages: pageDocs, id };
|
||
}
|
||
|
||
/**
|
||
* A journal of nothing but pictures, for showing to players.
|
||
*
|
||
* title.show is FALSE and there is no caption on purpose: when the GM hits Show Players
|
||
* the table gets the image and nothing else — no heading telling them which room this
|
||
* is, no label naming the thing they are looking at. The page NAME is for the GM's list
|
||
* in the sidebar, so they can find the right one in a hurry; the players never see it.
|
||
*/
|
||
function imageJournal(name, key, plates) {
|
||
const id = idFor(`journal:${key}`);
|
||
const pageDocs = plates.map((p, i) => {
|
||
// A missing plate is a blank frame on a projector in front of six people.
|
||
if (!existsSync(path.join(ROOT, p.src))) throw new Error(`${name}: no plate at ${p.src}`);
|
||
return {
|
||
_id: idFor(`journal:${key}:${i}`),
|
||
name: p.name, type: "image",
|
||
title: { show: false, level: 1 },
|
||
src: `systems/ringbrp/${p.src}`,
|
||
image: { caption: "" },
|
||
text: { format: 1, content: "", markdown: "" },
|
||
video: { controls: true, volume: 0.5 },
|
||
system: {}, sort: (i + 1) * 100, ownership: { default: 2 }, flags: {},
|
||
category: null
|
||
};
|
||
});
|
||
return {
|
||
doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [],
|
||
...BASE(), ownership: { default: 2 }, flags: { ringbrp: { brand: true } } },
|
||
pages: pageDocs, id
|
||
};
|
||
}
|
||
|
||
/* ---------------- writer ---------------- */
|
||
|
||
/**
|
||
* A compendium folder. Foundry stores these as !folders! documents inside the pack and
|
||
* an actor joins one by setting its `folder` to the folder id. Thirty-six pregens in a
|
||
* flat list is the difference between a GM finding a character and a GM giving up.
|
||
*/
|
||
function packFolder(packName, name, sort, type = "Actor") {
|
||
const _id = idFor(`folder:${packName}:${name}`);
|
||
return { _id, name, type, sorting: "a", sort, color: null, description: "",
|
||
folder: null, flags: {}, ...BASE() };
|
||
}
|
||
|
||
async function writePack(packName, entries) {
|
||
const dir = path.join(PACKS, packName);
|
||
await rm(dir, { recursive: true, force: true });
|
||
await mkdir(dir, { recursive: true });
|
||
const db = new ClassicLevel(dir, { valueEncoding: "json" });
|
||
await db.open();
|
||
const batch = db.batch();
|
||
for (const [key, value] of entries) batch.put(key, value);
|
||
await batch.write();
|
||
await db.close();
|
||
console.log(` ${packName.padEnd(10)} ${String(entries.length).padStart(4)} keys`);
|
||
}
|
||
|
||
/* ---------------- main ---------------- */
|
||
|
||
// Refuse to build if a rule has been duplicated. The packs used to be built under
|
||
// one rule and played under another; this makes that impossible to ship.
|
||
//
|
||
// fileURLToPath, not .pathname: on Windows a file URL's pathname is "/C:/Users/...",
|
||
// and node resolves that against the drive root, so every one of these spawned
|
||
// "C:\C:\Users\...\check-rules.mjs" and the build aborted on a module it could not
|
||
// find. npm run build has never worked on Windows. Same root as the packs/ CRLF bug —
|
||
// this repository has only ever been built on macOS and Linux.
|
||
{
|
||
const { execFileSync } = await import("node:child_process");
|
||
try {
|
||
execFileSync(process.execPath, [fileURLToPath(new URL("check-rules.mjs", import.meta.url))],
|
||
{ stdio: "inherit" });
|
||
// And refuse if a posting is issued kit the catalogue cannot supply.
|
||
execFileSync(process.execPath, [fileURLToPath(new URL("check-kits.mjs", import.meta.url))],
|
||
{ stdio: "inherit" });
|
||
// And refuse if the localisation file cannot be loaded, which un-localises
|
||
// every sheet in the system and gives no error the user would connect to it.
|
||
execFileSync(process.execPath, [fileURLToPath(new URL("check-lang.mjs", import.meta.url))],
|
||
{ stdio: "inherit" });
|
||
// And refuse if a template will not compile. A parse error in a .hbs is a sheet
|
||
// that never opens, and no other guard reads templates — npc-sheet.hbs shipped
|
||
// broken and every NPC in the system threw on click.
|
||
execFileSync(process.execPath, [fileURLToPath(new URL("check-templates.mjs", import.meta.url))],
|
||
{ stdio: "inherit" });
|
||
// And refuse if a rule a play-through already found is broken again. The static
|
||
// guards cannot tell whether a rule is READ; these tests can.
|
||
execFileSync(process.execPath, [fileURLToPath(new URL("check-behaviour.mjs", import.meta.url))],
|
||
{ stdio: "inherit" });
|
||
// And refuse a scenario that tells the GM to roll a skill nobody has. THROUGH TRAIN
|
||
// asked for Ride through several drafts and a playtest; there is no Ride skill.
|
||
execFileSync(process.execPath, [fileURLToPath(new URL("check-scenarios.mjs", import.meta.url))],
|
||
{ stdio: "inherit" });
|
||
// And refuse an actor spec the engine cannot build. buildActor throws on an unknown
|
||
// skill or item key, but says nothing about a bad species, a misspelt field or a
|
||
// species-locked talent on the wrong species — all of which pack and play.
|
||
execFileSync(process.execPath, [fileURLToPath(new URL("check-creatures.mjs", import.meta.url))],
|
||
{ stdio: "inherit" });
|
||
// And refuse a creature that has quietly become something else. The seven guards
|
||
// above check that content is well formed; none of them checks what it DOES, so a
|
||
// change to a damage modifier or an armour value could double a creature's lethality
|
||
// without touching one line of that creature.
|
||
// And refuse a body plan with a hole in its d20, a limb nothing draws, or a
|
||
// location kind that changes no number.
|
||
execFileSync(process.execPath, [fileURLToPath(new URL("check-anatomy.mjs", import.meta.url))],
|
||
{ stdio: "inherit" });
|
||
execFileSync(process.execPath, [fileURLToPath(new URL("check-lethality.mjs", import.meta.url))],
|
||
{ stdio: "inherit" });
|
||
} catch { console.error("build aborted: pre-build checks failed"); process.exit(1); }
|
||
}
|
||
|
||
console.log("Custodians | building compendium packs");
|
||
|
||
// skills
|
||
{
|
||
const entries = SKILL_CATALOGUE.map(([fam, spec, cat, base, name, extra]) => {
|
||
const doc = skillItem({ fam, spec, cat, base, name, extra: extra ?? {} });
|
||
return [`!items!${doc._id}`, doc];
|
||
});
|
||
await writePack("skills", entries);
|
||
}
|
||
|
||
// talents
|
||
await writePack("talents", TALENTS.map(t => { const d = talentItem(t); return [`!items!${d._id}`, d]; }));
|
||
|
||
// weapons
|
||
await writePack("weapons", WEAPONS.map(w => {
|
||
const d = weaponItem(w); return [`!items!${d._id}`, d];
|
||
}));
|
||
|
||
// armour
|
||
await writePack("armour", ARMOURS.map(a => {
|
||
const d = armourItem(a); return [`!items!${d._id}`, d];
|
||
}));
|
||
|
||
// gear + devices
|
||
{
|
||
const entries = [];
|
||
for (const g of GEAR) { const d = gearItem(g); entries.push([`!items!${d._id}`, d]); }
|
||
for (const dv of DEVICES) { const d = deviceItem(dv); entries.push([`!items!${d._id}`, d]); }
|
||
await writePack("gear", entries);
|
||
}
|
||
|
||
// vehicles — their own pack, because a lorry is not a piece of kit
|
||
{
|
||
const entries = VEHICLES.map(v => { const d = vehicleItem(v); return [`!items!${d._id}`, d]; });
|
||
await writePack("vehicles", entries);
|
||
}
|
||
|
||
// pregens — the six hand-written originals plus the ten-agent duty roster
|
||
{
|
||
const { ROSTER, BEFORE } = await import("./roster.mjs");
|
||
const roster = ROSTER.map(spec => {
|
||
const e = expandFromRegister(spec, { where: "roster" });
|
||
return { ...e, bio: `<p><strong>${e.label}, ${spec.rank}.</strong> ${e.blurb}</p>${spec.bio}` };
|
||
});
|
||
|
||
// The same ten before the department found them. Same key stem, same face, same
|
||
// characteristics — a person's STR does not change when they sign the Act. What
|
||
// changes is everything the job gave them, which is the whole point of the pair.
|
||
const { TRADES } = REG;
|
||
const before = ROSTER.map(spec => {
|
||
const b = BEFORE[spec.key];
|
||
if (!b) throw new Error(`roster: ${spec.name} has no "before" entry`);
|
||
const civ = expandFromRegister(
|
||
{ ...spec, key: `${spec.key}_before`, name: `${spec.name} (before)`,
|
||
role: undefined, trade: b.trade, rank: b.job,
|
||
age: { chronological: b.age, physiological: b.age },
|
||
// One talent, taken from the trade's own list rather than invented, so a
|
||
// civilian never holds a knack the department taught them.
|
||
talents: TRADES[b.trade].talents.slice(0, 1) },
|
||
{ where: "roster:before", civilian: true });
|
||
return { ...civ, bio: `<p><strong>${b.job}.</strong> ${civ.blurb}</p>${b.bio}` };
|
||
});
|
||
|
||
// The whole value of this set is that it holds NOTHING the department gave them, and
|
||
// that is a property of a flag passed three call-frames away. Check it here, where
|
||
// the objects actually are, rather than trusting the flag.
|
||
const { STANDING_ISSUE, INDUCTION } = REG;
|
||
for (const c of before) {
|
||
const carried = [...c.weapons, ...c.armour, ...c.gear];
|
||
// An item the trade itself owns stays, even when the department also hands it out:
|
||
// an interpreter had a notebook long before anyone issued her one. Only kit that
|
||
// could ONLY have come from the department is a failure.
|
||
const ownKit = TRADES[c.trade].kit;
|
||
const issued = carried.filter(k => !ownKit.includes(k)
|
||
&& (STANDING_ISSUE.includes(k) || INDUCTION.kit.includes(k)));
|
||
if (issued.length) throw new Error(`${c.name} is carrying issued kit: ${issued.join(", ")}`);
|
||
const odd = carried.filter(k => {
|
||
const it = GEAR_LOOKUP.get(k) ?? WEAPON_LOOKUP.get(k) ?? ARMOUR_LOOKUP.get(k);
|
||
return !!it && (it.era === "anomalous" || !!it.coherence);
|
||
});
|
||
if (odd.length) throw new Error(`${c.name} is carrying something anomalous: ${odd.join(", ")}`);
|
||
if (c.armour.length) throw new Error(`${c.name} is wearing armour: ${c.armour.join(", ")}`);
|
||
const guns = c.weapons.filter(k => WEAPON_LOOKUP.get(k)?.cls !== "melee");
|
||
if (guns.length) throw new Error(`${c.name} is carrying a firearm: ${guns.join(", ")}`);
|
||
// Same rule for skills: the linguist's core list contains trade cant, because it is
|
||
// a language and she is a linguist. Only a rating she could ONLY have been taught
|
||
// at the training centre is a failure.
|
||
const ownSkills = new Set([...TRADES[c.trade].core, ...TRADES[c.trade].support]);
|
||
const taught = c.skills.filter(s => {
|
||
const id = s.spec ? `${s.fam}:${s.spec}` : s.fam;
|
||
return !ownSkills.has(id) && (INDUCTION.skills.includes(id) || id === "language:trade_cant");
|
||
});
|
||
if (taught.length) throw new Error(`${c.name} knows induction skills: ${taught.map(s => s.spec ? `${s.fam}:${s.spec}` : s.fam).join(", ")}`);
|
||
}
|
||
|
||
const fDuty = packFolder("pregens", "Duty roster — agents", 0);
|
||
const fBefore = packFolder("pregens", "Before the department — civilians", 1);
|
||
const fOrig = packFolder("pregens", "Original pregens", 2);
|
||
const folderFor = spec =>
|
||
before.includes(spec) ? fBefore._id : roster.includes(spec) ? fDuty._id : fOrig._id;
|
||
|
||
const entries = [
|
||
[`!folders!${fDuty._id}`, fDuty],
|
||
[`!folders!${fBefore._id}`, fBefore],
|
||
[`!folders!${fOrig._id}`, fOrig]
|
||
];
|
||
for (const p of [...PREGENS, ...roster, ...before]) {
|
||
const { actorDoc, itemDocs } = buildActor(p, "character");
|
||
actorDoc.folder = folderFor(p);
|
||
entries.push([`!actors!${actorDoc._id}`, actorDoc]);
|
||
for (const it of itemDocs) entries.push([`!actors.items!${actorDoc._id}.${it._id}`, it]);
|
||
}
|
||
await writePack("pregens", entries);
|
||
}
|
||
|
||
// npcs
|
||
{
|
||
const entries = [];
|
||
for (const n of NPCS) {
|
||
const { actorDoc, itemDocs } = buildActor(n, "npc");
|
||
entries.push([`!actors!${actorDoc._id}`, actorDoc]);
|
||
for (const it of itemDocs) entries.push([`!actors.items!${actorDoc._id}.${it._id}`, it]);
|
||
}
|
||
await writePack("npcs", entries);
|
||
}
|
||
|
||
// tables
|
||
{
|
||
const entries = [];
|
||
for (const [spec, key] of [[SITE_TABLE, "sites"], [HAZARD_TABLE, "hazards"]]) {
|
||
const t = tableDoc(spec, key);
|
||
entries.push([`!tables!${t.id}`, t.doc]);
|
||
for (const r of t.results) entries.push([`!tables.results!${t.id}.${r._id}`, r]);
|
||
}
|
||
await writePack("tables", entries);
|
||
}
|
||
|
||
// PAPER WINDOW — the starter scenario, shipped as an Adventure so the whole thing
|
||
// imports in one click rather than as nine loose documents a GM has to assemble.
|
||
{
|
||
const S = await import("./scenario-starter.mjs");
|
||
|
||
// Expanded by the shared helper; the starter adds its own biography on top.
|
||
const expand = spec => {
|
||
const e = expandFromRegister(spec, { where: "starter" });
|
||
return { ...e,
|
||
bio: `<p><strong>${e.label}, ${spec.rank}.</strong> ${e.blurb}</p>`
|
||
+ `<p><strong>Your job in this case.</strong> ${spec.spotlight}</p>`
|
||
+ (spec.complication ? `<p><strong>Your complication.</strong> ${spec.complication}</p>` : "")
|
||
+ `<p class="rb-gm-note"><em>${spec.note}</em></p>` };
|
||
};
|
||
|
||
const actorDocs = [], itemDocsFor = new Map();
|
||
for (const spec of S.STARTER_TEAM) {
|
||
const { actorDoc, itemDocs } = buildActor(expand(spec), "character");
|
||
actorDoc.items = itemDocs; // Adventures embed full documents
|
||
actorDocs.push(actorDoc);
|
||
}
|
||
for (const spec of S.STARTER_CAST) {
|
||
const { actorDoc, itemDocs } = buildActor(spec, "npc");
|
||
actorDoc.items = itemDocs;
|
||
actorDocs.push(actorDoc);
|
||
}
|
||
|
||
// A case may carry no credential at all — BLACK PLATFORM's station is already shut
|
||
// to the public, so there is nobody to be somebody else in front of.
|
||
const A = S.STARTER_AUTHORITY;
|
||
const authId = A ? idFor(`item:${A.key}`) : null;
|
||
const authority = A ? {
|
||
_id: authId, name: A.name, type: "authority", img: IC("cover_id") ?? ICON.gear,
|
||
system: {
|
||
description: A.description,
|
||
holderName: A.holderName, holderRole: A.holderRole, issuer: A.issuer,
|
||
scope: A.scope, scopeNote: A.scopeNote,
|
||
trust: { value: A.trust, max: 4 },
|
||
expires: "", redFlags: A.redFlags, dossier: [],
|
||
establishesRemit: false, timesWidened: 0, mergeSteps: 0,
|
||
burned: false, suspect: false, aiPosture: "", notes: ""
|
||
},
|
||
effects: [], ...BASE(), flags: { ringbrp: { key: A.key } }
|
||
} : null;
|
||
|
||
// The case file, already at the scenario's opening state.
|
||
const C = S.STARTER_CASE;
|
||
const caseId = idFor(`actor:${C.key}`);
|
||
// Group ITEMS from the scenario data. resolveContact looks these up and returns null
|
||
// without one, so a standing with no group silently switches off READ/SIGNAL/OFFER.
|
||
const groupDocs = S.STARTER_GROUPS.map(g => ({
|
||
_id: idFor(`owned:${C.key}:group_${g.groupId}`),
|
||
name: g.name, type: "group", img: ICON.actor,
|
||
system: {
|
||
description: g.description,
|
||
groupId: g.groupId, speciesId: "baseline", population: g.population,
|
||
npcTemplateId: g.npcTemplateId,
|
||
taboos: g.taboos, desires: g.desires,
|
||
correctOfferForm: g.correctOfferForm,
|
||
rewardsByStanding: g.rewardsByStanding,
|
||
standing: 0, impression: 0
|
||
},
|
||
effects: [], ...BASE()
|
||
}));
|
||
|
||
const caseDoc = {
|
||
_id: caseId, name: C.name, type: "expedition", img: ICON.caseFile ?? ICON.actor,
|
||
system: {
|
||
integrityMode: "abstract",
|
||
integrity: { value: C.containment, max: 10 },
|
||
supply: { value: C.resources, max: 10 },
|
||
power: { value: C.clearance, max: 10 },
|
||
assets: [], members: S.STARTER_TEAM.map(t => t.key),
|
||
journey: {
|
||
destinationId: "rookery_heath", destinationName: C.destination,
|
||
destinationKnowledge: "briefed", currentLeg: 0, elapsedDays: 0,
|
||
siteTableId: "sites", hazardTableId: "hazards", returnClock: 0,
|
||
legs: C.legs.map((l, i) => ({
|
||
// `kind`, not `kindId` — resolveLegDialog reads leg.kind and falls back to a
|
||
// regex on the terrain text. Writing the wrong field worked only because
|
||
// "Extraction — closing the register" happens to start with the word.
|
||
index: i, terrain: l.label, mode: "foot", kind: l.kindId,
|
||
difficulty: l.difficulty, modifier: l.mod, days: 0,
|
||
explored: true, resolved: false, forcedEncounterId: "",
|
||
costSupply: 1, costPower: 0
|
||
}))
|
||
},
|
||
standings: C.standings.map(id => ({ groupId: id, value: 0, impression: 0, encounters: 0,
|
||
knownTaboos: [], knownDesires: [], pidginEstablished: false, obligations: [] })),
|
||
across: false, crossingModifier: 0, salvageEra: "modern",
|
||
notes: C.notes
|
||
},
|
||
items: groupDocs, effects: [],
|
||
prototypeToken: { name: C.name, displayName: 20, actorLink: true, width: 1, height: 1,
|
||
texture: { src: ICON.actor }, sight: { enabled: false }, disposition: 1,
|
||
bar1: { attribute: "integrity" }, bar2: { attribute: "supply" }, flags: {} },
|
||
...BASE()
|
||
};
|
||
actorDocs.push(caseDoc);
|
||
|
||
const gmJournal = journalDoc("PAPER WINDOW — Run the case", "starter_gm", S.STARTER_PAGES);
|
||
const handouts = journalDoc("PAPER WINDOW — Player handouts", "starter_handouts", S.STARTER_HANDOUTS);
|
||
// Handouts are for handing out. A GM who has to change ownership on seven pages
|
||
// before the session starts will simply read them aloud instead.
|
||
handouts.doc.ownership = { default: 2 };
|
||
handouts.doc.flags = { ringbrp: { brand: true } };
|
||
for (const p of handouts.pages) p.ownership = { default: 2 };
|
||
|
||
const advId = idFor("adventure:black_platform");
|
||
const adventure = {
|
||
_id: advId,
|
||
name: "BLACK PLATFORM — The 00:17 Service",
|
||
img: IC("actor_case") ?? ICON.caseFile ?? ICON.actor,
|
||
caption: "A six-player action starter. Three to four hours.",
|
||
description:
|
||
"<p><em>A train is running through a station whose rails were taken out in 1976. "
|
||
+ "Two engineers boarded it and have not come back. It returns every eleven minutes, "
|
||
+ "and every time it comes back there are more people on it.</em></p>"
|
||
+ "<p>Go down, take the platform, get aboard, find the engineers, fight forward along "
|
||
+ "a moving train, and stop it before it reaches a terminus that does not exist in "
|
||
+ "this world.</p>"
|
||
+ "<p>The complete starter: GM scenario, player handouts, six pregenerated agents who "
|
||
+ "can all fight, the ticket inspectors, the Conductor, the two casualties, and the "
|
||
+ "case file already set to Containment 9, Resources 8, Clearance 5 with its four "
|
||
+ "phases loaded.</p>",
|
||
actors: actorDocs,
|
||
items: authority ? [authority] : [],
|
||
journal: [
|
||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||
{ ...handouts.doc, pages: handouts.pages }
|
||
],
|
||
scenes: [], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||
sort: 0, ...BASE()
|
||
};
|
||
|
||
await writePack("starter", [[`!adventures!${advId}`, adventure]]);
|
||
console.log(` starter BLACK PLATFORM — ${actorDocs.length} actors, `
|
||
+ `${S.STARTER_PAGES.length} GM pages, ${S.STARTER_HANDOUTS.length} handouts`);
|
||
}
|
||
|
||
/**
|
||
* Culland Hall as a playable Scene.
|
||
*
|
||
* The map is DRAWN (art/maps/culland-hall.svg) rather than generated, because a room
|
||
* count, a door position and a label are all load-bearing in this scenario and a
|
||
* generator cannot be trusted with any of them. 2800x2000 at 100px to the square, so
|
||
* the Foundry grid lands exactly on the grid drawn into the image.
|
||
*
|
||
* No walls or lighting: the scenario is a conversation with a floor plan behind it, and
|
||
* a GM who wants line-of-sight can add them in ten minutes. Shipping half-done walls
|
||
* would be worse than shipping none.
|
||
*/
|
||
function cullandScene() {
|
||
const id = idFor("scene:culland_hall");
|
||
return {
|
||
_id: id,
|
||
name: "Culland Hall — ground floor and grounds",
|
||
navigation: true, navName: "Culland Hall", active: false,
|
||
background: { src: "systems/ringbrp/art/maps/culland-hall.webp" },
|
||
foreground: null, thumb: null,
|
||
width: 2800, height: 2000, padding: 0.1,
|
||
initial: { x: 1400, y: 1000, scale: 0.4 },
|
||
backgroundColor: "#0c125b",
|
||
grid: { type: 1, size: 100, style: "solidLines", thickness: 1,
|
||
color: "#6674d8", alpha: 0.12, distance: 5, units: "ft" },
|
||
tokenVision: false, fog: { exploration: false, overlay: null },
|
||
globalLight: true, darkness: 0,
|
||
environment: { globalLight: { enabled: true }, darknessLevel: 0 },
|
||
drawings: [], tokens: [], lights: [], sounds: [], regions: [], templates: [],
|
||
tiles: [], walls: [], notes: [],
|
||
playlist: null, playlistSound: null, journal: null, journalEntryPage: null,
|
||
weather: "", flags: { ringbrp: { key: "culland_hall" } },
|
||
...BASE()
|
||
};
|
||
}
|
||
|
||
// THROUGH TRAIN — the crossing starter. Unlike the two prologues this one has a real
|
||
// case file, because it is the first time the players are sent ACROSS and the case
|
||
// tracks are the point.
|
||
{
|
||
const X = await import("./scenario-throughtrain.mjs");
|
||
|
||
const castDocs = [];
|
||
for (const spec of X.TT_CAST) {
|
||
const { actorDoc, itemDocs } = buildActor(spec, "npc");
|
||
actorDoc.items = itemDocs;
|
||
castDocs.push(actorDoc);
|
||
}
|
||
|
||
// The case file, already at its opening state, with Standing Order 9 in the notes.
|
||
const C = X.TT_CASE;
|
||
const caseId = idFor(`actor:${C.key}`);
|
||
const caseDoc = {
|
||
_id: caseId, name: C.name, type: "expedition", img: ICON.caseFile ?? ICON.actor,
|
||
system: {
|
||
integrityMode: "abstract",
|
||
integrity: { value: C.containment, max: 10 },
|
||
supply: { value: C.resources, max: 10 },
|
||
power: { value: C.clearance, max: 10 },
|
||
assets: [], members: [],
|
||
journey: {
|
||
destinationId: "mercy_draw", destinationName: C.destination,
|
||
destinationKnowledge: "briefed", currentLeg: 0, elapsedDays: 0,
|
||
siteTableId: "sites", hazardTableId: "hazards", returnClock: 0,
|
||
legs: C.legs.map((l, i) => ({
|
||
index: i, terrain: l.label, mode: "foot", kind: l.kindId,
|
||
difficulty: l.difficulty, modifier: l.mod, days: 0,
|
||
explored: true, resolved: false, forcedEncounterId: "",
|
||
costSupply: 1, costPower: 0
|
||
}))
|
||
},
|
||
standings: [],
|
||
across: false, crossingModifier: 0, salvageEra: "antique",
|
||
notes: C.notes
|
||
},
|
||
items: [], effects: [],
|
||
prototypeToken: { name: C.name, displayName: 20, actorLink: true, width: 1, height: 1,
|
||
texture: { src: ICON.actor }, sight: { enabled: false }, disposition: 1,
|
||
bar1: { attribute: "integrity" }, bar2: { attribute: "supply" }, flags: {} },
|
||
...BASE()
|
||
};
|
||
castDocs.push(caseDoc);
|
||
|
||
const gmJournal = journalDoc("THROUGH TRAIN — Run the case", "tt_gm", X.TT_PAGES);
|
||
const handouts = journalDoc("THROUGH TRAIN — Player handouts", "tt_handouts", X.TT_HANDOUTS);
|
||
handouts.doc.ownership = { default: 2 };
|
||
handouts.doc.flags = { ringbrp: { brand: true } };
|
||
for (const pg of handouts.pages) pg.ownership = { default: 2 };
|
||
|
||
const showJournal = imageJournal("THROUGH TRAIN — Show the players", "tt_show", [
|
||
{ name: "1 · The Bureau", src: "art/scenes/tt_11_bureau.webp" },
|
||
{ name: "2 · Stores, and the 1881 issue", src: "art/scenes/tt_02_stores.webp" },
|
||
{ name: "3 · Mercy Draw, now", src: "art/scenes/tt_01_draw.webp" },
|
||
{ name: "4 · The north bench, 1881", src: "art/scenes/tt_12_northbench.webp" },
|
||
{ name: "5 · Cordera, 1881", src: "art/scenes/tt_04_cordera.webp" },
|
||
{ name: "6 · Six men across the street", src: "art/scenes/tt_13_standoff.webp" },
|
||
{ name: "7 · The grade on the south bench", src: "art/scenes/tt_03_bench.webp" },
|
||
{ name: "8 · The water tower — boarding", src: "art/scenes/tt_05_boarding.webp" },
|
||
{ name: "9 · Inside car three, between passes", src: "art/scenes/tt_06_car.webp" },
|
||
{ name: "10 · The four minutes", src: "art/scenes/tt_08_gunfight.webp" },
|
||
{ name: "11 · The payroll box, car four", src: "art/scenes/tt_09_payroll.webp" },
|
||
{ name: "12 · The gap between the cars", src: "art/scenes/tt_10_couple.webp" },
|
||
{ name: "13 · Afterwards, the register", src: "art/scenes/tt_07_after.webp" }
|
||
]);
|
||
|
||
const advId = idFor("adventure:through_train");
|
||
const adventure = {
|
||
_id: advId,
|
||
name: "THROUGH TRAIN — The Mercy Draw Crossing",
|
||
img: IC("actor_case") ?? ICON.caseFile ?? ICON.actor,
|
||
caption: "The crossing starter. Four hours in five acts, and nothing modern goes through.",
|
||
description:
|
||
"<p><em>A train has been running the same four miles of track since the third of "
|
||
+ "November, 1881. It has never stopped. There are sixty-one people on it and they "
|
||
+ "are all still alive.</em></p>"
|
||
+ "<p>A railway company laid track to a filed survey instead of to the ground, then "
|
||
+ "described the line as being where it was not — ten thousand times, for six "
|
||
+ "months, at a constant speed. Reality keeps a record of what belongs where, and "
|
||
+ "that is the most patient argument there is.</p>"
|
||
+ "<p>The running is what holds the crossing open. The agents have to board a train "
|
||
+ "that cannot stop, get sixty-one people off it, and decide what to do about the "
|
||
+ "arithmetic when they find they cannot.</p>"
|
||
+ "<p><strong>Five acts, four hours.</strong> An induction at the Bureau; a crossing "
|
||
+ "that puts them where the paperwork says the railway is, which is an empty bench; "
|
||
+ "an investigation to find the line at all; six unpaid miners in a Cordera street "
|
||
+ "who think they are company detectives; forty seconds of slow track at a water "
|
||
+ "tower; and four minutes that have run about two million times.</p>"
|
||
+ "<p>Also the case that teaches Standing Order 9: <strong>nothing modern goes "
|
||
+ "through</strong>. Eight GM pages, ten handouts including the survey that started "
|
||
+ "it, the case file, fourteen of the cast, and thirteen plates to show.</p>",
|
||
actors: castDocs,
|
||
items: [],
|
||
journal: [
|
||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||
{ ...handouts.doc, pages: handouts.pages },
|
||
{ ...showJournal.doc, pages: showJournal.pages }
|
||
],
|
||
scenes: [], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||
sort: 0, ...BASE()
|
||
};
|
||
|
||
await writePack("throughtrain", [[`!adventures!${advId}`, adventure]]);
|
||
console.log(` throughtr THROUGH TRAIN — ${castDocs.length} actors, `
|
||
+ `${X.TT_PAGES.length} GM pages, ${X.TT_HANDOUTS.length} handouts, `
|
||
+ `${showJournal.pages.length} plates`);
|
||
}
|
||
|
||
// LAST ADMISSION — the two-hour prologue, and the one to run by default. Same shape as
|
||
// OPEN DAY's adventure and for the same reason: journals and cast only, because the
|
||
// player characters are the "(before)" pregens in the Pregens pack.
|
||
{
|
||
const L = await import("./scenario-lastadmission.mjs");
|
||
|
||
const castDocs = [];
|
||
for (const spec of L.LA_CAST) {
|
||
const { actorDoc, itemDocs } = buildActor(spec, "npc");
|
||
actorDoc.items = itemDocs;
|
||
castDocs.push(actorDoc);
|
||
}
|
||
|
||
// The plates, one per page, for showing at the table. Named for what the GM will be
|
||
// looking for under pressure, ordered by when it comes up.
|
||
const showJournal = imageJournal("LAST ADMISSION — Show the players", "la_show", [
|
||
{ name: "1 · The house, from the drive", src: "art/scenes/la_01_hall.webp" },
|
||
{ name: "2 · Floor plan — Culland Hall", src: "art/maps/culland-hall.webp" },
|
||
{ name: "3 · Backstage — the green room", src: "art/scenes/la_06_green.webp" },
|
||
{ name: "4 · The east corridor", src: "art/scenes/la_03_corridor.webp" },
|
||
{ name: "5 · The nursery", src: "art/scenes/la_04_nursery.webp" },
|
||
{ name: "6 · The glasshouse — Old Marrow", src: "art/scenes/la_05_glass.webp" },
|
||
{ name: "7 · The stairs — the woman standing", src: "art/scenes/la_07_stairs.webp" },
|
||
{ name: "8 · The car park, afterwards", src: "art/scenes/la_08_carpark.webp" }
|
||
]);
|
||
|
||
const gmJournal = journalDoc("LAST ADMISSION — Run the prologue", "la_gm", L.LA_PAGES);
|
||
const handouts = journalDoc("LAST ADMISSION — Player handouts", "la_handouts", L.LA_HANDOUTS);
|
||
handouts.doc.ownership = { default: 2 };
|
||
handouts.doc.flags = { ringbrp: { brand: true } };
|
||
for (const pg of handouts.pages) pg.ownership = { default: 2 };
|
||
|
||
const advId = idFor("adventure:last_admission");
|
||
const adventure = {
|
||
_id: advId,
|
||
name: "LAST ADMISSION — The Culland Haunting",
|
||
img: IC("journal") ?? ICON.journal,
|
||
caption: "The two-hour prologue. A haunted house, and a fight on the lawn.",
|
||
description:
|
||
"<p><em>Twelve thousand people paid to be told this house was haunted. It was "
|
||
+ "listening.</em></p>"
|
||
+ "<p>The last night of a cheap scare attraction in a real empty hall. Three of the "
|
||
+ "ghosts on the poster were invented in an afternoon by a marketing agency and "
|
||
+ "performed forty times a night for six weeks. They are in the building now, and "
|
||
+ "they have a page each, and they do the page.</p>"
|
||
+ "<p>There is a fourth name on the poster. The copywriter took it off a memorial "
|
||
+ "in the parish church because the layout needed four. She was real, nobody wrote "
|
||
+ "her a page, and she wants her name off the wall.</p>"
|
||
+ "<p>Two hours. One house, one decision, and something to hit. Played with the "
|
||
+ "second intake's <strong>“(before)”</strong> sheets from the Pregens "
|
||
+ "compendium.</p>",
|
||
actors: castDocs,
|
||
items: [],
|
||
journal: [
|
||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||
{ ...handouts.doc, pages: handouts.pages },
|
||
{ ...showJournal.doc, pages: showJournal.pages }
|
||
],
|
||
scenes: [cullandScene()], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||
sort: 0, ...BASE()
|
||
};
|
||
|
||
await writePack("lastadmission", [[`!adventures!${advId}`, adventure]]);
|
||
console.log(` lastadm LAST ADMISSION — ${castDocs.length} cast, `
|
||
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts, `
|
||
+ `${showJournal.pages.length} plates to show`);
|
||
}
|
||
|
||
// STANDING ORDER — the three-act crossing case. Journals, plates, the section map, and
|
||
// the two cast actors the Act Two detention needs. The GM pages are read from the
|
||
// scenario document itself at build time (see scenario-standingorder.mjs). Played on
|
||
// the duty-roster sheets in the Pregens pack.
|
||
{
|
||
const SO = await import("./scenario-standingorder.mjs");
|
||
|
||
const castDocs = [];
|
||
for (const spec of SO.SO_CAST) {
|
||
const { actorDoc, itemDocs } = buildActor(spec, "npc");
|
||
actorDoc.items = itemDocs;
|
||
castDocs.push(actorDoc);
|
||
}
|
||
|
||
const showJournal = imageJournal("STANDING ORDER — Show the players", "so_show", SO.SO_PLATES);
|
||
const facesJournal = imageJournal("STANDING ORDER — The people", "so_faces", SO.SO_FACES);
|
||
const gmJournal = journalDoc("STANDING ORDER — Run the case", "so_gm", SO.SO_PAGES);
|
||
const handouts = journalDoc("STANDING ORDER — Player handouts", "so_handouts", SO.SO_HANDOUTS);
|
||
handouts.doc.ownership = { default: 2 };
|
||
handouts.doc.flags = { ringbrp: { brand: true } };
|
||
for (const pg of handouts.pages) pg.ownership = { default: 2 };
|
||
|
||
const sceneId = idFor("scene:bellhouse");
|
||
const bellhouse = {
|
||
...cullandScene(),
|
||
_id: sceneId,
|
||
name: "Bellhouse — section",
|
||
navName: "Bellhouse",
|
||
background: { src: "systems/ringbrp/art/maps/bellhouse-section.webp" },
|
||
width: 2400, height: 1700,
|
||
initial: { x: 1200, y: 850, scale: 0.45 },
|
||
grid: { type: 0, size: 100, style: "solidLines", thickness: 1,
|
||
color: "#6674d8", alpha: 0, distance: 5, units: "m" },
|
||
flags: { ringbrp: { key: "bellhouse" } }
|
||
};
|
||
const plans = SO.SO_SCENES.map((sc, i) => ({
|
||
...cullandScene(),
|
||
_id: idFor(`scene:${sc.key}`),
|
||
name: sc.name,
|
||
navName: sc.nav,
|
||
background: { src: `systems/ringbrp/${sc.src}` },
|
||
initial: { x: 1400, y: 1000, scale: 0.4 },
|
||
backgroundColor: "#123f78",
|
||
grid: { type: 1, size: 100, style: "solidLines", thickness: 1,
|
||
color: "#e8f1fb", alpha: 0.08, distance: 5, units: "ft" },
|
||
sort: (i + 1) * 100,
|
||
flags: { ringbrp: { key: sc.key } }
|
||
}));
|
||
|
||
const advId = idFor("adventure:standing_order");
|
||
const adventure = {
|
||
_id: advId,
|
||
name: "STANDING ORDER — The Bellhouse Seat",
|
||
img: IC("journal") ?? ICON.journal,
|
||
caption: "A three-act crossing case. A thousand people underground, and the man who writes to them.",
|
||
description:
|
||
"<p><em>A thousand people are waiting under Sheldon Moor for the all-clear from a war "
|
||
+ "that never happened. Every ninety days, somebody writes to tell them to keep "
|
||
+ "waiting. It is not the government.</em></p>"
|
||
+ "<p>The agents cross into a sealed 1974 Regional Seat of Government that has run "
|
||
+ "itself like a council for fifty-two years. The brief is to bring its people home, "
|
||
+ "diplomatically. The Seat does not trust newcomers, its Controller has been told to "
|
||
+ "count them, and on Thursday it will surface and speak to the region in words that "
|
||
+ "are not the government's.</p>"
|
||
+ "<p>Three acts, 3h00 of play in a 3h15 slot. One detention, one broadcast, and the "
|
||
+ "second appearance of Mr Smith. Played on the duty-roster sheets from the Pregens "
|
||
+ "compendium.</p>",
|
||
actors: castDocs,
|
||
items: [],
|
||
journal: [
|
||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||
{ ...handouts.doc, pages: handouts.pages },
|
||
{ ...showJournal.doc, pages: showJournal.pages },
|
||
{ ...facesJournal.doc, pages: facesJournal.pages }
|
||
],
|
||
scenes: [bellhouse, ...plans], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||
sort: 0, ...BASE()
|
||
};
|
||
|
||
await writePack("standingorder", [[`!adventures!${advId}`, adventure]]);
|
||
console.log(` standing STANDING ORDER — ${castDocs.length} cast, ${SO.SO_PAGES.length} GM pages, `
|
||
+ `${SO.SO_HANDOUTS.length} handouts, ${showJournal.pages.length} plates, `
|
||
+ `${facesJournal.pages.length} faces, ${1 + plans.length} scenes`);
|
||
}
|
||
|
||
// OPEN DAY — the prologue. A much smaller adventure than BLACK PLATFORM: no case file,
|
||
// no contact groups, no authority, no combat. The player characters are the ten
|
||
// "(before)" pregens already in the Pregens pack — shipping a second copy here would
|
||
// give a GM two sets of the same ten people and no way to know which one their notes
|
||
// were on.
|
||
{
|
||
const O = await import("./scenario-openday.mjs");
|
||
|
||
const castDocs = [];
|
||
for (const spec of O.OPENDAY_CAST) {
|
||
const { actorDoc, itemDocs } = buildActor(spec, "npc");
|
||
actorDoc.items = itemDocs;
|
||
castDocs.push(actorDoc);
|
||
}
|
||
|
||
const gmJournal = journalDoc("OPEN DAY — Run the prologue", "openday_gm", O.OPENDAY_PAGES);
|
||
const handouts = journalDoc("OPEN DAY — Player handouts", "openday_handouts", O.OPENDAY_HANDOUTS);
|
||
handouts.doc.ownership = { default: 2 };
|
||
handouts.doc.flags = { ringbrp: { brand: true } };
|
||
for (const pg of handouts.pages) pg.ownership = { default: 2 };
|
||
|
||
const advId = idFor("adventure:open_day");
|
||
const adventure = {
|
||
_id: advId,
|
||
name: "OPEN DAY — Eleven Minutes",
|
||
img: IC("journal") ?? ICON.journal,
|
||
caption: "The prologue. Two and a quarter hours, played as civilians.",
|
||
description:
|
||
"<p><em>For eleven minutes the building was on a different street. You were inside "
|
||
+ "it.</em></p>"
|
||
+ "<p>A decommissioned telephone exchange, a heritage open day, a tea urn, and a "
|
||
+ "retired engineer who has waited thirty years to show somebody how the machine "
|
||
+ "worked. Nothing attacks anybody. Nothing wants anything. Eleven minutes pass on "
|
||
+ "a timetable nobody can alter, and afterwards each of them writes down what they "
|
||
+ "saw and does not cross any of it out.</p>"
|
||
+ "<p>Played with the ten <strong>“(before)”</strong> pregens from the "
|
||
+ "Pregens compendium — the duty roster as civilians, before any of this. Eight GM "
|
||
+ "pages, seven handouts, and the five people they meet.</p>",
|
||
actors: castDocs,
|
||
items: [],
|
||
journal: [
|
||
{ ...gmJournal.doc, pages: gmJournal.pages },
|
||
{ ...handouts.doc, pages: handouts.pages }
|
||
],
|
||
scenes: [], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
|
||
sort: 0, ...BASE()
|
||
};
|
||
|
||
await writePack("prologue", [[`!adventures!${advId}`, adventure]]);
|
||
console.log(` prologue OPEN DAY — ${castDocs.length} NPCs, `
|
||
+ `${O.OPENDAY_PAGES.length} GM pages, ${O.OPENDAY_HANDOUTS.length} handouts`);
|
||
}
|
||
|
||
// The player primer. Its own pack, OBSERVER by default, because it is the one thing
|
||
// in the system written to be handed to players before anybody has read a rule.
|
||
{
|
||
const { PRIMER_PAGES } = await import("./primer-text.mjs");
|
||
const j = journalDoc("The Custodians — Read this first", "primer", PRIMER_PAGES);
|
||
j.doc.ownership = { default: 2 };
|
||
j.doc.flags = { ringbrp: { brand: true } };
|
||
for (const pg of j.pages) pg.ownership = { default: 2 };
|
||
const entries = [[`!journal!${j.id}`, j.doc]];
|
||
for (const pg of j.pages) entries.push([`!journal.pages!${j.id}.${pg._id}`, pg]);
|
||
await writePack("primer", entries);
|
||
console.log(` primer ${PRIMER_PAGES.length} pages, player-visible`);
|
||
}
|
||
|
||
// the slice journal
|
||
{
|
||
const { SLICE_PAGES } = await import("./slice-text.mjs");
|
||
const j = journalDoc("The Ilfracombe Crossing", "slice", SLICE_PAGES);
|
||
j.doc.flags = { ringbrp: { brand: true } };
|
||
const entries = [[`!journal!${j.id}`, j.doc]];
|
||
for (const p of j.pages) entries.push([`!journal.pages!${j.id}.${p._id}`, p]);
|
||
await writePack("slice", entries);
|
||
}
|
||
|
||
// The Quiet Arts. Their own pack, because they are not gear and not talents.
|
||
{
|
||
const { POWERS } = await import("./content.mjs");
|
||
const { powerCostFor } = await import("../rules.mjs");
|
||
const entries = POWERS.map(a => {
|
||
const id = idFor(`power:${a.key}`);
|
||
return [`!items!${id}`, {
|
||
_id: id, name: a.name, type: "power", img: IC(`power_${a.art}`) ?? ICON.talent,
|
||
system: {
|
||
description: `<p><em>${a.summary}</em></p><p>${a.rule}</p><p><strong>Pushed.</strong> ${a.push}</p>`,
|
||
artId: a.art, summary: a.summary, rule: a.rule, pushRule: a.push,
|
||
value: 0,
|
||
coherenceCost: powerCostFor(a.art),
|
||
pushCost: powerCostFor(a.art, { pushed: true }),
|
||
origin: "", experienceTicked: false
|
||
},
|
||
effects: [], ...BASE(),
|
||
flags: { ringbrp: { key: a.key } }
|
||
}];
|
||
});
|
||
await writePack("powers", entries);
|
||
console.log(` powers ${entries.length} Arts`);
|
||
}
|
||
|
||
// The rules, generated from rules.mjs so the book cannot drift from the engine.
|
||
{
|
||
const { RULES_PAGES } = await import("./rules-text.mjs");
|
||
const j = journalDoc("The Custodians — Rules", "rules", RULES_PAGES);
|
||
j.doc.flags = { ringbrp: { brand: true } };
|
||
const entries = [[`!journal!${j.id}`, j.doc]];
|
||
for (const p of j.pages) entries.push([`!journal.pages!${j.id}.${p._id}`, p]);
|
||
await writePack("rules", entries);
|
||
console.log(` rules ${RULES_PAGES.length} pages`);
|
||
}
|
||
|
||
// the configured expedition, with its project, discovery and contact group
|
||
{
|
||
const expId = idFor("actor:ilfracombe_case");
|
||
const projId = idFor("owned:expedition:project");
|
||
const discId = idFor("owned:expedition:discovery");
|
||
const grpId = idFor("owned:expedition:group_hanging");
|
||
|
||
const project = {
|
||
_id: projId, name: "Permeability field initiation", type: "project", img: ICON.skill,
|
||
system: {
|
||
description: "<p>The question the whole expedition turns on.</p>",
|
||
projectId: "regulator_operation_project",
|
||
question: "How is a threshold held open past its window?",
|
||
status: "active", maxResearchers: 3, currentStep: 0,
|
||
yieldsDiscoveryId: "regulator_operation",
|
||
steps: [
|
||
{ stepType: "field", objective: "Recover an intact regulator control plate from the abandoned vehicle.",
|
||
skillFamilyId: "engineering", skillSpecialisationId: "field_systems",
|
||
difficulty: "average", resolveInPlay: true, hours: null, hoursRemaining: null,
|
||
facility: null, state: "pending", isSetback: false },
|
||
{ stepType: "laboratory", objective: null,
|
||
skillFamilyId: "engineering", skillSpecialisationId: "field_systems",
|
||
difficulty: "difficult", resolveInPlay: false, hours: 200, hoursRemaining: 200,
|
||
facility: "portable", state: "pending", isSetback: false },
|
||
{ stepType: "armchair", objective: null,
|
||
skillFamilyId: "research", skillSpecialisationId: "",
|
||
difficulty: "average", resolveInPlay: false, hours: 160, hoursRemaining: 160,
|
||
facility: null, computerAccess: true, state: "pending", isSetback: false }
|
||
],
|
||
complications: []
|
||
}, effects: [], ...BASE()
|
||
};
|
||
|
||
const discovery = {
|
||
_id: discId, name: "Initiating a permeability field", type: "discovery", img: ICON.device,
|
||
system: {
|
||
description: "<p>Not yet earned. Completing the project creates it.</p><p>It does two things: it unlocks <strong>operate</strong>, and it drops <strong>repair</strong> from Difficult-with-no-analogue to Average at −20% familiarity. Research does not merely grant permission — it makes the work possible.</p>",
|
||
discoveryId: "regulator_operation", earned: false,
|
||
question: "How is a threshold held open past its window?",
|
||
unlocks: [{ documentId: "crossing_regulator", functionId: "operate" }],
|
||
publishable: true, published: false
|
||
}, effects: [], ...BASE()
|
||
};
|
||
|
||
const group = {
|
||
_id: grpId, name: "The Quiet Neighbours", type: "group", img: ICON.actor,
|
||
system: {
|
||
description: "<p>Roughly thirty of them. They have lived beside the crossing point for four generations and have rules about it that nobody has written down.</p>",
|
||
groupId: "quiet_neighbours", speciesId: "baseline", population: 30,
|
||
npcTemplateId: "quiet_neighbours_npc",
|
||
taboos: [{ id: "ground_speech", text: "Will not converse with anyone standing on the ground. Addressing them from ground level is the offence — embarrassment, not hostility.", penaltyIfViolated: -30 }],
|
||
desires: [{ id: "medicine", text: "Antibiotics. Their own ran out two generations ago and they have watched the same three illnesses take people ever since.", offerBonusStep: "easy" }],
|
||
correctOfferForm: "A sealed course of antibiotics, handed over by someone standing above ground level — a stair, a vehicle roof, a low wall.",
|
||
/* Structured so the engine can actually apply them. `text` is what the GM
|
||
reads out; `effect` is what the phase resolver acts on. */
|
||
rewardsByStanding: {
|
||
"1": [
|
||
{ id: "r1_guide_in", text: "They walk you to the beacon by a path that avoids the road.",
|
||
effect: { type: "cancelModifier", phase: 2 } },
|
||
{ id: "r1_shelter", text: "You may shelter with them. The next phase costs nothing.",
|
||
effect: { type: "waiveCost", phase: 2 } }
|
||
],
|
||
"2": [
|
||
{ id: "r2_guide", text: "Ambaro walks the cliff path with you and knows where it gives way.",
|
||
effect: { type: "skillBonus", skillFamilyId: "navigate", value: 20, phases: [2, 3] } },
|
||
{ id: "r2_cliff", text: "The cliff path is walked in daylight, on their timing.",
|
||
effect: { type: "cancelModifier", phase: 3 } }
|
||
],
|
||
"3": [
|
||
{ id: "r3_supplies", text: "They give what they can spare, which is more than they can spare.",
|
||
effect: { type: "track", track: "supply", delta: 2 } },
|
||
{ id: "r3_truth", text: "They tell you what happened to the first team.",
|
||
effect: { type: "reveal", note: "The first team went down to the shore on the fourth night and did not come back up. Two of them were carrying something between them." } }
|
||
]
|
||
}
|
||
}, effects: [], ...BASE()
|
||
};
|
||
|
||
const exp = {
|
||
_id: expId, name: "Case File: ILFRACOMBE", type: "expedition", img: ICON.actor,
|
||
system: {
|
||
integrityMode: "abstract",
|
||
supply: { value: 8, max: 10 }, power: { value: 6, max: 10 }, integrity: { value: 10, max: 10 },
|
||
// The case file ships with its team. Bare ids, which is the shape the phase
|
||
// resolver reads; an empty members list made the case file unusable on import.
|
||
assets: [], members: PREGENS.map(g => g.key), // stable keys: raw ids do not survive import
|
||
journey: {
|
||
destinationId: "ilfracombe", destinationName: "Ilfracombe",
|
||
destinationKnowledge: "rumoured", currentLeg: 0, elapsedDays: 0,
|
||
siteTableId: "sites", hazardTableId: "hazards",
|
||
legs: [
|
||
{ index:0, terrain:"Silted river flats", mode:"foot", difficulty:"difficult", modifier:0, days:4, explored:true, resolved:false, forcedEncounterId:"", costSupply:1, costPower:0 },
|
||
{ index:1, terrain:"Disused railway cutting", mode:"foot", difficulty:"difficult", modifier:10, days:3, explored:true, resolved:false, forcedEncounterId:"The Quiet Neighbours — contact at the END of this phase", costSupply:1, costPower:0 },
|
||
{ index:2, terrain:"Overgrown allotments", mode:"foot", difficulty:"difficult", modifier:-10, days:5, explored:false, resolved:false, forcedEncounterId:"", costSupply:1, costPower:0 },
|
||
{ index:3, terrain:"Cliff path above the cove", mode:"foot", difficulty:"difficult", modifier:-20, days:6, explored:false, resolved:false, forcedEncounterId:"Cleaners at the midpoint", costSupply:1, costPower:0 }
|
||
]
|
||
},
|
||
standings: [{ groupId:"quiet_neighbours", value:0, impression:0, encounters:0,
|
||
knownTaboos:[], knownDesires:[], pidginEstablished:false, obligations:[] }],
|
||
notes: "<p>Critical-path invariant: the eye-storm never takes the brone, and Power can never be exhausted to a dead end — the rim-wall site is a Power-recovery installation.</p>"
|
||
},
|
||
items: [projId, discId, grpId], effects: [],
|
||
prototypeToken: { name: "Case File: ILFRACOMBE", displayName: 20, actorLink: true,
|
||
width: 1, height: 1, texture: { src: ICON.actor }, sight: { enabled: false },
|
||
disposition: 1, bar1: { attribute: "supply" }, bar2: { attribute: "power" }, flags: {} },
|
||
...BASE()
|
||
};
|
||
|
||
const entries = [[`!actors!${expId}`, exp]];
|
||
for (const it of [project, discovery, group]) entries.push([`!actors.items!${expId}.${it._id}`, it]);
|
||
await writePack("expedition", entries);
|
||
}
|
||
|
||
console.log("RingBRP | packs built");
|