1128 lines
59 KiB
JavaScript
1128 lines
59 KiB
JavaScript
const MODULE_ID = "sla-bpn-dispatch";
|
||
const DRAFT_KEY = "bpnDraft";
|
||
const ACTOR_FOLDER_NAME = "SLA BPN Dispatch NPCs";
|
||
let TABLES = null;
|
||
|
||
const SCENARIO_FRAMES = {
|
||
blue: [
|
||
{
|
||
id: "blue-pump-clearance",
|
||
shortTitle: "Pump Ghosts",
|
||
missionFocus: "Eliminate a threat before it reaches public view.",
|
||
colourMissionIdea: "Clear Carrien from a sewer pumping station before repair crews go live on-site.",
|
||
location: "Sewer network and maintenance tunnels beneath a low-SCL tenement arc.",
|
||
atmosphere: "Oppressive rain above ground, ankle-deep runoff below, and every tunnel echoing with machine throb.",
|
||
opposition: "Carrien packs and one nest-alpha using the flooded access galleries.",
|
||
oppositionAttitude: "Are starving, territorial, and driven to charge anything making light or noise.",
|
||
civilianFaction: "Maintenance workers trying to keep the block from flooding into the hab levels.",
|
||
extraObjective: "Recover one intact sample of the contamination feeding the nest.",
|
||
escalationEvent: "If the pumps stay offline too long, the whole block begins to flood and civilians panic upward.",
|
||
complication: "The maintenance foreman has been selling access schematics to scavengers and will try to hide it.",
|
||
localTwist: "The contamination is not accidental; someone seeded the tunnels to force a redevelopment contract.",
|
||
issuingDepartment: "Mort Department / Ops Control",
|
||
contactRole: "Shift Maintenance Controller",
|
||
contactArchetype: "controller",
|
||
contactLocation: "A barricaded relay room overlooking the lower pump shafts.",
|
||
threatArchetype: "carrien",
|
||
threatRole: "Nest Alpha",
|
||
threatCountLabel: "One alpha and a fast-moving pack."
|
||
},
|
||
{
|
||
id: "blue-eviction-cleanout",
|
||
shortTitle: "Residue Notice",
|
||
missionFocus: "Clean up an old SLA operational mess.",
|
||
colourMissionIdea: "Evict squatters and secure a condemned recycler before demolition crews arrive.",
|
||
location: "A half-powered recycling depot in a condemned housing strip.",
|
||
atmosphere: "Chemical fog hangs low and the whole block stinks of hot metal, wet plaster, and old blood.",
|
||
opposition: "A desperate street gang holding the depot plus a few terrified scavenger families inside the same shell.",
|
||
oppositionAttitude: "Talk hard, posture for cameras, but are ready to fold if offered one way out.",
|
||
civilianFaction: "Neighbourhood residents who want the gang gone but do not trust SLA to distinguish them from the problem.",
|
||
extraObjective: "Keep the recycler’s core machinery functional for sponsor inspection.",
|
||
escalationEvent: "A scavenger fire starts in the upper catwalks, pushing everyone toward the same exit lanes.",
|
||
complication: "The depot contains evidence from an earlier SLA kill-team visit that nobody filed correctly.",
|
||
localTwist: "The original paperwork deliberately misclassified the site so a corporate shell company could seize it cheaply.",
|
||
issuingDepartment: "Department-M / Internal Containment",
|
||
contactRole: "Department-M Disposal Clerk",
|
||
contactArchetype: "analyst",
|
||
contactLocation: "A mobile command kiosk parked behind a portable blast shield.",
|
||
threatArchetype: "ganger",
|
||
threatRole: "Depot Gang Boss",
|
||
threatCountLabel: "One loud boss and half a dozen badly coordinated shooters."
|
||
}
|
||
],
|
||
yellow: [
|
||
{
|
||
id: "yellow-prototype-recovery",
|
||
shortTitle: "Cold Return",
|
||
missionFocus: "Retrieve an item or specimen intact.",
|
||
colourMissionIdea: "Recover a stolen prototype before it disappears into the black-market chain.",
|
||
location: "A transit interchange feeding into a hidden market spine.",
|
||
atmosphere: "Packed concourses, vendor noise, flashing route boards, and too many bodies to easily track the courier.",
|
||
opposition: "A retrieval crew working for organised crime with local route control and fast bikes on standby.",
|
||
oppositionAttitude: "Would rather run than fight, but will kill witnesses if boxed in.",
|
||
civilianFaction: "Black-market traders who know the unofficial routes and sell them to whoever pays first.",
|
||
extraObjective: "Bring back the data case unopened and verifiably untampered.",
|
||
escalationEvent: "The courier network triggers a station lockdown and reroutes all civilian flow through the kill zone.",
|
||
complication: "The package is still broadcasting, making every interested party converge on the same target.",
|
||
localTwist: "The prototype is bait; Head Office actually wants the retrieval crew followed back to a higher-value buyer.",
|
||
issuingDepartment: "Cloak Tactical Oversight",
|
||
contactRole: "Independent Financier",
|
||
contactArchetype: "fixer",
|
||
contactLocation: "A quiet cafe mezzanine overlooking the station’s arrivals board.",
|
||
threatArchetype: "retrieval-crew",
|
||
threatRole: "Courier Handler",
|
||
threatCountLabel: "One handler, two bike-runners, and a sniper looking after the exchange."
|
||
}
|
||
],
|
||
green: [
|
||
{
|
||
id: "green-cannibal-perimeter",
|
||
shortTitle: "Wall Bite",
|
||
missionFocus: "Eliminate a threat before it reaches public view.",
|
||
colourMissionIdea: "Hit a breach team before it can break through the Cannibal Sector perimeter and trigger a broadcast panic.",
|
||
location: "The Cannibal Sector perimeter wall and the service roads wrapped around it.",
|
||
atmosphere: "Sirens, floodlights, wet concrete, and the constant thump of distant impact on the wall plating.",
|
||
opposition: "Cannibal raiders with improvised breaching charges and a local guide inside the city side.",
|
||
oppositionAttitude: "Are committed, feral, and keen to take someone back with them if the breach fails.",
|
||
civilianFaction: "A transient cluster of freight workers trapped near the service roads during lockdown.",
|
||
extraObjective: "Capture one living raider who can identify the city-side collaborator.",
|
||
escalationEvent: "A secondary breach warning forces Shiver units to peel away, leaving the squad exposed.",
|
||
complication: "The internal guide is wearing a legitimate contractor pass and can blend into evac traffic.",
|
||
localTwist: "The breach is a distraction for an unrelated extraction happening two gates away.",
|
||
issuingDepartment: "Shiver Command Liaison Desk",
|
||
contactRole: "Perimeter Shiver Sergeant",
|
||
contactArchetype: "shiver",
|
||
contactLocation: "An armoured checkpoint container bolted to the inner wall road.",
|
||
threatArchetype: "raider",
|
||
threatRole: "Breacher Chief",
|
||
threatCountLabel: "A brutal lead breacher with a fast assault cell behind him."
|
||
},
|
||
{
|
||
id: "green-kill-order",
|
||
shortTitle: "Dead Feed",
|
||
missionFocus: "Hunt a named hostile and confirm status.",
|
||
colourMissionIdea: "Stop a contract killer before the second murder finishes the media pattern.",
|
||
location: "A rain-soaked rooftop district threaded with service bridges and maintenance lifts.",
|
||
atmosphere: "Visibility is broken by rain, neon reflections, and intermittent drone spotlights.",
|
||
opposition: "A professional killer with a two-person support team and pre-positioned escape routes.",
|
||
oppositionAttitude: "Remain cool, efficient, and willing to trade casualties for a clean getaway window.",
|
||
civilianFaction: "A cluster of thrill-streamers trying to capture the chase from nearby rooftops.",
|
||
extraObjective: "Recover the killer’s weapon package and comms slate intact.",
|
||
escalationEvent: "The support team remotely kills the lights and forces the pursuit onto unsecured skybridges.",
|
||
complication: "The target has a face the public already knows, making collateral narrative control much harder.",
|
||
localTwist: "The supposed contract is only the visible layer; the true target is hidden among the responding authorities.",
|
||
issuingDepartment: "Mort Department / Ops Control",
|
||
contactRole: "Veteran Desk Op",
|
||
contactArchetype: "detective",
|
||
contactLocation: "A temporary command room in a shuttered rooftop transit office.",
|
||
threatArchetype: "killer",
|
||
threatRole: "Named Contract Killer",
|
||
threatCountLabel: "One surgical hitter backed by two disciplined support shooters."
|
||
}
|
||
],
|
||
white: [
|
||
{
|
||
id: "white-serial-pattern",
|
||
shortTitle: "Witness Static",
|
||
missionFocus: "Investigate a crime scene with political fallout.",
|
||
colourMissionIdea: "Link a string of identical killings before the murderer reaches a protected witness.",
|
||
location: "A residential block, its adjoining monorail stop, and the forensic seal around the newest murder scene.",
|
||
atmosphere: "The block is unnaturally quiet, everyone is watching from behind cheap screens, and Shivers are over-managing the cordon.",
|
||
opposition: "A serial killer cell staging scenes to manipulate a bigger investigation.",
|
||
oppositionAttitude: "Enjoy the attention, leave deliberate clues, and want the squad to see the pattern too late.",
|
||
civilianFaction: "Residents terrified of speaking after seeing just enough to know SLA is already late.",
|
||
extraObjective: "Secure the witness and move them somewhere the killer cannot predict.",
|
||
escalationEvent: "An old evidence trail suddenly points at a different scene and splits available manpower.",
|
||
complication: "The witness’s own statement is incomplete because they are protecting someone inside the block.",
|
||
localTwist: "The killer is recreating an older SLA-blacklisted case and expects the same official cover-up.",
|
||
issuingDepartment: "Mort Department / Ops Control",
|
||
contactRole: "Case Liaison Detective",
|
||
contactArchetype: "detective",
|
||
contactLocation: "A sealed witness room improvised from a laundrette office.",
|
||
threatArchetype: "killer",
|
||
threatRole: "Pattern Cell Leader",
|
||
threatCountLabel: "A lead killer with one cleanup partner and one obsessed documentarian."
|
||
},
|
||
{
|
||
id: "white-ammo-sabotage",
|
||
shortTitle: "Spent Truth",
|
||
missionFocus: "Retrieve data or recordings without contamination.",
|
||
colourMissionIdea: "Trace sabotaged SLA ammunition back through the supply chain before anyone realises how deep it goes.",
|
||
location: "A corporate ammo depot, a Shiver evidence locker, and a low-grade loading district in between.",
|
||
atmosphere: "Power flickers every few minutes, loading servitors keep stuttering mid-task, and everybody is too tense to blink.",
|
||
opposition: "Corporate security and one inside accountant trying to erase the trail before it reaches Head Office.",
|
||
oppositionAttitude: "Prefer intimidation and paperwork pressure, but will switch to lethal containment if exposed.",
|
||
civilianFaction: "Dock loaders who know the movement pattern of every crate but do not trust official investigators.",
|
||
extraObjective: "Recover one clean crate and one tampered crate for comparison testing.",
|
||
escalationEvent: "Depot lockdown procedures seal the archive level and begin deleting local logs.",
|
||
complication: "The inside accountant is cooperating because their family is already off the board if they stop.",
|
||
localTwist: "The sabotage was designed to make a specific squad fail publicly rather than to broadly poison supply.",
|
||
issuingDepartment: "Department-M / Internal Containment",
|
||
contactRole: "Corporate Asset Auditor",
|
||
contactArchetype: "analyst",
|
||
contactLocation: "A dark logistics office above the depot loading floor.",
|
||
threatArchetype: "corpsec",
|
||
threatRole: "Security Overseer",
|
||
threatCountLabel: "One hard-edged overseer and a disciplined internal security team."
|
||
}
|
||
],
|
||
grey: [
|
||
{
|
||
id: "grey-audit-escort",
|
||
shortTitle: "Quiet Ledger",
|
||
missionFocus: "Protect or escort a cargo asset.",
|
||
colourMissionIdea: "Keep an audit convoy moving while everyone who profited from the missing cargo tries to erase the proof.",
|
||
location: "An industrial spine road running between bonded warehouses and a secure internal archive.",
|
||
atmosphere: "The sector is all sodium light, surveillance masts, and thin excuses from everyone in a suit.",
|
||
opposition: "Departmentally protected smugglers, hired heavies, and one executive aide running the cover story.",
|
||
oppositionAttitude: "Would rather pressure, delay, and redirect than openly trade fire in company view.",
|
||
civilianFaction: "Warehouse staff who know something is wrong but fear losing the only stable work they have.",
|
||
extraObjective: "Keep the audit chain unbroken from seizure to final archive.",
|
||
escalationEvent: "A sponsor legal team arrives and tries to freeze the whole operation with paperwork.",
|
||
complication: "The archive itself contains evidence implicating the issuing department.",
|
||
localTwist: "The convoy is only half the story; the real theft walked out days ago wearing a department badge.",
|
||
issuingDepartment: "Department-M / Internal Containment",
|
||
contactRole: "Departmental Enforcement Liaison",
|
||
contactArchetype: "corporate",
|
||
contactLocation: "An armoured office container chained to the convoy lead truck.",
|
||
threatArchetype: "corpsec",
|
||
threatRole: "Internal Security Chief",
|
||
threatCountLabel: "One security chief and a quiet detail of professional enforcers."
|
||
}
|
||
],
|
||
silver: [
|
||
{
|
||
id: "silver-live-escort",
|
||
shortTitle: "Smile Cut",
|
||
missionFocus: "Protect or escort a person through a hostile district.",
|
||
colourMissionIdea: "Keep a sponsor-backed public figure alive without letting the cameras see how close the job gets.",
|
||
location: "A ribbon of sponsor venues, red-carpet choke points, and one ugly service corridor nobody told the cameras about.",
|
||
atmosphere: "Flash lamps, branded drones, crowd heat, and just enough panic underneath the applause to be dangerous.",
|
||
opposition: "A stalker cell, freelance agitators, and one rival sponsor fixer hoping to turn the event into a scandal.",
|
||
oppositionAttitude: "Would prefer embarrassment over blood but are ready to escalate if the window closes.",
|
||
civilianFaction: "Fans and paid attendees packed too tightly to separate cleanly from the real threat.",
|
||
extraObjective: "Protect the sponsor narrative as much as the body being moved.",
|
||
escalationEvent: "A live-feed malfunction dumps backstage footage into the public stream and sends the route sideways.",
|
||
complication: "The VIP is hiding a personal agenda and keeps deviating from the protected path.",
|
||
localTwist: "The entire appearance is bait designed to expose who inside the sponsor chain is leaking movement plans.",
|
||
issuingDepartment: "Media Relations / Sponsor Operations",
|
||
contactRole: "Media Fixer",
|
||
contactArchetype: "media",
|
||
contactLocation: "A mobile control booth hidden behind the main broadcast gantry.",
|
||
threatArchetype: "security",
|
||
threatRole: "Hostile Fixer",
|
||
threatCountLabel: "One well-dressed operator with hired hands embedded in the crowd."
|
||
}
|
||
],
|
||
jade: [
|
||
{
|
||
id: "jade-containment",
|
||
shortTitle: "Wet Bloom",
|
||
missionFocus: "Contain or quarantine an outbreak or Ebb event.",
|
||
colourMissionIdea: "Lock down a contaminated block before the spill crosses into the transit grid.",
|
||
location: "A mixed-use block connected to drainage canals and a damaged med-lab service annex.",
|
||
atmosphere: "Filtered alarms, chemical stink, and a wet sheen on every surface that might be alive.",
|
||
opposition: "Mutated survivors, a failed lab asset, and one containment officer already planning to burn the evidence.",
|
||
oppositionAttitude: "Act unpredictably and spike between blind panic and sudden aggression.",
|
||
civilianFaction: "A street clinic trying to keep the least infected residents alive long enough to evacuate.",
|
||
extraObjective: "Secure one uncontaminated sample for Karma analysis.",
|
||
escalationEvent: "Containment seals begin dropping floor by floor, isolating parts of the squad from one another.",
|
||
complication: "The official quarantine map is wrong and funnels civilians toward the hottest zone.",
|
||
localTwist: "The contamination profile matches a programme nobody admits still exists.",
|
||
issuingDepartment: "Karma Hazard Response",
|
||
contactRole: "Field Scientist",
|
||
contactArchetype: "scientist",
|
||
contactLocation: "A decontamination vestibule made from two bolted ambulance trailers.",
|
||
threatArchetype: "stormer",
|
||
threatRole: "Mutated Lab Asset",
|
||
threatCountLabel: "One unstable asset and several contaminated survivors acting around it."
|
||
}
|
||
],
|
||
red: [
|
||
{
|
||
id: "red-monorail-hostage",
|
||
shortTitle: "Dead Platform",
|
||
missionFocus: "Protect or escort a person through a hostile district.",
|
||
colourMissionIdea: "Board a hijacked monorail, cut the threat out, and stop a public disaster before it multiplies.",
|
||
location: "A moving monorail line, two sealed stations, and the service deck beneath the track.",
|
||
atmosphere: "Screaming brakes, emergency klaxons, static over public address, and media drones already racing the train.",
|
||
opposition: "A terror cell with improvised explosives and a hardline trigger operator.",
|
||
oppositionAttitude: "Need the spectacle as much as the tactical win and will kill quickly if denied it.",
|
||
civilianFaction: "Hostages trapped inside the train with just enough courage to help if guided correctly.",
|
||
extraObjective: "Keep the train itself intact enough for a survivor extraction.",
|
||
escalationEvent: "A secondary device threatens the inbound station and splits command priorities.",
|
||
complication: "One hostage is secretly tied to the cell and is feeding them squad movement.",
|
||
localTwist: "The train was chosen because someone important is riding under a false manifest.",
|
||
issuingDepartment: "Shiver Command Liaison Desk",
|
||
contactRole: "Transit Crisis Controller",
|
||
contactArchetype: "controller",
|
||
contactLocation: "A sealed signal room with live route telemetry and too many shouting department heads.",
|
||
threatArchetype: "riot",
|
||
threatRole: "Cell Trigger Man",
|
||
threatCountLabel: "One detonation lead, two shooters, and a train full of leverage."
|
||
},
|
||
{
|
||
id: "red-broadcast-retake",
|
||
shortTitle: "Signal Mercy",
|
||
missionFocus: "Eliminate a threat before it reaches public view.",
|
||
colourMissionIdea: "Retake a broadcast tower before the final message turns a bad district into a citywide panic.",
|
||
location: "A media tower, its control floors, and the access streets packed with fleeing civilians.",
|
||
atmosphere: "Emergency beacons, dropped live feeds, smoke in the ventilation stacks, and every screen in Mort pointing at the same problem.",
|
||
opposition: "An extremist crew with tower access and stolen media credentials.",
|
||
oppositionAttitude: "Expect to die but want the audience to watch it happen in the right order.",
|
||
civilianFaction: "Panicking evacuees clogging the lower service roads and making every clean shot harder.",
|
||
extraObjective: "Recover the message core and identify who green-lit tower access.",
|
||
escalationEvent: "Backup transmitters spin up on a timer and broaden the threat window.",
|
||
complication: "The tower engineer who can shut the system down has been wounded and hidden by the crowd.",
|
||
localTwist: "The message is less propaganda than data dump, and one department in SLA wants it to air.",
|
||
issuingDepartment: "Media Relations / Sponsor Operations",
|
||
contactRole: "Broadcast Security Captain",
|
||
contactArchetype: "shiver",
|
||
contactLocation: "A lower-floor security post running on emergency battery.",
|
||
threatArchetype: "security",
|
||
threatRole: "Tower Seizure Lead",
|
||
threatCountLabel: "One charismatic lead and a handful of committed technical shooters."
|
||
}
|
||
],
|
||
black: [
|
||
{
|
||
id: "black-silent-vault",
|
||
shortTitle: "Mute Entry",
|
||
missionFocus: "Retrieve data or recordings without contamination.",
|
||
colourMissionIdea: "Slip into a sealed vault, lift one file set, and leave the archive looking untouched.",
|
||
location: "A corporate research vault buried under a routine logistics floor.",
|
||
atmosphere: "Cold air, sound-dampened corridors, biometric locks, and too much silence to trust.",
|
||
opposition: "Internal black security and one compartmentalised watcher who does not know the whole job.",
|
||
oppositionAttitude: "Stay disciplined, silent, and willing to erase the evidence of their own failure.",
|
||
civilianFaction: "Night-shift technicians who do not understand the stakes but know every route in the building.",
|
||
extraObjective: "Remove a second innocuous-looking archive block that actually matters more than the main objective.",
|
||
escalationEvent: "A silent alarm brings in an internal response team with orders to close the floor permanently.",
|
||
complication: "The file set is mirrored to a live observer station and cannot simply be taken.",
|
||
localTwist: "The client is not paying for the theft; they are paying to confirm what Head Office already suspects.",
|
||
issuingDepartment: "Head Office Special Assignments",
|
||
contactRole: "Darkfinder Case Officer",
|
||
contactArchetype: "corporate",
|
||
contactLocation: "A blackout-safe service apartment three blocks from the target tower.",
|
||
threatArchetype: "blackops",
|
||
threatRole: "Vault Warden",
|
||
threatCountLabel: "One silent warden and a two-person ghost response team."
|
||
},
|
||
{
|
||
id: "black-disavowed-burn",
|
||
shortTitle: "Ash Ledger",
|
||
missionFocus: "Sabotage or destroy a target off the books.",
|
||
colourMissionIdea: "Burn a deniable site and seed the right false evidence before anyone asks why it existed.",
|
||
location: "A shell company warehouse wrapped around an unlisted lab basement.",
|
||
atmosphere: "Generator hum, shuttered loading bays, and an air that already feels abandoned on purpose.",
|
||
opposition: "Contract cleaners and one site supervisor prepared for a final-burn contingency.",
|
||
oppositionAttitude: "Know the site is disposable and only care about buying enough time to erase the real trail.",
|
||
civilianFaction: "Nearby dock crews who heard there was money in tonight’s unusual overtime orders.",
|
||
extraObjective: "Leave one trail leading toward a rival corporate culprit.",
|
||
escalationEvent: "A chemical fire spreads faster than expected and starts opening parts of the hidden basement.",
|
||
complication: "One asset inside the site is still alive and worth far more than the building.",
|
||
localTwist: "The false evidence you were told to plant matches a previous cover story almost word for word.",
|
||
issuingDepartment: "Head Office Special Assignments",
|
||
contactRole: "Freelance Financier",
|
||
contactArchetype: "fixer",
|
||
contactLocation: "A remote comms booth patched through a dead sponsor office.",
|
||
threatArchetype: "blackops",
|
||
threatRole: "Cleanup Supervisor",
|
||
threatCountLabel: "One hard cleanup lead, one torch specialist, and a final-burn team."
|
||
}
|
||
],
|
||
platinum: [
|
||
{
|
||
id: "platinum-head-office",
|
||
shortTitle: "Quiet Crown",
|
||
missionFocus: "Protect or escort a person through a hostile district.",
|
||
colourMissionIdea: "Extract a Head Office asset who knows too much without revealing the extraction is even happening.",
|
||
location: "A sealed executive sector stitched into an older transit artery beneath it.",
|
||
atmosphere: "Too clean above, too secret below, and everyone on-site acting as if the wrong word could kill them.",
|
||
opposition: "A compartmentalised internal response team and one asset handler trying to rewrite the operation in real time.",
|
||
oppositionAttitude: "Never shout, rarely miss, and assume everyone around them is expendable.",
|
||
civilianFaction: "None officially, though a handful of overlooked service workers are trapped inside the machinery.",
|
||
extraObjective: "Return every mission record to Head Office only.",
|
||
escalationEvent: "Command changes the extraction route mid-run, implying compromise at the original pickup.",
|
||
complication: "The protected asset is deciding whether the squad is the rescue team or the last cleanup team they will ever see.",
|
||
localTwist: "The extraction itself is cover for a private conversation one Head Office director wants another never to hear.",
|
||
issuingDepartment: "Head Office Special Assignments",
|
||
contactRole: "Head Office Courier",
|
||
contactArchetype: "corporate",
|
||
contactLocation: "A private transit berth accessible only by dead-route credentials.",
|
||
threatArchetype: "blackops",
|
||
threatRole: "Executive Recovery Lead",
|
||
threatCountLabel: "One elite retrieval leader and a pair of immaculate shadow operators."
|
||
}
|
||
]
|
||
};
|
||
|
||
const CONTACT_ARCHETYPES = {
|
||
detective: { combat: 42, instinct: 55, speed: 38, loyalty: 46, armor: 1, dr: 0, health: 11, hits: 2, weapon: "heavy-pistol", image: "icons/svg/mystery-man.svg" },
|
||
analyst: { combat: 28, instinct: 52, speed: 31, loyalty: 44, armor: 0, dr: 0, health: 9, hits: 2, weapon: "holdout", image: "icons/svg/mystery-man.svg" },
|
||
scientist: { combat: 24, instinct: 47, speed: 28, loyalty: 38, armor: 0, dr: 0, health: 9, hits: 2, weapon: "none", image: "icons/svg/lab.svg" },
|
||
fixer: { combat: 36, instinct: 49, speed: 44, loyalty: 32, armor: 1, dr: 0, health: 10, hits: 2, weapon: "machine-pistol", image: "icons/svg/mystery-man.svg" },
|
||
controller: { combat: 30, instinct: 50, speed: 34, loyalty: 48, armor: 1, dr: 0, health: 10, hits: 2, weapon: "holdout", image: "icons/svg/target.svg" },
|
||
shiver: { combat: 48, instinct: 44, speed: 38, loyalty: 53, armor: 3, dr: 1, health: 12, hits: 2, weapon: "service-carbine", image: "icons/svg/shield.svg" },
|
||
media: { combat: 20, instinct: 41, speed: 35, loyalty: 30, armor: 0, dr: 0, health: 8, hits: 2, weapon: "none", image: "icons/svg/book.svg" },
|
||
corporate: { combat: 34, instinct: 46, speed: 32, loyalty: 42, armor: 1, dr: 0, health: 10, hits: 2, weapon: "holdout", image: "icons/svg/mystery-man.svg" }
|
||
};
|
||
|
||
const THREAT_ARCHETYPES = {
|
||
ganger: { combat: 46, instinct: 32, speed: 39, armor: 1, dr: 0, health: 11, hits: 2, weapon: "smg", image: "icons/svg/skull.svg" },
|
||
raider: { combat: 49, instinct: 36, speed: 42, armor: 1, dr: 0, health: 12, hits: 2, weapon: "shotgun", image: "icons/svg/skull.svg" },
|
||
killer: { combat: 58, instinct: 45, speed: 51, armor: 2, dr: 1, health: 12, hits: 2, weapon: "machine-pistol", image: "icons/svg/cowled.svg" },
|
||
corpsec: { combat: 54, instinct: 40, speed: 38, armor: 3, dr: 1, health: 13, hits: 2, weapon: "service-carbine", image: "icons/svg/shield.svg" },
|
||
security: { combat: 52, instinct: 39, speed: 37, armor: 2, dr: 1, health: 12, hits: 2, weapon: "smg", image: "icons/svg/shield.svg" },
|
||
riot: { combat: 50, instinct: 34, speed: 35, armor: 3, dr: 1, health: 14, hits: 2, weapon: "shock-maul", image: "icons/svg/explosion.svg" },
|
||
blackops: { combat: 66, instinct: 52, speed: 57, armor: 4, dr: 2, health: 14, hits: 2, weapon: "silenced-carbine", image: "icons/svg/cowled.svg" },
|
||
stormer: { combat: 63, instinct: 41, speed: 48, armor: 4, dr: 2, health: 16, hits: 3, weapon: "storm-blade", image: "icons/svg/bones.svg" },
|
||
carrien: { combat: 57, instinct: 52, speed: 59, armor: 2, dr: 0, health: 13, hits: 2, weapon: "claws", image: "icons/svg/bones.svg" },
|
||
"retrieval-crew": { combat: 53, instinct: 37, speed: 49, armor: 2, dr: 1, health: 12, hits: 2, weapon: "machine-pistol", image: "icons/svg/skull.svg" }
|
||
};
|
||
|
||
const WEAPON_LIBRARY = {
|
||
none: null,
|
||
holdout: { name: "Stern Holdout", damage: "1d10", range: "Short", damageType: "impaling", skillRef: "Firearm (Pistol)", special: "concealable", weight: 0.8, woundEffect: "Gunshot" },
|
||
"heavy-pistol": { name: "FEN 203 Heavy Pistol", damage: "1d10+1", range: "Short", damageType: "impaling", skillRef: "Firearm (Pistol)", special: "stopping power", weight: 1.2, woundEffect: "Gunshot" },
|
||
"machine-pistol": { name: "FEN 209 Machine Pistol", damage: "1d8", range: "15m", damageType: "impaling", skillRef: "Firearm (SMG)", special: "burst", weight: 1.5, woundEffect: "Gunshot" },
|
||
smg: { name: "FEN 204 SMG", damage: "1d10", range: "30m", damageType: "impaling", skillRef: "Firearm (SMG)", special: "auto", weight: 2.8, woundEffect: "Gunshot" },
|
||
"service-carbine": { name: "Shiver Service Carbine", damage: "1d10+1", range: "50m", damageType: "impaling", skillRef: "Firearm (Rifle)", special: "single / burst", weight: 3.4, woundEffect: "Gunshot" },
|
||
"silenced-carbine": { name: "Silent Service Carbine", damage: "1d10+2", range: "50m", damageType: "impaling", skillRef: "Firearm (Rifle)", special: "suppressed", weight: 3.2, woundEffect: "Gunshot" },
|
||
shotgun: { name: "Riot Shotgun", damage: "2d6", range: "Close", damageType: "blunt", skillRef: "Firearm (Shotgun)", special: "spread", weight: 3.7, woundEffect: "Blunt Force" },
|
||
"shock-maul": { name: "Shock Maul", damage: "1d10", range: "Close", damageType: "blunt", skillRef: "Melee Club", special: "stun arcs", weight: 2.9, woundEffect: "Blunt Force" },
|
||
"storm-blade": { name: "Storm Blade", damage: "2d6", range: "Close", damageType: "cutting", skillRef: "Melee Blade 2H", special: "heavy cleave", weight: 4.4, woundEffect: "Gore & Massive" },
|
||
claws: { name: "Carrien Claws", damage: "1d10", range: "Close", damageType: "rending", skillRef: "Brawl", special: "bleed", weight: 0, woundEffect: "Gore & Massive" }
|
||
};
|
||
|
||
function getHtmlRoot(html) {
|
||
return html instanceof HTMLElement ? html : html?.[0] ?? null;
|
||
}
|
||
|
||
async function loadTables() {
|
||
if (TABLES) return TABLES;
|
||
const response = await fetch("modules/sla-bpn-dispatch/data/bpn-tables.json");
|
||
TABLES = await response.json();
|
||
return TABLES;
|
||
}
|
||
|
||
function pick(array) {
|
||
return Array.isArray(array) && array.length ? array[Math.floor(Math.random() * array.length)] : "";
|
||
}
|
||
|
||
function rollDie(faces) {
|
||
return Math.floor(Math.random() * faces) + 1;
|
||
}
|
||
|
||
function padNumber(n, width = 3) {
|
||
return String(n).padStart(width, "0");
|
||
}
|
||
|
||
function slugify(value = "") {
|
||
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||
}
|
||
|
||
function getColourAccent(key = "white") {
|
||
const accents = {
|
||
blue: "#57b3ff",
|
||
yellow: "#ffdb6e",
|
||
green: "#78d48f",
|
||
white: "#dce8f9",
|
||
grey: "#9da6b3",
|
||
silver: "#d7dee8",
|
||
jade: "#41d0a3",
|
||
red: "#ff6f6f",
|
||
black: "#9a7cff",
|
||
platinum: "#f5f0d7"
|
||
};
|
||
return accents[key] ?? accents.white;
|
||
}
|
||
|
||
function bpnIconPath(key = "white") {
|
||
const normalized = String(key ?? "white").toLowerCase();
|
||
return `modules/${MODULE_ID}/assets/bpn-${normalized}.svg`;
|
||
}
|
||
|
||
function formatScl(value = 0) {
|
||
const number = Number(value ?? 0);
|
||
if (!Number.isFinite(number)) return "+0.0";
|
||
const fixed = Math.abs(number) >= 1 ? number.toFixed(1) : number.toFixed(2);
|
||
const trimmed = fixed.replace(/\.?0+$/, "");
|
||
return `${number >= 0 ? "+" : ""}${trimmed}`;
|
||
}
|
||
|
||
function creditsFromRange(range = { min: 0, max: 0 }, tier = "mid") {
|
||
const min = Number(range?.min ?? 0);
|
||
const max = range?.max === null || range?.max === undefined ? null : Number(range.max);
|
||
if (!Number.isFinite(min)) return 0;
|
||
if (max === null || !Number.isFinite(max)) {
|
||
if (tier === "low") return Math.round(min);
|
||
if (tier === "mid") return Math.round(min * 1.15);
|
||
return Math.round(min * 1.35);
|
||
}
|
||
if (tier === "low") return Math.round(min);
|
||
if (tier === "high") return Math.round(max);
|
||
return Math.round((min + max) / 2);
|
||
}
|
||
|
||
function tierFromRoll(roll = 1) {
|
||
if (roll <= 2) return "low";
|
||
if (roll <= 4) return "mid";
|
||
return "high";
|
||
}
|
||
|
||
function mediaFromRoll(roll = 1) {
|
||
if (roll <= 1) return "None";
|
||
if (roll <= 3) return "Station Analysis";
|
||
if (roll <= 5) return "Third Eye News";
|
||
return "GoreZone / Contract Circuit Special";
|
||
}
|
||
|
||
function objectiveFromFocus(focusText = "") {
|
||
const text = String(focusText ?? "").toLowerCase();
|
||
if (text.includes("eliminate")) return "Neutralise the designated threat and confirm status.";
|
||
if (text.includes("escort") || text.includes("protect")) return "Keep the protected asset moving and alive through the full route.";
|
||
if (text.includes("retrieve")) return "Recover the designated item, data, or specimen intact and preserve chain of custody.";
|
||
if (text.includes("investigate")) return "Identify culprit(s), secure evidence, and produce an admissible corporate finding.";
|
||
if (text.includes("contain") || text.includes("quarantine")) return "Lock the threat down before it jumps sectors.";
|
||
if (text.includes("riot") || text.includes("crowd")) return "Restore order without losing the public narrative.";
|
||
if (text.includes("hunt")) return "Track, isolate, and capture or terminate the named target.";
|
||
if (text.includes("clean up")) return "Recover all traces of the prior SLA incident before public disclosure.";
|
||
if (text.includes("sabotage") || text.includes("destroy")) return "Disable the objective and leave a believable false story behind.";
|
||
return "Complete the assigned contract objective and report all outcomes.";
|
||
}
|
||
|
||
function cleanupDirectiveFromColour(colourKey = "blue") {
|
||
const key = String(colourKey ?? "blue").toLowerCase();
|
||
if (key === "blue") return "Restore basic street function and hand the site back looking manageable.";
|
||
if (key === "yellow") return "Protect the recovered asset’s chain of custody at all times.";
|
||
if (key === "white") return "Return with evidence, witnesses, and a narrative command can sign off on.";
|
||
if (key === "silver") return "Keep public optics sponsor-safe from briefing to extraction.";
|
||
if (key === "jade") return "Seal contamination and route all samples through Karma only.";
|
||
if (key === "red") return "Stabilise the zone first, then explain it to the cameras.";
|
||
if (key === "black") return "Remove traces, minimise witnesses, and suppress operational signatures.";
|
||
if (key === "platinum") return "Preserve classification and return all material to Head Office only.";
|
||
return "Recover evidence, suppress compromising exposure, and file a command-ready debrief.";
|
||
}
|
||
|
||
function colourByKey(tables, key = "white") {
|
||
const normalized = String(key ?? "white").toLowerCase();
|
||
return tables.colours.find((colour) => colour.key === normalized) ?? tables.colours[0];
|
||
}
|
||
|
||
function rollColourFromTable(tables) {
|
||
const roll = rollDie(20);
|
||
const entry = tables.colourRollTable.find((row) => roll >= row.min && roll <= row.max) ?? tables.colourRollTable[0];
|
||
return { roll, colour: colourByKey(tables, entry.key) };
|
||
}
|
||
|
||
function resolveSelection(value, fallback = "") {
|
||
return String(value ?? "").trim() || String(fallback ?? "").trim();
|
||
}
|
||
|
||
function buildBpnId(colour) {
|
||
return `${colour.label.toUpperCase().slice(0, 2)}-${padNumber(rollDie(999))}-${padNumber(rollDie(999))}`;
|
||
}
|
||
|
||
function squadSizeValue(value) {
|
||
const numeric = Number(value ?? 4);
|
||
if (!Number.isFinite(numeric)) return 4;
|
||
return Math.max(1, Math.min(8, Math.round(numeric)));
|
||
}
|
||
|
||
function getScenarioFramesForColour(key = "white") {
|
||
return SCENARIO_FRAMES[key] ?? SCENARIO_FRAMES.white;
|
||
}
|
||
|
||
function frameByKey(colourKey, frameKey) {
|
||
return getScenarioFramesForColour(colourKey).find((frame) => frame.id === frameKey) ?? null;
|
||
}
|
||
|
||
function chooseScenarioFrame(colourKey, requestedFrameKey = "") {
|
||
const existing = requestedFrameKey ? frameByKey(colourKey, requestedFrameKey) : null;
|
||
if (existing) return existing;
|
||
return pick(getScenarioFramesForColour(colourKey));
|
||
}
|
||
|
||
function buildContactName(tables) {
|
||
return `${pick(tables.contactFirstNames)} ${pick(tables.contactLastNames)}`;
|
||
}
|
||
|
||
function buildPrimaryContact(frame, tables, fallback = "") {
|
||
if (String(fallback ?? "").trim()) return String(fallback).trim();
|
||
return `${buildContactName(tables)} - ${frame.contactRole}`;
|
||
}
|
||
|
||
function rewardPackageForColour(colour, input = {}) {
|
||
const paymentTypeRoll = rollDie(6);
|
||
const cbsTierRoll = rollDie(6);
|
||
const sclTierRoll = rollDie(6);
|
||
const mediaRoll = rollDie(6);
|
||
const financierRoll = rollDie(6);
|
||
|
||
const squadSize = squadSizeValue(input.squadSize);
|
||
const paymentType = input.paymentType && input.paymentType !== "__RANDOM__"
|
||
? String(input.paymentType)
|
||
: (paymentTypeRoll <= 4 ? "Per Squad" : "Per Operative");
|
||
const cbsTier = tierFromRoll(cbsTierRoll);
|
||
const sclTier = tierFromRoll(sclTierRoll);
|
||
|
||
const grossPerOp = creditsFromRange(colour.cbsPerOpRange, cbsTier);
|
||
const grossPerSquad = creditsFromRange(colour.cbsPerSquadRange, cbsTier);
|
||
const grossContract = paymentType === "Per Operative" ? grossPerOp * squadSize : grossPerSquad;
|
||
const financierCutPercent = input.financierCutPercent
|
||
? Number(input.financierCutPercent)
|
||
: (financierRoll <= 2 ? 10 : financierRoll <= 4 ? 15 : 20);
|
||
const netContract = Math.max(0, Math.round(grossContract * (1 - financierCutPercent / 100)));
|
||
const estimatedTakeHomeEach = Math.max(1, Math.floor(netContract / squadSize));
|
||
|
||
const sclByTier = {
|
||
low: Number(colour.sclRange?.min ?? 0.1),
|
||
mid: Number(colour.sclRange?.typical ?? colour.sclRange?.min ?? 0.1),
|
||
high: Number(colour.sclRange?.max ?? colour.sclRange?.typical ?? 0.2)
|
||
};
|
||
const sclIncreaseValue = formatScl(sclByTier[sclTier] ?? sclByTier.mid);
|
||
|
||
return {
|
||
paymentType,
|
||
cbsTier,
|
||
sclTier,
|
||
grossPerOp,
|
||
grossPerSquad,
|
||
grossContract,
|
||
netContract,
|
||
estimatedTakeHomeEach,
|
||
financierCutPercent,
|
||
cbsBandOp: `${colour.cbsPerOpRange?.min ?? 0}c - ${colour.cbsPerOpRange?.max ?? "?"}c`,
|
||
cbsBandSquad: `${colour.cbsPerSquadRange?.min ?? 0}c - ${colour.cbsPerSquadRange?.max ?? "?"}c`,
|
||
sclIncreaseValue,
|
||
mediaCoverage: input.mediaCoverage && input.mediaCoverage !== "__RANDOM__"
|
||
? String(input.mediaCoverage)
|
||
: mediaFromRoll(mediaRoll),
|
||
notes: `Financier cut assumed at ${financierCutPercent}%. Estimated take-home is after the financier slice and split across ${squadSize} Operatives.`,
|
||
rolls: {
|
||
paymentType: paymentTypeRoll,
|
||
cbsTier: cbsTierRoll,
|
||
sclTier: sclTierRoll,
|
||
media: mediaRoll,
|
||
financier: financierRoll
|
||
}
|
||
};
|
||
}
|
||
|
||
function buildMissionBrief(draft) {
|
||
return [
|
||
`${draft.issuingDepartment} has issued ${draft.bpnId}, a ${draft.colourLabel.toUpperCase()} ${draft.colourType.toLowerCase()} contract.`,
|
||
`${draft.primaryContact} wants the squad to ${objectiveFromFocus(draft.missionFocus).toLowerCase()}`,
|
||
`The operational spine runs through ${draft.location.toLowerCase()}, where ${draft.colourMissionIdea.toLowerCase()}`,
|
||
`Expect ${draft.opposition.toLowerCase()}, who ${draft.oppositionAttitude.toLowerCase()}`,
|
||
`${draft.complication}`,
|
||
`If the squad loses tempo, ${draft.escalationEvent.toLowerCase()}`,
|
||
`Media posture is ${draft.mediaCoverage.toLowerCase()}, and command expects a clean narrative.`
|
||
].join(" ");
|
||
}
|
||
|
||
function defaultDraft() {
|
||
return {
|
||
shortTitle: "Sector Sweep",
|
||
frameKey: "",
|
||
bpnId: "WH-201-771",
|
||
colourKey: "white",
|
||
colourLabel: "White",
|
||
colourType: "Investigation",
|
||
colourSummary: "",
|
||
colourDetails: "",
|
||
colourAccent: getColourAccent("white"),
|
||
iconPath: bpnIconPath("white"),
|
||
issuingDepartment: "Mort Department / Ops Control",
|
||
squadSize: 4,
|
||
primaryContact: "",
|
||
primaryContactLocation: "",
|
||
sclRequirement: "10 - 7",
|
||
paymentType: "Per Squad",
|
||
mediaCoverage: "Station Analysis",
|
||
missionFocus: "",
|
||
colourMissionIdea: "",
|
||
location: "",
|
||
atmosphere: "",
|
||
opposition: "",
|
||
oppositionAttitude: "",
|
||
civilianFaction: "",
|
||
extraObjective: "",
|
||
escalationEvent: "",
|
||
complication: "",
|
||
localTwist: "",
|
||
missionBrief: "",
|
||
grossContract: "0c",
|
||
estimatedTakeHomeEach: "0c",
|
||
financierCut: "15%",
|
||
sclIncrease: "+0.1",
|
||
cbsBand: "",
|
||
operationalNotes: "",
|
||
threatLead: "",
|
||
threatCountLabel: "",
|
||
npcSummary: "",
|
||
generatedNpcLinks: []
|
||
};
|
||
}
|
||
|
||
function draftFromReward(base, reward) {
|
||
return {
|
||
...base,
|
||
grossContract: `${reward.grossContract}c (${reward.paymentType})`,
|
||
estimatedTakeHomeEach: `${reward.estimatedTakeHomeEach}c each`,
|
||
financierCut: `${reward.financierCutPercent}%`,
|
||
sclIncrease: reward.sclIncreaseValue,
|
||
mediaCoverage: reward.mediaCoverage,
|
||
cbsBand: `${reward.cbsBandOp} per Op / ${reward.cbsBandSquad} per Squad`,
|
||
reward
|
||
};
|
||
}
|
||
|
||
function getDraft() {
|
||
return foundry.utils.mergeObject(defaultDraft(), game.settings.get(MODULE_ID, DRAFT_KEY) ?? {});
|
||
}
|
||
|
||
async function setDraft(draft) {
|
||
const safeDraft = foundry.utils.deepClone(draft ?? {});
|
||
delete safeDraft.reward;
|
||
delete safeDraft.generatedNpcLinks;
|
||
await game.settings.set(MODULE_ID, DRAFT_KEY, foundry.utils.mergeObject(defaultDraft(), safeDraft));
|
||
}
|
||
|
||
async function composeDraft(input = {}, { randomizeMissing = false } = {}) {
|
||
const tables = await loadTables();
|
||
const base = foundry.utils.mergeObject(defaultDraft(), input ?? {});
|
||
const colourRoll = randomizeMissing || !String(base.colourKey ?? "").trim()
|
||
? rollColourFromTable(tables)
|
||
: { roll: 0, colour: colourByKey(tables, base.colourKey) };
|
||
const colour = colourRoll.colour;
|
||
const frame = chooseScenarioFrame(colour.key, randomizeMissing ? "" : base.frameKey);
|
||
|
||
const reward = rewardPackageForColour(colour, base);
|
||
const draft = draftFromReward({
|
||
...base,
|
||
frameKey: frame.id,
|
||
bpnId: resolveSelection(base.bpnId, randomizeMissing ? buildBpnId(colour) : buildBpnId(colour)),
|
||
shortTitle: resolveSelection(base.shortTitle, frame.shortTitle),
|
||
colourKey: colour.key,
|
||
colourLabel: colour.label,
|
||
colourType: colour.type,
|
||
colourSummary: colour.summary,
|
||
colourDetails: colour.details,
|
||
colourAccent: getColourAccent(colour.key),
|
||
iconPath: bpnIconPath(colour.key),
|
||
issuingDepartment: resolveSelection(base.issuingDepartment, frame.issuingDepartment),
|
||
squadSize: squadSizeValue(base.squadSize),
|
||
primaryContact: buildPrimaryContact(frame, tables, base.primaryContact),
|
||
primaryContactLocation: resolveSelection(base.primaryContactLocation, frame.contactLocation),
|
||
sclRequirement: resolveSelection(base.sclRequirement, colour.sclReq),
|
||
missionFocus: resolveSelection(base.missionFocus, frame.missionFocus),
|
||
colourMissionIdea: resolveSelection(base.colourMissionIdea, frame.colourMissionIdea),
|
||
location: resolveSelection(base.location, frame.location),
|
||
atmosphere: resolveSelection(base.atmosphere, frame.atmosphere),
|
||
opposition: resolveSelection(base.opposition, frame.opposition),
|
||
oppositionAttitude: resolveSelection(base.oppositionAttitude, frame.oppositionAttitude),
|
||
civilianFaction: resolveSelection(base.civilianFaction, frame.civilianFaction),
|
||
extraObjective: resolveSelection(base.extraObjective, frame.extraObjective),
|
||
escalationEvent: resolveSelection(base.escalationEvent, frame.escalationEvent),
|
||
complication: resolveSelection(base.complication, frame.complication),
|
||
localTwist: resolveSelection(base.localTwist, frame.localTwist),
|
||
threatLead: resolveSelection(base.threatLead, frame.threatRole),
|
||
threatCountLabel: resolveSelection(base.threatCountLabel, frame.threatCountLabel),
|
||
npcSummary: `${frame.contactRole} / ${frame.threatRole}`,
|
||
operationalNotes: String(base.operationalNotes ?? "").trim(),
|
||
scenarioFrame: frame,
|
||
generatedNpcLinks: Array.isArray(base.generatedNpcLinks) ? base.generatedNpcLinks : []
|
||
}, reward);
|
||
|
||
draft.missionBrief = resolveSelection(base.missionBrief, buildMissionBrief(draft));
|
||
draft.rolls = {
|
||
colour: colourRoll.roll,
|
||
paymentType: reward.rolls.paymentType,
|
||
cbsTier: reward.rolls.cbsTier,
|
||
sclTier: reward.rolls.sclTier,
|
||
media: reward.rolls.media,
|
||
financier: reward.rolls.financier
|
||
};
|
||
return draft;
|
||
}
|
||
|
||
function compactChatHtml(draft) {
|
||
const e = foundry.utils.escapeHTML;
|
||
return `
|
||
<section class="sla-bpn-chat">
|
||
<header class="sla-bpn-chat__hero" style="--bpn-accent:${e(draft.colourAccent)};">
|
||
<img class="sla-bpn-chat__icon" src="${e(draft.iconPath || bpnIconPath(draft.colourKey))}" alt="${e(draft.colourLabel)} BPN">
|
||
<div class="sla-bpn-chat__heading">
|
||
<div class="sla-bpn-chat__eyebrow">SLA Industries Dispatch</div>
|
||
<h3>${e(draft.colourLabel)} BPN - ${e(draft.shortTitle)}</h3>
|
||
<p>${e(draft.colourType)} // SCL ${e(draft.sclRequirement)} // ${e(draft.grossContract)}</p>
|
||
</div>
|
||
</header>
|
||
<div class="sla-bpn-chat__body">
|
||
<p class="sla-bpn-chat__brief">${e(draft.missionBrief)}</p>
|
||
<div class="sla-bpn-chat__grid">
|
||
<div><strong>Primary Contact</strong><span>${e(draft.primaryContact)}</span><em>${e(draft.primaryContactLocation)}</em></div>
|
||
<div><strong>Threat Lead</strong><span>${e(draft.threatLead)}</span><em>${e(draft.threatCountLabel)}</em></div>
|
||
<div><strong>Operational Hook</strong><span>${e(draft.colourMissionIdea)}</span></div>
|
||
<div><strong>Extra Objective</strong><span>${e(draft.extraObjective)}</span></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function playerHtml(draft) {
|
||
const e = foundry.utils.escapeHTML;
|
||
return `
|
||
<section class="sla-bpn-journal" style="--bpn-accent:${e(draft.colourAccent)};">
|
||
<header class="sla-bpn-journal__hero">
|
||
<img class="sla-bpn-journal__icon" src="${e(draft.iconPath || bpnIconPath(draft.colourKey))}" alt="${e(draft.colourLabel)} BPN">
|
||
<div>
|
||
<div class="sla-bpn-journal__eyebrow">SLA Industries / Corporate Dispatch</div>
|
||
<h2>${e(draft.colourLabel)} BPN - ${e(draft.shortTitle)}</h2>
|
||
<p>${e(draft.colourType)} // ${e(draft.colourSummary)}</p>
|
||
</div>
|
||
</header>
|
||
<section class="sla-bpn-journal__meta">
|
||
<div><strong>BPN ID</strong><span>${e(draft.bpnId)}</span></div>
|
||
<div><strong>SCL Requirement</strong><span>${e(draft.sclRequirement)}</span></div>
|
||
<div><strong>Gross Contract</strong><span>${e(draft.grossContract)}</span></div>
|
||
<div><strong>Take-Home</strong><span>${e(draft.estimatedTakeHomeEach)}</span></div>
|
||
</section>
|
||
<section class="sla-bpn-journal__briefing">
|
||
<h3>Mission Brief</h3>
|
||
<p>${e(draft.missionBrief)}</p>
|
||
</section>
|
||
<section class="sla-bpn-journal__grid">
|
||
<div><strong>Mission Focus</strong><span>${e(draft.missionFocus)}</span></div>
|
||
<div><strong>Operational Hook</strong><span>${e(draft.colourMissionIdea)}</span></div>
|
||
<div><strong>Primary Objective</strong><span>${e(objectiveFromFocus(draft.missionFocus))}</span></div>
|
||
<div><strong>Extra Objective</strong><span>${e(draft.extraObjective)}</span></div>
|
||
<div><strong>Location</strong><span>${e(draft.location)}</span></div>
|
||
<div><strong>Atmosphere</strong><span>${e(draft.atmosphere)}</span></div>
|
||
<div><strong>Expected Opposition</strong><span>${e(draft.opposition)}</span></div>
|
||
<div><strong>Opposition Attitude</strong><span>${e(draft.oppositionAttitude)}</span></div>
|
||
<div><strong>Primary Contact</strong><span>${e(draft.primaryContact)}</span></div>
|
||
<div><strong>Contact Position</strong><span>${e(draft.primaryContactLocation)}</span></div>
|
||
<div><strong>Civilian Pressure</strong><span>${e(draft.civilianFaction)}</span></div>
|
||
<div><strong>Complication</strong><span>${e(draft.complication)}</span></div>
|
||
<div><strong>Escalation Trigger</strong><span>${e(draft.escalationEvent)}</span></div>
|
||
<div><strong>Cleanup Directive</strong><span>${e(cleanupDirectiveFromColour(draft.colourKey))}</span></div>
|
||
</section>
|
||
<section class="sla-bpn-journal__meta">
|
||
<div><strong>Media Coverage</strong><span>${e(draft.mediaCoverage)}</span></div>
|
||
<div><strong>CBS Band</strong><span>${e(draft.cbsBand)}</span></div>
|
||
<div><strong>Financier Cut</strong><span>${e(draft.financierCut)}</span></div>
|
||
<div><strong>SCL Increase</strong><span>${e(draft.sclIncrease)}</span></div>
|
||
</section>
|
||
${draft.operationalNotes ? `<section class="sla-bpn-journal__briefing"><h3>Operational Notes</h3><p>${e(draft.operationalNotes)}</p></section>` : ""}
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function gmHtml(draft, playerEntryUuid = "", npcLinks = []) {
|
||
const e = foundry.utils.escapeHTML;
|
||
const playerLink = playerEntryUuid ? `@UUID[${playerEntryUuid}]{Player BPN Brief}` : "Player BPN Brief";
|
||
const npcBlock = npcLinks.length
|
||
? `<ul>${npcLinks.map((entry) => `<li>@UUID[${entry.uuid}]{${e(entry.name)}}</li>`).join("")}</ul>`
|
||
: "<p>No NPC actors created.</p>";
|
||
return `
|
||
<section>
|
||
<h2>GM Debrief - ${e(draft.shortTitle)}</h2>
|
||
<p><strong>Linked Brief:</strong> ${playerLink}</p>
|
||
<p><strong>True Pressure Point:</strong> ${e(draft.complication)}</p>
|
||
<p><strong>Operational Twist:</strong> ${e(draft.localTwist)}</p>
|
||
<p><strong>Threat Read:</strong> ${e(draft.threatLead)} // ${e(draft.threatCountLabel)}</p>
|
||
<p><strong>Opposition Attitude:</strong> ${e(draft.oppositionAttitude)}</p>
|
||
<p><strong>Financier Note:</strong> ${e(draft.reward?.notes ?? "")}</p>
|
||
<p><strong>Levers:</strong> ${e(draft.civilianFaction)} If pressure rises, ${e(draft.escalationEvent.toLowerCase())}.</p>
|
||
<hr>
|
||
<h3>Generated NPCs</h3>
|
||
${npcBlock}
|
||
<hr>
|
||
<p><strong>Roll State:</strong> Colour ${Number(draft.rolls?.colour ?? 0)}, payment ${Number(draft.rolls?.paymentType ?? 0)}, CBS ${Number(draft.rolls?.cbsTier ?? 0)}, SCL ${Number(draft.rolls?.sclTier ?? 0)}, media ${Number(draft.rolls?.media ?? 0)}, financier ${Number(draft.rolls?.financier ?? 0)}.</p>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function buildWeaponPayload(weaponKey) {
|
||
const weapon = WEAPON_LIBRARY[weaponKey];
|
||
if (!weapon) return null;
|
||
return {
|
||
name: weapon.name,
|
||
type: "weapon",
|
||
system: {
|
||
damage: weapon.damage,
|
||
weight: weapon.weight,
|
||
cost: 0,
|
||
carryState: "equipped",
|
||
equipped: true,
|
||
useAmmo: false,
|
||
ammo: 0,
|
||
shots: 0,
|
||
curShots: 0,
|
||
bonus: 0,
|
||
woundEffect: weapon.woundEffect,
|
||
ranges: { short: 0, medium: 0, long: 0, value: weapon.range },
|
||
sla: {
|
||
weaponType: weapon.skillRef,
|
||
skillRef: weapon.skillRef,
|
||
damageType: weapon.damageType,
|
||
fireModes: weapon.special,
|
||
currentFireMode: weapon.special,
|
||
special: weapon.special,
|
||
notes: [weapon.special]
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
function actorPayloadFromArchetype({ name, profile, role, location, description, bpnId, colourKey, folderId, image }) {
|
||
return {
|
||
name,
|
||
type: "creature",
|
||
img: image || profile.image || "icons/svg/mystery-man.svg",
|
||
folder: folderId,
|
||
flags: {
|
||
[MODULE_ID]: {
|
||
generated: true,
|
||
role,
|
||
bpnId,
|
||
colourKey
|
||
}
|
||
},
|
||
system: {
|
||
description: `${description}\n\nAssigned location: ${location}`,
|
||
notes: `${role} // ${location}`,
|
||
health: { value: profile.health, max: profile.health },
|
||
hits: { value: 0, max: profile.hits },
|
||
stats: {
|
||
combat: { value: profile.combat, enabled: true },
|
||
instinct: { value: profile.instinct, enabled: true },
|
||
speed: { value: profile.speed, enabled: true },
|
||
loyalty: { value: profile.loyalty ?? 0, enabled: Number.isFinite(profile.loyalty) },
|
||
sanity: { value: 35, enabled: true },
|
||
armor: {
|
||
value: profile.armor,
|
||
mod: profile.armor,
|
||
damageReduction: profile.dr,
|
||
cover: profile.armor >= 4 ? "heavy" : profile.armor >= 2 ? "light" : "none",
|
||
enabled: true
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
async function ensureFolder(name, type, parent = null) {
|
||
let folder = game.folders.find((entry) => entry.type === type && entry.name === name && (entry.folder?.id ?? null) === (parent?.id ?? null));
|
||
if (folder) return folder;
|
||
return Folder.create({ name, type, folder: parent?.id ?? null, color: "#39414e" });
|
||
}
|
||
|
||
async function createNpcActorsForDraft(draft) {
|
||
const frame = draft.scenarioFrame;
|
||
if (!frame) return [];
|
||
|
||
const rootFolder = await ensureFolder(ACTOR_FOLDER_NAME, "Actor");
|
||
const folder = await ensureFolder(`${draft.bpnId} - ${draft.shortTitle}`, "Actor", rootFolder);
|
||
const existing = game.actors.filter((actor) => actor.folder?.id === folder.id && actor.flags?.[MODULE_ID]?.generated);
|
||
if (existing.length) await Actor.deleteDocuments(existing.map((actor) => actor.id));
|
||
|
||
const tables = await loadTables();
|
||
const contactName = draft.primaryContact.split(" - ")[0]?.trim() || buildContactName(tables);
|
||
const contactProfile = CONTACT_ARCHETYPES[frame.contactArchetype] ?? CONTACT_ARCHETYPES.detective;
|
||
const threatProfile = THREAT_ARCHETYPES[frame.threatArchetype] ?? THREAT_ARCHETYPES.ganger;
|
||
const threatNameBase = frame.threatArchetype === "carrien"
|
||
? `${draft.shortTitle} Alpha`
|
||
: `${buildContactName(tables)} "${frame.threatRole}"`;
|
||
|
||
const contactActor = await Actor.create(actorPayloadFromArchetype({
|
||
name: contactName,
|
||
profile: contactProfile,
|
||
role: frame.contactRole,
|
||
location: draft.primaryContactLocation,
|
||
description: `${frame.contactRole} assigned to ${draft.bpnId}. ${draft.primaryContact} is the authorised operational voice on-site.`,
|
||
bpnId: draft.bpnId,
|
||
colourKey: draft.colourKey,
|
||
folderId: folder.id,
|
||
image: contactProfile.image
|
||
}), { renderSheet: false });
|
||
|
||
const threatActor = await Actor.create(actorPayloadFromArchetype({
|
||
name: threatNameBase,
|
||
profile: threatProfile,
|
||
role: frame.threatRole,
|
||
location: draft.location,
|
||
description: `${frame.threatRole} connected to ${draft.bpnId}. ${draft.opposition} ${draft.oppositionAttitude}`,
|
||
bpnId: draft.bpnId,
|
||
colourKey: draft.colourKey,
|
||
folderId: folder.id,
|
||
image: threatProfile.image
|
||
}), { renderSheet: false });
|
||
|
||
const contactWeapon = buildWeaponPayload(contactProfile.weapon);
|
||
const threatWeapon = buildWeaponPayload(threatProfile.weapon);
|
||
if (contactWeapon) await contactActor.createEmbeddedDocuments("Item", [contactWeapon]);
|
||
if (threatWeapon) await threatActor.createEmbeddedDocuments("Item", [threatWeapon]);
|
||
|
||
const roster = [
|
||
{ name: contactActor.name, uuid: contactActor.uuid },
|
||
{ name: threatActor.name, uuid: threatActor.uuid }
|
||
];
|
||
draft.generatedNpcLinks = roster;
|
||
return roster;
|
||
}
|
||
|
||
class SlaBpnDispatchApp extends FormApplication {
|
||
static get defaultOptions() {
|
||
return foundry.utils.mergeObject(super.defaultOptions, {
|
||
id: "sla-bpn-dispatch-app",
|
||
title: "SLA BPN Dispatch",
|
||
template: "modules/sla-bpn-dispatch/templates/bpn-dispatch.hbs",
|
||
classes: ["sla-bpn-dispatch"],
|
||
width: 980,
|
||
height: 900,
|
||
closeOnSubmit: false,
|
||
submitOnChange: false,
|
||
resizable: true
|
||
});
|
||
}
|
||
|
||
async getData() {
|
||
const tables = await loadTables();
|
||
const draft = await composeDraft(getDraft(), { randomizeMissing: false });
|
||
return {
|
||
draft,
|
||
colourOptions: tables.colours.map((colour) => ({ key: colour.key, label: colour.label })),
|
||
frameOptions: getScenarioFramesForColour(draft.colourKey).map((frame) => ({ key: frame.id, label: frame.shortTitle }))
|
||
};
|
||
}
|
||
|
||
activateListeners(html) {
|
||
super.activateListeners(html);
|
||
html.find("[data-action='randomize']").on("click", async () => {
|
||
const draft = await composeDraft({}, { randomizeMissing: true });
|
||
await setDraft(draft);
|
||
this.render(false);
|
||
});
|
||
html.find("[data-action='journal']").on("click", async () => {
|
||
const draft = await this._collectDraft();
|
||
const npcLinks = await createNpcActorsForDraft(draft);
|
||
await setDraft(draft);
|
||
const playerEntry = await JournalEntry.create({
|
||
name: `${draft.colourLabel} BPN - ${draft.shortTitle}`,
|
||
img: draft.iconPath || bpnIconPath(draft.colourKey),
|
||
ownership: { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.OBSERVER },
|
||
pages: [{ name: "Mission Brief", type: "text", text: { content: playerHtml(draft) } }]
|
||
}, { renderSheet: false });
|
||
await JournalEntry.create({
|
||
name: `${draft.colourLabel} BPN - ${draft.shortTitle} - GM Debrief`,
|
||
img: draft.iconPath || bpnIconPath(draft.colourKey),
|
||
ownership: { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE },
|
||
pages: [{ name: "GM Debrief", type: "text", text: { content: gmHtml(draft, playerEntry?.uuid ?? "", npcLinks) } }]
|
||
}, { renderSheet: false });
|
||
ui.notifications.info(`Created BPN journals and ${npcLinks.length} generated NPC actors for ${draft.shortTitle}.`);
|
||
});
|
||
html.find("[data-action='chat']").on("click", async () => {
|
||
const draft = await this._collectDraft();
|
||
await setDraft(draft);
|
||
await ChatMessage.create({
|
||
user: game.user.id,
|
||
speaker: { alias: "SLA BPN Dispatch" },
|
||
content: compactChatHtml(draft)
|
||
});
|
||
});
|
||
html.find("[data-action='save']").on("click", async () => {
|
||
const draft = await this._collectDraft();
|
||
await setDraft(draft);
|
||
this.render(false);
|
||
ui.notifications.info("SLA BPN draft saved.");
|
||
});
|
||
html.find("select[name='colourKey'], select[name='frameKey']").on("change", async () => {
|
||
const draft = await this._collectDraft();
|
||
await setDraft(draft);
|
||
this.render(false);
|
||
});
|
||
}
|
||
|
||
async _collectDraft() {
|
||
const fd = new foundry.applications.ux.FormDataExtended(this.form);
|
||
return composeDraft(fd.object, { randomizeMissing: false });
|
||
}
|
||
|
||
async _updateObject(_event, formData) {
|
||
const draft = await composeDraft(formData, { randomizeMissing: false });
|
||
await setDraft(draft);
|
||
ui.notifications.info("SLA BPN draft saved.");
|
||
}
|
||
|
||
static open() {
|
||
return new SlaBpnDispatchApp().render(true);
|
||
}
|
||
}
|
||
|
||
function appendOpenButton(root, selector, className, label) {
|
||
const header = root?.querySelector(selector);
|
||
if (!header || header.querySelector(`.${className}`)) return false;
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = className;
|
||
button.innerHTML = `<i class="fas fa-file-contract"></i> ${label}`;
|
||
button.addEventListener("click", () => SlaBpnDispatchApp.open());
|
||
header.appendChild(button);
|
||
return true;
|
||
}
|
||
|
||
function ensureLaunchRow(root, parentSelector, rowClass) {
|
||
const parent = root?.querySelector(parentSelector);
|
||
if (!parent) return null;
|
||
let row = parent.querySelector(`.${rowClass}`);
|
||
if (row) return row;
|
||
row = document.createElement("div");
|
||
row.className = rowClass;
|
||
parent.appendChild(row);
|
||
return row;
|
||
}
|
||
|
||
function installJournalButton(_app, html) {
|
||
const root = getHtmlRoot(html);
|
||
const launchRow = ensureLaunchRow(root, ".directory-header", "sla-bpn-dispatch-launch-row");
|
||
if (launchRow && !launchRow.querySelector(".sla-bpn-dispatch-open")) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "sla-bpn-dispatch-open";
|
||
button.innerHTML = `<i class="fas fa-file-contract"></i> BPN Dispatch`;
|
||
button.addEventListener("click", () => SlaBpnDispatchApp.open());
|
||
launchRow.appendChild(button);
|
||
return;
|
||
}
|
||
appendOpenButton(root, ".directory-header .header-actions, .directory-header .action-buttons", "sla-bpn-dispatch-open", "BPN Dispatch");
|
||
}
|
||
|
||
function installSettingsButton(_app, html) {
|
||
const root = getHtmlRoot(html);
|
||
appendOpenButton(root, ".settings-sidebar #game-details, .settings-sidebar .directory-header .header-actions, .settings-sidebar .action-buttons", "sla-bpn-dispatch-settings-open", "BPN Dispatch");
|
||
}
|
||
|
||
Hooks.once("init", () => {
|
||
game.settings.register(MODULE_ID, DRAFT_KEY, {
|
||
scope: "world",
|
||
config: false,
|
||
type: Object,
|
||
default: defaultDraft()
|
||
});
|
||
game.settings.registerMenu(MODULE_ID, "bpnDispatchMenu", {
|
||
name: "SLA BPN Dispatch",
|
||
label: "Open BPN Dispatch",
|
||
hint: "Create richer SLA BPN briefings, GM debriefs, and generated NPC actors.",
|
||
icon: "fas fa-file-contract",
|
||
type: SlaBpnDispatchApp,
|
||
restricted: true
|
||
});
|
||
});
|
||
|
||
Hooks.on("renderJournalDirectory", installJournalButton);
|
||
Hooks.on("renderSettings", installSettingsButton);
|