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.Gen.RoleRandomBlurb")}
${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") {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.
+ +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: `