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
+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
|
||||
|
||||
Reference in New Issue
Block a user