commit 4d8cfa6e48a8d44a1dd337eb977d4769f87e07c7 Author: Tim Evans (slaguru666) Date: Tue Mar 31 17:00:35 2026 +0100 chore: initial deploy — SLA Industries Foundry VTT Co-authored-by: slaguru666 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b24d620 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +npm-debug.log* +.npm/ +dist/ +*.min.js +*.min.css +.DS_Store +.DS_Store? +._* +Thumbs.db +.vscode/ +.idea/ +*.swp +*.swo +worlds/ diff --git a/assets/bpn-black.svg b/assets/bpn-black.svg new file mode 100644 index 0000000..3c31702 --- /dev/null +++ b/assets/bpn-black.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/bpn-blue.svg b/assets/bpn-blue.svg new file mode 100644 index 0000000..6c6dae1 --- /dev/null +++ b/assets/bpn-blue.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/bpn-green.svg b/assets/bpn-green.svg new file mode 100644 index 0000000..62c693f --- /dev/null +++ b/assets/bpn-green.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/bpn-grey.svg b/assets/bpn-grey.svg new file mode 100644 index 0000000..0573c34 --- /dev/null +++ b/assets/bpn-grey.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/bpn-jade.svg b/assets/bpn-jade.svg new file mode 100644 index 0000000..744ff6b --- /dev/null +++ b/assets/bpn-jade.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/bpn-platinum.svg b/assets/bpn-platinum.svg new file mode 100644 index 0000000..7f972dc --- /dev/null +++ b/assets/bpn-platinum.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/bpn-red.svg b/assets/bpn-red.svg new file mode 100644 index 0000000..99b10bc --- /dev/null +++ b/assets/bpn-red.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/bpn-silver.svg b/assets/bpn-silver.svg new file mode 100644 index 0000000..9df07cd --- /dev/null +++ b/assets/bpn-silver.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/bpn-white.svg b/assets/bpn-white.svg new file mode 100644 index 0000000..245a72e --- /dev/null +++ b/assets/bpn-white.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/bpn-yellow.svg b/assets/bpn-yellow.svg new file mode 100644 index 0000000..9113b32 --- /dev/null +++ b/assets/bpn-yellow.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/data/bpn-tables.json b/data/bpn-tables.json new file mode 100644 index 0000000..d4c0c90 --- /dev/null +++ b/data/bpn-tables.json @@ -0,0 +1,352 @@ +{ + "colours": [ + { + "key": "blue", + "label": "Blue", + "type": "Street Maintenance", + "summary": "Street maintenance and dirty clean-up contracts.", + "details": "Sewer clearances, vermin extermination, infrastructure support, and low-end evictions.", + "sclReq": "10 - 9", + "sclRange": { "min": 0.05, "typical": 0.1, "max": 0.2 }, + "cbsPerOpRange": { "min": 50, "max": 150 }, + "cbsPerSquadRange": { "min": 100, "max": 300 }, + "mediaCoverage": "Rare / None" + }, + { + "key": "yellow", + "label": "Yellow", + "type": "Retrieval", + "summary": "Recover a person, item, data package, or specimen.", + "details": "Primary objective is bring it back, often issued by Housing, Cloak, or Karma.", + "sclReq": "10 - 9", + "sclRange": { "min": 0.05, "typical": 0.1, "max": 0.2 }, + "cbsPerOpRange": { "min": 100, "max": 200 }, + "cbsPerSquadRange": { "min": 200, "max": 400 }, + "mediaCoverage": "Station Analysis / None" + }, + { + "key": "green", + "label": "Green", + "type": "Cloak / Extermination", + "summary": "Combat-focused extermination and suppression assignments.", + "details": "Kill or capture designated threats before they escalate into full emergency response.", + "sclReq": "10 - 8", + "sclRange": { "min": 0.1, "typical": 0.1, "max": 0.3 }, + "cbsPerOpRange": { "min": 100, "max": 250 }, + "cbsPerSquadRange": { "min": 200, "max": 500 }, + "mediaCoverage": "Station Analysis / Third Eye" + }, + { + "key": "white", + "label": "White", + "type": "Investigation", + "summary": "Detective and analytical assignments.", + "details": "Evidence-led cases with witness work, profiling, and pattern analysis.", + "sclReq": "10 - 7", + "sclRange": { "min": 0.1, "typical": 0.1, "max": 0.3 }, + "cbsPerOpRange": { "min": 150, "max": 300 }, + "cbsPerSquadRange": { "min": 250, "max": 500 }, + "mediaCoverage": "Third Eye / Station Analysis" + }, + { + "key": "grey", + "label": "Grey", + "type": "Departmental Enforcement / Corporate", + "summary": "Internal enforcement and politically sensitive corporate work.", + "details": "Escort, asset protection, audit support, and departmentally awkward clean-ups.", + "sclReq": "9 - 7", + "sclRange": { "min": 0.1, "typical": 0.2, "max": 0.3 }, + "cbsPerOpRange": { "min": 200, "max": 400 }, + "cbsPerSquadRange": { "min": 300, "max": 600 }, + "mediaCoverage": "Station Analysis" + }, + { + "key": "silver", + "label": "Silver", + "type": "Diplomatic / Escort / PR", + "summary": "High-profile, social, and reputation-sensitive contracts.", + "details": "VIP escort, public appearances, negotiations, and camera-facing operations.", + "sclReq": "9 - 6", + "sclRange": { "min": 0.1, "typical": 0.2, "max": 0.4 }, + "cbsPerOpRange": { "min": 200, "max": 500 }, + "cbsPerSquadRange": { "min": 300, "max": 800 }, + "mediaCoverage": "Third Eye / GoreZone" + }, + { + "key": "jade", + "label": "Jade", + "type": "Environmental / Biohazard", + "summary": "Containment and neutralisation of biological and chemical threats.", + "details": "Biohazard incidents, toxic leaks, mutations, and containment failures.", + "sclReq": "9 - 6", + "sclRange": { "min": 0.1, "typical": 0.2, "max": 0.4 }, + "cbsPerOpRange": { "min": 250, "max": 500 }, + "cbsPerSquadRange": { "min": 400, "max": 800 }, + "mediaCoverage": "Third Eye / Contract Circuit" + }, + { + "key": "red", + "label": "Red", + "type": "Emergency Response", + "summary": "Crisis-response and red-alert operations.", + "details": "Riots, terror incidents, major failures, and high-risk live-response deployments.", + "sclReq": "10 - 5", + "sclRange": { "min": 0.1, "typical": 0.2, "max": 0.5 }, + "cbsPerOpRange": { "min": 200, "max": 500 }, + "cbsPerSquadRange": { "min": 300, "max": 1000 }, + "mediaCoverage": "Third Eye / GoreZone" + }, + { + "key": "black", + "label": "Black", + "type": "Covert / Black Ops", + "summary": "Classified, deniable, and often off-record operations.", + "details": "Assassination, sabotage, deep infiltration, and disavowed clean-up actions.", + "sclReq": "7 - 4", + "sclRange": { "min": 0.2, "typical": 0.4, "max": 0.7 }, + "cbsPerOpRange": { "min": 500, "max": 1000 }, + "cbsPerSquadRange": { "min": 800, "max": 2000 }, + "mediaCoverage": "None (classified)" + }, + { + "key": "platinum", + "label": "Platinum", + "type": "Head Office / Top Secret", + "summary": "Highest-level Head Office operations.", + "details": "World-shaping, deeply classified, and politically lethal assignments.", + "sclReq": "5 - 1", + "sclRange": { "min": 0.3, "typical": 0.5, "max": 1.0 }, + "cbsPerOpRange": { "min": 1000, "max": null }, + "cbsPerSquadRange": { "min": 1500, "max": null }, + "mediaCoverage": "Classified / Special" + } + ], + "colourRollTable": [ + { "min": 1, "max": 4, "key": "blue" }, + { "min": 5, "max": 7, "key": "yellow" }, + { "min": 8, "max": 9, "key": "green" }, + { "min": 10, "max": 12, "key": "white" }, + { "min": 13, "max": 13, "key": "grey" }, + { "min": 14, "max": 14, "key": "silver" }, + { "min": 15, "max": 15, "key": "jade" }, + { "min": 16, "max": 17, "key": "red" }, + { "min": 18, "max": 18, "key": "black" }, + { "min": 19, "max": 19, "key": "black" }, + { "min": 20, "max": 20, "key": "platinum" } + ], + "missionFocus": [ + "Eliminate a threat before it reaches public view.", + "Capture a target alive for interrogation.", + "Protect or escort a person through a hostile district.", + "Protect or escort a cargo asset.", + "Retrieve an item or specimen intact.", + "Retrieve data or recordings without contamination.", + "Investigate a crime scene with political fallout.", + "Profile and locate a serial killer before media lock-on.", + "Contain or quarantine an outbreak or Ebb event.", + "Crowd control and riot suppression under cameras.", + "Hunt a named hostile and confirm status.", + "Clean up an old SLA operational mess." + ], + "locations": [ + "Downtown backstreets.", + "Sewer network and maintenance tunnels.", + "Industrial sector and power spine.", + "Residential block or tenement arc.", + "Transit system and monorail stations.", + "Cannibal Sector perimeter.", + "Corporate laboratory or asset vault.", + "Nightclub and entertainment block.", + "Broadcast tower and media hub.", + "Hospital, triage zone, or Karma lab." + ], + "opposition": [ + "Street gang with light arms and local lookouts.", + "Organised crime syndicate with political backing.", + "Serial killer cell already moving to a second target.", + "DarkNight agents with a deniable brief.", + "Carrien pack using service tunnels.", + "Rogue biogenetic asset or escaped Stormer variant.", + "Rival SLA squad chasing the same payout.", + "Corrupt Shivers protecting the wrong people.", + "Cultists, Sh'ien influence, or Ebb residue.", + "Corporate security with strict no-survivor orders." + ], + "complications": [ + "The issuing department withheld one critical fact.", + "Third Eye goes live before the squad enters the zone.", + "A witness can identify one operative by name.", + "The target is embedded in civilians.", + "The package is alive and unstable.", + "Transit lockdown traps everyone inside the district.", + "The real employer is not the signatory on the BPN.", + "L.A.D. response in the zone is unreliable.", + "Communications are jammed or intercepted.", + "The BPN is partly a ratings play dressed up as emergency work." + ], + "colourMissionIdeas": { + "blue": [ + "Clear out Carrien and secure the pump station before repairs begin.", + "Survey a derelict subway spur and mark safe lanes for clean-up crews.", + "Evict a low-level gang from a recycling depot without wrecking the machinery." + ], + "yellow": [ + "Recover a stolen prototype before it disappears through the black market.", + "Locate a missing departmental courier and return the carried drives intact.", + "Retrieve a captured informant before rivals extract anything useful." + ], + "green": [ + "Neutralise a threat before it can trigger full emergency escalation.", + "Sweep a district after repeated monster sightings and stop the panic source.", + "Guard a prisoner transfer while hostile assets try to reclaim the cargo." + ], + "white": [ + "Uncover who is issuing forged BPNs to lure squads into ambushes.", + "Trace sabotaged SLA-manufactured ammo back to the source.", + "Link a string of identical killings across multiple sectors." + ], + "grey": [ + "Protect engineers installing surveillance hardware in a tense district.", + "Assist a corporate audit team investigating missing shipments.", + "Guard a prototype vehicle during a live test run." + ], + "silver": [ + "Escort a diplomatic envoy without provoking a public incident.", + "Keep a sponsor-backed appearance alive while controlling optics.", + "Secure a high-profile interview venue before the cameras roll." + ], + "jade": [ + "Contain a biohazard leak in a mixed-use block and secure samples.", + "Neutralise a mutated outbreak before it reaches transport lines.", + "Lock down a failed research ward and extract surviving technicians." + ], + "red": [ + "Board a hijacked monorail in motion and rescue the hostages.", + "Retake a broadcast tower seized by extremists.", + "Stabilise a district where fires, looters, and panic are all spreading." + ], + "black": [ + "Infiltrate a sealed compound, remove a target, and leave no public trace.", + "Sabotage a rival operation and redirect blame elsewhere.", + "Extract records from an internal vault while command denies the mission exists." + ], + "platinum": [ + "Recover a lost prototype before anyone realises it escaped.", + "Investigate an off-limits sector where sensors keep going dark.", + "Quietly remove an inconvenient public hero from the board." + ] + }, + "contactRoles": [ + "Shiver precinct liaison", + "Mort Department dispatcher", + "Department-M observer", + "Corporate asset handler", + "Embedded media fixer", + "Sector security captain", + "Karma field scientist", + "Independent financier", + "Freelance financier", + "Darkfinder case officer" + ], + "contactFirstNames": [ + "Anika", + "Blaine", + "Caro", + "Drex", + "Ephra", + "Fenn", + "Galia", + "Havel", + "Iona", + "Jarek", + "Kiva", + "Luther", + "Mara", + "Nikol", + "Orin", + "Petra" + ], + "contactLastNames": [ + "Voss", + "Kane", + "Talon", + "Mercer", + "Sloane", + "Varik", + "Drahl", + "Kessler", + "Morn", + "Black", + "Harrow", + "Vale", + "Kestral", + "Renn", + "Soren", + "Yarrow" + ], + "civilianFactions": [ + "Local residents trying to keep their block standing.", + "Black-market traders who know the unofficial routes.", + "A fan club for a specific operative or squad.", + "A semi-legal street clinic with decent gear and bad ties.", + "Displaced refugees from a devastated sector.", + "A corporate outreach team doing smiling PR work." + ], + "oppositionAttitudes": [ + "Underestimate the squad and get cocky.", + "Are terrified of SLA but cornered and desperate.", + "See the squad as expendable obstacles, not people.", + "Are only in it for the money.", + "Hope to cut a deal or run rather than die.", + "Use the squad as a distraction for a bigger move." + ], + "atmospheres": [ + "Oppressive rain, thunder, and ankle-deep water in the streets.", + "Chemical smog and neon halos around every light source.", + "Rolling blackouts, flicker failures, and generator hum.", + "Industrial noise and heavy machine vibration.", + "Crowded streets with sale events or festivals in progress.", + "Curfew with Shivers on every corner and everyone watching." + ], + "localTwists": [ + "The district is regarded as cursed by locals.", + "A recent promo shoot left half-finished set pieces everywhere.", + "A minor celebrity is present and drawing attention.", + "Surveillance coverage is patchy or disabled.", + "Someone is independently live-streaming the operation.", + "A rival squad failed a BPN here recently and left damage behind." + ], + "extraObjectives": [ + "Capture at least one enemy alive for interrogation.", + "Recover specific data intact with no EMP or overkill.", + "Ensure a particular building remains undamaged.", + "Plant a tracking device unnoticed.", + "Secure a specific piece of gear for a sponsor.", + "Ensure that no civilians witness one critical event." + ], + "escalationEvents": [ + "Opposition reinforcements arrive mid-operation.", + "The environment becomes more dangerous: fire, flood, or collapse.", + "Media crews show up and start reframing the mission.", + "A key NPC flees, forcing an ugly pursuit.", + "Communications fail at the worst possible moment.", + "A supposedly neutral faction chooses a side." + ], + "issuingDepartments": [ + "Mort Department / Ops Control", + "Department-M / Internal Containment", + "Shiver Command Liaison Desk", + "Karma Hazard Response", + "Cloak Tactical Oversight", + "Media Relations / Sponsor Operations", + "Head Office Special Assignments" + ], + "mediaOptions": [ + "None", + "Rare / None", + "Station Analysis", + "Third Eye News", + "GoreZone / Contract Circuit Special", + "Classified / Special" + ] +} diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..2dfebfe --- /dev/null +++ b/lang/en.json @@ -0,0 +1,3 @@ +{ + "SLABPNDispatch.Title": "SLA BPN Dispatch" +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..a78795b --- /dev/null +++ b/module.json @@ -0,0 +1,32 @@ +{ + "id": "sla-bpn-dispatch", + "title": "SLA BPN Dispatch", + "description": "Creates random SLA BPN briefings and matching GM debrief journals using SLA mission colour logic.", + "version": "0.3.0", + "compatibility": { + "minimum": "13", + "verified": "13.350", + "maximum": "13" + }, + "authors": [ + { + "name": "Tim Evans" + } + ], + "esmodules": [ + "module/sla-bpn-dispatch.mjs" + ], + "styles": [ + "styles/sla-bpn-dispatch.css" + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], + "manifest": "https://raw.githubusercontent.com/slaguru666/sla-bpn-dispatch/main/module.json", + "download": "https://github.com/slaguru666/sla-bpn-dispatch/releases/latest/download/sla-bpn-dispatch.zip", + "url": "https://github.com/slaguru666/sla-bpn-dispatch" +} \ No newline at end of file diff --git a/module/sla-bpn-dispatch.mjs b/module/sla-bpn-dispatch.mjs new file mode 100644 index 0000000..64e21a8 --- /dev/null +++ b/module/sla-bpn-dispatch.mjs @@ -0,0 +1,1127 @@ +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 ` +
+
+ ${e(draft.colourLabel)} BPN +
+
SLA Industries Dispatch
+

${e(draft.colourLabel)} BPN - ${e(draft.shortTitle)}

+

${e(draft.colourType)} // SCL ${e(draft.sclRequirement)} // ${e(draft.grossContract)}

+
+
+
+

${e(draft.missionBrief)}

+
+
Primary Contact${e(draft.primaryContact)}${e(draft.primaryContactLocation)}
+
Threat Lead${e(draft.threatLead)}${e(draft.threatCountLabel)}
+
Operational Hook${e(draft.colourMissionIdea)}
+
Extra Objective${e(draft.extraObjective)}
+
+
+
+ `; +} + +function playerHtml(draft) { + const e = foundry.utils.escapeHTML; + return ` +
+
+ ${e(draft.colourLabel)} BPN +
+
SLA Industries / Corporate Dispatch
+

${e(draft.colourLabel)} BPN - ${e(draft.shortTitle)}

+

${e(draft.colourType)} // ${e(draft.colourSummary)}

+
+
+
+
BPN ID${e(draft.bpnId)}
+
SCL Requirement${e(draft.sclRequirement)}
+
Gross Contract${e(draft.grossContract)}
+
Take-Home${e(draft.estimatedTakeHomeEach)}
+
+
+

Mission Brief

+

${e(draft.missionBrief)}

+
+
+
Mission Focus${e(draft.missionFocus)}
+
Operational Hook${e(draft.colourMissionIdea)}
+
Primary Objective${e(objectiveFromFocus(draft.missionFocus))}
+
Extra Objective${e(draft.extraObjective)}
+
Location${e(draft.location)}
+
Atmosphere${e(draft.atmosphere)}
+
Expected Opposition${e(draft.opposition)}
+
Opposition Attitude${e(draft.oppositionAttitude)}
+
Primary Contact${e(draft.primaryContact)}
+
Contact Position${e(draft.primaryContactLocation)}
+
Civilian Pressure${e(draft.civilianFaction)}
+
Complication${e(draft.complication)}
+
Escalation Trigger${e(draft.escalationEvent)}
+
Cleanup Directive${e(cleanupDirectiveFromColour(draft.colourKey))}
+
+
+
Media Coverage${e(draft.mediaCoverage)}
+
CBS Band${e(draft.cbsBand)}
+
Financier Cut${e(draft.financierCut)}
+
SCL Increase${e(draft.sclIncrease)}
+
+ ${draft.operationalNotes ? `

Operational Notes

${e(draft.operationalNotes)}

` : ""} +
+ `; +} + +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 + ? `` + : "

