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
+55
View File
@@ -4653,3 +4653,58 @@ came out at 32.9 of 33 kg, which is as close to the line as this kit should get.
The shield ships **slung**. Raising it is a choice made at the table, which is the whole
design — and for the Warden it is the difference between two points of armour and a
real one.
## Trades — recruited from an ordinary job
Ten of them, and they are not postings. A posting is what the department made of you;
a **trade** is what you were before any of it, because there is no degree in crossings
and the department has to recruit somebody. So it recruits a doctor, a joiner, a
detective sergeant, and puts them through six weeks at the training centre.
Construction Worker · Doctor · Professor · Engineer · Law Enforcement · IT Specialist ·
Scientist · Linguist · Entertainer · Criminal.
**A trade character is built in two halves, and the join is the whole point.**
* The **trade** is genuine expertise, rated on exactly the same bands a posting's
skills are. A doctor is a proper doctor: Medicine 55, Biology 59.
* **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, on top of standing issue.
That band is low deliberately. Six weeks is not a career, and a surgeon recruited last
year is a far better surgeon than they are a Custodian. Ten generated agents show the
gap plainly: trade skills land in the high forties and fifties, induction in the
twenties and thirties, on the same sheet.
**Induction never demotes you.** A former police officer already trains Firearm
(Pistol) and Tradecraft, so induction skips them rather than re-teaching them at a
worse number — the generated Law Enforcement agent carries pistol 58 and tradecraft 58
from the trade, not 26 from the course. They do end up with two sidearms, their own
revolver and the issued pistol, which reads as correct rather than as a bug.
**check-kits now guards trades and induction too.** Same standard as a posting: every
kit key must name a real item, every skill and talent must resolve, no posting may be
issued a weapon it cannot use, and INDUCTION is checked once because a typo in it is a
typo in ten characters at once. Both new paths were verified by breaking them:
criminal (Criminal): issued Marksman rifle, trains no firearm:rifle
induction: issues Compact pistol, teaches no firearm:pistol
## Rolling them yourself
The generator can produce eight characteristics in a millisecond and nobody at a table
has ever wanted that. There is now a tickbox — *Roll the characteristics myself* — that
walks them one at a time, each roll announced to chat so the dice actually fall, with
"Take them" locked until all eight are down and a rolled characteristic standing once
it is rolled.
**A hand-rolled character is taken exactly as it fell.** The automatic path takes the
best of three for the posting's key stat and applies the species shift; the hand-rolled
path does neither, and the code says why. Verified: eight nines passed in came back as
eight nines for both a baseline and a vesh, while the automatic path on the same
posting produced STR 17.
Worth recording as a probe error rather than a defect: my first check compared the two
sets with `JSON.stringify` and reported a mismatch. Every value was identical — the key
order was not. The rule was right and the comparison was wrong.
+18 -1
View File
@@ -907,5 +907,22 @@
"RINGBRP.Item.IsShield": "Is a shield",
"RINGBRP.Item.Raised": "Raised",
"RINGBRP.Item.Wear": "Wear",
"RINGBRP.Item.WearMax": "Breaks at"
"RINGBRP.Item.WearMax": "Breaks at",
"RINGBRP.Gen.Source": "Recruited as",
"RINGBRP.Gen.SourcePosting": "A posting — career department",
"RINGBRP.Gen.SourceTrade": "A trade — recruited from an ordinary job",
"RINGBRP.Gen.Trade": "Trade",
"RINGBRP.Gen.PlusInduction": "Plus induction",
"RINGBRP.Gen.PlusIssue": "Plus issue",
"RINGBRP.Gen.HandRoll": "Roll the characteristics myself",
"RINGBRP.Gen.HandRollHint": "Eight rolls, one at a time, to chat. Taken exactly as they fall — no nudging the key stat, no species shift.",
"RINGBRP.Gen.RollTitle": "Roll them yourself",
"RINGBRP.Gen.RollFor": "Roll them yourself — {name}",
"RINGBRP.Gen.RollHint": "One at a time. Once a characteristic is rolled it stands.",
"RINGBRP.Gen.RollIt": "Roll",
"RINGBRP.Gen.RollRest": "Roll the rest",
"RINGBRP.Gen.RolledCount": "{n} of {of} rolled",
"RINGBRP.Gen.RolledChar": "{ch}",
"RINGBRP.Gen.TakeThem": "Take them",
"RINGBRP.Gen.Nth": "agent {n}"
}
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:45.974918 171407000 Delete type=3 #1
2026/08/11-21:45:19.783353 170823000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:46.010315 1703ef000 Delete type=3 #1
2026/08/11-21:45:19.820792 17183b000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:45.977183 171407000 Delete type=3 #1
2026/08/11-21:45:19.786189 170017000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:45.995509 171c13000 Delete type=3 #1
2026/08/11-21:45:19.803268 17102f000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:46.003934 1703ef000 Delete type=3 #1
2026/08/11-21:45:19.812896 170017000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:45.985591 171c13000 Delete type=3 #1
2026/08/11-21:45:19.793778 170017000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:46.008640 171c13000 Delete type=3 #1
2026/08/11-21:45:19.817876 17183b000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:45.965730 171c13000 Delete type=3 #1
2026/08/11-21:45:19.775653 17102f000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:46.002345 1703ef000 Delete type=3 #1
2026/08/11-21:45:19.811223 170823000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:46.000570 170bfb000 Delete type=3 #1
2026/08/11-21:45:19.808540 170823000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:45.970370 1703ef000 Delete type=3 #1
2026/08/11-21:45:19.778961 17183b000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:45.980477 171c13000 Delete type=3 #1
2026/08/11-21:45:19.788634 17183b000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/11-21:36:45.972739 171c13000 Delete type=3 #1
2026/08/11-21:45:19.781220 170017000 Delete type=3 #1
+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
+24 -1
View File
@@ -17,6 +17,12 @@ const ARMOUR_BY_KEY = new Map(ARMOURS.map(a => [a.key, a]));
const src = await readFile(new URL("../ringbrp.mjs", import.meta.url), "utf8");
const ROLES = eval("(" + src.match(/export const ROLES = (\{[\s\S]*?\n\});/)[1] + ")");
// Trades are postings by another name as far as this guard is concerned: the same
// rules about naming a real skill, a real talent and a real item, and about being
// able to use what you are handed. INDUCTION is checked too — it is issued to every
// trade character, so a typo in it is a typo in ten characters at once.
const TRADES = eval("(" + src.match(/export const TRADES = (\{[\s\S]*?\n\});/)[1] + ")");
const INDUCTION = eval("(" + src.match(/export const INDUCTION = (\{[\s\S]*?\n\});/)[1] + ")");
const skills = new Set(SKILL_CATALOGUE.map(s => (s[1] ? `${s[0]}:${s[1]}` : s[0])));
const talents = new Set(TALENTS.map(t => t.key));
@@ -27,7 +33,7 @@ const problems = [];
const kitSets = new Map();
const issued = new Map();
for (const [id, r] of Object.entries(ROLES)) {
for (const [id, r] of Object.entries({ ...ROLES, ...TRADES })) {
const where = `${id} (${r.label})`;
if (!stats.has(r.stat)) problems.push(`${where}: stat "${r.stat}" is not a characteristic`);
for (const s of [...r.core, ...r.support]) if (!skills.has(s)) problems.push(`${where}: no skill "${s}"`);
@@ -62,6 +68,22 @@ for (const [id, r] of Object.entries(ROLES)) {
kitSets.set(sig, id);
}
// INDUCTION is handed to every trade character, so its keys are checked once here
// rather than ten times above.
for (const k of INDUCTION.skills) {
if (!skills.has(k)) problems.push(`induction: no skill "${k}"`);
}
for (const g of INDUCTION.kit) {
if (!kit.has(g)) problems.push(`induction: no catalogue item "${g}"`);
const w = WEAPON_BY_KEY.get(g);
if (w?.fam) {
const need = w.spec ? `${w.fam}:${w.spec}` : w.fam;
if (!INDUCTION.skills.includes(need)) {
problems.push(`induction: issues ${w.name}, teaches no ${need}`);
}
}
}
// Every posting should be issued at least one thing no other posting gets. This
// is a design rule, not a correctness one, so it warns rather than fails.
const noSignature = Object.entries(ROLES)
@@ -73,5 +95,6 @@ if (problems.length) {
process.exit(1);
}
console.log(`check-kits: OK — ${Object.keys(ROLES).length} roles, `
+ `${Object.keys(TRADES).length} trades, `
+ `${kit.size} catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct`);
if (noSignature.length) console.warn(` note: no item unique to ${noSignature.join(", ")}`);
+24 -2
View File
@@ -37,8 +37,10 @@ const L = key => LANG[key] ?? key;
// ROLES lives in ringbrp.mjs, which cannot be imported outside Foundry — it reaches
// for globals on load. Read the literal out of the source, the way check-kits does,
// so the postings page is generated from the same table the game uses.
const ROLES = eval("(" + (await readFile(new URL("../ringbrp.mjs", import.meta.url), "utf8"))
.match(/export const ROLES = (\{[\s\S]*?\n\});/)[1] + ")");
const ENGINE_SRC = await readFile(new URL("../ringbrp.mjs", import.meta.url), "utf8");
const ROLES = eval("(" + ENGINE_SRC.match(/export const ROLES = (\{[\s\S]*?\n\});/)[1] + ")");
const TRADES = eval("(" + ENGINE_SRC.match(/export const TRADES = (\{[\s\S]*?\n\});/)[1] + ")");
const INDUCTION = eval("(" + ENGINE_SRC.match(/export const INDUCTION = (\{[\s\S]*?\n\});/)[1] + ")");
const pc = n => `${n > 0 ? "+" : ""}${n}%`;
/* ---------- small builders so the tables below stay readable ---------- */
@@ -596,6 +598,26 @@ ${table(["Posting", "Key", "What it is"],
Object.values(ROLES).map(r => [`<strong>${r.label}</strong>`, r.stat.toUpperCase(), r.blurb ?? ""]))}
<p>None of this is a class. A posting is what the department has you down as, and the
skills follow from the job rather than the other way round.</p>
<h2>Or a trade, and six weeks</h2>
<p>The department does not only promote from within, because there is no degree in
crossings. It recruits <strong>a doctor, a joiner, a detective sergeant</strong> — and
puts them through the training centre.</p>
${table(["Trade", "Key", "What they were"],
Object.values(TRADES).map(r => [`<strong>${r.label}</strong>`, r.stat.toUpperCase(), r.blurb ?? ""]))}
<p>A trade character is built in two halves, and the join is the interesting part. The
<strong>trade is real expertise</strong>, rated exactly as a posting's skills are: a
doctor is a proper doctor. On top of it goes <strong>${L(INDUCTION.label) === INDUCTION.label ? INDUCTION.label : L(INDUCTION.label)}</strong>
— ${INDUCTION.blurb}</p>
<ul>
<li><strong>Taught:</strong> ${INDUCTION.skills.map(k => LANG[`RINGBRP.Skill.${k}`] ?? k).join(", ")},
at <strong>${INDUCTION.band[0]}–${INDUCTION.band[1]}%</strong>.</li>
<li><strong>Issued:</strong> a sidearm, a vest, cordon kit, a cover identity and a ward,
on top of standing issue.</li>
</ul>
<p>That band is low <em>on purpose</em>. Six weeks is not a career. A surgeon recruited
last year is still a far better surgeon than they are a Custodian, and the distance
between those two numbers is most of what it feels like to play one.</p>
` },
{ name: "6 · A case", content: `