Ten trades, an induction layer, and rolling the stats yourself

TRADES: ten ordinary jobs the department recruits from — construction worker, doctor,
professor, engineer, law enforcement, IT specialist, scientist, linguist, entertainer,
criminal. They are not postings. A posting is what the department made of you; a trade
is what you were before any of it.

A trade character is built in two halves. The trade is genuine expertise on the same
bands a posting's skills use — a doctor is a proper doctor. INDUCTION goes on top at
25-40%: Tradecraft, Anomaly Lore, First Aid, Firearm (Pistol), Dodge, plus a sidearm, a
vest, cordon kit, a cover identity and a ward. The band is low on purpose; six weeks is
not a career, and that gap is most of what it feels like to play one.

Induction never demotes: a former police officer keeps pistol 58 from the trade rather
than being re-taught it at 26.

The creation dialog gains a "Recruited as" switch with its own trade list and card
(which shows both halves), and check-kits now holds trades and induction to the same
standard as postings — both new guard paths verified by breaking them.

Rolling them yourself: a tickbox that walks the eight characteristics one at a time,
each roll announced to chat so the dice actually fall, "Take them" locked until all
eight are down. A hand-rolled character is taken exactly as it fell — no best-of-three
on the key stat, no species shift. Verified both ways.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
slaguru666
2026-08-11 21:48:06 +01:00
co-authored by Claude Opus 5
parent 53eb72c100
commit 79acc8227f
19 changed files with 433 additions and 48 deletions
+299 -31
View File
@@ -3938,6 +3938,114 @@ export const ROLES = {
* An agent used to leave creation with five things and an anchor, which is not what
* a real posting looks like — most of this job is small equipment and paperwork.
*/
/**
* TRADES — where the recruit came from before any of this.
*
* A posting is what the department made of you. A TRADE is what you were, and the
* department does not recruit specialists in crossings because there is no such
* degree: it recruits a doctor, a joiner, a detective sergeant, and puts them
* through six weeks at the training centre.
*
* So a trade character is built in two halves. The trade is genuine expertise, rated
* at the same bands a posting's skills are — a doctor is a real doctor. INDUCTION is
* the thin agency layer on top, and it is deliberately thin, because six weeks is not
* a career and the gap between the two is the whole texture of playing one.
*/
export const TRADES = {
builder: { label: "Construction Worker",
blurb: "Twenty years of sites, scaffold and other people's mistakes. Knows what a building is doing before it does it.", stat: "str",
core: ["athletics","repair:mechanical","melee_weapon:prybar","spot","brawl"],
support: ["demolition","engineering:field_systems","first_aid","listen","dodge"],
talents: ["hard_landing","first_through","clean_scene","unblinking"],
kit: ["prybar","toolkit","ear_defenders","site_overalls","groundsheet"] },
doctor: { label: "Doctor",
blurb: "A&E, mostly. Used to people at their worst and to being the calmest thing in the room.", stat: "edu",
core: ["medicine","first_aid","science:biology","insight","knowledge"],
support: ["research","status","persuade","listen","spot"],
talents: ["triage","debriefer","unblinking","clean_scene"],
kit: ["medkit","trauma_unit","analgesics","specimen_jars","swab_kit"] },
professor: { label: "Professor",
blurb: "A chair in something narrow, and a reading list nobody else has finished. Argues for a living.", stat: "edu",
core: ["knowledge","research","anthropology","language:own","status"],
support: ["insight","persuade","science:botany","listen","art"],
talents: ["debriefer","read_room","requisition","wrong_memory"],
kit: ["carbon_book","notebook","spectacles","clipboard","quill_ink"] },
engineer: { label: "Engineer",
blurb: "Chartered, and unimpressed. Wants to know what it is made of and what happens when it stops.", stat: "int",
core: ["repair:mechanical","repair:electronics","engineering:field_systems","computer_use","demolition"],
support: ["athletics","spot","knowledge","pilot:ground_car","first_aid"],
talents: ["hard_landing","requisition","chain_custody","first_through"],
kit: ["toolkit","spare_cells","probe","power_cell","field_slate"] },
police: { label: "Law Enforcement",
blurb: "Fifteen years on division. Knows how a statement is taken and how a scene is lost.", stat: "con",
core: ["firearm:pistol","spot","insight","brawl","tradecraft"],
support: ["fast_talk","listen","athletics","dodge","first_aid"],
talents: ["read_room","chain_custody","clean_scene","first_through"],
kit: ["revolver","restraints","stab_vest","evidence_tape","clipboard"] },
it: { label: "IT Specialist",
blurb: "Second line, then infrastructure. Has read the logs nobody else reads and believes them over people.", stat: "int",
core: ["computer_use","repair:electronics","research","knowledge","spot"],
support: ["insight","listen","tradecraft","sleight_of_hand","engineering:field_systems"],
talents: ["requisition","chain_custody","debriefer","echo_sense"],
kit: ["data_wafer","scanner","toolkit","cell_stack","field_slate"] },
scientist: { label: "Scientist",
blurb: "Postdoc, a lab, and a method. Will not say a thing is impossible, only that it is not yet measured.", stat: "edu",
core: ["science:physics","science:biology","research","knowledge","repair:electronics"],
support: ["science:botany","computer_use","spot","first_aid","listen"],
talents: ["chain_custody","requisition","debriefer","echo_sense"],
kit: ["scanner","specimen_jars","geiger","thermometer","sample_case"] },
linguist: { label: "Linguist",
blurb: "Six languages properly and four enough to argue in. Hears grammar where other people hear noise.", stat: "edu",
core: ["language:own","language:sign","language:trade_cant","anthropology","insight"],
support: ["research","knowledge","persuade","listen","status"],
talents: ["debriefer","read_room","wrong_memory","cover_work"],
kit: ["dictaphone","notebook","wax_tablet","carbon_book","translator_pin"] },
entertainer: { label: "Entertainer",
blurb: "Clubs, cruise ships and one season on television. Can hold a room that does not want to be held.", stat: "cha",
core: ["art","persuade","fast_talk","status","insight"],
support: ["athletics","sleight_of_hand","listen","language:own","dodge"],
talents: ["cover_work","read_room","debriefer","ambidextrous"],
kit: ["dictaphone","cover_id","camera","pocket_watch","vacuum_flask"] },
criminal: { label: "Criminal",
blurb: "Nothing organised. Doors, locks and other people's assumptions, and a caution the department has quietly lost.", stat: "dex",
core: ["stealth","sleight_of_hand","fast_talk","spot","dodge"],
support: ["brawl","melee_weapon:knife","bargain","insight","athletics"],
talents: ["cover_work","clean_scene","ambidextrous","read_room"],
kit: ["lock_rakes","dagger","master_keys","door_wedges","black_mirror"] }
};
/**
* INDUCTION — the agency half of a trade character.
*
* Six weeks at the training centre: how to hold a cordon, how to write it up, which
* end of the issued sidearm to point, and enough Anomaly Lore to know what you are
* looking at. It is rated LOW on purpose. A doctor recruited last year is still a
* far better doctor than they are a Custodian, and that gap is the point of playing
* one — a posting is a career, this is a course.
*/
/** The eight the data model defines, and what each is rolled with. */
export const CHARACTERISTIC_DICE = {
str: "3d6", con: "3d6", siz: "2d6+6", int: "2d6+6",
pow: "3d6", dex: "3d6", cha: "3d6", edu: "3d6+3"
};
export const INDUCTION = {
label: "Induction",
blurb: "Six weeks at the training centre, and a warrant card that is still stiff.",
band: [25, 40],
skills: ["tradecraft", "anomaly_lore", "first_aid", "firearm:pistol", "dodge"],
kit: ["pistol_light", "stab_vest", "cordon_kit", "cover_id", "ward"]
};
export const STANDING_ISSUE = ["anchor", "handset", "warrant_card", "dressings",
"torch", "chalk", "whistle", "notebook"];
@@ -4026,29 +4134,41 @@ async function rollTotal(formula) { return (await new Roll(formula).evaluate()).
* case immediately. Pass any subset to pin those choices and leave the rest random.
*/
export async function generateCharacter({
name = null, role = null, tier = "officer", species = null,
rank = null, extraTalents = [], toChat = true
name = null, role = null, trade = null, tier = "officer", species = null,
rank = null, extraTalents = [], characteristics = null, toChat = true
} = {}) {
// A TRADE character is built from where they came from plus the induction course,
// rather than from a posting. Everything downstream — kit, talents, bonus points —
// is the same machinery, so `R` is whichever table was asked for.
const isTrade = !!(trade && TRADES[trade]);
// An unrecognised role used to reach `R.stat` as undefined and throw, which turns
// a typo in a macro into a stack trace. Fall back the way tier already does.
const roleKey = (role && ROLES[role]) ? role : pick(Object.keys(ROLES));
const roleKey = isTrade ? trade
: ((role && ROLES[role]) ? role : pick(Object.keys(ROLES)));
const tierKey = TIERS[tier] ? tier : "officer";
const R = ROLES[roleKey], T = TIERS[tierKey];
const R = isTrade ? TRADES[trade] : ROLES[roleKey];
const T = TIERS[tierKey];
// Baseline is common but not universal; a bare call should sometimes surprise you.
const SP = SPECIES[species ?? pick(["baseline","baseline","baseline","vesh","cadence"])] ?? SPECIES.baseline;
// characteristics: BRP rolls, nudged so the role's key stat is actually good
const ch = {};
// Exactly the eight the data model defines. This used to roll `app` and `mas`,
// which do not exist, so the Vesh APP shift was modifying a dead field.
for (const [k, f] of Object.entries({ str: "3d6", con: "3d6", siz: "2d6+6", int: "2d6+6",
pow: "3d6", dex: "3d6", cha: "3d6", edu: "3d6+3" })) {
ch[k] = await rollTotal(f);
// Characteristics rolled by hand at the table are taken exactly as they fell: no
// nudging the key stat, no species shift applied twice. If you rolled it, it is
// yours, and that is the entire point of having rolled it.
if (characteristics) {
for (const k of Object.keys(CHARACTERISTIC_DICE)) {
ch[k] = Math.max(1, Number(characteristics[k]) || 0);
}
} else {
// Exactly the eight the data model defines. This used to roll `app` and `mas`,
// which do not exist, so the Vesh APP shift was modifying a dead field.
for (const [k, f] of Object.entries(CHARACTERISTIC_DICE)) ch[k] = await rollTotal(f);
// Best of the original and two focused rolls: the role's stat is never weak,
// and Containment is strong enough to carry what Containment is issued.
ch[R.stat] = Math.max(ch[R.stat], await rollTotal("2d6+6"), await rollTotal("2d6+6"));
for (const [k, d] of Object.entries(SP.shift ?? {})) ch[k] = Math.max(3, (ch[k] ?? 10) + d);
}
// Best of the original and two focused rolls: the role's stat is never weak,
// and Containment is strong enough to carry what Containment is issued.
ch[R.stat] = Math.max(ch[R.stat], await rollTotal("2d6+6"), await rollTotal("2d6+6"));
for (const [k, d] of Object.entries(SP.shift ?? {})) ch[k] = Math.max(3, (ch[k] ?? 10) + d);
const finalName = name ?? `${pick(NAME_POOLS.GIVEN)} ${pick(NAME_POOLS.FAMILY)}`;
const age = rndInt(26, 54);
@@ -4081,6 +4201,16 @@ export async function generateCharacter({
};
for (const k of R.core) add(k, T.core);
for (const k of R.support) add(k, T.support);
// The agency half. Added AFTER the trade, and skipped where the trade already
// covers it — a former police officer does not un-learn their sidearm to be taught
// it again at a worse number.
if (isTrade) {
for (const k of INDUCTION.skills) {
const doc = byKey.get(k); if (!doc) continue;
if (items.some(i => i.name === doc.name)) continue;
add(k, INDUCTION.band);
}
}
// spread the tier's bonus points over the core skills so no two agents are identical
const language = byKey.get("language:trade_cant");
if (language && !items.some(i => i.name === language.name)) {
@@ -4103,8 +4233,9 @@ export async function generateCharacter({
const missingKit = [];
// Standing issue, then the posting's kit, then what seniority adds, then one
// thing that is the agent's own. Duplicates fall out on the name check below.
const issue = [...STANDING_ISSUE, ...R.kit, ...(TIER_ISSUE[tierKey] ?? []),
pick(PERSONAL_EFFECTS)];
const issue = [...STANDING_ISSUE, ...R.kit,
...(isTrade ? INDUCTION.kit : []),
...(TIER_ISSUE[tierKey] ?? []), pick(PERSONAL_EFFECTS)];
for (const key of issue) {
const doc = kitDocs.find(d => d.name && keyMatches(d, key));
if (!doc) { missingKit.push(key); continue; }
@@ -4453,12 +4584,101 @@ export async function grantFullSkillList(actor) {
* fastest path is: open, press Create. Change anything you care about and leave
* the rest. "Randomise" re-rolls the unlocked choices.
*/
/**
* ROLL THEM YOURSELF.
*
* The generator can produce eight characteristics in a millisecond and nobody at a
* table has ever wanted that. This is the other way: one die roll at a time, each one
* announced to chat so the dice actually fall — Dice So Nice and all — and the number
* lands in front of you before you know what the next one is.
*
* Purely theatre. It returns exactly what the automatic path would have returned, and
* a character rolled this way is taken AS ROLLED: no nudging the key stat upward, no
* species shift applied on top. You rolled it; it is yours.
*/
export async function rollCharacteristicsDialog({ actorName = "" } = {}) {
const keys = Object.keys(CHARACTERISTIC_DICE);
const rows = keys.map(k => `
<tr data-ch="${k}">
<th>${game.i18n.localize(`RINGBRP.Char.${k}`)}</th>
<td class="rb-roll-formula">${CHARACTERISTIC_DICE[k]}</td>
<td class="rb-roll-value" data-value="">&mdash;</td>
<td><a class="rb-cbtn rb-roll-one" data-ch="${k}">${game.i18n.localize("RINGBRP.Gen.RollIt")}</a></td>
</tr>`).join("");
const content = `<form class="rb-dialog rb-rollup">
<p class="notes">${game.i18n.localize("RINGBRP.Gen.RollHint")}</p>
<table class="rb-table rb-rolltable"><tbody>${rows}</tbody></table>
<div class="rb-rollup-foot">
<a class="rb-cbtn rb-roll-rest">${game.i18n.localize("RINGBRP.Gen.RollRest")}</a>
<span class="rb-rollup-count" data-count></span>
</div>
</form>`;
return new Promise(resolve => {
const dlg = new (rbDialogClass())({
title: actorName
? game.i18n.format("RINGBRP.Gen.RollFor", { name: actorName })
: game.i18n.localize("RINGBRP.Gen.RollTitle"),
content,
render: html => {
const el = html[0] ?? html;
const done = () => keys.filter(k => el.querySelector(`[data-ch="${k}"] .rb-roll-value`)?.dataset.value).length;
const refresh = () => {
const n = done();
el.querySelector("[data-count]").textContent =
game.i18n.format("RINGBRP.Gen.RolledCount", { n, of: keys.length });
const ok = el.closest(".window-app")?.querySelector("[data-button=take], .dialog-button.take button, button.take");
if (ok) ok.disabled = n < keys.length;
};
const rollOne = async k => {
const cell = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`);
if (!cell || cell.dataset.value) return; // rolled is rolled
const roll = rbDressDice(await new Roll(CHARACTERISTIC_DICE[k]).evaluate());
await roll.toMessage({
flavor: game.i18n.format("RINGBRP.Gen.RolledChar",
{ ch: game.i18n.localize(`RINGBRP.Char.${k}`) })
});
cell.dataset.value = String(roll.total);
cell.textContent = String(roll.total);
cell.classList.add("rb-rolled");
refresh();
};
el.querySelectorAll(".rb-roll-one").forEach(a =>
a.addEventListener("click", () => rollOne(a.dataset.ch)));
el.querySelector(".rb-roll-rest").addEventListener("click", async () => {
for (const k of keys) await rollOne(k);
});
refresh();
},
buttons: {
take: { label: game.i18n.localize("RINGBRP.Gen.TakeThem"),
callback: html => {
const el = html[0] ?? html;
const out = {};
for (const k of keys) {
const v = el.querySelector(`tr[data-ch="${k}"] .rb-roll-value`)?.dataset.value;
if (!v) return resolve(null); // incomplete: cancel
out[k] = Number(v);
}
resolve(out);
} },
cancel: { label: game.i18n.localize("RINGBRP.Dialog.Cancel"), callback: () => resolve(null) }
},
default: "take",
close: () => resolve(null)
});
dlg.render(true);
});
}
export async function generateDialog(kind = "character") {
const isPC = kind === "character";
const opts = (obj, sel) => Object.entries(obj).map(([k, v]) =>
`<option value="${k}" ${k === sel ? "selected" : ""}>${v.label}</option>`).join("");
const rnd = a => a[Math.floor(Math.random() * a.length)];
const roleSel = rnd(Object.keys(ROLES));
const tradeSel = rnd(Object.keys(TRADES));
const tierSel = "officer";
const threatSel = rnd(Object.keys(THREATS));
@@ -4468,9 +4688,19 @@ export async function generateDialog(kind = "character") {
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Species")}</label>
<select name="species"><option value="random">${game.i18n.localize("RINGBRP.Gen.RandomEach")}</option>
${opts(SPECIES, "baseline")}</select></div>
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Role")}</label>
${isPC ? `
<div class="form-group rb-source"><label>${game.i18n.localize("RINGBRP.Gen.Source")}</label>
<select name="source">
<option value="posting" selected>${game.i18n.localize("RINGBRP.Gen.SourcePosting")}</option>
<option value="trade">${game.i18n.localize("RINGBRP.Gen.SourceTrade")}</option>
</select></div>` : ""}
<div class="form-group" data-pick="posting"><label>${game.i18n.localize("RINGBRP.Gen.Role")}</label>
<select name="role"><option value="random" selected>${game.i18n.localize("RINGBRP.Gen.RandomEach")}</option>
${opts(ROLES, roleSel)}</select></div>
${isPC ? `
<div class="form-group" data-pick="trade" style="display:none"><label>${game.i18n.localize("RINGBRP.Gen.Trade")}</label>
<select name="trade"><option value="random" selected>${game.i18n.localize("RINGBRP.Gen.RandomEach")}</option>
${opts(TRADES, tradeSel)}</select></div>` : ""}
<div class="rb-rolecard" data-rolecard>
<p class="rb-rolecard-blurb">${game.i18n.localize("RINGBRP.Gen.RoleRandomBlurb")}</p>
</div>
@@ -4483,6 +4713,11 @@ export async function generateDialog(kind = "character") {
${opts(THREATS, threatSel)}</select></div>`}
<div class="form-group"><label>${game.i18n.localize("RINGBRP.Gen.Count")}</label>
<input type="number" name="count" value="1" min="1" max="12"/></div>
${isPC ? `
<div class="form-group">
<label class="rb-check"><input type="checkbox" name="handroll"/> ${game.i18n.localize("RINGBRP.Gen.HandRoll")}</label>
<p class="notes">${game.i18n.localize("RINGBRP.Gen.HandRollHint")}</p>
</div>` : ""}
<p class="notes">${game.i18n.localize("RINGBRP.Gen.Hint")}</p>
</form>`;
@@ -4504,8 +4739,8 @@ export async function generateDialog(kind = "character") {
// A dropdown of twenty-five job titles tells you nothing about what any of them
// does. Describe the posting as it is selected, with the numbers that follow from
// it, so the choice is informed rather than a guess at a name.
const roleCard = key => {
const R = ROLES[key];
const roleCard = (key, table = ROLES) => {
const R = table[key];
if (!R) return `<p class="rb-rolecard-blurb">${game.i18n.localize("RINGBRP.Gen.RoleRandomBlurb")}</p>`;
// Real catalogue names, not tidied-up ids: "Quill and ink horn", not "Quill Ink".
const skill = id => skillNames.get(id) ?? id;
@@ -4518,6 +4753,12 @@ export async function generateDialog(kind = "character") {
<dd>${R.core.map(skill).join(" · ")}</dd>
<dt>${game.i18n.localize("RINGBRP.Gen.Draws")}</dt>
<dd>${kitNames.join(" · ")}</dd>
${table === TRADES ? `
<dt>${game.i18n.localize("RINGBRP.Gen.PlusInduction")}</dt>
<dd>${INDUCTION.skills.map(skill).join(" · ")}
<span class="rb-dim">${INDUCTION.band[0]}&ndash;${INDUCTION.band[1]}%</span></dd>
<dt>${game.i18n.localize("RINGBRP.Gen.PlusIssue")}</dt>
<dd>${INDUCTION.kit.map(k => kitNames_.get(k) ?? k.replace(/_/g, " ")).join(" · ")}</dd>` : ""}
</dl>`;
};
@@ -4527,11 +4768,23 @@ export async function generateDialog(kind = "character") {
content,
render: html => {
const el = html[0] ?? html;
const sel = el.querySelector("[name=role]");
const card = el.querySelector("[data-rolecard]");
if (!sel || !card) return;
const paint = () => { card.innerHTML = roleCard(sel.value); };
sel.addEventListener("change", paint);
const roleSel_ = el.querySelector("[name=role]");
const tradeSel_ = el.querySelector("[name=trade]");
const source = el.querySelector("[name=source]");
if (!card) return;
const paint = () => {
const asTrade = source?.value === "trade";
el.querySelector('[data-pick="posting"]').style.display = asTrade ? "none" : "";
const tradeRow = el.querySelector('[data-pick="trade"]');
if (tradeRow) tradeRow.style.display = asTrade ? "" : "none";
card.innerHTML = asTrade
? roleCard(tradeSel_?.value, TRADES)
: roleCard(roleSel_?.value, ROLES);
};
roleSel_?.addEventListener("change", paint);
tradeSel_?.addEventListener("change", paint);
source?.addEventListener("change", paint);
paint();
},
buttons: {
@@ -4550,18 +4803,32 @@ export async function generateDialog(kind = "character") {
const made = [];
// Party mode: when several PCs are made with the role left random,
// guarantee coverage rather than rolling five Field Leads.
const roleFixed = each("role");
const roster = (isPC && !roleFixed && count > 1)
? pickN(Object.keys(ROLES), Math.min(count, Object.keys(ROLES).length))
const asTrade = isPC && el.querySelector("[name=source]")?.value === "trade";
const table = asTrade ? TRADES : ROLES;
const fixed = each(asTrade ? "trade" : "role");
const roster = (isPC && !fixed && count > 1)
? pickN(Object.keys(table), Math.min(count, Object.keys(table).length))
: [];
// Rolled by hand, once per character — the whole point is watching each
// one fall, so eight rolls happen before the next agent is started.
const byHand = isPC && el.querySelector("[name=handroll]")?.checked;
for (let i = 0; i < count; i++) {
const name = count > 1 && v("name") ? `${v("name")} ${i + 1}` : v("name");
const role = roleFixed ?? roster[i] ?? pick(Object.keys(ROLES));
const which = fixed ?? roster[i] ?? pick(Object.keys(table));
let characteristics = null;
if (byHand) {
characteristics = await rollCharacteristicsDialog({
actorName: name || game.i18n.format("RINGBRP.Gen.Nth", { n: i + 1 }) });
if (!characteristics) break; // cancelled: stop, keep what exists
}
made.push(isPC
? await generateCharacter({ name, role, tier: each("tier") ?? "officer",
species: each("species") ?? "baseline",
})
: await generateNPC({ name, role, threat: each("threat"), species: each("species") ?? "baseline" }));
? await generateCharacter({ name, characteristics,
role: asTrade ? null : which,
trade: asTrade ? which : null,
tier: each("tier") ?? "officer",
species: each("species") ?? "baseline" })
: await generateNPC({ name, role: which, threat: each("threat"),
species: each("species") ?? "baseline" }));
}
if (made.length === 1) made[0].sheet.render(true);
ui.notifications?.info(game.i18n.format("RINGBRP.Gen.Made", { n: made.length }));
@@ -6263,7 +6530,8 @@ Hooks.once("init", () => {
restFor, endOfCase, gearBonusFor,
hasTalent, talentAvailable, useTalent, requisition, armCleanScene, resetTalents,
generateCharacter, generateNPC, generateDialog, installContent, grantFullSkillList,
ROLES, TIERS, THREATS, SPECIES,
ROLES, TRADES, INDUCTION, CHARACTERISTIC_DICE, rollCharacteristicsDialog,
TIERS, THREATS, SPECIES,
npcTarget, npcShouldWithdraw, declareNpcTarget,
setReaction,
locationFor, locationMaxHp, resolveLocationHit, hitLocationRoll