A Foundry VTT game system: Basic Roleplaying d100 in a drab British agency that deals with crossings, where reality keeps a record of what belongs where and can be made to get it wrong. Contents: - rules.mjs as the single authority for every rule formula, with 40 rules and 132 spot-checked values. Nothing may restate a formula it defines. - 25 postings, each with a key characteristic, trained skills, talents and its own kit; a 175-item catalogue across four eras; 231 generated icons. - Reaction Lanes initiative, re-rolled every round including the first. - Coherence, and the Quiet Arts, which only ever arrive from a crossing gone wrong. - Graded defences: only a critical stops a critical. - Borrowed Authority — access as a record rather than a hacking roll. - A case generator, and a rules journal generated from rules.mjs so the book cannot drift from the code. Built compendia are committed so a remote Foundry needs only the files. node_modules is not; `npm install` restores it for rebuilding content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
618 lines
26 KiB
JavaScript
618 lines
26 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 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";
|
||
|
||
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, ignoredBy: [], degradation: { current: 0, max: 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,
|
||
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 },
|
||
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()
|
||
};
|
||
}
|
||
|
||
/* ---------------- 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.
|
||
speciesProfile: 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 ?? "";
|
||
}
|
||
|
||
const actorDoc = {
|
||
_id: actorId,
|
||
name: spec.name, type, img: ICON.actor,
|
||
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: ICON.actor },
|
||
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 };
|
||
}
|
||
|
||
/* ---------------- writer ---------------- */
|
||
|
||
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.
|
||
{
|
||
const { execFileSync } = await import("node:child_process");
|
||
try {
|
||
execFileSync(process.execPath, [new URL("check-rules.mjs", import.meta.url).pathname],
|
||
{ stdio: "inherit" });
|
||
// And refuse if a posting is issued kit the catalogue cannot supply.
|
||
execFileSync(process.execPath, [new URL("check-kits.mjs", import.meta.url).pathname],
|
||
{ 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, [new URL("check-lang.mjs", import.meta.url).pathname],
|
||
{ 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
|
||
{
|
||
const entries = [];
|
||
for (const p of PREGENS) {
|
||
const { actorDoc, itemDocs } = buildActor(p, "character");
|
||
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);
|
||
}
|
||
|
||
// the slice journal
|
||
{
|
||
const { SLICE_PAGES } = await import("./slice-text.mjs");
|
||
const j = journalDoc("The Ilfracombe Crossing", "slice", SLICE_PAGES);
|
||
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);
|
||
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");
|