generateNPC read R.core, R.support and R.label. It never read R.kit, so the
role card's DRAWS FROM STORES promised a Containment officer nine items and
the actor arrived holding a utility knife with a flat naturalArmour standing
in for armour it was never issued.
postings.mjs now owns the decision — drawKitFor(role, tier, {classify}) plus
a draw table per tier — because the engine resolves kit keys against the
compendium and check-generator resolves them against content.mjs, and the two
cannot import each other. Classification comes from catalogue membership and
never from the key: climbing_kit is "Breaching charges" and prybar is "Entry
bar", so anything reading a key's spelling is wrong in that posting first.
The weapon↔skill pairing table is gone. Each weapon item already names the
skill that fires it, so the skill is granted from the weapon, and a generated
NPC cannot hold what it cannot use. THREATS.attacks is now creature-tiers
only; those two keep their natural attacks on top of what they drew.
Armour is an item. naturalArmour drops to 0 on the four human tiers, where
the number was standing in for the item, and stays on the two creature tiers,
where it stacks with drawn armour as locationArmourFor already intends.
check-generator (guard 25) was written first and observed failing on nine
counts against unchanged source. One of its assertions was itself wrong —
it read for the creature-tier restriction after the loop instead of before
it, and so reported the fixed code as broken; re-scoped, it passes on the fix
and still fails against git show HEAD:ringbrp.mjs.
Measured with the new tools/npc-cohort.mjs, 2000 runs, seed 11, against the
party check-lethality freezes. Apex · Containment 68.2% -> 88.5% wipe,
Anomalous · Containment 0.3% -> 19.2%, and Dangerous · Field Lead got weaker
because its posting carries one pistol where the tier used to hand it a
rifle. Full table and three incidental findings in R-310.
Two of those findings are guards, not content. check-rules failed on a
comment that quoted the pattern it hunts for, so comment-only lines are
skipped now. update-readme held the guard list as a hardcoded array whose own
comments recorded it going stale twice; it reads the check script instead,
and so does the count-word rewrite that was separately capped at sixteen.
Not changed, and flagged in R-310: breach_suit and riot_shield declare no
coverage, so they armour every location including the head. The location
counterplay NPC_KIT_PLAN.md relies on does not exist, and that is a balance
decision rather than a side effect of this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
492 lines
30 KiB
JavaScript
492 lines
30 KiB
JavaScript
/**
|
||
* THE REGISTER — postings, trades, tiers and what stores hands out.
|
||
*
|
||
* Pure data, and deliberately free of Foundry: the engine imports it, the rulebook
|
||
* generator imports it, the guards import it and the tests import it. Until now the
|
||
* guards and the rulebook read these tables out of `ringbrp.mjs` with a regular
|
||
* expression and `eval`, because that file reaches for `game` on load and cannot be
|
||
* imported outside a browser. That worked and it was a trap — a table could be
|
||
* reformatted into something the regex no longer matched and every check that
|
||
* depended on it would quietly stop checking.
|
||
*
|
||
* Nothing in this file may import from the runtime or touch a Foundry global. The
|
||
* same rule rules.mjs lives by, for the same reason.
|
||
*/
|
||
|
||
/* ---------------- roles ---------------- */
|
||
/** Each role is a skill emphasis, a kit, a talent leaning and a rank feel. */
|
||
export const ROLES = {
|
||
lead: { label: "Field Lead",
|
||
blurb: "Runs the team on the ground and signs for what happens to it. Firearms, tradecraft and the nerve to go first.", stat: "dex",
|
||
core: ["firearm:pistol","dodge","tradecraft","navigate","athletics","stealth"],
|
||
support: ["insight","spot","first_aid","melee_weapon:knife","pilot:ground_car"],
|
||
talents: ["dead_reckon","first_through","hard_landing","cover_work"],
|
||
kit: ["pistol_med","soft_armour","cover_id","key","torch", "warrant_card", "cordon_kit", "door_wedges"] },
|
||
technical: { label: "Technical",
|
||
blurb: "Field systems, electronics and the physics of a site that is behaving incorrectly. Builds and breaks the equipment.", stat: "int",
|
||
core: ["engineering:field_systems","repair:electronics","computer_use","anomaly_lore","science:physics"],
|
||
support: ["repair:mechanical","research","first_aid","firearm:pistol","demolition"],
|
||
talents: ["chain_custody","requisition","clean_scene","echo_sense"],
|
||
kit: ["pistol_light","stab_vest","toolkit","scanner","power_cell", "density_scanner", "probe", "thermometer"] },
|
||
talker: { label: "Liaison",
|
||
blurb: "The face the department shows a witness. Reads a room, holds a cover, and gets the sentence nobody meant to say.", stat: "cha",
|
||
core: ["insight","persuade","fast_talk","bargain","anthropology","language:trade_cant"],
|
||
support: ["tradecraft","xenology:baseline","spot","status","firearm:pistol"],
|
||
talents: ["read_room","cover_work","debriefer","wrong_memory"],
|
||
kit: ["pistol_light","jacket","cover_id","notebook","camera", "blanket", "clipboard", "vacuum_flask"] },
|
||
medic: { label: "Physician",
|
||
blurb: "Keeps agents alive and works out what a body has been through. The only posting that can undo a Major Wound.", stat: "int",
|
||
core: ["medicine","first_aid","science:biology","insight","xenology:baseline"],
|
||
support: ["anthropology","spot","dodge","firearm:pistol","research"],
|
||
talents: ["triage","debriefer","read_room","not_quite_here"],
|
||
kit: ["pistol_light","stab_vest","medkit","fieldmedic","sample_case", "analgesics", "blanket", "specimen_jars"] },
|
||
heavy: { label: "Containment",
|
||
blurb: "Containment. Goes through the door, holds the corridor, and puts things in boxes that do not want to be in boxes.", stat: "str",
|
||
core: ["firearm:rifle","brawl","melee_weapon:prybar","athletics","dodge"],
|
||
support: ["demolition","first_aid","repair:mechanical","spot","melee_weapon:shield"],
|
||
talents: ["hard_landing","chain_custody","first_through","clean_scene"],
|
||
kit: ["rifle_med","breach_suit","prybar","riot_shield","restraints","climbing_kit", "bolt_croppers", "cordon_kit", "ear_defenders"] },
|
||
/* ---------------------------------------------------------------
|
||
The rest of the establishment.
|
||
|
||
Five postings were never the whole agency — they were the ones a
|
||
field team fields. These are the other twenty on the books: the
|
||
registry that keeps the record crossings are made against, the
|
||
archaic postings the agency has never got round to abolishing, and
|
||
the specialists nobody calls until the day they are the only thing
|
||
that helps. Each carries its own kit, and each kit has at least one
|
||
thing no other posting is issued.
|
||
--------------------------------------------------------------- */
|
||
|
||
registry: { label: "Registry Officer",
|
||
blurb: "Keeps the record that a crossing is made against. Knows what the file says and, more usefully, what it used to say.", stat: "edu",
|
||
core: ["research","occult","knowledge","anomaly_lore","language:own"],
|
||
support: ["computer_use","insight","spot","first_aid","tradecraft","firearm:pistol"],
|
||
talents: ["chain_custody","wrong_memory","debriefer","requisition"],
|
||
kit: ["spectacles","notebook","handset","jacket","pistol_light", "carbon_book", "date_stamp", "seals"] },
|
||
|
||
survey: { label: "Ground Survey Officer",
|
||
blurb: "Reads ground. Establishes where a site actually is, how it is shaped, and which way out will still be there later.", stat: "int",
|
||
core: ["navigate","engineering:field_systems","science:physics","spot","athletics"],
|
||
support: ["track","stealth","first_aid","repair:mechanical","firearm:pistol"],
|
||
talents: ["dead_reckon","first_through","hard_landing","echo_sense"],
|
||
kit: ["survey_kit","brass_compass","binocs","oilcloth_coat","rope", "probe", "groundsheet", "chalk"] },
|
||
|
||
anchor: { label: "Anchor Officer",
|
||
blurb: "Holds the door open. Transposition, and the discipline to stay filed correctly while doing it.", stat: "pow",
|
||
core: ["transposition","anomaly_lore","insight","listen","dodge"],
|
||
support: ["knowledge","spot","first_aid","tradecraft","research"],
|
||
talents: ["out_of_step","borrowed_hour","not_quite_here","echo_sense"],
|
||
kit: ["anchor","threshold_key","handset","power_cell","soft_armour", "locator_tags", "cold_light", "blanket"] },
|
||
|
||
interview: { label: "Interview Officer",
|
||
blurb: "Sits with the witness afterwards. Patient, unhurried, and very hard to lie to twice.", stat: "cha",
|
||
core: ["insight","persuade","listen","language:own","anthropology"],
|
||
support: ["psychology","spot","tradecraft","first_aid","firearm:pistol"],
|
||
talents: ["debriefer","read_room","wrong_memory","cover_work"],
|
||
kit: ["dictaphone","cover_id","notebook","jacket","pistol_light", "carbon_book", "vacuum_flask", "clipboard"] },
|
||
|
||
warden: { label: "Night Warden",
|
||
blurb: "Sits with a site overnight so that nobody else has to. Listens, watches, and writes down the times.", stat: "con",
|
||
core: ["listen","spot","stealth","dodge","brawl"],
|
||
support: ["first_aid","melee_weapon:shield","athletics","melee_weapon:club","anomaly_lore"],
|
||
talents: ["dead_reckon","out_of_step","first_through","clean_scene"],
|
||
kit: ["oil_lantern","ward","cudgel","riot_shield","stab_vest","torch", "vacuum_flask", "stopwatch", "blanket"] },
|
||
|
||
stores: { label: "Stores Officer",
|
||
blurb: "Requisition, custody and the paperwork that moves materiel. Can get you a thing the department says it does not have.", stat: "edu",
|
||
core: ["bargain","status","knowledge","repair:mechanical","computer_use"],
|
||
support: ["insight","research","first_aid","firearm:pistol","tradecraft"],
|
||
talents: ["requisition","chain_custody","read_room","debriefer"],
|
||
kit: ["sealed_cannister","spare_cells","rations","water","jacket", "carbon_book", "seals", "master_keys"] },
|
||
|
||
transport: { label: "Transport Officer",
|
||
blurb: "Drives, flies, and gets a wounded team off a site. Keeps the vehicle running with what is in the boot.", stat: "dex",
|
||
core: ["pilot:ground_car","pilot:aircraft","navigate","repair:mechanical","spot"],
|
||
support: ["dodge","athletics","first_aid","firearm:pistol","listen"],
|
||
talents: ["dead_reckon","hard_landing","first_through","borrowed_hour"],
|
||
kit: ["beacon","revolver","impact_vest","torch","rope", "master_keys", "groundsheet", "blanket"] },
|
||
|
||
pathology: { label: "Pathology Officer",
|
||
blurb: "Examines what is left. Establishes what a thing was, which is usually the question the case turns on.", stat: "int",
|
||
core: ["medicine","science:biology","xenology:baseline","insight","research"],
|
||
support: ["first_aid","spot","religion","dodge","melee_weapon:knife"],
|
||
talents: ["triage","chain_custody","wrong_memory","clean_scene"],
|
||
kit: ["chirurgeon_kit","gas_mask","envsuit","sample_case","dagger", "specimen_jars", "swab_kit", "barrier_gloves"] },
|
||
|
||
materials: { label: "Materials Officer",
|
||
blurb: "Substances, residues and the objects a site leaves behind. Handles what nobody else is cleared to touch.", stat: "int",
|
||
core: ["science:physics","science:botany","repair:electronics","research","anomaly_lore"],
|
||
support: ["engineering:field_systems","spot","first_aid","computer_use","firearm:pistol"],
|
||
talents: ["chain_custody","echo_sense","requisition","hard_landing"],
|
||
kit: ["field_kit","geiger","breather","stab_vest","pistol_light", "specimen_jars", "barrier_gloves", "seal_foam"] },
|
||
|
||
recording: { label: "Recording Officer",
|
||
blurb: "Documents the site as found. Photographs, measurements and an exhibit trail that survives a tribunal.", stat: "dex",
|
||
core: ["art","spot","computer_use","sleight_of_hand","stealth"],
|
||
support: ["repair:electronics","insight","first_aid","listen","firearm:pistol"],
|
||
talents: ["clean_scene","chain_custody","cover_work","echo_sense"],
|
||
kit: ["optic_visor","data_wafer","evidence_tape","jacket","pistol_light", "uv_lamp", "cordon_kit", "chalk"] },
|
||
|
||
access: { label: "Access Officer",
|
||
blurb: "Gets the team into places the owner has locked, and out again without a record of either.", stat: "dex",
|
||
core: ["sleight_of_hand","stealth","repair:mechanical","spot","dodge"],
|
||
support: ["computer_use","athletics","first_aid","tradecraft","melee_weapon:knife"],
|
||
talents: ["first_through","clean_scene","cover_work","out_of_step"],
|
||
kit: ["iron_manacles","monoblade","key","toolkit","soft_armour", "lock_rakes", "master_keys", "door_wedges"] },
|
||
|
||
disposal: { label: "Disposal Officer",
|
||
blurb: "Renders a thing safe, or renders it gone. Demolition, containment and a very steady hand.", stat: "int",
|
||
core: ["demolition","repair:electronics","engineering:field_systems","spot","dodge"],
|
||
support: ["first_aid","athletics","anomaly_lore","firearm:rifle","melee_weapon:axe"],
|
||
talents: ["first_through","hard_landing","chain_custody","clean_scene"],
|
||
kit: ["field_net","entrenching_tool","hardsuit","toolkit","torch", "seal_foam", "cordon_kit", "ear_defenders"] },
|
||
|
||
overwatch: { label: "Overwatch Officer",
|
||
blurb: "Holds the long angle and tells the team what is coming. Shoots when nothing else will do.", stat: "dex",
|
||
core: ["firearm:rifle","spot","stealth","dodge","athletics"],
|
||
support: ["navigate","first_aid","listen","track","firearm:pistol"],
|
||
talents: ["dead_reckon","read_room","out_of_step","hard_landing"],
|
||
kit: ["rifle_heavy","binocs","soft_armour","rations","torch", "spyglass", "groundsheet", "cold_light"] },
|
||
|
||
rigging: { label: "Rigging Officer",
|
||
blurb: "Height, depth and confined access. Gets people and equipment up and down shafts that were not built for it.", stat: "str",
|
||
core: ["athletics","repair:mechanical","engineering:field_systems","dodge","melee_weapon:axe"],
|
||
support: ["first_aid","spot","navigate","brawl","stealth"],
|
||
talents: ["hard_landing","first_through","requisition","ambidextrous"],
|
||
kit: ["boarding_axe","climbing_kit","rope","impact_vest","torch", "bolt_croppers", "probe", "door_wedges"] },
|
||
|
||
translation: { label: "Translation Officer",
|
||
blurb: "Language, sign and the things said in neither. Reads a document nobody else in the building can.", stat: "int",
|
||
core: ["language:trade_cant","language:sign","language:own","anthropology","insight"],
|
||
support: ["research","knowledge","first_aid","xenology:baseline","firearm:pistol"],
|
||
talents: ["debriefer","read_room","borrowed_hour","wrong_memory"],
|
||
kit: ["wax_tablet","notebook","handset","jacket","pistol_light", "quill_ink", "carbon_book", "spectacles"] },
|
||
|
||
naturalist: { label: "Field Naturalist",
|
||
blurb: "Living things, and things that are pretending to be. Tracks, samples and identifies what came through.", stat: "int",
|
||
core: ["science:botany","science:biology","track","scent","spot"],
|
||
support: ["navigate","stealth","first_aid","athletics","melee_weapon:sword"],
|
||
talents: ["dead_reckon","echo_sense","hard_landing","triage"],
|
||
kit: ["machete","sample_case","binocs","oilcloth_coat","rations", "specimen_jars", "swab_kit", "groundsheet"] },
|
||
|
||
antiquities: { label: "Antiquities Officer",
|
||
blurb: "An archaic posting the department never abolished. Old objects, older records, and the manners to ask about both.", stat: "edu",
|
||
core: ["religion","art","anomaly_lore","research","status"],
|
||
support: ["knowledge","insight","spot","first_aid","melee_weapon:sword","firearm:pistol"],
|
||
talents: ["chain_custody","wrong_memory","borrowed_hour","cover_work"],
|
||
kit: ["pocket_watch","tallow_candle","duelling_sword","brigandine","flintlock", "quill_ink", "sealing_wax", "sand_glass"] },
|
||
|
||
pursuit: { label: "Pursuit Officer",
|
||
blurb: "Follows what has left the site. Ground, scent, and the stamina to keep going after it stops being sensible.", stat: "con",
|
||
core: ["track","athletics","spot","listen","stealth"],
|
||
support: ["navigate","dodge","first_aid","scent","firearm:rifle"],
|
||
talents: ["dead_reckon","first_through","out_of_step","hard_landing"],
|
||
kit: ["carbine","restraints","soft_armour","rations","torch", "locator_tags", "cold_light", "wake_tablets"] },
|
||
|
||
settlements: { label: "Settlements Officer",
|
||
blurb: "Makes the arrangement, files it, and never speaks of the terms again. Most cases close here rather than in a fight.", stat: "cha",
|
||
core: ["bargain","status","insight","persuade","knowledge"],
|
||
support: ["research","anthropology","first_aid","tradecraft","firearm:pistol"],
|
||
talents: ["read_room","debriefer","requisition","wrong_memory"],
|
||
kit: ["quiet_ledger","cover_id","notebook","jacket","pistol_light", "carbon_book", "sealing_wax", "vacuum_flask"] },
|
||
|
||
signals: { label: "Signals Officer",
|
||
blurb: "Communications, interception and the electronics of a site that is broadcasting when it should not be.", stat: "int",
|
||
core: ["repair:electronics","computer_use","listen","engineering:field_systems","spot"],
|
||
support: ["research","first_aid","dodge","firearm:pistol","anomaly_lore"],
|
||
talents: ["echo_sense","chain_custody","dead_reckon","requisition"],
|
||
kit: ["survey_drone","handset","power_cell","stab_vest","pistol_light", "field_slate", "locator_tags", "uv_lamp"] }
|
||
};
|
||
|
||
/** Experience tiers: how good, and what the agency calls you. */
|
||
/**
|
||
* What every officer is issued regardless of posting, what seniority adds, and the
|
||
* one thing that is theirs rather than the department's.
|
||
*
|
||
* 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","ambidextrous"],
|
||
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","psychology","persuade","listen","spot"],
|
||
talents: ["triage","debriefer","read_room","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","occult","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"
|
||
};
|
||
|
||
/**
|
||
* What a TRADE is worth, independent of Custodian seniority.
|
||
*
|
||
* Trades used to be rated on the agency tier bands, which had it exactly backwards:
|
||
* a probationary burglar came out on 35–50% and a veteran one on 65–80%, as though
|
||
* fifteen years of picking locks were something the department conferred. Prior
|
||
* career is prior career. It does not improve because somebody was promoted, and it
|
||
* does not start low because somebody is new.
|
||
*
|
||
* Agency rank still governs everything that IS the department's doing: the induction
|
||
* band, the talents, the issue and the bonus points.
|
||
*/
|
||
export const TRADE_BANDS = { core: [50, 65], support: [30, 50] };
|
||
|
||
export const INDUCTION = {
|
||
label: "Induction",
|
||
blurb: "Six weeks at the training centre, and a warrant card that is still stiff.",
|
||
// Induction is the department's, so unlike the trade it DOES improve with service.
|
||
// A veteran who came in through a trade has been on the courses for years.
|
||
band: [25, 40],
|
||
bandByTier: {
|
||
probationary: [20, 32], officer: [25, 40], senior: [35, 50], veteran: [45, 60]
|
||
},
|
||
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"];
|
||
|
||
/** Seniority is issued more, and is trusted with more. */
|
||
export const TIER_ISSUE = {
|
||
probationary: [],
|
||
officer: ["cover_id"],
|
||
senior: ["cover_id", "seals", "master_keys"],
|
||
veteran: ["cover_id", "seals", "master_keys", "carbon_book", "trauma_unit"]
|
||
};
|
||
|
||
/**
|
||
* One personal effect. Not issued, not logged, and the reason the anchor works —
|
||
* an agent who carries nothing of their own has nothing to be filed against.
|
||
*/
|
||
export const PERSONAL_EFFECTS = ["pocket_watch", "spectacles", "brass_compass",
|
||
"vacuum_flask", "notebook", "camera", "tallow_candle",
|
||
"wax_tablet", "sand_glass", "returning_coin"];
|
||
|
||
export const TIERS = {
|
||
probationary: { label: "Probationary", rank: "Probationary", core: [35, 50], support: [20, 35], talents: 1, bonusPts: 0 },
|
||
officer: { label: "Field Officer", rank: "Field Officer", core: [45, 60], support: [25, 45], talents: 2, bonusPts: 20 },
|
||
senior: { label: "Senior Field Officer", rank: "Senior Field Officer", core: [55, 70], support: [30, 50], talents: 3, bonusPts: 50 },
|
||
veteran: { label: "Case Officer", rank: "Case Officer", core: [65, 80], support: [35, 55], talents: 4, bonusPts: 90 }
|
||
};
|
||
|
||
/** NPC threat tiers — bystanders through anomalies, and the things in between. */
|
||
/**
|
||
* Threat tiers. `attacks` pairs a weapon with the EXACT skill that fires it —
|
||
* they are granted together or not at all. Previously the tier chose the weapon
|
||
* and an unrelated random role chose the skills, so most generated NPCs could not
|
||
* use what they were holding: eight Apexes in ten had no usable attack.
|
||
*
|
||
* `draw` is how much of the POSTING's stores the tier takes: the posting says what
|
||
* it carries, the tier says how much of it and how good with it. `attacks` is now
|
||
* only for the two creature tiers, which have no posting to draw from — and they
|
||
* keep their natural attacks on top of whatever they drew, because a revenant of a
|
||
* Containment officer is still wearing and holding what it died in.
|
||
*/
|
||
export const THREATS = {
|
||
bystander: { label: "Bystander", hpMul: 1.0, armour: 0, skill: [15, 30], talents: 0, attacks: [],
|
||
draw: { weapons: 0, armour: 0, gear: 2 } },
|
||
trained: { label: "Trained", hpMul: 1.0, armour: 1, skill: [30, 45], talents: 0,
|
||
attacks: [{ weapon: "dagger", skill: "melee_weapon:knife" }],
|
||
draw: { weapons: 1, armour: 1, gear: 3 } },
|
||
armed: { label: "Armed", hpMul: 1.0, armour: 2, skill: [40, 55], talents: 0,
|
||
attacks: [{ weapon: "pistol_med", skill: "firearm:pistol" }],
|
||
draw: { weapons: 1, armour: 1, gear: 3 } },
|
||
dangerous: { label: "Dangerous", hpMul: 1.2, armour: 3, skill: [50, 65], talents: 1,
|
||
attacks: [{ weapon: "rifle_med", skill: "firearm:rifle" },
|
||
{ weapon: "baton", skill: "melee_weapon:club" }],
|
||
draw: { weapons: 2, armour: 1, gear: 4 } },
|
||
anomalous: { label: "Anomalous", hpMul: 1.5, armour: 5, skill: [55, 75], talents: 2,
|
||
attacks: [{ weapon: "grasp", skill: "brawl" }],
|
||
draw: { weapons: 1, armour: 1, gear: 3 } },
|
||
apex: { label: "Apex", hpMul: 2.0, armour: 7, skill: [65, 85], talents: 3,
|
||
attacks: [{ weapon: "grasp", skill: "brawl" },
|
||
{ weapon: "unmaker", skill: "energy_weapon:rifle" }],
|
||
draw: { weapons: 2, armour: 2, gear: 4 } }
|
||
};
|
||
|
||
/**
|
||
* The two tiers that are not people.
|
||
*
|
||
* Named once, here with the tiers, because four separate decisions turn on it — whether
|
||
* the body's own armour is real, whether the tier's natural attacks apply, what the role
|
||
* card calls the thing, and how the measurement harness stands one up. Written out at
|
||
* each site, the four would drift, and the one that drifted would be the one nobody
|
||
* looked at.
|
||
*/
|
||
export const CREATURE_TIER = new Set(["anomalous", "apex"]);
|
||
|
||
/** Species: characteristic shifts and the location profile they use. */
|
||
export const SPECIES = {
|
||
baseline: { label: "Baseline", profile: "baseline", shift: {} },
|
||
vesh: { label: "Vesh", profile: "vesh", shift: { dex: +2, cha: -2, pow: +1 }, talents: ["unblinking","out_of_step"] },
|
||
cadence: { label: "Cadence", profile: "cadence", shift: { con: +2, str: -2, int: +1 }, talents: ["distributed","quorum"] }
|
||
};
|
||
|
||
/* ---------------- name pools ---------------- */
|
||
const GIVEN = ["Ceyla","Dunn","Rakhi","Tomas","Ambaro","Nils","Halina","Ottoline","Bez","Marek",
|
||
"Sunniva","Idris","Perpetua","Jorge","Nkechi","Vasha","Emeric","Lark","Tobin","Saoirse",
|
||
"Anneke","Ruben","Ilse","Cadogan","Mirren","Fenn","Orla","Kester","Zofia","Rune"];
|
||
const FAMILY = ["Marspont","Halvorsen","Oyelaran-Vost","Ekwueme","Sandoval","Achterberg","Quist",
|
||
"Delacroix-Ng","Bramall","Iwu","Fenwick","Oyelaran","Strand","Adeyemi","Kowalczyk","Rees-Mogg",
|
||
"Vanterpool","Okonkwo","Lindqvist","Brennan","Achebe","Dumont","Sarraceno","Halloran","Vance"];
|
||
const NPC_NAMES = { bystander:["Local","Witness","Neighbour","Publican","Warden","Ferryman"],
|
||
trained:["Constable","Ranger","Fisher","Guard","Surveyor"],
|
||
armed:["Contractor","Cleaner","Sentry","Enforcer"],
|
||
dangerous:["Team lead","Specialist","Hunter","Marksman"],
|
||
anomalous:["Revenant","Echo","Walker","The thing in the water"],
|
||
apex:["Displaced","The one that came back","Apex"] };
|
||
|
||
export const NAME_POOLS = { GIVEN, FAMILY, NPC_NAMES };
|
||
|
||
/* ---------------- what stores hands out ---------------- */
|
||
|
||
/**
|
||
* What a posting issues to somebody at a given tier.
|
||
*
|
||
* One place decides this, because it is read from two sides that cannot import each
|
||
* other: the engine resolves the keys against the compendium packs, and
|
||
* check-generator resolves them against tools/content.mjs. A second copy of the
|
||
* decision is the drift this project has paid for five times.
|
||
*
|
||
* `classify` is supplied by the caller and answers weapon / armour / gear for a key.
|
||
* This file must not know the catalogues — and more importantly, nothing may infer a
|
||
* key's class from its spelling. Containment's `climbing_kit` is "Breaching charges"
|
||
* and its `prybar` is "Entry bar": the key is an identifier that has drifted from the
|
||
* name it was coined for, so membership of a catalogue is the only honest answer.
|
||
*
|
||
* There is no default classifier on purpose. A call without one could only issue
|
||
* nothing, which is the exact defect this function exists to end, and it would do it
|
||
* silently.
|
||
*
|
||
* Returns `{ weapons, armour, gear, unknown }`, all arrays of keys. `unknown` is the
|
||
* keys the classifier did not recognise; the caller reports them rather than
|
||
* swallowing them, because a silent substitution during a case is worse than a noisy
|
||
* one.
|
||
*/
|
||
export function drawKitFor(roleKey, tierKey, { classify, rand = Math.random } = {}) {
|
||
if (typeof classify !== "function") {
|
||
throw new Error("drawKitFor needs a classify(key) → weapon|armour|gear function");
|
||
}
|
||
const R = ROLES[roleKey] ?? TRADES[roleKey];
|
||
const TH = THREATS[tierKey];
|
||
if (!R || !TH) return null;
|
||
const d = TH.draw ?? { weapons: 0, armour: 0, gear: 0 };
|
||
|
||
const byClass = { weapon: [], armour: [], gear: [] };
|
||
const unknown = [];
|
||
for (const key of R.kit ?? []) {
|
||
const c = classify(key);
|
||
if (c && byClass[c]) byClass[c].push(key);
|
||
else unknown.push(key);
|
||
}
|
||
// One clamp, named, rather than a Math.max around each count. Written inline it read
|
||
// `Math.max(0, d.armour | 0)`, which is the shape check-rules hunts for — an inline
|
||
// subtraction of armour from damage. It was a count, not a damage calculation, but a
|
||
// guard that has to tell those apart by regex should not be asked to.
|
||
const take = n => (Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0);
|
||
// Weapons and armour in the order the posting lists them: kits are authored
|
||
// primary first, so a Containment officer's first weapon is the rifle the
|
||
// posting is built around rather than whichever one a shuffle reached.
|
||
const weapons = byClass.weapon.slice(0, take(d.weapons));
|
||
const armour = byClass.armour.slice(0, take(d.armour));
|
||
// Gear at random, so four guards stood up off one statblock are not four
|
||
// identical guards. Drawn without replacement, in a copy.
|
||
const pool = byClass.gear.slice();
|
||
const gear = [];
|
||
for (let n = take(d.gear); n > 0 && pool.length; n--) {
|
||
gear.push(...pool.splice(Math.floor(rand() * pool.length), 1));
|
||
}
|
||
return { weapons, armour, gear, unknown };
|
||
}
|