No NPC actors created.

"; + return ` +
+

GM Debrief - ${e(draft.shortTitle)}

+

Linked Brief: ${playerLink}

+

True Pressure Point: ${e(draft.complication)}

+

Operational Twist: ${e(draft.localTwist)}

+

Threat Read: ${e(draft.threatLead)} // ${e(draft.threatCountLabel)}

+

Opposition Attitude: ${e(draft.oppositionAttitude)}

+

Financier Note: ${e(draft.reward?.notes ?? "")}

+

Levers: ${e(draft.civilianFaction)} If pressure rises, ${e(draft.escalationEvent.toLowerCase())}.

+
+

Generated NPCs

+ ${npcBlock} +
+

Roll State: 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)}.

+
+ `; +} + +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 = ` ${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 = ` 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); diff --git a/styles/sla-bpn-dispatch.css b/styles/sla-bpn-dispatch.css new file mode 100644 index 0000000..bd8f05a --- /dev/null +++ b/styles/sla-bpn-dispatch.css @@ -0,0 +1,471 @@ +.sla-bpn-dispatch { + font-family: "Trebuchet MS", "Arial", sans-serif; +} + +.sla-bpn-dispatch .window-content { + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.06), transparent 28%), + linear-gradient(180deg, #0d1117 0%, #151d27 56%, #11151d 100%); + color: #edf3fb !important; +} + +.sla-bpn-dispatch-form { + --panel: rgba(18, 24, 34, 0.92); + --panel-alt: rgba(11, 16, 24, 0.9); + --line: rgba(255, 255, 255, 0.11); + --soft: rgba(255, 255, 255, 0.68); + --text: #edf3fb; + display: grid; + gap: 14px; + padding: 4px; + background-image: + linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px); + background-size: 26px 26px; +} + +.sla-bpn-dispatch .window-title, +.sla-bpn-dispatch p, +.sla-bpn-dispatch span, +.sla-bpn-dispatch strong, +.sla-bpn-dispatch label, +.sla-bpn-dispatch h1, +.sla-bpn-dispatch h2, +.sla-bpn-dispatch h3, +.sla-bpn-dispatch input, +.sla-bpn-dispatch select, +.sla-bpn-dispatch textarea, +.sla-bpn-dispatch option { + color: var(--text) !important; +} + +.sla-bpn-hero { + display: grid; + grid-template-columns: 150px 1fr 310px; + gap: 14px; + align-items: stretch; + padding: 16px; + border: 1px solid color-mix(in srgb, var(--bpn-accent) 55%, transparent); + border-radius: 18px; + background: + linear-gradient(135deg, color-mix(in srgb, var(--bpn-accent) 16%, transparent), transparent 45%), + linear-gradient(180deg, rgba(21, 28, 39, 0.95), rgba(8, 13, 20, 0.97)); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03) inset, 0 18px 32px rgba(0, 0, 0, 0.28); +} + +.sla-bpn-hero-band { + display: grid; + place-items: center; + align-content: center; + min-height: 112px; + border-radius: 14px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--bpn-accent) 25%, #0c1119), #0d1117); + border: 1px solid color-mix(in srgb, var(--bpn-accent) 58%, transparent); + font-size: 1.25rem; + font-weight: 900; + letter-spacing: 0.14em; + text-transform: uppercase; + text-align: center; +} + +.sla-bpn-hero-icon { + width: 60px; + height: 60px; + margin-bottom: 8px; +} + +.sla-bpn-hero-band-label { + line-height: 1.05; +} + +.sla-bpn-overline { + font-size: 0.74rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: color-mix(in srgb, var(--bpn-accent) 66%, #dce6f3) !important; +} + +.sla-bpn-hero-copy h1 { + margin: 8px 0 4px; + font-size: 2rem; + line-height: 1.05; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.sla-bpn-hero-copy p { + margin: 0; + color: var(--soft) !important; + font-size: 0.98rem; +} + +.sla-bpn-hero-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.sla-bpn-chip { + padding: 10px 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.035); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.sla-bpn-chip span { + display: block; + margin-bottom: 4px; + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: color-mix(in srgb, var(--bpn-accent) 55%, #dde7f5) !important; +} + +.sla-bpn-chip strong { + font-size: 1rem; + font-weight: 900; +} + +.sla-bpn-dispatch-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.sla-bpn-dispatch-grid--top, +.sla-bpn-dispatch-grid--meta { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.sla-bpn-dispatch-grid--top { + grid-template-columns: 1.15fr 1fr 0.9fr 1fr; +} + +.sla-bpn-dispatch-field, +.sla-bpn-dispatch-card { + position: relative; + padding: 12px 14px; + border-radius: 16px; + background: + linear-gradient(180deg, rgba(27, 35, 48, 0.92), rgba(13, 18, 26, 0.95)); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.sla-bpn-dispatch-field::before, +.sla-bpn-dispatch-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + border-radius: 16px 0 0 16px; + background: linear-gradient(180deg, var(--bpn-accent), transparent); + opacity: 0.8; +} + +.sla-bpn-dispatch-field label, +.sla-bpn-dispatch-card h3 { + display: block; + margin-bottom: 8px; + font-size: 0.74rem; + font-weight: 900; + letter-spacing: 0.12em; + text-transform: uppercase; + color: color-mix(in srgb, var(--bpn-accent) 55%, #ebf4ff) !important; +} + +.sla-bpn-dispatch-card p { + margin: 0 0 8px; + color: #f2f6fd !important; +} + +.sla-bpn-dispatch-card p strong { + color: var(--bpn-accent) !important; +} + +.sla-bpn-dispatch-field input, +.sla-bpn-dispatch-field select, +.sla-bpn-dispatch-field textarea { + width: 100%; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(5, 9, 14, 0.48); + color: #f5f8fd !important; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.03); +} + +.sla-bpn-dispatch-field textarea { + min-height: 90px; + resize: vertical; +} + +.sla-bpn-field-help { + margin-top: 8px; + font-size: 0.72rem; + line-height: 1.25; + color: color-mix(in srgb, var(--bpn-accent) 38%, #c2cfdf) !important; +} + +.sla-bpn-dispatch-field--wide textarea, +.sla-bpn-dispatch-field textarea[name="missionBrief"] { + min-height: 120px; +} + +.sla-bpn-dispatch-field--danger { + background: + linear-gradient(180deg, rgba(45, 21, 28, 0.9), rgba(18, 11, 16, 0.96)); + border-color: rgba(255, 123, 123, 0.18); +} + +.sla-bpn-dispatch-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.sla-bpn-dispatch-actions button, +.sla-bpn-dispatch-open, +.sla-bpn-dispatch-settings-open { + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--bpn-accent) 45%, rgba(255,255,255,0.2)) !important; + background: linear-gradient(180deg, color-mix(in srgb, var(--bpn-accent) 48%, #f7d17d), color-mix(in srgb, var(--bpn-accent) 36%, #8f631d)) !important; + color: #111317 !important; + font-weight: 900; + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 10px 16px; +} + +.sla-bpn-dispatch-launch-row { + display: flex; + justify-content: flex-end; + padding: 6px 0 0; +} + +.journal-sidebar .sla-bpn-dispatch-launch-row .sla-bpn-dispatch-open, +.tab.journal .sla-bpn-dispatch-launch-row .sla-bpn-dispatch-open, +.sla-bpn-dispatch-launch-row .sla-bpn-dispatch-open { + width: 100%; + justify-content: center; + display: inline-flex; + align-items: center; + gap: 8px; + color: #f8f2e3 !important; + background: linear-gradient(180deg, #1d2430, #10161f) !important; + border: 1px solid color-mix(in srgb, var(--bpn-accent) 38%, #ffb174) !important; + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04); +} + +.sla-bpn-dispatch-actions button:hover, +.sla-bpn-dispatch-open:hover, +.sla-bpn-dispatch-settings-open:hover { + filter: brightness(1.06); +} + +.sla-bpn-dispatch-form[data-colour-key="red"] .sla-bpn-hero, +.sla-bpn-dispatch-form[data-colour-key="black"] .sla-bpn-hero, +.sla-bpn-dispatch-form[data-colour-key="jade"] .sla-bpn-hero { + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.03) inset, 0 18px 32px rgba(0, 0, 0, 0.36), 0 0 28px color-mix(in srgb, var(--bpn-accent) 18%, transparent); +} + +.chat-message .sla-bpn-chat { + --bpn-accent: #dce8f9; + color: #181512 !important; + background: + radial-gradient(circle at top right, rgba(0,0,0,0.04), transparent 30%), + linear-gradient(180deg, #f0ead8 0%, #e6ddc6 100%); + border: 1px solid rgba(56, 43, 20, 0.18); + border-radius: 16px; + overflow: hidden; +} + +.sla-bpn-chat__hero { + display: grid; + grid-template-columns: 64px 1fr; + gap: 12px; + align-items: center; + padding: 14px; + background: + linear-gradient(90deg, color-mix(in srgb, var(--bpn-accent) 28%, transparent), transparent 50%), + rgba(16, 20, 27, 0.94); +} + +.sla-bpn-chat__icon { + width: 56px; + height: 56px; +} + +.sla-bpn-chat__eyebrow { + font-size: 0.66rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: color-mix(in srgb, var(--bpn-accent) 70%, #ffffff) !important; +} + +.sla-bpn-chat__heading h3, +.sla-bpn-chat__heading p { + margin: 0; + color: #f4f7fb !important; +} + +.sla-bpn-chat__heading h3 { + font-size: 1.55rem; + line-height: 1; + text-transform: uppercase; +} + +.sla-bpn-chat__heading p { + margin-top: 6px; + font-weight: 700; + color: color-mix(in srgb, var(--bpn-accent) 32%, #f4f7fb) !important; +} + +.sla-bpn-chat__body { + padding: 14px; +} + +.sla-bpn-chat__brief { + margin: 0 0 12px; + font-size: 1rem; + line-height: 1.42; + color: #211b15 !important; +} + +.sla-bpn-chat__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.sla-bpn-chat__grid div { + padding: 10px 12px; + border-radius: 12px; + background: rgba(255,255,255,0.46); + border: 1px solid rgba(56, 43, 20, 0.12); +} + +.sla-bpn-chat__grid strong, +.sla-bpn-chat__grid span, +.sla-bpn-chat__grid em { + display: block; + color: #191613 !important; +} + +.sla-bpn-chat__grid strong { + margin-bottom: 4px; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.sla-bpn-chat__grid em { + margin-top: 4px; + opacity: 0.72; + font-style: normal; +} + +.sla-bpn-journal { + --bpn-accent: #dce8f9; + color: #181512; + display: grid; + gap: 16px; +} + +.sla-bpn-journal__hero { + display: grid; + grid-template-columns: 88px 1fr; + gap: 16px; + align-items: center; + padding: 18px; + border-radius: 18px; + background: + linear-gradient(90deg, color-mix(in srgb, var(--bpn-accent) 22%, transparent), transparent 45%), + linear-gradient(180deg, #161c24, #10161e); +} + +.sla-bpn-journal__icon { + width: 76px; + height: 76px; +} + +.sla-bpn-journal__eyebrow { + font-size: 0.72rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: color-mix(in srgb, var(--bpn-accent) 68%, #ffffff) !important; +} + +.sla-bpn-journal__hero h2, +.sla-bpn-journal__hero p { + margin: 0; + color: #f6f8fc !important; +} + +.sla-bpn-journal__hero h2 { + margin-top: 6px; + text-transform: uppercase; +} + +.sla-bpn-journal__hero p { + margin-top: 8px; + color: color-mix(in srgb, var(--bpn-accent) 35%, #f6f8fc) !important; +} + +.sla-bpn-journal__meta, +.sla-bpn-journal__grid { + display: grid; + gap: 12px; +} + +.sla-bpn-journal__meta { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.sla-bpn-journal__grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.sla-bpn-journal__meta div, +.sla-bpn-journal__grid div, +.sla-bpn-journal__briefing { + padding: 14px 16px; + border-radius: 16px; + background: linear-gradient(180deg, #f4eddb, #e6dcc5); + border: 1px solid rgba(72, 54, 24, 0.16); +} + +.sla-bpn-journal__meta strong, +.sla-bpn-journal__grid strong, +.sla-bpn-journal__briefing h3 { + display: block; + margin-bottom: 6px; + font-size: 0.76rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: color-mix(in srgb, var(--bpn-accent) 40%, #312615) !important; +} + +.sla-bpn-journal__meta span, +.sla-bpn-journal__grid span, +.sla-bpn-journal__briefing p { + color: #1b1712 !important; +} + +@media (max-width: 960px) { + .sla-bpn-hero { + grid-template-columns: 1fr; + } + + .sla-bpn-hero-meta, + .sla-bpn-dispatch-grid--top, + .sla-bpn-dispatch-grid--meta, + .sla-bpn-dispatch-grid, + .sla-bpn-chat__grid, + .sla-bpn-journal__meta, + .sla-bpn-journal__grid { + grid-template-columns: 1fr; + } +} diff --git a/templates/bpn-dispatch.hbs b/templates/bpn-dispatch.hbs new file mode 100644 index 0000000..3b96283 --- /dev/null +++ b/templates/bpn-dispatch.hbs @@ -0,0 +1,168 @@ +
+
+
+ {{draft.colourLabel}} BPN +
{{draft.colourLabel}} BPN
+
+
+
SLA INDUSTRIES // CORPORATE DISPATCH GRID
+

{{draft.shortTitle}}

+

{{draft.colourType}} // {{draft.colourSummary}}

+
+
+
+ BPN ID + {{draft.bpnId}} +
+
+ SCL + {{draft.sclRequirement}} +
+
+ Gross + {{draft.grossContract}} +
+
+ Take-home + {{draft.estimatedTakeHomeEach}} +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
{{draft.colourType}}
+
+
+ + +
{{draft.shortTitle}}
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+

Colour Dossier

+

Type: {{draft.colourType}}

+

Summary: {{draft.colourSummary}}

+

Details: {{draft.colourDetails}}

+

Primary Contact: {{draft.primaryContact}}

+
+
+

Pressure Stack

+

Media: {{draft.mediaCoverage}}

+

CBS Band: {{draft.cbsBand}}

+

Financier Cut: {{draft.financierCut}}

+

SCL Increase: {{draft.sclIncrease}}

+

Threat Lead: {{draft.threatLead}}

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + + + +
+