diff --git a/docs/REVIEW_LOG.md b/docs/REVIEW_LOG.md index 413007d..67c98e7 100644 --- a/docs/REVIEW_LOG.md +++ b/docs/REVIEW_LOG.md @@ -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. diff --git a/lang/en.json b/lang/en.json index 8cc9d88..10c549c 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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}" } diff --git a/packs/armour/LOG b/packs/armour/LOG index 2e84cf9..862a48e 100644 --- a/packs/armour/LOG +++ b/packs/armour/LOG @@ -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 diff --git a/packs/expedition/LOG b/packs/expedition/LOG index 26d962d..2c148c0 100644 --- a/packs/expedition/LOG +++ b/packs/expedition/LOG @@ -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 diff --git a/packs/gear/LOG b/packs/gear/LOG index d9f5564..b4994bf 100644 --- a/packs/gear/LOG +++ b/packs/gear/LOG @@ -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 diff --git a/packs/npcs/LOG b/packs/npcs/LOG index 7615028..e89b74d 100644 --- a/packs/npcs/LOG +++ b/packs/npcs/LOG @@ -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 diff --git a/packs/powers/LOG b/packs/powers/LOG index d86c829..5a27595 100644 --- a/packs/powers/LOG +++ b/packs/powers/LOG @@ -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 diff --git a/packs/pregens/LOG b/packs/pregens/LOG index 096f735..e817dc4 100644 --- a/packs/pregens/LOG +++ b/packs/pregens/LOG @@ -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 diff --git a/packs/rules/000003.log b/packs/rules/000003.log index 916d086..47c05ee 100644 Binary files a/packs/rules/000003.log and b/packs/rules/000003.log differ diff --git a/packs/rules/LOG b/packs/rules/LOG index f335a61..31f1d02 100644 --- a/packs/rules/LOG +++ b/packs/rules/LOG @@ -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 diff --git a/packs/skills/LOG b/packs/skills/LOG index 1bb1c63..d4e9550 100644 --- a/packs/skills/LOG +++ b/packs/skills/LOG @@ -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 diff --git a/packs/slice/LOG b/packs/slice/LOG index 373fe2b..aaeb21f 100644 --- a/packs/slice/LOG +++ b/packs/slice/LOG @@ -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 diff --git a/packs/tables/LOG b/packs/tables/LOG index b9229d0..bcdd8e6 100644 --- a/packs/tables/LOG +++ b/packs/tables/LOG @@ -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 diff --git a/packs/talents/LOG b/packs/talents/LOG index 5167af1..bc27c46 100644 --- a/packs/talents/LOG +++ b/packs/talents/LOG @@ -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 diff --git a/packs/vehicles/LOG b/packs/vehicles/LOG index 088f352..8a1dab8 100644 --- a/packs/vehicles/LOG +++ b/packs/vehicles/LOG @@ -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 diff --git a/packs/weapons/LOG b/packs/weapons/LOG index 153a60d..626b92a 100644 --- a/packs/weapons/LOG +++ b/packs/weapons/LOG @@ -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 diff --git a/ringbrp.mjs b/ringbrp.mjs index fd758e9..38101fa 100644 --- a/ringbrp.mjs +++ b/ringbrp.mjs @@ -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 => ` + + ${game.i18n.localize(`RINGBRP.Char.${k}`)} + ${CHARACTERISTIC_DICE[k]} + — + ${game.i18n.localize("RINGBRP.Gen.RollIt")} + `).join(""); + + const content = `
+

${game.i18n.localize("RINGBRP.Gen.RollHint")}

+ ${rows}
+
+ ${game.i18n.localize("RINGBRP.Gen.RollRest")} + +
+
`; + + 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]) => ``).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") {
-
+ ${isPC ? ` +
+
` : ""} +
+ ${isPC ? ` + ` : ""}

${game.i18n.localize("RINGBRP.Gen.RoleRandomBlurb")}

@@ -4483,6 +4713,11 @@ export async function generateDialog(kind = "character") { ${opts(THREATS, threatSel)}
`}
+ ${isPC ? ` +
+ +

${game.i18n.localize("RINGBRP.Gen.HandRollHint")}

+
` : ""}

${game.i18n.localize("RINGBRP.Gen.Hint")}

`; @@ -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 `

${game.i18n.localize("RINGBRP.Gen.RoleRandomBlurb")}

`; // 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") {
${R.core.map(skill).join(" · ")}
${game.i18n.localize("RINGBRP.Gen.Draws")}
${kitNames.join(" · ")}
+ ${table === TRADES ? ` +
${game.i18n.localize("RINGBRP.Gen.PlusInduction")}
+
${INDUCTION.skills.map(skill).join(" · ")} + ${INDUCTION.band[0]}–${INDUCTION.band[1]}%
+
${game.i18n.localize("RINGBRP.Gen.PlusIssue")}
+
${INDUCTION.kit.map(k => kitNames_.get(k) ?? k.replace(/_/g, " ")).join(" · ")}
` : ""} `; }; @@ -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 diff --git a/tools/check-kits.mjs b/tools/check-kits.mjs index ebc0c0f..a97bf06 100644 --- a/tools/check-kits.mjs +++ b/tools/check-kits.mjs @@ -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(", ")}`); diff --git a/tools/rules-text.mjs b/tools/rules-text.mjs index 0df43eb..4ab9036 100644 --- a/tools/rules-text.mjs +++ b/tools/rules-text.mjs @@ -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 => [`${r.label}`, r.stat.toUpperCase(), r.blurb ?? ""]))}

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.

+ +

Or a trade, and six weeks

+

The department does not only promote from within, because there is no degree in +crossings. It recruits a doctor, a joiner, a detective sergeant — and +puts them through the training centre.

+${table(["Trade", "Key", "What they were"], + Object.values(TRADES).map(r => [`${r.label}`, r.stat.toUpperCase(), r.blurb ?? ""]))} +

A trade character is built in two halves, and the join is the interesting part. The +trade is real expertise, rated exactly as a posting's skills are: a +doctor is a proper doctor. On top of it goes ${L(INDUCTION.label) === INDUCTION.label ? INDUCTION.label : L(INDUCTION.label)} +— ${INDUCTION.blurb}

+ +

That band is low on purpose. 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.

` }, { name: "6 · A case", content: `