Selectable fire, with the modifiers on the choice Every weapon has carried a fireMode since the packs were first built and nothing read it: a musket could rip a ten-round burst and "rapid" bought a weapon nothing. FIRE_MODE now gates the burst rules and carries its own attack modifier — slow +10% and then you work for the next one, fast a second shot at -20% to both, rapid bursts, beam +5% and it can be held on the target for damage instead of hits. The gate lives in burstAttack too, so no macro can route round it. `fast` was on five catalogue weapons and was not in the system's list of modes at all, so re-saving one of them silently blanked it; the list is derived from the rule now. burstAttack shipped complete and was reachable from nothing. It is the resolution path the selector chooses, and takes the dialog's whole stack rather than charging the player twice for the same burst. Sights and lamps that apply themselves A sight is graded by range band, so a telescopic sight is a -10% liability inside ten metres and worth +20% at extreme, and it only counts if it fits. The band is already known when the modifier is wanted, so nothing is selected and nothing is forgotten. The dark costs something now; a carried lamp buys it back to dim, never to daylight, and the card says it shows them exactly where you are. A migration fits gear that predates the fields, matched on the stamped key rather than by name. The damage card The last card still wearing Foundry's stock dice styling — it posted a bare Roll and let the core template dress it. It renders through the module's own card now, with the Roll still on the message so dice stay inspectable. A rail instead of a strip Eight tabs across an 820px sheet compressed to 11px type. They run down the side now, one line each, with counts as badges; the sheet is 152px wider to pay for it. Also fixed: any weapon with a Coherence cost could not be attacked with AT ALL — the handler re-read the weapon after an await, when currentTarget is already null, and the attack died on a TypeError with nothing shown. And the duplicate-rule guard failed an honest table because FIRE_MODE and speedBands share two key names; it wants a majority now, and prints partial overlaps rather than swallowing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
624 lines
26 KiB
JavaScript
624 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,
|
||
// 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,
|
||
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()
|
||
};
|
||
}
|
||
|
||
/* ---------------- 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");
|