Blueprint News 0 Engine v0.5.0 — BPN generator + Zero Engine NPC actors
Based on sla-blueprint-news. On Create, in a zero-engine world, also spawns playable Zero Engine npc actors for the briefing: a threat group (named leader + mooks) matched to the BPN opposition (gangs, Carrien, corpsec, Stormers, black-ops...) plus the named cast — each with attributes, hit points, armour, damage, threat and embedded weapons. Stat blocks mirror the system's own NPC Creator (SLA_NPC_TYPES / _slaWeaponData). De-collided ids/globals/buttons so it coexists with the base module. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
*.zip
|
||||
@@ -0,0 +1,50 @@
|
||||
# Blueprint News 0 Engine (Foundry VTT module)
|
||||
|
||||
Everything [SLA Blueprint News](https://github.com/slaguru666/sla-blueprint-news)
|
||||
does — generate BPN mission briefings as a journal **package** (player Brief +
|
||||
Field Dossier, GM Dossier, a character sheet per NPC with a black-and-white
|
||||
portrait, and an item card per OTEM) — **plus**, when run under the **Zero
|
||||
Engine** SLA system, it spawns real, playable **NPC actors** for the briefing.
|
||||
|
||||
## Zero Engine NPC generation
|
||||
|
||||
On **Create**, in a world using the `zero-engine` system, the module also builds
|
||||
an **Actor folder** for the briefing containing:
|
||||
|
||||
- a **threat group** matched to the BPN's opposition — gangs, Carrien, corp
|
||||
security, Stormers, black-ops, etc. — as a **named leader plus several mooks**,
|
||||
- the **named cast** (contact and supporting NPCs).
|
||||
|
||||
Each actor is a Zero Engine `npc` with attributes (Strength/Agility/Wits/
|
||||
Empathy), **hit points**, **armour**, **damage**, a **threat level**, and
|
||||
**embedded weapons** (pistols, rifles, blades, claws…). **Initiative** is rolled
|
||||
by the Zero Engine in combat, driven by Agility + Mobility, which these actors
|
||||
have. The stat blocks mirror the system's own NPC Creator (`SLA_NPC_TYPES`), so
|
||||
they behave exactly like hand-built Zero Engine NPCs — drop them on a scene and
|
||||
roll initiative.
|
||||
|
||||
If the world is **not** using the Zero Engine, the module still produces the full
|
||||
journal package; it simply skips actor creation.
|
||||
|
||||
## Use
|
||||
|
||||
GM only. Open from the **Journal** sidebar button ("Blueprint News 0E"), the
|
||||
**Notes** toolbar, or `game.slaBlueprintNewsZero.open()`. Pick the band, SCL
|
||||
level and reward sliders, then **Create**.
|
||||
|
||||
## Install
|
||||
|
||||
Setup → Add-on Modules → Install Module → manifest URL:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/slaguru666/sla-blueprint-news-zero/main/module.json
|
||||
```
|
||||
|
||||
Requires the **Zero Engine (SLA Industries)** system for actor generation;
|
||||
compatible with Foundry VTT v13–v14 (verified 14.361).
|
||||
|
||||
## Relationship to the base module
|
||||
|
||||
This is a standalone superset and can be installed alongside the base
|
||||
`sla-blueprint-news` module without conflict (separate ids, buttons and
|
||||
globals). Use this one for Zero Engine worlds.
|
||||
@@ -0,0 +1,270 @@
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-1.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-0.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-2.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-1.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-0.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-2.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-1.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-0.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-2.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-1.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-0.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Josefin Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(g-joefinan-2.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-5.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-8.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-6.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-3.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-4.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-5.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-8.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-6.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-3.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-4.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-5.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-8.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-6.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-3.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-4.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(g-robotomono-7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Bundled SLA Industries house faces — fully local, no network required.
|
||||
Josefin Sans (display + UI) and Roboto Mono (data/labels) come from the
|
||||
rewritten Google CSS; Modesto Condensed (heavy industrial wordmark) is
|
||||
carried over from the SLA-BRP system. */
|
||||
|
||||
@import url("fonts-google.css");
|
||||
|
||||
@font-face {
|
||||
font-family: "Modesto Condensed";
|
||||
src: url("modesto-condensed.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Modesto Condensed";
|
||||
src: url("modesto-condensed-bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"SLABPN.Title": "SLA Blueprint News",
|
||||
"SLABPN.Open": "Blueprint News",
|
||||
"SLABPN.ToolbarOpen": "Open Blueprint News"
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"id": "sla-blueprint-news-zero",
|
||||
"title": "Blueprint News 0 Engine",
|
||||
"description": "Blueprint News for the Zero Engine (SLA Industries): generates BPN briefings as journal packages AND spawns Zero Engine NPC actors — gangs, Carrien and more — with attributes, weapons, hit points, armour and initiative.",
|
||||
"version": "0.5.0",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14.361",
|
||||
"maximum": "14"
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tim Evans"
|
||||
}
|
||||
],
|
||||
"esmodules": [
|
||||
"module/blueprint-news.mjs"
|
||||
],
|
||||
"styles": [
|
||||
"fonts/fonts.css",
|
||||
"styles/bpn-render.css",
|
||||
"styles/module.css"
|
||||
],
|
||||
"languages": [
|
||||
{
|
||||
"lang": "en",
|
||||
"name": "English",
|
||||
"path": "lang/en.json"
|
||||
}
|
||||
],
|
||||
"manifest": "https://raw.githubusercontent.com/slaguru666/sla-blueprint-news-zero/main/module.json",
|
||||
"download": "https://github.com/slaguru666/sla-blueprint-news-zero/releases/latest/download/sla-blueprint-news-zero.zip",
|
||||
"url": "https://github.com/slaguru666/sla-blueprint-news-zero",
|
||||
"relationships": {
|
||||
"systems": [
|
||||
{
|
||||
"id": "zero-engine",
|
||||
"type": "system",
|
||||
"compatibility": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* SLA Blueprint News — generator window (ApplicationV2 + Handlebars).
|
||||
*
|
||||
* Mirrors the standalone app over the vendored engine: pick band / frame /
|
||||
* squad / payment, set the operative SCL level, dial the credits and SCL-
|
||||
* increase rewards on sliders (live preview, no hardcoded numbers), then
|
||||
* Create Journals (player Brief page + GM-only Dossier page) or Post to Chat.
|
||||
*
|
||||
* The render layer is engine-free; this file is the only Foundry-aware code.
|
||||
*/
|
||||
|
||||
import { generateBPN, randomSeed } from "../vendor/core/bpn-core.js";
|
||||
import { COLOURS } from "../vendor/core/data/colours.js";
|
||||
import { framesForColour } from "../vendor/core/data/frames.js";
|
||||
import { briefHTML, fieldDossierHTML, dossierHTML, npcCardHTML, itemCardHTML } from "../vendor/render/bpn-render.js";
|
||||
import { crestDataURI, npcPortraitDataURI, itemIconDataURI } from "../vendor/art/bpn-art.js";
|
||||
import { createZeroActors, zeroEngineActive } from "../zero-adapter.mjs";
|
||||
|
||||
const MODULE_ID = "sla-blueprint-news-zero";
|
||||
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
||||
const ART = { artMode: "img" };
|
||||
|
||||
const previewHTMLFor = (bpn, view) => {
|
||||
if (view === "field") return fieldDossierHTML(bpn, ART);
|
||||
if (view === "gm") return dossierHTML(bpn, ART);
|
||||
if (view === "both") return briefHTML(bpn, ART) + fieldDossierHTML(bpn, ART) + dossierHTML(bpn, ART);
|
||||
return briefHTML(bpn, ART);
|
||||
};
|
||||
|
||||
const cbsReadoutFor = (r) =>
|
||||
`${r.cbsBasisValue}c ${r.cbsBasis === "op" ? "per Op" : "per Squad"}${r.cbsOpenEnded ? "+" : ""} · gross ${r.grossContractValue}c · ${r.takeHomeEach}`;
|
||||
|
||||
export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
state = {
|
||||
seed: randomSeed(),
|
||||
colourKey: "",
|
||||
frameId: "",
|
||||
squadSize: 4,
|
||||
paymentType: "",
|
||||
sclLevel: null, // null → core defaults per band
|
||||
cbsValue: null, // null → use the seed's rolled default
|
||||
sclIncrease: null,
|
||||
view: "player"
|
||||
};
|
||||
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: "sla-blueprint-news-zero-app",
|
||||
tag: "div",
|
||||
classes: ["sla-bpn-window"],
|
||||
window: { title: "Blueprint News 0 Engine", resizable: true, icon: "fas fa-skull" },
|
||||
position: { width: 1080, height: 860 },
|
||||
actions: {
|
||||
reroll: BlueprintNewsApp.#onReroll,
|
||||
applySeed: BlueprintNewsApp.#onApplySeed,
|
||||
copySeed: BlueprintNewsApp.#onCopySeed,
|
||||
view: BlueprintNewsApp.#onView,
|
||||
create: BlueprintNewsApp.#onCreate,
|
||||
chat: BlueprintNewsApp.#onChat
|
||||
}
|
||||
};
|
||||
|
||||
static PARTS = { body: { template: `modules/${MODULE_ID}/templates/generator.html` } };
|
||||
|
||||
static open() { return new BlueprintNewsApp().render({ force: true }); }
|
||||
|
||||
/** Resolve the current briefing from UI state (deterministic from seed). */
|
||||
_bpn() {
|
||||
return generateBPN({
|
||||
seed: this.state.seed,
|
||||
colourKey: this.state.colourKey || undefined,
|
||||
frameId: this.state.frameId || undefined,
|
||||
squadSize: this.state.squadSize,
|
||||
sclLevel: this.state.sclLevel ?? undefined,
|
||||
cbsValue: this.state.cbsValue ?? undefined,
|
||||
sclIncrease: this.state.sclIncrease ?? undefined,
|
||||
overrides: { paymentType: this.state.paymentType || "__RANDOM__" }
|
||||
});
|
||||
}
|
||||
|
||||
async _prepareContext() {
|
||||
const bpn = this._bpn();
|
||||
this.state.seed = bpn.seed;
|
||||
this.state.sclLevel = bpn.sclLevel; // reflect the resolved level in the select
|
||||
|
||||
const colourOptions = [{ key: "", label: "Any (roll d20)", selected: this.state.colourKey === "" }]
|
||||
.concat(COLOURS.map((c) => ({ key: c.key, label: `${c.label} — ${c.type}`, selected: c.key === this.state.colourKey })));
|
||||
|
||||
const frames = this.state.colourKey ? framesForColour(this.state.colourKey) : [];
|
||||
const frameOptions = [{ id: "", label: "Any in band", selected: this.state.frameId === "" }]
|
||||
.concat(frames.map((f) => ({ id: f.id, label: f.shortTitle, selected: f.id === this.state.frameId })));
|
||||
|
||||
const paymentOptions = [
|
||||
{ value: "", label: "Any" },
|
||||
{ value: "Per Squad", label: "Per Squad" },
|
||||
{ value: "Per Operative", label: "Per Operative" }
|
||||
].map((o) => ({ ...o, selected: o.value === this.state.paymentType }));
|
||||
|
||||
const sclOptions = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1].map((n) => ({
|
||||
value: n,
|
||||
label: `SCL ${n}${n === 10 ? " (lowest)" : n === 1 ? " (highest)" : ""}`,
|
||||
selected: n === bpn.sclLevel
|
||||
}));
|
||||
|
||||
const viewButtons = [
|
||||
{ view: "player", label: "Player Brief" },
|
||||
{ view: "field", label: "Field Dossier" },
|
||||
{ view: "gm", label: "GM Dossier" },
|
||||
{ view: "both", label: "All" }
|
||||
].map((v) => ({ ...v, active: v.view === this.state.view }));
|
||||
|
||||
const r = bpn.reward;
|
||||
const reward = {
|
||||
basisLabel: r.cbsBasis === "op" ? "per operative" : "per squad",
|
||||
cbsMin: r.cbsMin, cbsMax: r.cbsMax, cbsValue: r.cbsBasisValue,
|
||||
sclMin: r.sclMin, sclMax: r.sclMax, sclValue: r.sclIncreaseValue,
|
||||
cbsReadout: cbsReadoutFor(r),
|
||||
scliReadout: `${r.sclIncrease} SCL`
|
||||
};
|
||||
|
||||
return { state: this.state, colourOptions, frameOptions, paymentOptions, sclOptions, viewButtons, reward, previewHTML: previewHTMLFor(bpn, this.state.view) };
|
||||
}
|
||||
|
||||
_onRender() {
|
||||
const root = this.element;
|
||||
root.querySelectorAll("[data-field]").forEach((el) => {
|
||||
el.addEventListener("change", () => {
|
||||
const f = el.dataset.field;
|
||||
if (f === "squadSize") this.state.squadSize = Number(el.value) || 4;
|
||||
else if (f === "sclLevel") this.state.sclLevel = Number(el.value) || 10;
|
||||
else this.state[f] = el.value;
|
||||
// changing the band or payment basis resets the dialled rewards
|
||||
if (f === "colourKey") { this.state.frameId = ""; this.state.cbsValue = null; this.state.sclIncrease = null; }
|
||||
if (f === "paymentType") { this.state.cbsValue = null; this.state.sclIncrease = null; }
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
// sliders update the preview live, without a full re-render (keeps the grip)
|
||||
root.querySelectorAll("[data-slider]").forEach((el) => {
|
||||
el.addEventListener("input", () => {
|
||||
this.state[el.dataset.slider] = Number(el.value);
|
||||
this.#refreshPreview();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#refreshPreview() {
|
||||
const bpn = this._bpn();
|
||||
const host = this.element.querySelector(".sla-bpn-gen__preview");
|
||||
if (host) host.innerHTML = previewHTMLFor(bpn, this.state.view);
|
||||
const cbsR = this.element.querySelector('[data-readout="cbs"]');
|
||||
const scliR = this.element.querySelector('[data-readout="scli"]');
|
||||
if (cbsR) cbsR.textContent = cbsReadoutFor(bpn.reward);
|
||||
if (scliR) scliR.textContent = `${bpn.reward.sclIncrease} SCL`;
|
||||
}
|
||||
|
||||
static #onReroll() { this.state.seed = randomSeed(); this.state.cbsValue = null; this.state.sclIncrease = null; this.render(); }
|
||||
|
||||
static #onApplySeed() {
|
||||
const v = this.element.querySelector('[data-field="seed"]')?.value?.trim();
|
||||
if (v) this.state.seed = v;
|
||||
this.state.cbsValue = null; this.state.sclIncrease = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
static async #onCopySeed() {
|
||||
try { await navigator.clipboard.writeText(this.state.seed); ui.notifications.info("Seed copied."); } catch (_) {}
|
||||
}
|
||||
|
||||
static #onView(event, target) { this.state.view = target.dataset.view || "player"; this.render(); }
|
||||
|
||||
static async #onCreate() {
|
||||
const bpn = this._bpn();
|
||||
const O = CONST.DOCUMENT_OWNERSHIP_LEVELS;
|
||||
const HTML = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
|
||||
const accent = bpn.colour.accent;
|
||||
const wrap = (html) => `<div class="bpn">${html}</div>`;
|
||||
const base = `${bpn.colour.label} BPN — ${bpn.shortTitle}`;
|
||||
const flags = { [MODULE_ID]: { seed: bpn.seed, colourKey: bpn.colourKey, frameId: bpn.gm.frameId, sclLevel: bpn.sclLevel } };
|
||||
const page = (name, content, owner) => ({ name, type: "text", title: { show: false, level: 1 }, ownership: { default: owner }, text: { format: HTML, content } });
|
||||
|
||||
// A folder is the "package" that holds the whole briefing's documents.
|
||||
const folder = await Folder.create({ name: base, type: "JournalEntry", color: accent });
|
||||
const fid = folder.id;
|
||||
|
||||
// Separate player document: Mission Brief + Field Dossier handout.
|
||||
const playerEntry = await JournalEntry.create({
|
||||
name: `${base} [Player]`, folder: fid, ownership: { default: O.OBSERVER }, flags,
|
||||
pages: [
|
||||
page("Mission Brief", wrap(briefHTML(bpn, ART)), O.OBSERVER),
|
||||
page("Field Dossier", wrap(fieldDossierHTML(bpn, ART)), O.OBSERVER)
|
||||
]
|
||||
});
|
||||
|
||||
// Separate GM-only document, linked back to the player document.
|
||||
await JournalEntry.create({
|
||||
name: `${base} [GM]`, folder: fid, ownership: { default: O.NONE }, flags,
|
||||
pages: [page("GM Dossier", wrap(dossierHTML(bpn, ART)) + `<p class="bpn-rolls">Player document: @UUID[${playerEntry.uuid}]{${base} [Player]}</p>`, O.NONE)]
|
||||
});
|
||||
|
||||
// One character-sheet document per NPC (player handout, portrait + sheet).
|
||||
const npcEntries = bpn.cast.map((npc) => ({
|
||||
name: npc.name, folder: fid,
|
||||
ownership: { default: O.OBSERVER },
|
||||
flags: { [MODULE_ID]: { kind: "npc", seed: npc.seed, bpn: bpn.seed } },
|
||||
pages: [page("Character Sheet", wrap(npcCardHTML(npc, { artMode: "img", accent })), O.OBSERVER)]
|
||||
}));
|
||||
|
||||
// One card document per item (player handout, icon + description).
|
||||
const itemEntries = bpn.otem.map((item) => ({
|
||||
name: item.name, folder: fid,
|
||||
ownership: { default: O.OBSERVER },
|
||||
flags: { [MODULE_ID]: { kind: "otem", seed: item.seed, bpn: bpn.seed } },
|
||||
pages: [page(item.name, wrap(itemCardHTML(item, { artMode: "img", accent })), O.OBSERVER)]
|
||||
}));
|
||||
|
||||
await JournalEntry.createDocuments([...npcEntries, ...itemEntries]);
|
||||
|
||||
// Zero Engine: also spawn playable NPC actors (gangs, Carrien, the cast)
|
||||
// with stats, weapons, hit points, armour and initiative.
|
||||
let actorMsg = "";
|
||||
if (zeroEngineActive()) {
|
||||
try {
|
||||
const res = await createZeroActors(bpn);
|
||||
if (res.created) actorMsg = ` + ${res.created} Zero Engine NPC actors (1 ${res.threatLabel} lead, ${res.mookCount}× ${res.mookLabel}, plus cast)`;
|
||||
} catch (err) {
|
||||
console.warn("Blueprint News 0E | actor generation failed:", err);
|
||||
ui.notifications.warn("BPN journals created, but NPC actor generation failed — see console.");
|
||||
}
|
||||
} else {
|
||||
actorMsg = " (enable the Zero Engine system to also spawn NPC actors)";
|
||||
}
|
||||
|
||||
ui.notifications.info(`Created package "${base}": player + GM documents, ${bpn.cast.length} NPC sheets and ${bpn.otem.length} item cards${actorMsg}.`);
|
||||
playerEntry.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async #onChat() {
|
||||
const bpn = this._bpn();
|
||||
const card = `<div class="bpn"><div class="bpn-chat-card" style="--bpn-accent:${bpn.colour.accent};">
|
||||
<div class="bpn-chat-card__head">
|
||||
<img src="${crestDataURI(bpn, { size: 56 })}" width="56" height="56" alt="">
|
||||
<div>
|
||||
<div class="bpn-chat-card__band">${bpn.colour.label} BPN · ${bpn.colour.type}</div>
|
||||
<div class="bpn-chat-card__title">${bpn.shortTitle}</div>
|
||||
<div class="bpn-chat-card__meta">${bpn.bpnId} · ${bpn.sclRequirement} · ${bpn.reward.grossContract}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="bpn-chat-card__brief">${foundry.utils.escapeHTML(bpn.missionBrief)}</p>
|
||||
</div></div>`;
|
||||
await ChatMessage.create({ speaker: { alias: "SLA Blueprint News" }, content: card });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* SLA Blueprint News — entry point.
|
||||
*
|
||||
* Thin Foundry wrapper around the engine-agnostic component vendored under
|
||||
* module/vendor/. Registers a global API and a Journal-directory button that
|
||||
* opens the generator; the generator writes briefings into journal entries.
|
||||
*/
|
||||
|
||||
import { BlueprintNewsApp } from "./app/generator-app.mjs";
|
||||
|
||||
const MODULE_ID = "sla-blueprint-news-zero";
|
||||
|
||||
Hooks.once("init", () => {
|
||||
game.slaBlueprintNewsZero = { open: () => BlueprintNewsApp.open() };
|
||||
});
|
||||
|
||||
/* Journal directory header button (GM only) — the natural home, since this
|
||||
tool produces journal entries. */
|
||||
Hooks.on("renderJournalDirectory", (_app, html) => {
|
||||
if (!game.user?.isGM) return;
|
||||
const root = html instanceof HTMLElement ? html : html?.[0];
|
||||
if (!root) return;
|
||||
const header =
|
||||
root.querySelector(".directory-header .action-buttons") ??
|
||||
root.querySelector(".directory-header .header-actions") ??
|
||||
root.querySelector(".directory-header");
|
||||
if (!header || header.querySelector(".sla-bpn-zero-open")) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "sla-bpn-open sla-bpn-zero-open";
|
||||
btn.innerHTML = `<i class="fas fa-skull"></i> Blueprint News 0E`;
|
||||
btn.addEventListener("click", () => BlueprintNewsApp.open());
|
||||
header.appendChild(btn);
|
||||
});
|
||||
|
||||
/* Notes-layer toolbar button as a second entry point (v13/v14 control shape). */
|
||||
Hooks.on("getSceneControlButtons", (controls) => {
|
||||
if (!game.user?.isGM) return;
|
||||
const tool = {
|
||||
name: "slaBpnZeroOpen",
|
||||
title: "Open Blueprint News 0 Engine",
|
||||
icon: "fas fa-skull",
|
||||
button: true,
|
||||
onClick: () => BlueprintNewsApp.open()
|
||||
};
|
||||
if (controls && typeof controls === "object" && !Array.isArray(controls)) {
|
||||
const notes = controls.notes;
|
||||
if (notes) {
|
||||
notes.tools ??= {};
|
||||
notes.tools[tool.name] ??= tool;
|
||||
}
|
||||
} else if (Array.isArray(controls)) {
|
||||
const notes = controls.find((c) => c.name === "notes");
|
||||
if (notes) {
|
||||
notes.tools ??= [];
|
||||
if (!notes.tools.some((t) => t.name === tool.name)) notes.tools.push(tool);
|
||||
}
|
||||
}
|
||||
});
|
||||
Vendored
+512
@@ -0,0 +1,512 @@
|
||||
/* Blueprint News — procedural SVG artwork.
|
||||
*
|
||||
* Flat, graphic, stencil/propaganda-poster styling: hard angles, heavy blacks,
|
||||
* a single colour-band accent, halftone and scanline textures. Deliberately
|
||||
* NOT photorealistic. All shapes derive from the BPN seed, so a given briefing
|
||||
* always renders the same art while every briefing differs.
|
||||
*
|
||||
* Each colour band gets its own SCENE — a foreground motif (sewer arch, transit
|
||||
* concourse, perimeter wall, vault corridor, …) plus tuned atmosphere and
|
||||
* lighting — so a Blue street job and a Black covert op never look alike.
|
||||
*
|
||||
* No DOM and no engine APIs — each function returns an SVG markup string.
|
||||
*/
|
||||
|
||||
/* --- seeded rng (local copy so art is usable standalone) ----------------- */
|
||||
|
||||
function hashSeed(str) {
|
||||
let h = 2166136261 >>> 0;
|
||||
const s = String(str);
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function rngFor(seed, salt) {
|
||||
return mulberry32(hashSeed(`${seed}::${salt}`));
|
||||
}
|
||||
|
||||
const rint = (rng, lo, hi) => Math.floor(rng() * (hi - lo + 1)) + lo;
|
||||
const rfloat = (rng, lo, hi) => rng() * (hi - lo) + lo;
|
||||
const r2 = (n) => Math.round(n * 100) / 100;
|
||||
|
||||
/* --- colour helpers ------------------------------------------------------ */
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = String(hex || "#dce8f9").replace("#", "");
|
||||
const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
|
||||
return { r: parseInt(n.slice(0, 2), 16), g: parseInt(n.slice(2, 4), 16), b: parseInt(n.slice(4, 6), 16) };
|
||||
}
|
||||
|
||||
function mix(hex, withHex, t) {
|
||||
const a = hexToRgb(hex);
|
||||
const b = hexToRgb(withHex);
|
||||
const c = (k) => Math.round(a[k] + (b[k] - a[k]) * t);
|
||||
return `rgb(${c("r")},${c("g")},${c("b")})`;
|
||||
}
|
||||
|
||||
function rgba(hex, alpha) {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
/* --- per-band scene definitions ----------------------------------------- */
|
||||
/* motif: which foreground silhouette to draw.
|
||||
rain/smog/crowd: atmospheric layer. floodlights/beacons: lighting.
|
||||
density: building/back-skyline busyness (0 sparse … 1 dense). */
|
||||
const SCENES = {
|
||||
blue: { motif: "tunnel", rain: 0.8, smog: 0, crowd: 0, floodlights: false, beacons: false, density: 0.5 },
|
||||
yellow: { motif: "transit", rain: 0, smog: 0.2, crowd: 0.9, floodlights: false, beacons: false, density: 0.7 },
|
||||
green: { motif: "wall", rain: 0.5, smog: 0.1, crowd: 0, floodlights: true, beacons: false, density: 0.5 },
|
||||
white: { motif: "cordon", rain: 0.2, smog: 0.1, crowd: 0, floodlights: false, beacons: false, density: 0.6 },
|
||||
grey: { motif: "gantry", rain: 0, smog: 0.6, crowd: 0, floodlights: false, beacons: false, density: 0.8 },
|
||||
silver: { motif: "stage", rain: 0, smog: 0, crowd: 0.7, floodlights: true, beacons: false, density: 0.6 },
|
||||
jade: { motif: "canal", rain: 0.3, smog: 0.8, crowd: 0, floodlights: false, beacons: false, density: 0.5 },
|
||||
red: { motif: "tower", rain: 0.6, smog: 0.3, crowd: 0, floodlights: true, beacons: true, density: 0.7 },
|
||||
black: { motif: "vault", rain: 0, smog: 0, crowd: 0, floodlights: false, beacons: false, density: 0.2 },
|
||||
platinum: { motif: "exec", rain: 0, smog: 0.1, crowd: 0, floodlights: false, beacons: false, density: 0.3 }
|
||||
};
|
||||
|
||||
/* Mission-focus glyph drawn inside the crest pupil. */
|
||||
function focusGlyph(focus = "", accent = "#fff") {
|
||||
const f = String(focus).toLowerCase();
|
||||
const stroke = `stroke="${accent}" stroke-width="3.5" fill="none" stroke-linecap="round" stroke-linejoin="round"`;
|
||||
if (f.includes("eliminate") || f.includes("hunt") || f.includes("kill")) return `<path d="M-9 -9 L9 9 M9 -9 L-9 9" ${stroke}/>`;
|
||||
if (f.includes("escort") || f.includes("protect")) return `<path d="M0 -11 L10 -5 V4 C10 10 5 12 0 12 C-5 12 -10 10 -10 4 V-5 Z" ${stroke}/>`;
|
||||
if (f.includes("retrieve")) return `<path d="M-9 -6 H9 V8 H-9 Z M-9 -6 L0 -11 L9 -6" ${stroke}/>`;
|
||||
if (f.includes("investigate") || f.includes("profile")) return `<circle r="6" cx="-2" cy="-2" ${stroke}/><path d="M3 3 L10 10" ${stroke}/>`;
|
||||
if (f.includes("contain") || f.includes("quarantine")) return `<circle r="3" ${stroke}/><path d="M0 -11 A11 11 0 0 1 9.5 5 M0 11 A11 11 0 0 1 -9.5 5 M0 11 A11 11 0 0 0 9.5 -5" ${stroke}/>`;
|
||||
if (f.includes("riot") || f.includes("crowd")) return `<path d="M-10 8 V-2 M-3 8 V-6 M4 8 V-2 M11 8 V-8" ${stroke}/>`;
|
||||
return `<path d="M0 -10 V10 M-7 0 H7 M-5 -6 H5 M-5 6 H5" ${stroke}/>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* CREST — the BPN sigil. */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export function crestSVG(bpn, { size = 128 } = {}) {
|
||||
const accent = bpn?.colour?.accent ?? "#dce8f9";
|
||||
const seed = bpn?.seed ?? "BPN";
|
||||
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "");
|
||||
const rng = rngFor(seed, "crest");
|
||||
const dark = "#101722";
|
||||
const rim = mix(accent, "#ffffff", 0.12);
|
||||
const glyph = focusGlyph(bpn?.missionFocus ?? "", mix(accent, "#ffffff", 0.25));
|
||||
const rivet = (x, y) => `<circle cx="${x}" cy="${y}" r="2.4" fill="${rgba(accent, 0.55)}"/>`;
|
||||
const ticks = Array.from({ length: 12 }, (_, i) => {
|
||||
const a = (i / 12) * Math.PI * 2;
|
||||
const x1 = 64 + Math.cos(a) * 50, y1 = 64 + Math.sin(a) * 50;
|
||||
const x2 = 64 + Math.cos(a) * (i % 3 === 0 ? 44 : 47), y2 = 64 + Math.sin(a) * (i % 3 === 0 ? 44 : 47);
|
||||
return `<line x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}" stroke="${rgba(accent, 0.4)}" stroke-width="${i % 3 === 0 ? 3 : 1.5}"/>`;
|
||||
}).join("");
|
||||
const jitter = r2(rfloat(rng, -2, 2));
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="${size}" height="${size}" role="img" aria-label="${bpn?.colour?.label ?? ""} BPN crest">
|
||||
<defs>
|
||||
<linearGradient id="cg-${uid}" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="${mix(dark, accent, 0.18)}"/>
|
||||
<stop offset="1" stop-color="${dark}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="120" height="120" rx="22" fill="url(#cg-${uid})"/>
|
||||
<rect x="9" y="9" width="110" height="110" rx="18" fill="none" stroke="${rim}" stroke-width="3.5"/>
|
||||
${ticks}
|
||||
${rivet(20, 20)}${rivet(108, 20)}${rivet(20, 108)}${rivet(108, 108)}
|
||||
<g transform="translate(${64 + jitter} 64)">
|
||||
<path d="M0 -34 C26 -34 40 -10 40 0 C40 10 26 34 0 34 C-26 34 -40 10 -40 0 C-40 -10 -26 -34 0 -34 Z" fill="none" stroke="${accent}" stroke-width="4"/>
|
||||
<path d="M0 -22 C18 -22 28 -7 28 0 C28 7 18 22 0 22 C-18 22 -28 7 -28 0 C-28 -7 -18 -22 0 -22 Z" fill="${rgba(accent, 0.10)}"/>
|
||||
<circle r="14" fill="${rgba(dark, 0.9)}" stroke="${rgba(accent, 0.5)}" stroke-width="1.5"/>
|
||||
${glyph}
|
||||
</g>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* FOREGROUND MOTIFS — one per scene. Each returns markup positioned within */
|
||||
/* a 1200x360 banner space (W,H passed in). */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
function litWindowGrid(rng, x, y, w, h, accent, alpha, step = 14) {
|
||||
const out = [];
|
||||
for (let wx = x + 6; wx < x + w - 6; wx += step) {
|
||||
for (let wy = y + 8; wy < y + h - 6; wy += 18) {
|
||||
if (rng() < 0.32) out.push(`<rect x="${r2(wx)}" y="${r2(wy)}" width="4" height="6" fill="${rgba(accent, alpha * rfloat(rng, 0.5, 1))}"/>`);
|
||||
}
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
const MOTIFS = {
|
||||
// generic angular skyline used by most street-level scenes
|
||||
skyline(rng, W, H, accent, density) {
|
||||
let x = -20; const out = [];
|
||||
const gap = density > 0.6 ? [2, 8] : [6, 16];
|
||||
while (x < W + 20) {
|
||||
const w = rint(rng, 34, 96), h = rint(rng, H * 0.4, H * 0.82), top = H + 4 - h;
|
||||
out.push(`<rect x="${x}" y="${r2(top)}" width="${w}" height="${r2(H - top)}" fill="#060a11"/>`);
|
||||
if (rng() < 0.4) { const mx = x + rint(rng, 6, w - 6); out.push(`<line x1="${mx}" y1="${r2(top)}" x2="${mx}" y2="${r2(top - rint(rng, 10, 34))}" stroke="#060a11" stroke-width="2"/>`); }
|
||||
out.push(litWindowGrid(rng, x, top, w, H - top, accent, 0.85));
|
||||
x += w + rint(rng, gap[0], gap[1]);
|
||||
}
|
||||
return out.join("");
|
||||
},
|
||||
tunnel(rng, W, H, accent) {
|
||||
const cx = W * rfloat(rng, 0.4, 0.6), out = [];
|
||||
for (let i = 0; i < 5; i++) { const ry = H * (0.34 + i * 0.11), rx = ry * 1.5; out.push(`<ellipse cx="${r2(cx)}" cy="${r2(H * 0.62)}" rx="${r2(rx)}" ry="${r2(ry)}" fill="none" stroke="${rgba(accent, 0.10 + i * 0.03)}" stroke-width="${6 - i}"/>`); }
|
||||
out.push(`<rect x="0" y="${r2(H * 0.7)}" width="${W}" height="${r2(H * 0.3)}" fill="#04070c"/>`);
|
||||
for (let i = 0; i < 26; i++) { const x = rfloat(rng, 0, W); out.push(`<line x1="${r2(x)}" y1="${r2(H * 0.72)}" x2="${r2(x)}" y2="${r2(H * 0.74 + rfloat(rng, 0, 8))}" stroke="${rgba(accent, 0.2)}" stroke-width="1"/>`); } // water glints
|
||||
out.push(`<rect x="40" y="${r2(H * 0.3)}" width="14" height="${r2(H * 0.5)}" fill="#060a11"/><rect x="${W - 54}" y="${r2(H * 0.3)}" width="14" height="${r2(H * 0.5)}" fill="#060a11"/>`); // side pipes
|
||||
return out.join("");
|
||||
},
|
||||
transit(rng, W, H, accent) {
|
||||
const out = [MOTIFS.skyline(rng, W, H, accent, 0.7)];
|
||||
for (let i = 0; i < 6; i++) { const x = (i + 0.5) * (W / 6); out.push(`<rect x="${r2(x - 7)}" y="${r2(H * 0.32)}" width="14" height="${r2(H * 0.68)}" fill="#04070c"/>`); if (rng() < 0.8) out.push(`<rect x="${r2(x - 26)}" y="${r2(H * 0.28)}" width="52" height="14" rx="2" fill="${rgba(accent, 0.5)}"/>`); } // pillars + route boards
|
||||
return out.join("");
|
||||
},
|
||||
wall(rng, W, H, accent) {
|
||||
const out = [], top = H * 0.34;
|
||||
out.push(`<rect x="0" y="${r2(top)}" width="${W}" height="${r2(H - top)}" fill="#070b12"/>`);
|
||||
for (let x = 0; x < W; x += 64) out.push(`<rect x="${x + 4}" y="${r2(top)}" width="56" height="${r2(H - top)}" fill="none" stroke="${rgba(accent, 0.12)}" stroke-width="1.5"/>`); // plating
|
||||
for (let x = 0; x < W; x += 64) out.push(`<rect x="${x + 18}" y="${r2(top - 16)}" width="28" height="16" fill="#070b12"/>`); // battlements
|
||||
return out.join("");
|
||||
},
|
||||
cordon(rng, W, H, accent) {
|
||||
const out = [MOTIFS.skyline(rng, W, H, accent, 0.6)];
|
||||
// diagonal cordon tape across the lower third
|
||||
for (let k = -1; k <= 1; k += 2) { const y = H * (0.72 + k * 0.06); out.push(`<g transform="rotate(${k * 4} ${W / 2} ${y})"><rect x="-40" y="${r2(y)}" width="${W + 80}" height="13" fill="${rgba(accent, 0.5)}"/></g>`); }
|
||||
return out.join("");
|
||||
},
|
||||
gantry(rng, W, H, accent) {
|
||||
const out = [MOTIFS.skyline(rng, W, H, accent, 0.8)];
|
||||
for (let i = 0; i < 10; i++) { const x = rfloat(rng, 0, W - 70), y = H - rint(rng, 24, 70); out.push(`<rect x="${r2(x)}" y="${r2(y)}" width="64" height="22" fill="#04070c" stroke="${rgba(accent, 0.18)}" stroke-width="1"/>`); } // containers
|
||||
out.push(`<path d="M${r2(W * 0.6)} ${r2(H * 0.3)} h120 v10 h-120 z M${r2(W * 0.62)} ${r2(H * 0.31)} v${r2(H * 0.5)}" fill="#04070c" stroke="${rgba(accent, 0.2)}" stroke-width="2"/>`); // gantry crane
|
||||
return out.join("");
|
||||
},
|
||||
stage(rng, W, H, accent) {
|
||||
const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)];
|
||||
out.push(`<path d="M${r2(W * 0.2)} ${r2(H * 0.26)} H${r2(W * 0.8)} V${r2(H * 0.3)} H${r2(W * 0.2)} Z" fill="#04070c" stroke="${rgba(accent, 0.3)}" stroke-width="2"/>`); // truss
|
||||
for (let i = 0; i < 5; i++) { const x = W * (0.28 + i * 0.11); out.push(`<polygon points="${r2(x)},${r2(H * 0.3)} ${r2(x - 40)},${H} ${r2(x + 40)},${H}" fill="${rgba(accent, 0.06)}"/>`); } // spotlight cones
|
||||
return out.join("");
|
||||
},
|
||||
canal(rng, W, H, accent) {
|
||||
const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)];
|
||||
out.push(`<rect x="0" y="${r2(H * 0.78)}" width="${W}" height="${r2(H * 0.22)}" fill="${rgba(accent, 0.07)}"/>`); // canal
|
||||
for (let i = 0; i < 7; i++) { const x = rfloat(rng, 30, W - 30); out.push(`<rect x="${r2(x)}" y="${r2(H * 0.72)}" width="18" height="26" rx="3" fill="#04070c" stroke="${rgba(accent, 0.4)}" stroke-width="1.5"/>`); } // hazard barrels
|
||||
return out.join("");
|
||||
},
|
||||
tower(rng, W, H, accent) {
|
||||
const out = [MOTIFS.skyline(rng, W, H, accent, 0.7)];
|
||||
const tx = W * rfloat(rng, 0.4, 0.6);
|
||||
out.push(`<rect x="${r2(tx - 16)}" y="${r2(H * 0.12)}" width="32" height="${r2(H * 0.88)}" fill="#04070c" stroke="${rgba(accent, 0.25)}" stroke-width="1.5"/>`); // broadcast tower
|
||||
out.push(`<line x1="${r2(tx)}" y1="${r2(H * 0.12)}" x2="${r2(tx)}" y2="${r2(H * 0.02)}" stroke="${rgba(accent, 0.5)}" stroke-width="2"/>`);
|
||||
out.push(`<line x1="0" y1="${r2(H * 0.5)}" x2="${W}" y2="${r2(H * 0.46)}" stroke="#04070c" stroke-width="8"/>`); // monorail line
|
||||
return out.join("");
|
||||
},
|
||||
vault(rng, W, H, accent) {
|
||||
const cx = W * 0.5, out = [];
|
||||
for (let i = 0; i < 6; i++) { const s = 1 - i * 0.15, w = W * 0.5 * s, h = H * 0.9 * s; out.push(`<rect x="${r2(cx - w / 2)}" y="${r2(H * 0.5 - h / 2)}" width="${r2(w)}" height="${r2(h)}" fill="none" stroke="${rgba(accent, 0.06 + i * 0.02)}" stroke-width="1.5"/>`); } // receding corridor
|
||||
out.push(`<rect x="${r2(cx - 20)}" y="${r2(H * 0.36)}" width="40" height="${r2(H * 0.28)}" fill="#04070c" stroke="${rgba(accent, 0.3)}" stroke-width="1.5"/>`); // far door
|
||||
for (let i = 0; i < 5; i++) out.push(`<rect x="${r2(cx + 26)}" y="${r2(H * 0.4 + i * 12)}" width="4" height="4" fill="${rgba(accent, 0.5)}"/>`); // biometric panel
|
||||
return out.join("");
|
||||
},
|
||||
exec(rng, W, H, accent) {
|
||||
const out = []; let x = 40;
|
||||
while (x < W - 40) { const w = rint(rng, 70, 120), h = rint(rng, H * 0.55, H * 0.95), top = H + 4 - h; out.push(`<rect x="${x}" y="${r2(top)}" width="${w}" height="${r2(H - top)}" fill="#05080e" stroke="${rgba(accent, 0.10)}" stroke-width="1"/>`); out.push(litWindowGrid(rng, x, top, w, H - top, accent, 0.4, 18)); x += w + rint(rng, 40, 90); }
|
||||
out.push(`<line x1="0" y1="${r2(H * 0.7)}" x2="${W}" y2="${r2(H * 0.68)}" stroke="#04070c" stroke-width="6"/>`);
|
||||
return out.join("");
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* BANNER — the wide hero backdrop, composed from the band's scene. */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export function bannerSVG(bpn, { width = 1200, height = 360 } = {}) {
|
||||
const accent = bpn?.colour?.accent ?? "#dce8f9";
|
||||
const key = bpn?.colourKey ?? "white";
|
||||
const seed = bpn?.seed ?? "BPN";
|
||||
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "");
|
||||
const scene = SCENES[key] ?? SCENES.white;
|
||||
const rng = rngFor(seed, "banner");
|
||||
const dark = "#0b1018";
|
||||
const W = width, H = height;
|
||||
|
||||
// hazy back skyline for depth (skip for vault/exec which set their own mood)
|
||||
let back = "";
|
||||
if (!["vault", "exec"].includes(scene.motif)) {
|
||||
let x = -20; const out = [];
|
||||
while (x < W + 20) { const w = rint(rng, 40, 96), h = rint(rng, H * 0.28, H * 0.6), top = H * 0.92 - h; out.push(`<rect x="${x}" y="${r2(top)}" width="${w}" height="${r2(H - top)}" fill="${mix(dark, accent, 0.06)}"/>`); x += w + rint(rng, 4, 14); }
|
||||
back = out.join("");
|
||||
}
|
||||
|
||||
const fg = (MOTIFS[scene.motif] ?? MOTIFS.skyline)(rng, W, H, accent, scene.density);
|
||||
|
||||
// searchlight beams
|
||||
const beams = Array.from({ length: rint(rng, 2, 4) }, () => { const ox = rfloat(rng, 0, W), s = rfloat(rng, 40, 120); return `<polygon points="${r2(ox)},-10 ${r2(ox - s)},${H} ${r2(ox + s)},${H}" fill="${rgba(accent, 0.05)}"/>`; }).join("");
|
||||
|
||||
// atmosphere layers
|
||||
let rain = "", smog = "", crowd = "";
|
||||
if (scene.rain > 0) rain = Array.from({ length: Math.round(scene.rain * 110) }, () => { const x = rfloat(rng, 0, W), y = rfloat(rng, 0, H), len = rfloat(rng, 3, 11); return `<line x1="${r2(x)}" y1="${r2(y)}" x2="${r2(x + 1.5)}" y2="${r2(y + len)}" stroke="${rgba(accent, rfloat(rng, 0.05, 0.18))}" stroke-width="1"/>`; }).join("");
|
||||
if (scene.smog > 0) smog = Array.from({ length: Math.round(scene.smog * 5) }, (_, i) => `<ellipse cx="${r2(rfloat(rng, 0, W))}" cy="${r2(rfloat(rng, H * 0.4, H))}" rx="${r2(rfloat(rng, 120, 320))}" ry="${r2(rfloat(rng, 30, 80))}" fill="${rgba(accent, 0.04)}"/>`).join("");
|
||||
if (scene.crowd > 0) crowd = Array.from({ length: Math.round(scene.crowd * 90) }, () => { const x = rfloat(rng, 0, W), y = rfloat(rng, H * 0.82, H - 4); return `<circle cx="${r2(x)}" cy="${r2(y)}" r="${r2(rfloat(rng, 1.5, 3))}" fill="${rgba(accent, rfloat(rng, 0.1, 0.3))}"/>`; }).join("");
|
||||
|
||||
// lighting
|
||||
let floods = "", beaconGlow = "";
|
||||
if (scene.floodlights) floods = Array.from({ length: rint(rng, 2, 3) }, () => { const x = rfloat(rng, W * 0.2, W * 0.8); return `<circle cx="${r2(x)}" cy="${r2(H * 0.12)}" r="${r2(rfloat(rng, 8, 14))}" fill="${rgba(accent, 0.6)}"/><polygon points="${r2(x)},${r2(H * 0.12)} ${r2(x - 90)},${H} ${r2(x + 90)},${H}" fill="${rgba(accent, 0.06)}"/>`; }).join("");
|
||||
if (scene.beacons) beaconGlow = Array.from({ length: rint(rng, 2, 4) }, () => `<circle cx="${r2(rfloat(rng, 0, W))}" cy="${r2(rfloat(rng, H * 0.2, H * 0.5))}" r="${r2(rfloat(rng, 16, 34))}" fill="${rgba(accent, 0.10)}"/>`).join("");
|
||||
|
||||
// hazard chevrons for high-alert bands
|
||||
let hazard = "";
|
||||
if (["red", "black", "jade", "green"].includes(key)) { const bandY = H - 16, chev = []; for (let x = -20; x < W + 20; x += 28) chev.push(`<path d="M${x} ${bandY} l14 8 l14 -8 l0 8 l-14 8 l-14 -8 Z" fill="${rgba(accent, 0.5)}"/>`); hazard = `<g>${chev.join("")}</g>`; }
|
||||
|
||||
// giant faint Third Eye
|
||||
const eyeX = rng() < 0.5 ? W * 0.78 : W * 0.22, eyeScale = rfloat(rng, 1.6, 2.2);
|
||||
const eye = `<g transform="translate(${r2(eyeX)} ${r2(H * 0.46)}) scale(${r2(eyeScale)})" opacity="0.12"><path d="M0 -34 C26 -34 40 -10 40 0 C40 10 26 34 0 34 C-26 34 -40 10 -40 0 C-40 -10 -26 -34 0 -34 Z" fill="none" stroke="${accent}" stroke-width="3"/><circle r="13" fill="none" stroke="${accent}" stroke-width="3"/></g>`;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="xMidYMid slice" role="img" aria-label="${bpn?.colour?.label ?? ""} sector backdrop">
|
||||
<defs>
|
||||
<linearGradient id="sky-${uid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(dark, accent, 0.16)}"/><stop offset="0.55" stop-color="${dark}"/><stop offset="1" stop-color="#04070c"/></linearGradient>
|
||||
<radialGradient id="glow-${uid}" cx="50%" cy="0%" r="80%"><stop offset="0" stop-color="${rgba(accent, 0.18)}"/><stop offset="1" stop-color="rgba(0,0,0,0)"/></radialGradient>
|
||||
<pattern id="scan-${uid}" width="3" height="3" patternUnits="userSpaceOnUse"><rect width="3" height="1" fill="rgba(0,0,0,0.22)"/></pattern>
|
||||
<radialGradient id="vig-${uid}" cx="50%" cy="50%" r="75%"><stop offset="0.55" stop-color="rgba(0,0,0,0)"/><stop offset="1" stop-color="rgba(0,0,0,0.55)"/></radialGradient>
|
||||
<filter id="grain-${uid}"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="${W}" height="${H}" fill="url(#sky-${uid})"/>
|
||||
<rect width="${W}" height="${H}" fill="url(#glow-${uid})"/>
|
||||
${beams}${beaconGlow}${smog}
|
||||
${eye}
|
||||
${back}
|
||||
${fg}
|
||||
${floods}${crowd}${rain}
|
||||
${hazard}
|
||||
<rect width="${W}" height="${H}" fill="url(#scan-${uid})"/>
|
||||
<rect width="${W}" height="${H}" filter="url(#grain-${uid})" opacity="0.05"/>
|
||||
<rect width="${W}" height="${H}" fill="url(#vig-${uid})"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* PORTRAIT — a stylised halftone contact silhouette for the brief. */
|
||||
/* Flat shoulders + head bust, halftone dot shading, accent rim. Not a face: */
|
||||
/* an anonymous corporate ID-photo abstraction, deliberately non-realistic. */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export function portraitSVG(bpn, { size = 120 } = {}) {
|
||||
const accent = bpn?.colour?.accent ?? "#dce8f9";
|
||||
const seed = bpn?.seed ?? "BPN";
|
||||
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "p";
|
||||
const rng = rngFor(seed, "portrait");
|
||||
const dark = "#0c1119";
|
||||
// halftone dots over the bust area
|
||||
const dots = [];
|
||||
for (let y = 18; y < 120; y += 8) {
|
||||
for (let x = 12; x < 108; x += 8) {
|
||||
const d = Math.hypot(x - 60, y - 70);
|
||||
const p = Math.max(0, 1 - d / 70);
|
||||
if (rng() < p * 0.7) dots.push(`<circle cx="${x}" cy="${y}" r="${r2(0.8 + p * 1.6)}" fill="${rgba(accent, 0.18 + p * 0.22)}"/>`);
|
||||
}
|
||||
}
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="${size}" height="${size}" role="img" aria-label="contact ident">
|
||||
<defs><clipPath id="clip-${uid}"><path d="M60 24 a18 18 0 0 1 18 18 a18 18 0 0 1 -36 0 a18 18 0 0 1 18 -18 Z M24 120 v-14 c0 -20 16 -32 36 -32 c20 0 36 12 36 32 v14 Z"/></clipPath></defs>
|
||||
<rect x="2" y="2" width="116" height="116" rx="12" fill="${dark}" stroke="${rgba(accent, 0.4)}" stroke-width="2"/>
|
||||
<g clip-path="url(#clip-${uid})">
|
||||
<rect x="0" y="0" width="120" height="120" fill="${rgba(accent, 0.08)}"/>
|
||||
${dots.join("")}
|
||||
</g>
|
||||
<path d="M60 24 a18 18 0 0 1 18 18 a18 18 0 0 1 -36 0 a18 18 0 0 1 18 -18 Z" fill="none" stroke="${rgba(accent, 0.55)}" stroke-width="2"/>
|
||||
<path d="M24 120 v-14 c0 -20 16 -32 36 -32 c20 0 36 12 36 32 v14" fill="none" stroke="${rgba(accent, 0.55)}" stroke-width="2"/>
|
||||
<line x1="10" y1="104" x2="110" y2="104" stroke="${rgba(accent, 0.3)}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* NPC PORTRAIT — a simple black-and-white "ident" mugshot, procedural and */
|
||||
/* deterministic per NPC. Stencil ink line-art: head, hair/hood/helmet, eyes */
|
||||
/* or visor, mouth or breather, an accessory, light hatch shading. Greyscale */
|
||||
/* only; the colour band shows just as a thin tag bar. */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export function npcPortraitSVG(npc, { size = 168, accent = "#9fb0c4" } = {}) {
|
||||
const seed = npc?.seed ?? npc?.name ?? "npc";
|
||||
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "n";
|
||||
const rng = rngFor(seed, "face");
|
||||
const ink = "#e9eef4";
|
||||
const dark = "#0d1117";
|
||||
const skin = "#1b212b";
|
||||
const stroke = (w = 2) => `fill="none" stroke="${ink}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"`;
|
||||
|
||||
const choose = (arr) => arr[Math.floor(rng() * arr.length)];
|
||||
const rx = choose([18, 20, 22]);
|
||||
const ry = choose([25, 28, 32]);
|
||||
const top = choose(["hairShort", "hairCrop", "bald", "hood", "helmet", "cap"]);
|
||||
const eyes = choose(["plain", "visor", "goggles", "augmetic"]);
|
||||
const mouth = choose(["neutral", "grim", "breather", "smirk"]);
|
||||
const extra = choose(["none", "earpiece", "scar", "collar"]);
|
||||
const cx = 84, cy = 78; // head centre in a 168x190 space
|
||||
|
||||
const head = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${skin}" stroke="${ink}" stroke-width="2"/>`;
|
||||
const jaw = `<path d="M${cx - rx + 2} ${cy + ry - 8} Q${cx} ${cy + ry + 6} ${cx + rx - 2} ${cy + ry - 8}" ${stroke(1.5)}/>`;
|
||||
|
||||
let hair = "";
|
||||
if (top === "hairShort") hair = `<path d="M${cx - rx - 2} ${cy - 6} Q${cx} ${cy - ry - 12} ${cx + rx + 2} ${cy - 6} Q${cx + rx - 6} ${cy - ry + 4} ${cx} ${cy - ry + 2} Q${cx - rx + 6} ${cy - ry + 4} ${cx - rx - 2} ${cy - 6} Z" fill="${ink}" opacity="0.9"/>`;
|
||||
else if (top === "hairCrop") hair = `<path d="M${cx - rx} ${cy - ry + 8} Q${cx} ${cy - ry - 4} ${cx + rx} ${cy - ry + 8}" ${stroke(5)}/>`;
|
||||
else if (top === "bald") hair = `<path d="M${cx - 8} ${cy - ry + 4} Q${cx} ${cy - ry - 2} ${cx + 8} ${cy - ry + 4}" ${stroke(1)} opacity="0.5"/>`;
|
||||
else if (top === "hood") hair = `<path d="M${cx - rx - 10} ${cy + 8} Q${cx - rx - 12} ${cy - ry - 18} ${cx} ${cy - ry - 20} Q${cx + rx + 12} ${cy - ry - 18} ${cx + rx + 10} ${cy + 8}" ${stroke(2.5)}/>`;
|
||||
else if (top === "helmet") hair = `<path d="M${cx - rx - 3} ${cy - 2} Q${cx} ${cy - ry - 14} ${cx + rx + 3} ${cy - 2}" ${stroke(3)}/><line x1="${cx}" y1="${cy - ry - 10}" x2="${cx}" y2="${cy - 4}" ${stroke(1.5)}/>`;
|
||||
else if (top === "cap") hair = `<path d="M${cx - rx - 6} ${cy - ry + 10} H${cx + rx + 2} Q${cx} ${cy - ry - 8} ${cx - rx - 6} ${cy - ry + 10} Z" fill="${ink}" opacity="0.85"/>`;
|
||||
|
||||
const ey = cy - 4;
|
||||
let eyeMarks = "";
|
||||
if (eyes === "plain") eyeMarks = `<line x1="${cx - 13}" y1="${ey}" x2="${cx - 6}" y2="${ey}" ${stroke(2)}/><line x1="${cx + 6}" y1="${ey}" x2="${cx + 13}" y2="${ey}" ${stroke(2)}/><line x1="${cx - 14}" y1="${ey - 6}" x2="${cx - 5}" y2="${ey - 7}" ${stroke(1.2)}/><line x1="${cx + 5}" y1="${ey - 7}" x2="${cx + 14}" y2="${ey - 6}" ${stroke(1.2)}/>`;
|
||||
else if (eyes === "visor") eyeMarks = `<rect x="${cx - 16}" y="${ey - 4}" width="32" height="8" rx="4" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx - 12}" y1="${ey}" x2="${cx + 12}" y2="${ey}" stroke="${ink}" stroke-width="1" opacity="0.5"/>`;
|
||||
else if (eyes === "goggles") eyeMarks = `<circle cx="${cx - 9}" cy="${ey}" r="5.5" ${stroke(2)}/><circle cx="${cx + 9}" cy="${ey}" r="5.5" ${stroke(2)}/><line x1="${cx - 3.5}" y1="${ey}" x2="${cx + 3.5}" y2="${ey}" ${stroke(2)}/>`;
|
||||
else eyeMarks = `<line x1="${cx - 13}" y1="${ey}" x2="${cx - 6}" y2="${ey}" ${stroke(2)}/><rect x="${cx + 5}" y="${ey - 4}" width="10" height="8" rx="1.5" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx + 7}" y1="${ey}" x2="${cx + 13}" y2="${ey}" stroke="${ink}" stroke-width="1"/>`;
|
||||
|
||||
const nose = `<path d="M${cx} ${ey + 4} L${cx - 3} ${cy + 8} L${cx + 2} ${cy + 8}" ${stroke(1.4)}/>`;
|
||||
|
||||
const my = cy + 17;
|
||||
let mouthMark = "";
|
||||
if (mouth === "neutral") mouthMark = `<line x1="${cx - 7}" y1="${my}" x2="${cx + 7}" y2="${my}" ${stroke(1.8)}/>`;
|
||||
else if (mouth === "grim") mouthMark = `<path d="M${cx - 8} ${my - 1} Q${cx} ${my + 3} ${cx + 8} ${my - 1}" ${stroke(1.8)}/>`;
|
||||
else if (mouth === "smirk") mouthMark = `<path d="M${cx - 7} ${my + 1} Q${cx} ${my - 1} ${cx + 8} ${my - 3}" ${stroke(1.8)}/>`;
|
||||
else mouthMark = `<rect x="${cx - 12}" y="${my - 7}" width="24" height="15" rx="4" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx - 12}" y1="${my}" x2="${cx - 22}" y2="${my - 4}" ${stroke(1.5)}/><line x1="${cx + 12}" y1="${my}" x2="${cx + 22}" y2="${my - 4}" ${stroke(1.5)}/><circle cx="${cx}" cy="${my}" r="2" fill="${ink}"/>`;
|
||||
|
||||
let extraMark = "";
|
||||
if (extra === "earpiece") extraMark = `<circle cx="${cx + rx - 1}" cy="${ey + 4}" r="3" ${stroke(1.5)}/><line x1="${cx + rx + 1}" y1="${ey + 6}" x2="${cx + rx + 6}" y2="${ey + 14}" ${stroke(1.5)}/>`;
|
||||
else if (extra === "scar") extraMark = `<line x1="${cx - 12}" y1="${cy - 14}" x2="${cx - 4}" y2="${cy + 6}" ${stroke(1.5)}/>`;
|
||||
else if (extra === "collar") extraMark = `<path d="M${cx - 22} 168 L${cx - 8} ${cy + ry + 6} M${cx + 22} 168 L${cx + 8} ${cy + ry + 6}" ${stroke(2)}/>`;
|
||||
|
||||
// light hatch shading down the right cheek
|
||||
const hatch = Array.from({ length: 4 }, (_, i) =>
|
||||
`<line x1="${cx + 6 + i * 3}" y1="${cy - 6 + i * 2}" x2="${cx + 10 + i * 3}" y2="${cy + 10 + i * 2}" stroke="${ink}" stroke-width="1" opacity="0.18"/>`).join("");
|
||||
|
||||
const shoulders = `<path d="M22 190 V164 C22 142 48 130 84 130 C120 130 146 142 146 164 V190 Z" fill="${skin}" stroke="${ink}" stroke-width="2"/>`;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 190" width="${size}" height="${Math.round(size * 190 / 168)}" role="img" aria-label="${npc?.name ?? "ident"} portrait">
|
||||
<rect x="2" y="2" width="164" height="186" rx="10" fill="${dark}" stroke="${rgba(accent, 0.45)}" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="164" height="16" rx="10" fill="${rgba(accent, 0.18)}"/>
|
||||
<text x="10" y="14" font-family="Roboto Mono, monospace" font-size="9" letter-spacing="2" fill="${rgba(accent, 0.85)}">IDENT</text>
|
||||
${shoulders}
|
||||
${extra === "collar" ? extraMark : ""}
|
||||
${head}${jaw}${hatch}
|
||||
${hair}
|
||||
${eyeMarks}${nose}${mouthMark}
|
||||
${extra !== "collar" ? extraMark : ""}
|
||||
<line x1="10" y1="178" x2="158" y2="178" stroke="${rgba(accent, 0.3)}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* ITEM ICON — simple black-and-white line-art keyed to the OTEM category. */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export function itemIconSVG(item, { size = 120, accent = "#9fb0c4" } = {}) {
|
||||
const ink = "#e9eef4";
|
||||
const dark = "#0d1117";
|
||||
const s = (w = 2.4) => `fill="none" stroke="${ink}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"`;
|
||||
const cat = item?.category ?? "slate";
|
||||
const glyphs = {
|
||||
slate: `<rect x="34" y="24" width="32" height="52" rx="4" ${s()}/><line x1="40" y1="36" x2="60" y2="36" ${s(1.6)}/><line x1="40" y1="44" x2="60" y2="44" ${s(1.6)}/><line x1="40" y1="52" x2="54" y2="52" ${s(1.6)}/><circle cx="50" cy="68" r="2.2" fill="${ink}"/>`,
|
||||
canister: `<rect x="38" y="26" width="24" height="48" rx="6" ${s()}/><ellipse cx="50" cy="26" rx="12" ry="4" ${s(1.6)}/><line x1="38" y1="46" x2="62" y2="46" ${s(1.6)}/><rect x="44" y="18" width="12" height="8" rx="2" ${s(1.6)}/>`,
|
||||
weapon: `<path d="M28 44 H64 V54 H50 L46 64 H40 L42 54 H28 Z" ${s()}/><line x1="34" y1="44" x2="34" y2="38" ${s(1.6)}/>`,
|
||||
chip: `<rect x="34" y="34" width="32" height="32" rx="3" ${s()}/><rect x="42" y="42" width="16" height="16" rx="2" ${s(1.6)}/><line x1="38" y1="30" x2="38" y2="34" ${s(1.4)}/><line x1="50" y1="30" x2="50" y2="34" ${s(1.4)}/><line x1="62" y1="30" x2="62" y2="34" ${s(1.4)}/><line x1="38" y1="66" x2="38" y2="70" ${s(1.4)}/><line x1="62" y1="66" x2="62" y2="70" ${s(1.4)}/>`,
|
||||
crate: `<rect x="28" y="32" width="44" height="36" rx="3" ${s()}/><line x1="28" y1="50" x2="72" y2="50" ${s(1.6)}/><line x1="50" y1="32" x2="50" y2="68" ${s(1.6)}/><circle cx="34" cy="38" r="1.8" fill="${ink}"/><circle cx="66" cy="38" r="1.8" fill="${ink}"/>`,
|
||||
bug: `<ellipse cx="50" cy="52" rx="10" ry="14" ${s()}/><circle cx="50" cy="36" r="5" ${s(1.6)}/><line x1="47" y1="32" x2="44" y2="26" ${s(1.4)}/><line x1="53" y1="32" x2="56" y2="26" ${s(1.4)}/><line x1="40" y1="46" x2="30" y2="42" ${s(1.4)}/><line x1="40" y1="56" x2="30" y2="58" ${s(1.4)}/><line x1="60" y1="46" x2="70" y2="42" ${s(1.4)}/><line x1="60" y1="56" x2="70" y2="58" ${s(1.4)}/>`,
|
||||
component: `<circle cx="50" cy="50" r="14" ${s()}/><circle cx="50" cy="50" r="5" ${s(1.6)}/><g stroke="${ink}" stroke-width="2.4" stroke-linecap="round">${Array.from({ length: 8 }, (_, i) => { const a = i / 8 * Math.PI * 2; return `<line x1="${50 + Math.cos(a) * 14}" y1="${50 + Math.sin(a) * 14}" x2="${50 + Math.cos(a) * 19}" y2="${50 + Math.sin(a) * 19}"/>`; }).join("")}</g>`,
|
||||
relay: `<line x1="50" y1="30" x2="50" y2="70" ${s()}/><rect x="44" y="68" width="12" height="6" ${s(1.6)}/><path d="M40 34 Q50 24 60 34" ${s(1.6)}/><path d="M36 30 Q50 16 64 30" ${s(1.4)}/><circle cx="50" cy="30" r="2.5" fill="${ink}"/>`,
|
||||
kit: `<rect x="30" y="36" width="40" height="30" rx="4" ${s()}/><line x1="42" y1="36" x2="42" y2="30" ${s(1.6)}/><line x1="58" y1="36" x2="58" y2="30" ${s(1.6)}/><line x1="50" y1="44" x2="50" y2="58" ${s(2)}/><line x1="43" y1="51" x2="57" y2="51" ${s(2)}/>`,
|
||||
drone: `<rect x="44" y="44" width="12" height="12" rx="2" ${s()}/><line x1="44" y1="44" x2="32" y2="32" ${s(1.6)}/><line x1="56" y1="44" x2="68" y2="32" ${s(1.6)}/><line x1="44" y1="56" x2="32" y2="68" ${s(1.6)}/><line x1="56" y1="56" x2="68" y2="68" ${s(1.6)}/><circle cx="30" cy="30" r="4" ${s(1.6)}/><circle cx="70" cy="30" r="4" ${s(1.6)}/><circle cx="30" cy="70" r="4" ${s(1.6)}/><circle cx="70" cy="70" r="4" ${s(1.6)}/>`,
|
||||
key: `<circle cx="38" cy="50" r="10" ${s()}/><line x1="48" y1="50" x2="72" y2="50" ${s()}/><line x1="66" y1="50" x2="66" y2="58" ${s(1.8)}/><line x1="72" y1="50" x2="72" y2="60" ${s(1.8)}/>`,
|
||||
card: `<rect x="28" y="36" width="44" height="30" rx="3" ${s()}/><rect x="33" y="42" width="12" height="14" rx="1.5" ${s(1.6)}/><line x1="50" y1="44" x2="66" y2="44" ${s(1.4)}/><line x1="50" y1="50" x2="66" y2="50" ${s(1.4)}/><line x1="50" y1="56" x2="60" y2="56" ${s(1.4)}/>`
|
||||
};
|
||||
const inner = glyphs[cat] ?? glyphs.slate;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${size}" height="${size}" role="img" aria-label="${item?.name ?? "item"} icon">
|
||||
<rect x="2" y="2" width="96" height="96" rx="10" fill="${dark}" stroke="${rgba(accent, 0.45)}" stroke-width="2"/>
|
||||
${inner}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* SITE MAP — a schematic, non-photorealistic sector diagram for the Field */
|
||||
/* Dossier. Numbered nodes (one per location) wired along a route over a */
|
||||
/* faint grid, with an entry marker. Labels live in the dossier list. */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
export function siteMapSVG(bpn, { width = 640, height = 320 } = {}) {
|
||||
const accent = bpn?.colour?.accent ?? "#dce8f9";
|
||||
const seed = bpn?.seed ?? "BPN";
|
||||
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "m";
|
||||
const rng = rngFor(seed, "map");
|
||||
const dark = "#0b1018";
|
||||
const sites = Array.isArray(bpn?.sites) && bpn.sites.length ? bpn.sites : [{}, {}, {}];
|
||||
const n = sites.length;
|
||||
const W = width, H = height;
|
||||
const mx = 70, my = 64;
|
||||
|
||||
// node positions: spread along x, seeded y within the band
|
||||
const nodes = sites.map((_, i) => ({
|
||||
x: mx + (n === 1 ? (W - 2 * mx) / 2 : (i * (W - 2 * mx)) / (n - 1)),
|
||||
y: my + rfloat(rng, 0, H - 2 * my)
|
||||
}));
|
||||
|
||||
// route polyline through the nodes
|
||||
const route = nodes.map((p, i) => `${i ? "L" : "M"}${r2(p.x)} ${r2(p.y)}`).join(" ");
|
||||
|
||||
const nodeMarks = nodes.map((p, i) => `
|
||||
<g transform="translate(${r2(p.x)} ${r2(p.y)})">
|
||||
<circle r="16" fill="${dark}" stroke="${accent}" stroke-width="2.5"/>
|
||||
<circle r="22" fill="none" stroke="${rgba(accent, 0.25)}" stroke-width="1"/>
|
||||
<text x="0" y="5" text-anchor="middle" font-family="Roboto Mono, monospace" font-size="15" font-weight="700" fill="${mix(accent, "#ffffff", 0.3)}">${i + 1}</text>
|
||||
</g>`).join("");
|
||||
|
||||
// entry chevron at the first node
|
||||
const e = nodes[0];
|
||||
const entry = `<g transform="translate(${r2(e.x - 30)} ${r2(e.y)})"><path d="M-10 -8 L2 0 L-10 8" fill="none" stroke="${accent}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><text x="-12" y="-12" text-anchor="end" font-family="Roboto Mono, monospace" font-size="10" letter-spacing="1.5" fill="${rgba(accent, 0.7)}">ENTRY</text></g>`;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="sector schematic">
|
||||
<defs>
|
||||
<linearGradient id="mg-${uid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(dark, accent, 0.10)}"/><stop offset="1" stop-color="#05080d"/></linearGradient>
|
||||
<pattern id="grid-${uid}" width="28" height="28" patternUnits="userSpaceOnUse"><path d="M28 0 H0 V28" fill="none" stroke="${rgba(accent, 0.10)}" stroke-width="1"/></pattern>
|
||||
</defs>
|
||||
<rect width="${W}" height="${H}" rx="10" fill="url(#mg-${uid})"/>
|
||||
<rect width="${W}" height="${H}" rx="10" fill="url(#grid-${uid})"/>
|
||||
<rect x="1" y="1" width="${W - 2}" height="${H - 2}" rx="10" fill="none" stroke="${rgba(accent, 0.4)}" stroke-width="1.5"/>
|
||||
<text x="18" y="28" font-family="Roboto Mono, monospace" font-size="11" letter-spacing="2" fill="${rgba(accent, 0.75)}">SECTOR SCHEMATIC // ${String(bpn?.bpnId ?? "")}</text>
|
||||
<path d="${route}" fill="none" stroke="${rgba(accent, 0.55)}" stroke-width="2" stroke-dasharray="2 6" stroke-linecap="round"/>
|
||||
${entry}
|
||||
${nodeMarks}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* Convenience: data URIs (handy as <img> srcs or journal icons, and required
|
||||
in hosts that sanitise inline <svg> such as Foundry journal pages). */
|
||||
export function crestDataURI(bpn, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(crestSVG(bpn, opts))}`;
|
||||
}
|
||||
export function bannerDataURI(bpn, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(bannerSVG(bpn, opts))}`;
|
||||
}
|
||||
export function portraitDataURI(bpn, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(portraitSVG(bpn, opts))}`;
|
||||
}
|
||||
export function siteMapDataURI(bpn, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(siteMapSVG(bpn, opts))}`;
|
||||
}
|
||||
export function npcPortraitDataURI(npc, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(npcPortraitSVG(npc, opts))}`;
|
||||
}
|
||||
export function itemIconDataURI(item, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(itemIconSVG(item, opts))}`;
|
||||
}
|
||||
Vendored
+501
@@ -0,0 +1,501 @@
|
||||
/* Blueprint News — engine-agnostic generation core.
|
||||
*
|
||||
* No DOM, no game-engine APIs. Given a seed (and optional overrides) it
|
||||
* produces a fully-resolved BPN object that is identical every time for the
|
||||
* same inputs — which lets the renderer and the procedural art stay in sync.
|
||||
*
|
||||
* The returned object deliberately separates what the squad is allowed to see
|
||||
* (top-level fields) from what only the GM sees (the `gm` block).
|
||||
*/
|
||||
|
||||
import { COLOURS, COLOUR_ROLL_TABLE, colourByKey } from "./data/colours.js";
|
||||
import { TABLES, COLOUR_MISSION_IDEAS } from "./data/tables.js";
|
||||
import { framesForColour } from "./data/frames.js";
|
||||
import { DETAIL } from "./data/detail.js";
|
||||
|
||||
/* ---- seeded randomness -------------------------------------------------- */
|
||||
|
||||
function hashSeed(str) {
|
||||
let h = 2166136261 >>> 0;
|
||||
const s = String(str);
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function makeRng(seed) {
|
||||
return mulberry32(hashSeed(seed));
|
||||
}
|
||||
|
||||
/** A fresh human-friendly seed, e.g. "BPN-7F3A-21C9". Uses Math.random by
|
||||
* design — call this once at the app layer, then everything downstream is
|
||||
* deterministic from the returned string. */
|
||||
export function randomSeed() {
|
||||
const block = () => Math.floor(Math.random() * 0xffff).toString(16).toUpperCase().padStart(4, "0");
|
||||
return `BPN-${block()}-${block()}`;
|
||||
}
|
||||
|
||||
/* ---- small helpers ------------------------------------------------------ */
|
||||
|
||||
function pick(rng, arr) {
|
||||
return Array.isArray(arr) && arr.length ? arr[Math.floor(rng() * arr.length)] : "";
|
||||
}
|
||||
|
||||
function rollDie(rng, faces) {
|
||||
return Math.floor(rng() * faces) + 1;
|
||||
}
|
||||
|
||||
function pad(n, width = 3) {
|
||||
return String(n).padStart(width, "0");
|
||||
}
|
||||
|
||||
function take(value, fallback = "") {
|
||||
const v = String(value ?? "").trim();
|
||||
return v || String(fallback ?? "").trim();
|
||||
}
|
||||
|
||||
function clampSquad(value) {
|
||||
const n = Number(value ?? 4);
|
||||
if (!Number.isFinite(n)) return 4;
|
||||
return Math.max(1, Math.min(8, Math.round(n)));
|
||||
}
|
||||
|
||||
function formatScl(value = 0) {
|
||||
const n = Number(value ?? 0);
|
||||
if (!Number.isFinite(n)) return "+0.0";
|
||||
const fixed = Math.abs(n) >= 1 ? n.toFixed(1) : n.toFixed(2);
|
||||
const trimmed = fixed.replace(/\.?0+$/, "");
|
||||
return `${n >= 0 ? "+" : ""}${trimmed}`;
|
||||
}
|
||||
|
||||
/* ---- reward / SCL maths ------------------------------------------------- */
|
||||
|
||||
function creditsFromRange(range = { min: 0, max: 0 }, tier = "mid") {
|
||||
const min = Number(range?.min ?? 0);
|
||||
const max = range?.max == null ? 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 clampNum(n, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, n));
|
||||
}
|
||||
|
||||
/* For an open-ended band (Platinum has no max) we still need a finite slider
|
||||
ceiling — three times the floor reads as "and up". */
|
||||
function basisBounds(range) {
|
||||
const min = Number(range?.min ?? 0);
|
||||
const openEnded = range?.max == null;
|
||||
const max = openEnded ? Math.round(min * 3) : Number(range.max);
|
||||
return { min, max, openEnded };
|
||||
}
|
||||
|
||||
function rewardPackage(rng, colour, input) {
|
||||
const paymentTypeRoll = rollDie(rng, 6);
|
||||
const cbsTierRoll = rollDie(rng, 6);
|
||||
const sclTierRoll = rollDie(rng, 6);
|
||||
const mediaRoll = rollDie(rng, 6);
|
||||
const financierRoll = rollDie(rng, 6);
|
||||
|
||||
const squadSize = clampSquad(input.squadSize);
|
||||
const paymentType = input.paymentType && input.paymentType !== "__RANDOM__"
|
||||
? String(input.paymentType)
|
||||
: (paymentTypeRoll <= 4 ? "Per Squad" : "Per Operative");
|
||||
|
||||
// Credits are expressed per the chosen payment basis (per Operative or per
|
||||
// Squad). The slider runs across that band's range; an explicit cbsValue
|
||||
// (from the slider) overrides the rolled default.
|
||||
const basis = paymentType === "Per Operative" ? "op" : "squad";
|
||||
const range = basis === "op" ? colour.cbsPerOpRange : colour.cbsPerSquadRange;
|
||||
const { min: cbsMin, max: cbsMax, openEnded: cbsOpenEnded } = basisBounds(range);
|
||||
const rolledBasis = creditsFromRange(range, tierFromRoll(cbsTierRoll));
|
||||
const cbsBasisValue = input.cbsValue != null && Number.isFinite(Number(input.cbsValue))
|
||||
? clampNum(Math.round(Number(input.cbsValue)), cbsMin, cbsOpenEnded ? Math.max(cbsMax, Math.round(Number(input.cbsValue))) : cbsMax)
|
||||
: clampNum(rolledBasis, cbsMin, cbsMax);
|
||||
const grossContractValue = basis === "op" ? cbsBasisValue * squadSize : cbsBasisValue;
|
||||
|
||||
const financierCutPercent = input.financierCutPercent
|
||||
? Number(input.financierCutPercent)
|
||||
: (financierRoll <= 2 ? 10 : financierRoll <= 4 ? 15 : 20);
|
||||
const netContract = Math.max(0, Math.round(grossContractValue * (1 - financierCutPercent / 100)));
|
||||
const takeHomeEach = Math.max(1, Math.floor(netContract / squadSize));
|
||||
|
||||
// SCL increase also runs across the band's range; an explicit sclIncrease
|
||||
// (from the slider) overrides the rolled default.
|
||||
const sclMin = Number(colour.sclRange?.min ?? 0.05);
|
||||
const sclMax = Number(colour.sclRange?.max ?? colour.sclRange?.typical ?? 0.2);
|
||||
const sclByTier = {
|
||||
low: sclMin,
|
||||
mid: Number(colour.sclRange?.typical ?? sclMin),
|
||||
high: sclMax
|
||||
};
|
||||
const sclIncreaseValue = input.sclIncrease != null && Number.isFinite(Number(input.sclIncrease))
|
||||
? clampNum(Number(input.sclIncrease), sclMin, sclMax)
|
||||
: clampNum(sclByTier[tierFromRoll(sclTierRoll)] ?? sclByTier.mid, sclMin, sclMax);
|
||||
|
||||
return {
|
||||
paymentType,
|
||||
squadSize,
|
||||
basis,
|
||||
cbsBasisValue,
|
||||
cbsMin,
|
||||
cbsMax,
|
||||
cbsOpenEnded,
|
||||
grossContractValue,
|
||||
netContract,
|
||||
takeHomeEach,
|
||||
financierCutPercent,
|
||||
cbsBandOp: `${colour.cbsPerOpRange?.min ?? 0}c - ${colour.cbsPerOpRange?.max ?? "?"}c`,
|
||||
cbsBandSquad: `${colour.cbsPerSquadRange?.min ?? 0}c - ${colour.cbsPerSquadRange?.max ?? "?"}c`,
|
||||
sclIncreaseValue,
|
||||
sclMin,
|
||||
sclMax,
|
||||
sclIncrease: formatScl(sclIncreaseValue),
|
||||
mediaCoverage: input.mediaCoverage && input.mediaCoverage !== "__RANDOM__"
|
||||
? String(input.mediaCoverage)
|
||||
: mediaFromRoll(mediaRoll),
|
||||
notes: `Financier cut assumed at ${financierCutPercent}%. Take-home is after the financier slice, split across ${squadSize} Operatives.`,
|
||||
rolls: { paymentType: paymentTypeRoll, cbsTier: cbsTierRoll, sclTier: sclTierRoll, media: mediaRoll, financier: financierRoll }
|
||||
};
|
||||
}
|
||||
|
||||
/* ---- derived prose ------------------------------------------------------ */
|
||||
|
||||
function objectiveFromFocus(focusText = "") {
|
||||
const t = String(focusText ?? "").toLowerCase();
|
||||
if (t.includes("eliminate")) return "Neutralise the designated threat and confirm status.";
|
||||
if (t.includes("escort") || t.includes("protect")) return "Keep the protected asset moving and alive through the full route.";
|
||||
if (t.includes("retrieve")) return "Recover the designated item, data, or specimen intact and preserve chain of custody.";
|
||||
if (t.includes("investigate")) return "Identify culprit(s), secure evidence, and produce an admissible corporate finding.";
|
||||
if (t.includes("contain") || t.includes("quarantine")) return "Lock the threat down before it jumps sectors.";
|
||||
if (t.includes("riot") || t.includes("crowd")) return "Restore order without losing the public narrative.";
|
||||
if (t.includes("hunt")) return "Track, isolate, and capture or terminate the named target.";
|
||||
if (t.includes("clean up")) return "Recover all traces of the prior SLA incident before public disclosure.";
|
||||
if (t.includes("sabotage") || t.includes("destroy")) return "Disable the objective and leave a believable false story behind.";
|
||||
return "Complete the assigned contract objective and report all outcomes.";
|
||||
}
|
||||
|
||||
function cleanupDirective(colourKey = "blue") {
|
||||
const k = String(colourKey ?? "blue").toLowerCase();
|
||||
const map = {
|
||||
blue: "Restore basic street function and hand the site back looking manageable.",
|
||||
yellow: "Protect the recovered asset's chain of custody at all times.",
|
||||
green: "Confirm every kill and leave nothing twitching for the cameras.",
|
||||
white: "Return with evidence, witnesses, and a narrative command can sign off on.",
|
||||
grey: "Keep the paperwork clean and the department's name out of the fallout.",
|
||||
silver: "Keep public optics sponsor-safe from briefing to extraction.",
|
||||
jade: "Seal contamination and route all samples through Karma only.",
|
||||
red: "Stabilise the zone first, then explain it to the cameras.",
|
||||
black: "Remove traces, minimise witnesses, and suppress operational signatures.",
|
||||
platinum: "Preserve classification and return all material to Head Office only."
|
||||
};
|
||||
return map[k] ?? "Recover evidence, suppress compromising exposure, and file a command-ready debrief.";
|
||||
}
|
||||
|
||||
/** Trim trailing whitespace/punctuation so clauses join cleanly. */
|
||||
function trimEnd(s = "") {
|
||||
return String(s).replace(/[.\s]+$/, "");
|
||||
}
|
||||
|
||||
/** Lowercase only the first letter — keeps acronyms (SLA) and names (DarkNight). */
|
||||
function lcFirst(s = "") {
|
||||
const v = String(s);
|
||||
return v.charAt(0).toLowerCase() + v.slice(1);
|
||||
}
|
||||
|
||||
function buildMissionBrief(b) {
|
||||
const objective = lcFirst(trimEnd(objectiveFromFocus(b.missionFocus)));
|
||||
const loc = trimEnd(b.location);
|
||||
const opp = lcFirst(trimEnd(b.opposition));
|
||||
return [
|
||||
`${b.issuingDepartment} has issued ${b.bpnId}, a ${b.colour.label.toUpperCase()} ${b.colour.type.toLowerCase()} contract.`,
|
||||
`${b.contact.name} — ${b.contact.role} — wants the squad to ${objective}.`,
|
||||
`Operational spine: ${loc}.`,
|
||||
`${b.hook}`,
|
||||
`Expect ${opp}.`,
|
||||
`Media posture is ${b.reward.mediaCoverage.toLowerCase()}; command expects a clean narrative.`
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/* ---- expanded detail: cast / OTEM / sites ------------------------------- */
|
||||
|
||||
/** Pick n distinct entries from arr (deterministic via rng). */
|
||||
function sample(rng, arr, n) {
|
||||
const pool = [...arr];
|
||||
const out = [];
|
||||
for (let i = 0; i < n && pool.length; i++) {
|
||||
out.push(pool.splice(Math.floor(rng() * pool.length), 1)[0]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ucFirst(s = "") {
|
||||
const v = String(s);
|
||||
return v.charAt(0).toUpperCase() + v.slice(1);
|
||||
}
|
||||
|
||||
/** Classify an OTEM name into an icon category for the line-art item icon. */
|
||||
function otemCategory(name = "") {
|
||||
const n = String(name).toLowerCase();
|
||||
if (n.includes("slate") || n.includes("data")) return "slate";
|
||||
if (n.includes("canister") || n.includes("sample")) return "canister";
|
||||
if (n.includes("weapon") || n.includes("cache")) return "weapon";
|
||||
if (n.includes("chip") || n.includes("credential")) return "chip";
|
||||
if (n.includes("crate") || n.includes("evidence")) return "crate";
|
||||
if (n.includes("bug") || n.includes("surveillance")) return "bug";
|
||||
if (n.includes("component") || n.includes("prototype")) return "component";
|
||||
if (n.includes("relay") || n.includes("comms")) return "relay";
|
||||
if (n.includes("med") || n.includes("kit")) return "kit";
|
||||
if (n.includes("drone") || n.includes("camera")) return "drone";
|
||||
if (n.includes("key")) return "key";
|
||||
if (n.includes("card") || n.includes("id")) return "card";
|
||||
return "slate";
|
||||
}
|
||||
|
||||
/** Build the cast, OTEM and locations. Each NPC is a small character sheet
|
||||
* (description, significance, appearance + a stable seed for its portrait);
|
||||
* each item carries a description + category for its line-art icon. Player
|
||||
* facing fields plus a GM-only `gm` secret. */
|
||||
function buildDetail(rng, ctx) {
|
||||
const makeNpc = (i, base) => {
|
||||
const build = pick(rng, DETAIL.npcBuilds);
|
||||
const feature = pick(rng, DETAIL.npcFeatures);
|
||||
const habit = pick(rng, DETAIL.npcHabits);
|
||||
const role = base.role ?? pick(rng, DETAIL.npcRoles);
|
||||
return {
|
||||
seed: `${ctx.seed}::npc::${i}`,
|
||||
name: base.name,
|
||||
role,
|
||||
disposition: base.disposition ?? pick(rng, DETAIL.npcDispositions),
|
||||
appearance: { build, feature, habit },
|
||||
description: `${ucFirst(build)} ${role.toLowerCase()} with ${feature}; ${habit}.`,
|
||||
significance: base.significance ?? pick(rng, DETAIL.npcSignificance),
|
||||
player: base.player ?? pick(rng, DETAIL.npcPlayerNotes),
|
||||
gm: base.gm ?? pick(rng, DETAIL.npcSecrets)
|
||||
};
|
||||
};
|
||||
|
||||
const cast = [
|
||||
makeNpc(0, {
|
||||
name: ctx.contactName,
|
||||
role: ctx.contactRole,
|
||||
disposition: "Cooperative",
|
||||
significance: "The squad's authorised contact on the ground.",
|
||||
player: `On-site contact, holding at ${lcFirst(trimEnd(ctx.contactLocation))}.`
|
||||
}),
|
||||
makeNpc(1, {
|
||||
name: ctx.threatLead || "Reported hostile",
|
||||
role: "Designated threat",
|
||||
disposition: "Openly hostile",
|
||||
significance: "The hostile command flagged as the squad's primary problem.",
|
||||
player: ctx.threatCount || "Hostile lead flagged by command.",
|
||||
gm: ctx.oppositionAttitude ? `Really: ${lcFirst(ctx.oppositionAttitude)}` : pick(rng, DETAIL.npcSecrets)
|
||||
}),
|
||||
makeNpc(2, { name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}` }),
|
||||
makeNpc(3, { name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}` })
|
||||
];
|
||||
|
||||
const otem = sample(rng, DETAIL.otemNames, 3).map((name, i) => ({
|
||||
seed: `${ctx.seed}::otem::${i}`,
|
||||
name,
|
||||
category: otemCategory(name),
|
||||
description: pick(rng, DETAIL.otemDescriptions),
|
||||
player: pick(rng, DETAIL.otemPlayerNotes),
|
||||
gm: pick(rng, DETAIL.otemSecrets)
|
||||
}));
|
||||
|
||||
const sites = sample(rng, DETAIL.siteNames, 3).map((name) => ({
|
||||
name: ucFirst(name),
|
||||
player: pick(rng, DETAIL.sitePlayerNotes),
|
||||
gm: pick(rng, DETAIL.siteSecrets)
|
||||
}));
|
||||
|
||||
return { cast, otem, sites };
|
||||
}
|
||||
|
||||
/* ---- SCL level ---------------------------------------------------------- */
|
||||
|
||||
/** Operative Security Clearance Level: 10 (lowest) … 1 (highest). */
|
||||
function clampScl(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return 10;
|
||||
return Math.max(1, Math.min(10, Math.round(v)));
|
||||
}
|
||||
|
||||
/** A sensible default level per band — the top (easiest) end of its range. */
|
||||
function defaultSclFromBand(colour) {
|
||||
const m = String(colour?.sclReq ?? "10").match(/\d+/);
|
||||
return clampScl(m ? Number(m[0]) : 10);
|
||||
}
|
||||
|
||||
/* ---- public API --------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Resolve a complete BPN from inputs.
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.seed] Reproducibility seed; one is generated if absent.
|
||||
* @param {string} [opts.colourKey] Force a colour band; otherwise rolled on the d20 table.
|
||||
* @param {string} [opts.frameId] Force a scenario frame within the band.
|
||||
* @param {number} [opts.squadSize] Default 4.
|
||||
* @param {number} [opts.sclLevel] Operative SCL level 1–10; defaults to the band's top.
|
||||
* @param {number} [opts.cbsValue] Explicit credits for the chosen payment basis (slider).
|
||||
* @param {number} [opts.sclIncrease] Explicit SCL increase reward (slider).
|
||||
* @param {object} [opts.overrides] Any field to pin to a specific value (e.g. {location: "..."}).
|
||||
*/
|
||||
export function generateBPN(opts = {}) {
|
||||
const seed = take(opts.seed, randomSeed());
|
||||
const rng = makeRng(seed);
|
||||
const ov = opts.overrides ?? {};
|
||||
|
||||
// Colour band: forced, or rolled on the d20 issue table.
|
||||
let colour;
|
||||
let colourRoll = 0;
|
||||
if (opts.colourKey) {
|
||||
colour = colourByKey(opts.colourKey);
|
||||
} else {
|
||||
colourRoll = rollDie(rng, 20);
|
||||
const row = COLOUR_ROLL_TABLE.find((r) => colourRoll >= r.min && colourRoll <= r.max) ?? COLOUR_ROLL_TABLE[0];
|
||||
colour = colourByKey(row.key);
|
||||
}
|
||||
|
||||
// Scenario frame within the band.
|
||||
const frames = framesForColour(colour.key);
|
||||
const frame = (opts.frameId && frames.find((f) => f.id === opts.frameId)) || pick(rng, frames);
|
||||
|
||||
const reward = rewardPackage(rng, colour, {
|
||||
squadSize: opts.squadSize ?? ov.squadSize,
|
||||
...ov,
|
||||
cbsValue: opts.cbsValue ?? ov.cbsValue,
|
||||
sclIncrease: opts.sclIncrease ?? ov.sclIncrease
|
||||
});
|
||||
|
||||
const sclLevel = clampScl(opts.sclLevel ?? ov.sclLevel ?? defaultSclFromBand(colour));
|
||||
|
||||
const contactName = take(
|
||||
ov.contactName,
|
||||
`${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}`
|
||||
);
|
||||
|
||||
const hookFallback = frame.colourMissionIdea || pick(rng, COLOUR_MISSION_IDEAS[colour.key] ?? []);
|
||||
|
||||
const bpn = {
|
||||
seed,
|
||||
bpnId: take(ov.bpnId, `${colour.label.toUpperCase().slice(0, 2)}-${pad(rollDie(rng, 999))}-${pad(rollDie(rng, 999))}`),
|
||||
shortTitle: take(ov.shortTitle, frame.shortTitle),
|
||||
colourKey: colour.key,
|
||||
colour,
|
||||
issuingDepartment: take(ov.issuingDepartment, frame.issuingDepartment || pick(rng, TABLES.issuingDepartments)),
|
||||
squadSize: reward.squadSize,
|
||||
sclLevel,
|
||||
sclRequirement: take(ov.sclRequirement, `SCL ${sclLevel}`),
|
||||
sclBandRange: colour.sclReq,
|
||||
|
||||
contact: {
|
||||
name: contactName,
|
||||
role: take(ov.contactRole, frame.contactRole),
|
||||
location: take(ov.contactLocation, frame.contactLocation)
|
||||
},
|
||||
|
||||
missionFocus: take(ov.missionFocus, frame.missionFocus || pick(rng, TABLES.missionFocus)),
|
||||
hook: take(ov.hook, hookFallback),
|
||||
objective: objectiveFromFocus(take(ov.missionFocus, frame.missionFocus)),
|
||||
location: take(ov.location, frame.location || pick(rng, TABLES.locations)),
|
||||
atmosphere: take(ov.atmosphere, frame.atmosphere || pick(rng, TABLES.atmospheres)),
|
||||
opposition: take(ov.opposition, frame.opposition || pick(rng, TABLES.opposition)),
|
||||
civilianFaction: take(ov.civilianFaction, frame.civilianFaction || pick(rng, TABLES.civilianFactions)),
|
||||
extraObjective: take(ov.extraObjective, frame.extraObjective || pick(rng, TABLES.extraObjectives)),
|
||||
cleanupDirective: cleanupDirective(colour.key),
|
||||
|
||||
reward: {
|
||||
paymentType: reward.paymentType,
|
||||
grossContract: `${reward.grossContractValue}c (${reward.paymentType})`,
|
||||
grossContractValue: reward.grossContractValue,
|
||||
takeHomeEach: `${reward.takeHomeEach}c each`,
|
||||
financierCut: `${reward.financierCutPercent}%`,
|
||||
financierCutPercent: reward.financierCutPercent,
|
||||
sclIncrease: reward.sclIncrease,
|
||||
sclIncreaseValue: reward.sclIncreaseValue,
|
||||
sclMin: reward.sclMin,
|
||||
sclMax: reward.sclMax,
|
||||
mediaCoverage: reward.mediaCoverage,
|
||||
cbsBandOp: reward.cbsBandOp,
|
||||
cbsBandSquad: reward.cbsBandSquad,
|
||||
cbsBasis: reward.basis,
|
||||
cbsBasisValue: reward.cbsBasisValue,
|
||||
cbsMin: reward.cbsMin,
|
||||
cbsMax: reward.cbsMax,
|
||||
cbsOpenEnded: reward.cbsOpenEnded
|
||||
},
|
||||
|
||||
// Seam for a future NPC/monster module: archetype keys only, no stat schema.
|
||||
npcSeam: {
|
||||
contactArchetype: frame.contactArchetype ?? null,
|
||||
threatArchetype: frame.threatArchetype ?? null,
|
||||
threatRole: frame.threatRole ?? null
|
||||
},
|
||||
|
||||
// GM-only truth. The renderer keeps this on a separate surface.
|
||||
gm: {
|
||||
complication: take(ov.complication, frame.complication || pick(rng, TABLES.complications)),
|
||||
localTwist: take(ov.localTwist, frame.localTwist || pick(rng, TABLES.localTwists)),
|
||||
oppositionAttitude: take(ov.oppositionAttitude, frame.oppositionAttitude || pick(rng, TABLES.oppositionAttitudes)),
|
||||
escalationEvent: take(ov.escalationEvent, frame.escalationEvent || pick(rng, TABLES.escalationEvents)),
|
||||
threatLead: take(ov.threatLead, frame.threatRole),
|
||||
threatCountLabel: take(ov.threatCountLabel, frame.threatCountLabel),
|
||||
financierNote: reward.notes,
|
||||
frameId: frame.id,
|
||||
rolls: { colour: colourRoll, ...reward.rolls }
|
||||
}
|
||||
};
|
||||
|
||||
bpn.missionBrief = take(ov.missionBrief, buildMissionBrief(bpn));
|
||||
|
||||
// Expanded detail for the separate player Field Dossier (player lines) and
|
||||
// the GM Dossier (secrets). Built last so it can read the resolved cast.
|
||||
const detail = buildDetail(rng, {
|
||||
seed,
|
||||
contactName: bpn.contact.name,
|
||||
contactRole: bpn.contact.role,
|
||||
contactLocation: bpn.contact.location,
|
||||
threatLead: bpn.gm.threatLead,
|
||||
threatCount: bpn.gm.threatCountLabel,
|
||||
oppositionAttitude: bpn.gm.oppositionAttitude
|
||||
});
|
||||
bpn.cast = detail.cast;
|
||||
bpn.otem = detail.otem;
|
||||
bpn.sites = detail.sites;
|
||||
|
||||
return bpn;
|
||||
}
|
||||
Vendored
+159
@@ -0,0 +1,159 @@
|
||||
/* SLA Industries BPN colour bands — the spine of the whole system.
|
||||
Pure data, no engine or DOM dependencies. Each band carries its
|
||||
classification, pay/SCL/media profile, and a render accent.
|
||||
|
||||
CBS pay ranges scale so the reward slider reaches up to ~2000c on standard
|
||||
emergency work (Red), with Black/Platinum running higher. */
|
||||
|
||||
export const 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: 300 },
|
||||
cbsPerSquadRange: { min: 100, max: 600 },
|
||||
mediaCoverage: "Rare / None",
|
||||
accent: "#57b3ff"
|
||||
},
|
||||
{
|
||||
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: 400 },
|
||||
cbsPerSquadRange: { min: 200, max: 900 },
|
||||
mediaCoverage: "Station Analysis / None",
|
||||
accent: "#ffdb6e"
|
||||
},
|
||||
{
|
||||
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: 150, max: 500 },
|
||||
cbsPerSquadRange: { min: 300, max: 1100 },
|
||||
mediaCoverage: "Station Analysis / Third Eye",
|
||||
accent: "#78d48f"
|
||||
},
|
||||
{
|
||||
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: 200, max: 600 },
|
||||
cbsPerSquadRange: { min: 400, max: 1300 },
|
||||
mediaCoverage: "Third Eye / Station Analysis",
|
||||
accent: "#dce8f9"
|
||||
},
|
||||
{
|
||||
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: 250, max: 700 },
|
||||
cbsPerSquadRange: { min: 500, max: 1500 },
|
||||
mediaCoverage: "Station Analysis",
|
||||
accent: "#9da6b3"
|
||||
},
|
||||
{
|
||||
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: 300, max: 850 },
|
||||
cbsPerSquadRange: { min: 600, max: 1700 },
|
||||
mediaCoverage: "Third Eye / GoreZone",
|
||||
accent: "#d7dee8"
|
||||
},
|
||||
{
|
||||
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: 350, max: 900 },
|
||||
cbsPerSquadRange: { min: 700, max: 1800 },
|
||||
mediaCoverage: "Third Eye / Contract Circuit",
|
||||
accent: "#41d0a3"
|
||||
},
|
||||
{
|
||||
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: 400, max: 1000 },
|
||||
cbsPerSquadRange: { min: 800, max: 2000 },
|
||||
mediaCoverage: "Third Eye / GoreZone",
|
||||
accent: "#ff6f6f"
|
||||
},
|
||||
{
|
||||
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: 600, max: 1500 },
|
||||
cbsPerSquadRange: { min: 1200, max: 3000 },
|
||||
mediaCoverage: "None (classified)",
|
||||
accent: "#9a7cff"
|
||||
},
|
||||
{
|
||||
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: 2000, max: null },
|
||||
mediaCoverage: "Classified / Special",
|
||||
accent: "#f5f0d7"
|
||||
}
|
||||
];
|
||||
|
||||
/* d20 issue table — how often each band lands when rolled randomly. */
|
||||
export const COLOUR_ROLL_TABLE = [
|
||||
{ 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: 19, key: "black" },
|
||||
{ min: 20, max: 20, key: "platinum" }
|
||||
];
|
||||
|
||||
export const COLOUR_BY_KEY = Object.fromEntries(COLOURS.map((c) => [c.key, c]));
|
||||
|
||||
export function colourByKey(key = "white") {
|
||||
return COLOUR_BY_KEY[String(key ?? "white").toLowerCase()] ?? COLOUR_BY_KEY.white;
|
||||
}
|
||||
Vendored
+128
@@ -0,0 +1,128 @@
|
||||
/* Vocabulary for the expanded briefing detail — cast (NPCs), OTEM (items &
|
||||
equipment), and locations. Pure data; bpn-core assembles entries from these
|
||||
with the briefing seed, so every briefing gets coherent, varied detail with
|
||||
a player-facing line and a GM-only secret.
|
||||
|
||||
"OTEM" is used here for mission-relevant items, gear, and objects of note. */
|
||||
|
||||
export const DETAIL = {
|
||||
/* ---- cast / NPCs ---- */
|
||||
npcRoles: [
|
||||
"Sector fixer", "Nervous witness", "Off-duty Shiver", "Street medic",
|
||||
"Corporate liaison", "Information broker", "Frightened bystander",
|
||||
"Local enforcer", "Black-market runner", "Maintenance technician",
|
||||
"Embedded journalist", "Karma field tech", "Departmental clerk",
|
||||
"Sponsor's handler", "Squatter elder", "Transit controller"
|
||||
],
|
||||
npcDispositions: [
|
||||
"Cooperative", "Wary", "Openly hostile", "Opportunistic", "Terrified",
|
||||
"Loyal to the issuing department", "Bitter and uncooperative",
|
||||
"Eager to please the cameras", "Quietly desperate"
|
||||
],
|
||||
npcPlayerNotes: [
|
||||
"Can point the squad to the right door if handled well.",
|
||||
"Keeps demanding to know who is paying for the damage.",
|
||||
"Has a name and a face the public already half-recognises.",
|
||||
"Will talk, but only away from the crowd.",
|
||||
"Carries a comms slate they keep glancing at.",
|
||||
"Insists they 'didn't see anything' a little too quickly.",
|
||||
"Offers help in exchange for a favour later.",
|
||||
"Is clearly waiting for someone who has not arrived."
|
||||
],
|
||||
npcSecrets: [
|
||||
"Is quietly feeding movement reports to the true employer.",
|
||||
"Knows where the real objective is and is sitting on it.",
|
||||
"Will flip to the squad's side if offered a clean way out.",
|
||||
"Is marked for removal by the opposition and does not know it.",
|
||||
"Has already sold the squad's arrival time to a third party.",
|
||||
"Is an undercover asset of a department that is not on the BPN.",
|
||||
"Plans to loot the site the moment the shooting starts.",
|
||||
"Is the actual target the GM is steering the mission toward."
|
||||
],
|
||||
npcBuilds: [
|
||||
"tall and gaunt", "short and wiry", "broad and scarred", "lean and twitchy",
|
||||
"heavyset and unhurried", "compact and precise", "stooped and tired", "rangy and restless"
|
||||
],
|
||||
npcFeatures: [
|
||||
"a battered breather mask", "a mirrored visor", "a shaved, tattooed scalp",
|
||||
"a long oilcloth coat", "a crude augmetic eye", "cropped iron-grey hair",
|
||||
"a jaw brace and old burns", "corporate-neat hair and a dead stare",
|
||||
"a hood pulled low", "a wired earpiece they keep touching"
|
||||
],
|
||||
npcHabits: [
|
||||
"never stops watching the exits", "talks too fast when cornered",
|
||||
"keeps one hand near a concealed weapon", "quotes regulations at everyone",
|
||||
"won't make eye contact", "smiles at exactly the wrong moments",
|
||||
"flinches at every comms burst", "counts the squad's gear without thinking"
|
||||
],
|
||||
npcSignificance: [
|
||||
"Holds the access the squad needs to move at all.",
|
||||
"Knows the layout better than the map command supplied.",
|
||||
"Can vouch for the squad — or burn them with one word.",
|
||||
"Is the only working line back to the contact.",
|
||||
"Controls who gets in and out of the site.",
|
||||
"Has eyes on the objective right now.",
|
||||
"Is the loose thread the whole job hangs on.",
|
||||
"Will be the first casualty if things go loud."
|
||||
],
|
||||
|
||||
/* ---- item descriptions ---- */
|
||||
otemDescriptions: [
|
||||
"Standard-issue casing, non-standard contents — somebody went to trouble over this.",
|
||||
"Looks ordinary until you notice the tamper seals have been replaced twice.",
|
||||
"Heavier than it should be, and warm to the touch.",
|
||||
"Marked with a department code that was retired years ago.",
|
||||
"Scuffed and field-worn, clearly passed through several hands.",
|
||||
"Pristine, sealed, and very obviously expensive.",
|
||||
"Jury-rigged from three other things that were never meant to meet.",
|
||||
"Quiet now, but the indicator light says it is still doing something."
|
||||
],
|
||||
|
||||
/* ---- OTEM (items & equipment) ---- */
|
||||
otemNames: [
|
||||
"Sealed data slate", "Sample canister", "Concealed weapon cache",
|
||||
"Forged clearance chip", "Departmental evidence crate", "Surveillance bug cluster",
|
||||
"Prototype component", "Encrypted comms relay", "Med-kit with restricted stims",
|
||||
"Sponsor-branded camera drone", "Maintenance master-key", "Blood-stained ID card"
|
||||
],
|
||||
otemPlayerNotes: [
|
||||
"Logged on the BPN manifest; command wants it back intact.",
|
||||
"Spotted on the contact's last report, location uncertain.",
|
||||
"Rumoured to be on-site but not officially confirmed.",
|
||||
"Will read as contraband if a Shiver scans the squad.",
|
||||
"Worth a quiet bonus to the right buyer.",
|
||||
"Fragile — rough handling will ruin its value."
|
||||
],
|
||||
otemSecrets: [
|
||||
"Is still broadcasting a locator only the GM's faction can hear.",
|
||||
"Is a decoy; the real article left the site hours ago.",
|
||||
"Is booby-trapped to wipe or detonate if opened wrong.",
|
||||
"Implicates the issuing department if it ever reaches Head Office.",
|
||||
"Is worth far more than the BPN pay and everyone on-site knows it.",
|
||||
"Matches evidence from a case SLA officially closed."
|
||||
],
|
||||
|
||||
/* ---- locations / sites ---- */
|
||||
siteNames: [
|
||||
"the loading dock", "a sub-level service gallery", "the relay/control room",
|
||||
"a collapsed access stair", "the upper catwalks", "a sealed storage bay",
|
||||
"the perimeter checkpoint", "a flooded maintenance shaft", "the lobby atrium",
|
||||
"a back-alley cut-through", "the rooftop plant deck", "an evidence locker"
|
||||
],
|
||||
sitePlayerNotes: [
|
||||
"Marked as the recommended entry point.",
|
||||
"Command flags it as the likely chokepoint.",
|
||||
"Civilian traffic runs straight through it.",
|
||||
"Reported as the contact's fallback position.",
|
||||
"Surveillance here is patchy at best.",
|
||||
"Last place the objective was logged."
|
||||
],
|
||||
siteSecrets: [
|
||||
"Has a second exit the opposition is using to flank.",
|
||||
"Is wired to seal on a silent alarm and trap whoever is inside.",
|
||||
"Hides the true objective behind a maintenance panel.",
|
||||
"Is where the escalation event will first break out.",
|
||||
"Conceals a body the issuing department wants nobody to find.",
|
||||
"Is overlooked on the official map command handed the squad."
|
||||
]
|
||||
};
|
||||
Vendored
+362
@@ -0,0 +1,362 @@
|
||||
/* Hand-authored scenario frames, two or more per colour band. Each frame is a
|
||||
coherent, ready-to-run BPN: player-facing situation plus the GM's true
|
||||
pressure point and twist. The `contactArchetype` / `threatArchetype` fields
|
||||
are deliberately retained as a seam — a future NPC/monster module can read
|
||||
them to spin up stat blocks without this module owning any engine schema. */
|
||||
|
||||
export const 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."
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function framesForColour(key = "white") {
|
||||
return FRAMES[String(key ?? "white").toLowerCase()] ?? FRAMES.white;
|
||||
}
|
||||
Vendored
+208
@@ -0,0 +1,208 @@
|
||||
/* Generic SLA flavour tables used to fill any gaps a scenario frame
|
||||
leaves open, and to drive fully-random briefings. Pure data. */
|
||||
|
||||
export const TABLES = {
|
||||
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."
|
||||
],
|
||||
|
||||
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"
|
||||
]
|
||||
};
|
||||
|
||||
/* Per-colour mission hooks — short pitched ideas keyed by band. */
|
||||
export const COLOUR_MISSION_IDEAS = {
|
||||
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."
|
||||
]
|
||||
};
|
||||
Vendored
+307
@@ -0,0 +1,307 @@
|
||||
/* Blueprint News — render layer.
|
||||
*
|
||||
* Turns a BPN object (from core) into the two separate surfaces:
|
||||
* - the Player Brief: the official corporate dispatch
|
||||
* - the GM Dossier: the handler-only truth
|
||||
*
|
||||
* Pure DOM + string building, framework-free. Functions come in two flavours:
|
||||
* briefHTML(bpn) / dossierHTML(bpn) -> markup strings (export, embedding)
|
||||
* renderBrief(bpn, el) / renderDossier(bpn, el) -> mount into an element
|
||||
*
|
||||
* The host page must include render/bpn-render.css and the bundled fonts.css.
|
||||
*/
|
||||
|
||||
import {
|
||||
crestSVG, bannerSVG, portraitSVG, siteMapSVG, npcPortraitSVG, itemIconSVG,
|
||||
crestDataURI, bannerDataURI, portraitDataURI, siteMapDataURI, npcPortraitDataURI, itemIconDataURI
|
||||
} from "../art/bpn-art.js";
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (c) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
||||
));
|
||||
}
|
||||
|
||||
/* The SLA "Third Eye" mark, used on the GM banner. */
|
||||
function thirdEyeSVG(colour = "#ff5b5b") {
|
||||
return `<svg class="bpn-eye" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M32 14 C50 14 58 28 58 32 C58 36 50 50 32 50 C14 50 6 36 6 32 C6 28 14 14 32 14 Z" fill="none" stroke="${colour}" stroke-width="3"/>
|
||||
<circle cx="32" cy="32" r="9" fill="none" stroke="${colour}" stroke-width="3"/>
|
||||
<line x1="10" y1="54" x2="54" y2="10" stroke="${colour}" stroke-width="3"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function fact(label, value) {
|
||||
return `<div><span class="bpn-label">${esc(label)}</span><span class="bpn-value">${esc(value)}</span></div>`;
|
||||
}
|
||||
|
||||
function card(label, value) {
|
||||
return `<div class="bpn-card"><span class="bpn-label">${esc(label)}</span><span class="bpn-value">${esc(value)}</span></div>`;
|
||||
}
|
||||
|
||||
/* ---------------- PLAYER BRIEF ---------------- */
|
||||
|
||||
export function briefHTML(bpn, { art = true, artMode = "inline" } = {}) {
|
||||
const c = bpn.colour;
|
||||
const img = artMode === "img";
|
||||
const banner = !art ? ""
|
||||
: img ? `<img class="bpn-hero__artimg" src="${bannerDataURI(bpn, { width: 1200, height: 360 })}" alt="">`
|
||||
: bannerSVG(bpn, { width: 1200, height: 360 });
|
||||
const crest = img ? `<img src="${crestDataURI(bpn, { size: 184 })}" alt="">` : crestSVG(bpn, { size: 184 });
|
||||
const portrait = img ? `<img src="${portraitDataURI(bpn, { size: 120 })}" alt="">` : portraitSVG(bpn, { size: 120 });
|
||||
|
||||
return `<article class="bpn-brief" style="--bpn-accent:${esc(c.accent)};">
|
||||
<div class="bpn-doc">
|
||||
<header class="bpn-hero">
|
||||
<div class="bpn-hero__art" aria-hidden="true">${banner}</div>
|
||||
<div class="bpn-stamp">Issued by<b>${esc(bpn.issuingDepartment)}</b></div>
|
||||
<div class="bpn-hero__crest">${crest}</div>
|
||||
<div class="bpn-hero__copy">
|
||||
<span class="bpn-hero__band"><b>${esc(c.label)}</b> BPN · ${esc(c.type)}</span>
|
||||
<h1 class="bpn-hero__title">${esc(bpn.shortTitle)}</h1>
|
||||
<p class="bpn-hero__sub">${esc(c.summary)}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="bpn-facts">
|
||||
${fact("BPN ID", bpn.bpnId)}
|
||||
${fact("SCL Requirement", bpn.sclRequirement)}
|
||||
${fact("Gross Contract", bpn.reward.grossContract)}
|
||||
${fact("Est. Take-home", bpn.reward.takeHomeEach)}
|
||||
</div>
|
||||
|
||||
<div class="bpn-body">
|
||||
<section>
|
||||
<h2 class="bpn-section__title">Mission Brief</h2>
|
||||
<p class="bpn-brief-text">${esc(bpn.missionBrief)}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="bpn-section__title">Objectives</h2>
|
||||
<div class="bpn-objective"><span class="bpn-label">Primary</span><span class="bpn-value">${esc(bpn.objective)}</span></div>
|
||||
<div class="bpn-objective" style="margin-top:8px;"><span class="bpn-label">Secondary</span><span class="bpn-value">${esc(bpn.extraObjective)}</span></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="bpn-section__title">Operational Picture</h2>
|
||||
<div class="bpn-grid">
|
||||
${card("Location", bpn.location)}
|
||||
${card("Atmosphere", bpn.atmosphere)}
|
||||
${card("Declared Opposition", bpn.opposition)}
|
||||
${card("Mission Focus", bpn.missionFocus)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="bpn-section__title">On-site Contact & Pressures</h2>
|
||||
<div class="bpn-contact">
|
||||
<div class="bpn-contact__ident">${portrait}</div>
|
||||
<div class="bpn-contact__detail">
|
||||
<span class="bpn-label">Primary Contact</span>
|
||||
<span class="bpn-value bpn-contact__name">${esc(bpn.contact.name)}</span>
|
||||
<span class="bpn-value">${esc(bpn.contact.role)}</span>
|
||||
<span class="bpn-contact__pos">${esc(bpn.contact.location)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bpn-grid" style="margin-top:12px;">
|
||||
${card("Civilian Pressure", bpn.civilianFaction)}
|
||||
${card("Operational Hook", bpn.hook)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="bpn-footer">
|
||||
${fact("Media Posture", bpn.reward.mediaCoverage)}
|
||||
${fact("Squad Size", `${bpn.squadSize} Operatives`)}
|
||||
${fact("SCL Increase", bpn.reward.sclIncrease)}
|
||||
${fact("Cleanup Directive", bpn.cleanupDirective)}
|
||||
</footer>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/* ---------------- PLAYER FIELD DOSSIER ---------------- */
|
||||
/* A separate player-facing document: the cast, OTEM and locations the squad
|
||||
are cleared to know, with a schematic site map. The GM-only secrets for the
|
||||
same entries live in the GM Dossier. */
|
||||
|
||||
/** A simple NPC character-sheet card: portrait, who they are, why they matter. */
|
||||
export function npcCardHTML(npc, { artMode = "inline", accent = "#9fb0c4" } = {}) {
|
||||
const art = artMode === "img"
|
||||
? `<img src="${npcPortraitDataURI(npc, { size: 168, accent })}" alt="${esc(npc.name)}">`
|
||||
: npcPortraitSVG(npc, { size: 168, accent });
|
||||
return `<div class="bpn-npc-card">
|
||||
<div class="bpn-npc-card__art">${art}</div>
|
||||
<div class="bpn-npc-card__body">
|
||||
<div class="bpn-npc-card__name">${esc(npc.name)}</div>
|
||||
<div class="bpn-npc-card__role">${esc(npc.role)} · ${esc(npc.disposition)}</div>
|
||||
<p class="bpn-npc-card__desc">${esc(npc.description)}</p>
|
||||
<div class="bpn-npc-card__sig"><span class="bpn-label">Why they matter</span>${esc(npc.significance)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** A simple item card: line-art icon, what it is, a description. */
|
||||
export function itemCardHTML(item, { artMode = "inline", accent = "#9fb0c4" } = {}) {
|
||||
const art = artMode === "img"
|
||||
? `<img src="${itemIconDataURI(item, { size: 120, accent })}" alt="${esc(item.name)}">`
|
||||
: itemIconSVG(item, { size: 120, accent });
|
||||
return `<div class="bpn-item-card">
|
||||
<div class="bpn-item-card__art">${art}</div>
|
||||
<div class="bpn-item-card__body">
|
||||
<div class="bpn-item-card__name">${esc(item.name)}</div>
|
||||
<p class="bpn-item-card__desc">${esc(item.description)}</p>
|
||||
<div class="bpn-item-card__note">${esc(item.player)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function siteRow(s, i) {
|
||||
return `<div class="bpn-detail-item">
|
||||
<div class="bpn-detail-item__name"><span class="bpn-site-no">${i + 1}</span>${esc(s.name)}</div>
|
||||
<div class="bpn-detail-item__line">${esc(s.player)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function fieldDossierHTML(bpn, { artMode = "inline" } = {}) {
|
||||
const c = bpn.colour;
|
||||
const map = artMode === "img"
|
||||
? `<img class="bpn-sitemap__img" src="${siteMapDataURI(bpn, { width: 640, height: 320 })}" alt="sector schematic">`
|
||||
: siteMapSVG(bpn, { width: 640, height: 320 });
|
||||
|
||||
return `<article class="bpn-field" style="--bpn-accent:${esc(c.accent)};">
|
||||
<div class="bpn-doc">
|
||||
<header class="bpn-field__head">
|
||||
<span class="bpn-hero__band"><b>${esc(c.label)}</b> Field Dossier</span>
|
||||
<h2 class="bpn-field__title">${esc(bpn.shortTitle)} — Operative Pack</h2>
|
||||
<p class="bpn-field__sub">Player reference: who you'll meet, what's in play, and where. Cleared for squad eyes.</p>
|
||||
</header>
|
||||
<div class="bpn-body">
|
||||
<section>
|
||||
<h2 class="bpn-section__title">Cast on the Ground</h2>
|
||||
<div class="bpn-card-grid">${bpn.cast.map((n) => npcCardHTML(n, { artMode, accent: c.accent })).join("")}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="bpn-section__title">OTEM · Items & Equipment</h2>
|
||||
<div class="bpn-card-grid bpn-card-grid--items">${bpn.otem.map((o) => itemCardHTML(o, { artMode, accent: c.accent })).join("")}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="bpn-section__title">Key Locations</h2>
|
||||
<div class="bpn-sitemap">${map}</div>
|
||||
<div class="bpn-detail-list">${bpn.sites.map(siteRow).join("")}</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/* ---------------- GM DOSSIER ---------------- */
|
||||
|
||||
export function dossierHTML(bpn, { artMode = "inline" } = {}) {
|
||||
const c = bpn.colour;
|
||||
const g = bpn.gm;
|
||||
const seam = bpn.npcSeam;
|
||||
const r = g.rolls ?? {};
|
||||
const eye = artMode === "img"
|
||||
? `<img class="bpn-eye" src="data:image/svg+xml;utf8,${encodeURIComponent(thirdEyeSVG("#ff5b5b"))}" alt="">`
|
||||
: thirdEyeSVG("#ff5b5b");
|
||||
|
||||
const seamRow = (seam.contactArchetype || seam.threatArchetype)
|
||||
? `<section class="bpn-seam">
|
||||
<p class="bpn-eyebrow">NPC / Monster Hand-off</p>
|
||||
<p class="bpn-value" style="margin:6px 0 0;">This brief reserves two cast slots for the companion NPC module:</p>
|
||||
<p style="margin:8px 0 0; line-height:1.9;">
|
||||
Contact archetype <code>${esc(seam.contactArchetype ?? "—")}</code> — ${esc(bpn.contact.name)}, ${esc(bpn.contact.role)}.<br>
|
||||
Threat archetype <code>${esc(seam.threatArchetype ?? "—")}</code> — ${esc(seam.threatRole ?? g.threatLead ?? "—")}.
|
||||
</p>
|
||||
</section>`
|
||||
: "";
|
||||
|
||||
return `<article class="bpn-dossier" style="--bpn-accent:${esc(c.accent)};">
|
||||
<div class="bpn-doc">
|
||||
<header class="bpn-dossier__banner">
|
||||
${eye}
|
||||
<h2>GM Dossier — ${esc(bpn.shortTitle)}</h2>
|
||||
<span class="bpn-eyebrow">Restricted · Handler Eyes Only</span>
|
||||
</header>
|
||||
|
||||
<div class="bpn-body">
|
||||
<div class="bpn-grid">
|
||||
<div class="bpn-reveal bpn-twist">
|
||||
<span class="bpn-label">Operational Twist</span>
|
||||
<span class="bpn-value">${esc(g.localTwist)}</span>
|
||||
</div>
|
||||
<div class="bpn-reveal">
|
||||
<span class="bpn-label">Withheld Fact</span>
|
||||
<span class="bpn-value">${esc(g.complication)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 class="bpn-section__title">True Threat Read</h2>
|
||||
<div class="bpn-card">
|
||||
<dl class="bpn-threat-read">
|
||||
<dt>Lead</dt><dd>${esc(g.threatLead || "—")}</dd>
|
||||
<dt>Numbers</dt><dd>${esc(g.threatCountLabel || "—")}</dd>
|
||||
<dt>Attitude</dt><dd>${esc(g.oppositionAttitude)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="bpn-section__title">Levers & Escalation</h2>
|
||||
<div class="bpn-grid">
|
||||
${card("Escalation Trigger", g.escalationEvent)}
|
||||
${card("Civilian Lever", bpn.civilianFaction)}
|
||||
${card("Financier Reality", g.financierNote)}
|
||||
${card("CBS Bands", `${bpn.reward.cbsBandOp} per Op / ${bpn.reward.cbsBandSquad} per Squad`)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="bpn-section__title">Cast · OTEM · Sites — GM Notes</h2>
|
||||
<div class="bpn-gmnotes">
|
||||
${bpn.cast.map((n) => `<div class="bpn-gmnote"><span class="bpn-gmnote__k">${esc(n.name)} — ${esc(n.role)}</span><span class="bpn-gmnote__v">${esc(n.gm)}</span></div>`).join("")}
|
||||
${bpn.otem.map((o) => `<div class="bpn-gmnote"><span class="bpn-gmnote__k">${esc(o.name)}</span><span class="bpn-gmnote__v">${esc(o.gm)}</span></div>`).join("")}
|
||||
${bpn.sites.map((s, i) => `<div class="bpn-gmnote"><span class="bpn-gmnote__k">${i + 1}. ${esc(s.name)}</span><span class="bpn-gmnote__v">${esc(s.gm)}</span></div>`).join("")}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${seamRow}
|
||||
|
||||
<p class="bpn-rolls">Seed ${esc(bpn.seed)} · frame ${esc(g.frameId)} · rolls: colour ${esc(r.colour ?? 0)}, pay ${esc(r.paymentType ?? 0)}, CBS ${esc(r.cbsTier ?? 0)}, SCL ${esc(r.sclTier ?? 0)}, media ${esc(r.media ?? 0)}, financier ${esc(r.financier ?? 0)}.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
/* ---------------- mount helpers ---------------- */
|
||||
|
||||
export function renderBrief(bpn, el, opts) {
|
||||
const root = typeof el === "string" ? document.querySelector(el) : el;
|
||||
if (!root) return;
|
||||
root.classList.add("bpn");
|
||||
root.innerHTML = briefHTML(bpn, opts);
|
||||
}
|
||||
|
||||
export function renderDossier(bpn, el) {
|
||||
const root = typeof el === "string" ? document.querySelector(el) : el;
|
||||
if (!root) return;
|
||||
root.classList.add("bpn");
|
||||
root.innerHTML = dossierHTML(bpn);
|
||||
}
|
||||
|
||||
export function renderField(bpn, el, opts) {
|
||||
const root = typeof el === "string" ? document.querySelector(el) : el;
|
||||
if (!root) return;
|
||||
root.classList.add("bpn");
|
||||
root.innerHTML = fieldDossierHTML(bpn, opts);
|
||||
}
|
||||
|
||||
/* Render all three surfaces into one element (the print / "all" view). */
|
||||
export function renderBoth(bpn, el, opts) {
|
||||
const root = typeof el === "string" ? document.querySelector(el) : el;
|
||||
if (!root) return;
|
||||
root.classList.add("bpn");
|
||||
root.innerHTML = briefHTML(bpn, opts) + fieldDossierHTML(bpn, opts) + dossierHTML(bpn, opts);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Zero Engine adapter for Blueprint News 0 Engine.
|
||||
*
|
||||
* Turns a generated BPN into real Zero Engine (system id `zero-engine`) NPC
|
||||
* actors — gangs, Carrien, corp security, and the named cast — with
|
||||
* attributes, hit points, armour, damage, threat level and embedded weapons.
|
||||
*
|
||||
* The actor + weapon shapes mirror the Zero Engine system's own NPC Creator
|
||||
* (SLA_NPC_TYPES / _slaWeaponData / _createNPC in zero-engine.mjs) so the
|
||||
* results are identical to what the system would build itself. Initiative is
|
||||
* rolled by the Zero Engine in combat (driven by Agility + Mobility), which
|
||||
* these actors have.
|
||||
*/
|
||||
|
||||
/* Weapon presets — copied from the Zero Engine system's _slaWeaponData. */
|
||||
const ZERO_WEAPONS = {
|
||||
pistol: { name: "Blitzer 10mm Pistol", weaponType: "Pistol", category: "ranged", damage: 2, range: "Short", rof: 2, ap: 0, magazine: 15, ammo: 15, fireModes: ["single"], cost: 300, img: "icons/weapons/guns/gun-pistol-flintlock-metal.webp" },
|
||||
rifle: { name: "M300 Assault Rifle", weaponType: "Rifle", category: "ranged", damage: 3, range: "Long", rof: 3, ap: 1, magazine: 30, ammo: 30, fireModes: ["single", "burst", "auto"], cost: 600, img: "icons/weapons/guns/rifle-bayonet.webp" },
|
||||
smg: { name: "Blitzer SMG", weaponType: "SMG", category: "ranged", damage: 2, range: "Short", rof: 3, ap: 0, magazine: 30, ammo: 30, fireModes: ["single", "auto"], cost: 450, img: "icons/weapons/guns/gun-pistol-flintlock-metal.webp" },
|
||||
heavy_melee: { name: "Power Blade", weaponType: "Blade", category: "melee", damage: 3, range: "Engaged", rof: 1, ap: 2, magazine: 0, ammo: 0, fireModes: [], cost: 400, img: "icons/weapons/swords/greatsword.webp" },
|
||||
knife: { name: "Combat Knife", weaponType: "Knife", category: "melee", damage: 2, range: "Engaged", rof: 2, ap: 0, magazine: 0, ammo: 0, fireModes: [], cost: 80, img: "icons/weapons/daggers/dagger-straight-sharp.webp" },
|
||||
baton: { name: "Shiver Baton", weaponType: "Baton", category: "melee", damage: 2, range: "Engaged", rof: 2, ap: 0, magazine: 0, ammo: 0, fireModes: [], cost: 50, img: "icons/weapons/clubs/club-simple-black.webp" },
|
||||
claws: { name: "Claws", weaponType: "Natural", category: "melee", damage: 3, range: "Engaged", rof: 2, ap: 0, magazine: 0, ammo: 0, fireModes: [], cost: 0, img: "icons/skills/melee/unarmed-punch-fist.webp" },
|
||||
blade: { name: "Mono-Edge Blade", weaponType: "Blade", category: "melee", damage: 3, range: "Engaged", rof: 2, ap: 2, magazine: 0, ammo: 0, fireModes: [], cost: 350, img: "icons/weapons/swords/sword-katana-gleaming.webp" },
|
||||
heavy_weapon: { name: "Heavy Autocannon", weaponType: "HW", category: "ranged", damage: 5, range: "Long", rof: 2, ap: 3, magazine: 20, ammo: 20, fireModes: ["burst", "auto"], cost: 2500, img: "icons/weapons/guns/gun-blunderbuss.webp" }
|
||||
};
|
||||
|
||||
function weaponItem(type) {
|
||||
const base = ZERO_WEAPONS[type] || ZERO_WEAPONS.pistol;
|
||||
const { img, ...sys } = base;
|
||||
return {
|
||||
name: base.name,
|
||||
type: "weapon",
|
||||
img: img || "icons/svg/item.svg",
|
||||
system: { ...sys, description: "", equipped: true, gearBonus: 0, initiativeMod: 0, ammoType: "standard", autoAmmoUse: 8 }
|
||||
};
|
||||
}
|
||||
|
||||
/* Stat profiles — the Zero Engine SLA_NPC_TYPES set, plus a Stormer profile
|
||||
the base set lacks. attrs clamp 2–5, armour 0–6, hp scales with threat. */
|
||||
const ZERO_PROFILES = {
|
||||
civilian: { label: "Civilian", threat: 1, attrs: { strength: 2, agility: 2, wits: 2, empathy: 2 }, skills: { observation: 1 }, hp: 4, armor: 0, weapons: [] },
|
||||
gang_grunt: { label: "Gang Grunt", threat: 1, attrs: { strength: 3, agility: 2, wits: 2, empathy: 2 }, skills: { melee: 2, force: 1 }, hp: 5, armor: 1, weapons: ["pistol", "knife"] },
|
||||
shiver: { label: "Shiver (Patrol)", threat: 2, attrs: { strength: 3, agility: 3, wits: 2, empathy: 2 }, skills: { marksmanship: 2, melee: 1, observation: 1 }, hp: 6, armor: 2, weapons: ["pistol", "baton"] },
|
||||
operative_green: { label: "SLA Operative (Green)", threat: 2, attrs: { strength: 2, agility: 3, wits: 2, empathy: 2 }, skills: { marksmanship: 2, mobility: 1, observation: 1 }, hp: 5, armor: 2, weapons: ["pistol", "knife"] },
|
||||
carrien: { label: "Carrien", threat: 2, attrs: { strength: 3, agility: 4, wits: 1, empathy: 1 }, skills: { melee: 3, mobility: 2 }, hp: 7, armor: 1, weapons: ["claws"] },
|
||||
operative_veteran: { label: "SLA Operative (Vet)", threat: 3, attrs: { strength: 3, agility: 3, wits: 3, empathy: 2 }, skills: { marksmanship: 3, melee: 2, stealth: 2, observation: 2 }, hp: 8, armor: 3, weapons: ["rifle", "pistol", "knife"] },
|
||||
stormer: { label: "Stormer", threat: 3, attrs: { strength: 4, agility: 3, wits: 2, empathy: 1 }, skills: { melee: 3, force: 2, stamina: 2 }, hp: 11, armor: 3, weapons: ["heavy_melee", "smg"] },
|
||||
dark_finder: { label: "Dark Finder", threat: 4, attrs: { strength: 4, agility: 3, wits: 3, empathy: 2 }, skills: { marksmanship: 4, melee: 3, stealth: 3, observation: 3 }, hp: 10, armor: 4, weapons: ["rifle", "pistol", "blade"] },
|
||||
manchine: { label: "Manchine", threat: 5, attrs: { strength: 5, agility: 4, wits: 3, empathy: 1 }, skills: { melee: 4, marksmanship: 3, force: 4, stamina: 4 }, hp: 14, armor: 5, weapons: ["heavy_weapon", "claws"] }
|
||||
};
|
||||
|
||||
/* BPN threat archetype → encounter group (a leader + a number of mooks). */
|
||||
const THREAT_GROUPS = {
|
||||
ganger: { leader: "gang_grunt", mook: "gang_grunt", mooks: [3, 5] },
|
||||
raider: { leader: "operative_green", mook: "gang_grunt", mooks: [3, 5] },
|
||||
killer: { leader: "operative_veteran", mook: "operative_green", mooks: [1, 2] },
|
||||
corpsec: { leader: "operative_veteran", mook: "shiver", mooks: [3, 4] },
|
||||
security: { leader: "shiver", mook: "shiver", mooks: [3, 4] },
|
||||
riot: { leader: "gang_grunt", mook: "gang_grunt", mooks: [5, 6] },
|
||||
blackops: { leader: "dark_finder", mook: "operative_veteran", mooks: [2, 3] },
|
||||
stormer: { leader: "manchine", mook: "stormer", mooks: [1, 2] },
|
||||
carrien: { leader: "carrien", mook: "carrien", mooks: [3, 5] },
|
||||
"retrieval-crew": { leader: "operative_veteran", mook: "operative_green", mooks: [2, 3] }
|
||||
};
|
||||
|
||||
/* Map a cast member's role text to a sensible non-combat profile. */
|
||||
function contactProfile(role = "") {
|
||||
const r = String(role).toLowerCase();
|
||||
if (r.includes("shiver") || r.includes("security") || r.includes("enforcer")) return "shiver";
|
||||
if (r.includes("operative") || r.includes("fixer") || r.includes("runner") || r.includes("financier")) return "operative_green";
|
||||
if (r.includes("scientist") || r.includes("tech") || r.includes("medic") || r.includes("clerk") || r.includes("controller")) return "civilian";
|
||||
return "civilian";
|
||||
}
|
||||
|
||||
/** Build a Zero Engine npc actor payload from a profile, scaled to a target
|
||||
* threat. Mirrors the system's _createNPC (derivedStats, skills, flux). */
|
||||
function npcActorData(profileKey, { threat, name, biography } = {}) {
|
||||
const profile = ZERO_PROFILES[profileKey] || ZERO_PROFILES.gang_grunt;
|
||||
const t = Math.max(1, Math.min(6, threat ?? profile.threat));
|
||||
const delta = t - profile.threat;
|
||||
const attrs = {};
|
||||
for (const [k, v] of Object.entries(profile.attrs)) attrs[k] = Math.max(2, Math.min(5, v + delta));
|
||||
const hp = Math.max(1, profile.hp + delta * 2);
|
||||
const armor = Math.max(0, Math.min(6, profile.armor + delta));
|
||||
const resolve = Math.max(2, attrs.wits + attrs.empathy);
|
||||
|
||||
return {
|
||||
actor: {
|
||||
name: name || `${profile.label} #${Math.floor(Math.random() * 900 + 100)}`,
|
||||
type: "npc",
|
||||
system: {
|
||||
biography: biography || "",
|
||||
threat: t,
|
||||
attributes: { ...attrs },
|
||||
health: { value: hp, max: hp },
|
||||
derivedStats: { health: { value: hp, max: hp }, resolve: { value: resolve, max: resolve } },
|
||||
skills: { ...profile.skills },
|
||||
armor,
|
||||
damage: Math.max(2, attrs.strength),
|
||||
flux: profile.flux ? { value: profile.flux, max: profile.flux } : { value: 0, max: 0 }
|
||||
}
|
||||
},
|
||||
// 'claws' is a natural attack reflected by the damage stat — not an item.
|
||||
weapons: (profile.weapons || []).filter((w) => w !== "claws").map(weaponItem),
|
||||
label: profile.label
|
||||
};
|
||||
}
|
||||
|
||||
function randInt(lo, hi) { return Math.floor(Math.random() * (hi - lo + 1)) + lo; }
|
||||
|
||||
/** True when actor creation is possible (running under the Zero Engine). */
|
||||
export function zeroEngineActive() {
|
||||
return game.system?.id === "zero-engine";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the BPN's NPC actors in a folder. Returns { created, folder } or
|
||||
* { created: 0, skipped: true } when not on the Zero Engine.
|
||||
*/
|
||||
export async function createZeroActors(bpn) {
|
||||
if (!zeroEngineActive()) return { created: 0, skipped: true };
|
||||
|
||||
const base = `${bpn.colour.label} BPN — ${bpn.shortTitle}`;
|
||||
const folder = await Folder.create({ name: base, type: "Actor", color: bpn.colour.accent });
|
||||
const fid = folder.id;
|
||||
|
||||
const builds = [];
|
||||
|
||||
// 1) The threat group: a named leader plus a number of mooks.
|
||||
const threatKey = bpn.npcSeam?.threatArchetype || "ganger";
|
||||
const group = THREAT_GROUPS[threatKey] || THREAT_GROUPS.ganger;
|
||||
const leaderProfile = ZERO_PROFILES[group.leader] || ZERO_PROFILES.gang_grunt;
|
||||
const leaderName = bpn.gm?.threatLead || `${leaderProfile.label} Leader`;
|
||||
builds.push(npcActorData(group.leader, {
|
||||
threat: leaderProfile.threat + 1,
|
||||
name: leaderName,
|
||||
biography: `Threat lead for ${base}.\n${bpn.opposition}\n${bpn.gm?.threatCountLabel ?? ""}`.trim()
|
||||
}));
|
||||
const mookCount = randInt(group.mooks[0], group.mooks[1]);
|
||||
const mookProfile = ZERO_PROFILES[group.mook] || ZERO_PROFILES.gang_grunt;
|
||||
for (let i = 0; i < mookCount; i++) {
|
||||
builds.push(npcActorData(group.mook, {
|
||||
name: `${mookProfile.label} ${i + 1}`,
|
||||
biography: `Part of the opposition for ${base}.`
|
||||
}));
|
||||
}
|
||||
|
||||
// 2) The named cast (skip the threat lead — already built above as leader).
|
||||
for (const npc of (bpn.cast || [])) {
|
||||
if (npc.role === "Designated threat") continue;
|
||||
builds.push(npcActorData(contactProfile(npc.role), {
|
||||
name: npc.name,
|
||||
biography: `${npc.role} · ${npc.disposition}\n${npc.description}\nSignificance: ${npc.significance}`
|
||||
}));
|
||||
}
|
||||
|
||||
// Create actors, then embed their weapons.
|
||||
let created = 0;
|
||||
for (const b of builds) {
|
||||
try {
|
||||
const actor = await Actor.create({ ...b.actor, folder: fid });
|
||||
if (actor && b.weapons.length) await actor.createEmbeddedDocuments("Item", b.weapons);
|
||||
created++;
|
||||
} catch (err) {
|
||||
console.warn("Blueprint News 0E | actor create failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return { created, folder, threatLabel: leaderProfile.label, mookCount, mookLabel: mookProfile.label };
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
/* Blueprint News — render layer styling.
|
||||
*
|
||||
* SLA Industries house look: heavy condensed uppercase titles, Roboto Mono
|
||||
* data labels, dark slate panels, a single colour-band accent per brief, thin
|
||||
* accent rules with inset glow, Third-Eye crest, stamp / redaction motifs.
|
||||
*
|
||||
* Two clearly distinct surfaces:
|
||||
* .bpn-brief — the official corporate dispatch the squad is allowed to see
|
||||
* .bpn-dossier — the handler-only truth behind it (darker, "RESTRICTED")
|
||||
*
|
||||
* Sizing is rem-based and tuned for legibility on a small running laptop:
|
||||
* body copy never drops below ~15px and stays high-contrast. Everything is
|
||||
* scoped under .bpn so it can drop into any host without bleeding. */
|
||||
|
||||
.bpn {
|
||||
/* fonts */
|
||||
--bpn-display: "Modesto Condensed", "Josefin Sans", "Oswald", sans-serif;
|
||||
--bpn-head: "Josefin Sans", system-ui, sans-serif;
|
||||
--bpn-body: "Josefin Sans", system-ui, sans-serif;
|
||||
--bpn-mono: "Roboto Mono", ui-monospace, monospace;
|
||||
|
||||
/* palette (from the SLA-BRP system) */
|
||||
--bpn-bg: #0d1117;
|
||||
--bpn-panel: #161c26;
|
||||
--bpn-panel-2: #1f2632;
|
||||
--bpn-line: #3a4250;
|
||||
--bpn-text: #eef3fa;
|
||||
--bpn-muted: #9fb0c4;
|
||||
--bpn-cream: #f4efe5;
|
||||
--bpn-gold: #d6b24e;
|
||||
--bpn-gold-deep: #b88a24;
|
||||
--bpn-danger: #ff5b5b;
|
||||
|
||||
/* per-brief accent is set inline from the colour band */
|
||||
--bpn-accent: #dce8f9;
|
||||
|
||||
color: var(--bpn-text);
|
||||
font-family: var(--bpn-body);
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.bpn *,
|
||||
.bpn *::before,
|
||||
.bpn *::after { box-sizing: border-box; }
|
||||
|
||||
/* ---------- shared atoms ------------------------------------------------- */
|
||||
|
||||
.bpn-eyebrow {
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 70%, var(--bpn-muted));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bpn-label {
|
||||
display: block;
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 50%, var(--bpn-muted));
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bpn-value { color: var(--bpn-text); font-size: 1rem; }
|
||||
|
||||
.bpn-card {
|
||||
position: relative;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
|
||||
border: 1px solid var(--bpn-line);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.bpn-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 3px;
|
||||
border-radius: 10px 0 0 10px;
|
||||
background: linear-gradient(180deg, var(--bpn-accent), transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bpn-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
/* ---------- the document shell ------------------------------------------- */
|
||||
|
||||
.bpn-doc {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--bpn-line);
|
||||
background: var(--bpn-bg);
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* ===================== PLAYER BRIEF ===================== */
|
||||
|
||||
.bpn-brief .bpn-hero {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 20px 22px;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.bpn-brief .bpn-hero__art {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
.bpn-brief .bpn-hero__art svg,
|
||||
.bpn-brief .bpn-hero__artimg { width: 100%; height: 100%; display: block; object-fit: cover; }
|
||||
|
||||
.bpn-brief .bpn-hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: linear-gradient(0deg, rgba(7, 10, 16, 0.92) 8%, rgba(7, 10, 16, 0.35) 60%, transparent);
|
||||
}
|
||||
|
||||
.bpn-hero__crest {
|
||||
width: 92px;
|
||||
height: 92px;
|
||||
flex: 0 0 auto;
|
||||
margin-right: 18px;
|
||||
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
.bpn-hero__crest svg,
|
||||
.bpn-hero__crest img { width: 100%; height: 100%; display: block; }
|
||||
|
||||
.bpn-hero__copy { min-width: 0; }
|
||||
|
||||
.bpn-hero__band {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 12px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bpn-accent) 22%, #0c1119);
|
||||
border: 1px solid color-mix(in srgb, var(--bpn-accent) 60%, transparent);
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 40%, #ffffff);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.bpn-hero__band b { color: color-mix(in srgb, var(--bpn-accent) 70%, #ffffff); }
|
||||
|
||||
.bpn-hero__title {
|
||||
font-family: var(--bpn-display);
|
||||
font-weight: 700;
|
||||
font-size: clamp(2.1rem, 6vw, 3.4rem);
|
||||
line-height: 0.92;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.bpn-hero__sub {
|
||||
margin: 6px 0 0;
|
||||
font-family: var(--bpn-head);
|
||||
color: var(--bpn-cream);
|
||||
font-size: 1.02rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
/* the corner "issued" stamp */
|
||||
.bpn-stamp {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 16px;
|
||||
z-index: 1;
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
text-align: right;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 55%, #fff);
|
||||
}
|
||||
.bpn-stamp b {
|
||||
display: block;
|
||||
padding: 2px 8px;
|
||||
margin-top: 4px;
|
||||
border: 1.5px solid color-mix(in srgb, var(--bpn-accent) 60%, transparent);
|
||||
border-radius: 4px;
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
|
||||
/* fact strip under the hero */
|
||||
.bpn-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--bpn-line);
|
||||
border-bottom: 1px solid var(--bpn-line);
|
||||
}
|
||||
.bpn-facts > div {
|
||||
background: var(--bpn-panel);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.bpn-facts .bpn-value {
|
||||
font-family: var(--bpn-mono);
|
||||
font-weight: 700;
|
||||
font-size: 1.06rem;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 22%, var(--bpn-text));
|
||||
}
|
||||
|
||||
.bpn-body { padding: 20px 22px 24px; display: grid; gap: 18px; }
|
||||
|
||||
.bpn-section__title {
|
||||
font-family: var(--bpn-head);
|
||||
font-weight: 700;
|
||||
font-size: 1.12rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 4px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--bpn-line);
|
||||
color: color-mix(in srgb, var(--bpn-accent) 30%, var(--bpn-text));
|
||||
}
|
||||
|
||||
.bpn-brief-text {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
color: var(--bpn-text);
|
||||
}
|
||||
|
||||
.bpn-objective {
|
||||
border-left: 3px solid var(--bpn-accent);
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--bpn-accent) 8%, transparent);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.bpn-contact {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
|
||||
border: 1px solid var(--bpn-line);
|
||||
}
|
||||
.bpn-contact__ident { width: 120px; height: 120px; }
|
||||
.bpn-contact__ident svg,
|
||||
.bpn-contact__ident img { width: 100%; height: 100%; display: block; }
|
||||
.bpn-contact__detail { display: grid; gap: 3px; }
|
||||
.bpn-contact__name {
|
||||
font-family: var(--bpn-head);
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 22%, var(--bpn-text));
|
||||
}
|
||||
.bpn-contact__pos { color: var(--bpn-muted); font-size: 0.95rem; margin-top: 4px; }
|
||||
|
||||
.bpn-footer {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid var(--bpn-line);
|
||||
background: var(--bpn-panel);
|
||||
font-family: var(--bpn-mono);
|
||||
}
|
||||
.bpn-footer .bpn-value { font-family: var(--bpn-head); font-size: 0.96rem; }
|
||||
|
||||
/* ===================== PLAYER FIELD DOSSIER ===================== */
|
||||
|
||||
.bpn-field__head {
|
||||
padding: 18px 22px;
|
||||
border-bottom: 1px solid var(--bpn-line);
|
||||
background:
|
||||
linear-gradient(90deg, color-mix(in srgb, var(--bpn-accent) 18%, transparent), transparent 55%),
|
||||
linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
|
||||
}
|
||||
.bpn-field__title {
|
||||
font-family: var(--bpn-display);
|
||||
font-weight: 700;
|
||||
font-size: clamp(1.7rem, 4vw, 2.4rem);
|
||||
line-height: 0.96;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
margin: 10px 0 4px;
|
||||
color: #fff;
|
||||
}
|
||||
.bpn-field__sub { margin: 0; color: var(--bpn-muted); font-size: 0.98rem; }
|
||||
|
||||
.bpn-detail-list { display: grid; gap: 10px; }
|
||||
.bpn-detail-item {
|
||||
position: relative;
|
||||
padding: 12px 14px 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
|
||||
border: 1px solid var(--bpn-line);
|
||||
}
|
||||
.bpn-detail-item::before {
|
||||
content: "";
|
||||
position: absolute; inset: 0 auto 0 0; width: 3px;
|
||||
border-radius: 10px 0 0 10px;
|
||||
background: linear-gradient(180deg, var(--bpn-accent), transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.bpn-detail-item__name {
|
||||
font-family: var(--bpn-head);
|
||||
font-weight: 700;
|
||||
font-size: 1.06rem;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 24%, var(--bpn-text));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.bpn-detail-item__role {
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bpn-muted);
|
||||
margin: 2px 0 5px;
|
||||
}
|
||||
.bpn-detail-item__line { color: var(--bpn-text); font-size: 1rem; line-height: 1.5; }
|
||||
.bpn-site-no {
|
||||
flex: 0 0 auto;
|
||||
width: 22px; height: 22px;
|
||||
display: inline-grid; place-items: center;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--bpn-accent) 24%, #0c1119);
|
||||
border: 1px solid color-mix(in srgb, var(--bpn-accent) 55%, transparent);
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bpn-sitemap { margin-bottom: 12px; }
|
||||
.bpn-sitemap svg, .bpn-sitemap__img { width: 100%; height: auto; display: block; border-radius: 10px; }
|
||||
|
||||
/* NPC + item cards */
|
||||
.bpn-card-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
.bpn-card-grid--items { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
||||
|
||||
.bpn-npc-card, .bpn-item-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
|
||||
border: 1px solid var(--bpn-line);
|
||||
}
|
||||
.bpn-npc-card { grid-template-columns: 96px 1fr; }
|
||||
.bpn-item-card { grid-template-columns: 72px 1fr; align-items: start; }
|
||||
|
||||
.bpn-npc-card__art { width: 96px; }
|
||||
.bpn-item-card__art { width: 72px; }
|
||||
.bpn-npc-card__art svg, .bpn-npc-card__art img,
|
||||
.bpn-item-card__art svg, .bpn-item-card__art img { width: 100%; height: auto; display: block; border-radius: 8px; }
|
||||
|
||||
.bpn-npc-card__name, .bpn-item-card__name {
|
||||
font-family: var(--bpn-head);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 24%, var(--bpn-text));
|
||||
}
|
||||
.bpn-npc-card__role {
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bpn-muted);
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.bpn-npc-card__desc, .bpn-item-card__desc { margin: 0 0 8px; font-size: 0.98rem; line-height: 1.5; color: var(--bpn-text); }
|
||||
.bpn-npc-card__sig .bpn-label { margin-bottom: 2px; }
|
||||
.bpn-npc-card__sig { font-size: 0.95rem; color: var(--bpn-text); }
|
||||
.bpn-item-card__note { font-family: var(--bpn-mono); font-size: 0.78rem; color: var(--bpn-muted); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bpn-npc-card { grid-template-columns: 72px 1fr; }
|
||||
.bpn-npc-card__art { width: 72px; }
|
||||
}
|
||||
|
||||
/* ===================== GM DOSSIER ===================== */
|
||||
|
||||
.bpn-dossier .bpn-doc { box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); }
|
||||
|
||||
.bpn-dossier__banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
background:
|
||||
repeating-linear-gradient(45deg, rgba(255, 91, 91, 0.16) 0 12px, transparent 12px 24px),
|
||||
#1a0f12;
|
||||
border-bottom: 2px solid var(--bpn-danger);
|
||||
}
|
||||
.bpn-dossier__banner .bpn-eye {
|
||||
width: 26px; height: 26px; flex: 0 0 auto;
|
||||
}
|
||||
.bpn-dossier__banner h2 {
|
||||
margin: 0;
|
||||
font-family: var(--bpn-display);
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #ffd9d9;
|
||||
}
|
||||
.bpn-dossier__banner .bpn-eyebrow { color: #ff9b9b; margin-left: auto; }
|
||||
|
||||
.bpn-dossier .bpn-body { padding: 18px 20px 22px; }
|
||||
|
||||
.bpn-reveal {
|
||||
position: relative;
|
||||
padding: 14px 16px 14px 18px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, #221a22, #161019);
|
||||
border: 1px solid #4a2b3a;
|
||||
}
|
||||
.bpn-reveal::before {
|
||||
content: "";
|
||||
position: absolute; inset: 0 auto 0 0; width: 3px;
|
||||
background: var(--bpn-danger);
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
.bpn-reveal .bpn-label { color: #ff9b9b; }
|
||||
|
||||
.bpn-twist { border-color: #6a2b2b; }
|
||||
.bpn-twist .bpn-label { color: var(--bpn-danger); }
|
||||
|
||||
.bpn-threat-read {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 14px;
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.bpn-threat-read dt { color: var(--bpn-muted); letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.72rem; align-self: center; }
|
||||
.bpn-threat-read dd { margin: 0; color: var(--bpn-text); }
|
||||
|
||||
/* NPC seam — clearly flagged as the hand-off point for the future module */
|
||||
.bpn-seam {
|
||||
border: 1px dashed var(--bpn-line);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.bpn-seam .bpn-eyebrow { color: var(--bpn-gold); }
|
||||
.bpn-seam code {
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.86rem;
|
||||
color: var(--bpn-gold);
|
||||
background: rgba(214, 178, 78, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.bpn-gmnotes { display: grid; gap: 8px; }
|
||||
.bpn-gmnote {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 38%) 1fr;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid var(--bpn-line);
|
||||
}
|
||||
.bpn-gmnote__k {
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 30%, var(--bpn-text));
|
||||
}
|
||||
.bpn-gmnote__v { color: var(--bpn-text); font-size: 0.96rem; }
|
||||
|
||||
.bpn-rolls {
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--bpn-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.bpn-gmnote { grid-template-columns: 1fr; gap: 3px; }
|
||||
}
|
||||
|
||||
/* ---------- responsive: small running laptop ----------------------------- */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.bpn { font-size: 15px; }
|
||||
.bpn-facts { grid-template-columns: repeat(2, 1fr); }
|
||||
.bpn-hero__crest { width: 64px; height: 64px; margin-right: 12px; }
|
||||
.bpn-brief .bpn-hero { min-height: 150px; padding: 16px; }
|
||||
.bpn-stamp { display: none; }
|
||||
.bpn-contact { grid-template-columns: 88px 1fr; gap: 12px; }
|
||||
.bpn-contact__ident { width: 88px; height: 88px; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.bpn-facts { grid-template-columns: 1fr 1fr; }
|
||||
.bpn-hero__title { font-size: clamp(1.8rem, 9vw, 2.4rem); }
|
||||
}
|
||||
|
||||
/* ---------- print / PDF export ------------------------------------------- */
|
||||
|
||||
@media print {
|
||||
.bpn { font-size: 12pt; }
|
||||
.bpn-doc { box-shadow: none; border: 1px solid #000; max-width: 100%; }
|
||||
.bpn-doc, .bpn-brief, .bpn-dossier { break-inside: avoid; }
|
||||
.bpn-dossier { margin-top: 18px; }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* SLA Blueprint News — generator window chrome + chat card.
|
||||
The briefing surfaces themselves are styled by styles/bpn-render.css. */
|
||||
|
||||
.sla-bpn-open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.sla-bpn-gen {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
font-family: "Josefin Sans", system-ui, sans-serif;
|
||||
color: #eef3fa;
|
||||
}
|
||||
|
||||
.sla-bpn-gen__rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: #11161f;
|
||||
border-right: 1px solid #2c333f;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sla-bpn-gen__field { display: grid; gap: 5px; }
|
||||
.sla-bpn-gen__field > label {
|
||||
font-family: "Roboto Mono", monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #9fb0c4;
|
||||
}
|
||||
.sla-bpn-gen__field select,
|
||||
.sla-bpn-gen__field input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid #2c333f;
|
||||
background: #0a0e14;
|
||||
color: #eef3fa;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sla-bpn-gen__row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
|
||||
.sla-bpn-gen__field input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #b88a24, #d6b24e);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sla-bpn-gen__field input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: #f4efe5; border: 2px solid #b88a24; cursor: pointer;
|
||||
}
|
||||
.sla-bpn-gen__field input[type="range"]::-moz-range-thumb {
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: #f4efe5; border: 2px solid #b88a24; cursor: pointer;
|
||||
}
|
||||
.sla-bpn-gen__readout {
|
||||
font-family: "Roboto Mono", monospace;
|
||||
font-size: 0.72rem;
|
||||
color: #d6b24e;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sla-bpn-gen__btn {
|
||||
border-radius: 7px;
|
||||
border: 1px solid #2c333f;
|
||||
background: #161c26;
|
||||
color: #eef3fa;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.sla-bpn-gen__btn:hover { filter: brightness(1.14); }
|
||||
.sla-bpn-gen__btn--primary { border-color: #b88a24; }
|
||||
.sla-bpn-gen__btn--go {
|
||||
border: 1px solid #b88a24;
|
||||
background: linear-gradient(180deg, #d6b24e, #b88a24);
|
||||
color: #1a1208;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.sla-bpn-gen__divider { height: 1px; background: #2c333f; }
|
||||
.sla-bpn-gen__hint { font-size: 0.78rem; color: #9fb0c4; line-height: 1.4; margin: 0; }
|
||||
.sla-bpn-gen__hint b { color: #d6b24e; }
|
||||
|
||||
.sla-bpn-gen__stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: #0a0e14;
|
||||
}
|
||||
.sla-bpn-gen__viewtoggle {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #2c333f;
|
||||
}
|
||||
.sla-bpn-gen__viewtoggle button {
|
||||
border: 1px solid #2c333f;
|
||||
background: #11161f;
|
||||
color: #9fb0c4;
|
||||
font-family: "Roboto Mono", monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sla-bpn-gen__viewtoggle button:first-child { border-radius: 999px 0 0 999px; }
|
||||
.sla-bpn-gen__viewtoggle button:last-child { border-radius: 0 999px 999px 0; }
|
||||
.sla-bpn-gen__viewtoggle button.is-active {
|
||||
background: linear-gradient(180deg, #d6b24e, #b88a24);
|
||||
color: #1a1208;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sla-bpn-gen__preview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* chat card */
|
||||
.bpn-chat-card {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--bpn-accent, #888);
|
||||
background: linear-gradient(180deg, #161c26, #0d1117);
|
||||
}
|
||||
.bpn-chat-card__head {
|
||||
display: grid;
|
||||
grid-template-columns: 56px 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--bpn-accent, #888) 22%, transparent), transparent 55%);
|
||||
}
|
||||
.bpn-chat-card__band {
|
||||
font-family: "Roboto Mono", monospace;
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--bpn-accent, #888) 60%, #fff);
|
||||
}
|
||||
.bpn-chat-card__title {
|
||||
font-family: "Modesto Condensed", "Josefin Sans", sans-serif;
|
||||
font-size: 1.5rem;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
.bpn-chat-card__meta { font-family: "Roboto Mono", monospace; font-size: 0.72rem; color: #9fb0c4; margin-top: 3px; }
|
||||
.bpn-chat-card__brief { padding: 10px 12px; margin: 0; color: #dfe7f1; font-size: 0.92rem; line-height: 1.45; }
|
||||
|
||||
/* keep our briefing readable inside Foundry journal sheets */
|
||||
.journal-entry-content .bpn,
|
||||
.journal-sheet .bpn,
|
||||
.prose-mirror .bpn { color: #eef3fa; }
|
||||
@@ -0,0 +1,82 @@
|
||||
<div class="sla-bpn-gen">
|
||||
<aside class="sla-bpn-gen__rail">
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>Colour Band</label>
|
||||
<select data-field="colourKey">
|
||||
{{#each colourOptions}}<option value="{{key}}" {{#if selected}}selected{{/if}}>{{label}}</option>{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>Scenario Frame</label>
|
||||
<select data-field="frameId" {{#if frameDisabled}}disabled{{/if}}>
|
||||
{{#each frameOptions}}<option value="{{id}}" {{#if selected}}selected{{/if}}>{{label}}</option>{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sla-bpn-gen__row">
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>Squad Size</label>
|
||||
<input type="number" min="1" max="8" data-field="squadSize" value="{{state.squadSize}}">
|
||||
</div>
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>Payment</label>
|
||||
<select data-field="paymentType">
|
||||
{{#each paymentOptions}}<option value="{{value}}" {{#if selected}}selected{{/if}}>{{label}}</option>{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>Operative SCL Level</label>
|
||||
<select data-field="sclLevel">
|
||||
{{#each sclOptions}}<option value="{{value}}" {{#if selected}}selected{{/if}}>{{label}}</option>{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>Reward — {{reward.basisLabel}}</label>
|
||||
<input type="range" data-slider="cbsValue" min="{{reward.cbsMin}}" max="{{reward.cbsMax}}" step="5" value="{{reward.cbsValue}}">
|
||||
<div class="sla-bpn-gen__readout" data-readout="cbs">{{reward.cbsReadout}}</div>
|
||||
</div>
|
||||
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>SCL Increase Reward</label>
|
||||
<input type="range" data-slider="sclIncrease" min="{{reward.sclMin}}" max="{{reward.sclMax}}" step="0.05" value="{{reward.sclValue}}">
|
||||
<div class="sla-bpn-gen__readout" data-readout="scli">{{reward.scliReadout}}</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="sla-bpn-gen__btn sla-bpn-gen__btn--primary" data-action="reroll">
|
||||
<i class="fas fa-dice"></i> Re-roll (new seed)
|
||||
</button>
|
||||
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>Seed</label>
|
||||
<input type="text" data-field="seed" value="{{state.seed}}" spellcheck="false">
|
||||
<div class="sla-bpn-gen__row" style="margin-top:6px;">
|
||||
<button type="button" class="sla-bpn-gen__btn" data-action="applySeed">Apply</button>
|
||||
<button type="button" class="sla-bpn-gen__btn" data-action="copySeed">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sla-bpn-gen__divider"></div>
|
||||
|
||||
<button type="button" class="sla-bpn-gen__btn sla-bpn-gen__btn--go" data-action="create">
|
||||
<i class="fas fa-file-contract"></i> Create Journal
|
||||
</button>
|
||||
<button type="button" class="sla-bpn-gen__btn" data-action="chat">
|
||||
<i class="fas fa-comment"></i> Post to Chat
|
||||
</button>
|
||||
|
||||
<p class="sla-bpn-gen__hint">Creates one journal entry with a player-facing <b>Mission Brief</b> page and a GM-only <b>Dossier</b> page.</p>
|
||||
</aside>
|
||||
|
||||
<main class="sla-bpn-gen__stage">
|
||||
<div class="sla-bpn-gen__viewtoggle">
|
||||
{{#each viewButtons}}
|
||||
<button type="button" data-action="view" data-view="{{view}}" class="{{#if active}}is-active{{/if}}">{{label}}</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
<div class="sla-bpn-gen__preview bpn">{{{previewHTML}}}</div>
|
||||
</main>
|
||||
</div>
|
||||
Reference in New Issue
Block a user