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:
co-authored by
Claude Opus 5
parent
53eb72c100
commit
79acc8227f
@@ -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
@@ -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
@@ -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 @@
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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="">—</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]}–${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
@@ -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
@@ -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: `
|
||||
|
||||
Reference in New Issue
Block a user