Files
npc-portrait-pack/scripts/npc-generator.mjs
T

1470 lines
65 KiB
JavaScript

// npc-generator.mjs — SLA Industries NPC Generator
// Builds ready-to-use NPC actors with stats, skills, gear, portrait, and background.
import { MODULE_ID } from './settings.mjs';
// ============================================================================
// Name tables — 485 first names + 500 surnames from SLA Industries Name Reference
// ============================================================================
// 485 SLA Industries first names (used across all human-presenting species)
const FIRST_NAMES = [
'Axel','Blade','Cain','Drex','Echo','Flux','Ghost','Havoc',
'Irek','Judas','Knox','Lash','Mace','Nox','Onyx','Punk',
'Quill','Raze','Scar','Thorn','Ulric','Vex','Wraith','Xane',
'Yael','Zane','Arc','Brix','Colt','Dusk','Edge','Fang',
'Grim','Haze','Itch','Jolt','Kron','Lurk','Mire','Nail',
'Omen','Pyre','Quin','Rust','Slag','Taunt','Urge','Void',
'Wrath','Xerx','Yank','Zero','Ash','Burn','Crag','Dread',
'Elx','Fell','Gore','Hook','Inch','Jab','Kink','Leech',
'Mort','Noose','Orge','Pox','Quake','Rift','Shiv','Taint',
'Ugly','Vain','Weld','Xray','Yore','Zeal','Abis','Bale',
'Crux','Dirge','Effi','Funk','Gust','Hilt','Ivor','Jest',
'Kade','Lore','Munk','Nave','Odis','Prod','Qade','Rend',
'Smog','Tusk','Ulna','Vark','Wisp','Xell','Yost','Zona',
'Alek','Bren','Cass','Dace','Erin','Fenn','Gale','Holt',
'Isak','Jace','Kael','Lind','Marc','Neal','Orin','Penn',
'Rand','Sage','Tane','Ulan','Venn','Ward','Xord','Yule',
'Zack','Amos','Boyd','Cole','Dale','Earl','Ford','Glen',
'Hank','Ivan','Joel','Kent','Lane','Mark','Neil','Owen',
'Paul','Reed','Seth','Todd','Umar','Wade','York','Zion',
'Arno','Bram','Cruz','Deon','Erik','Finn','Greg','Hugo',
'Ilan','Jude','Karl','Lars','Milo','Nils','Otto','Petr',
'Rolf','Sven','Theo','Ulf','Vito','Wulf','Xavi','Yuri',
'Zeno','Adan','Blas','Ciro','Dain','Egon','Falk','Gabe',
'Hein','Ingo','Jaro','Knut','Leon','Maik','Niko','Olaf',
'Pavo','Rudi','Simo','Timo','Uros','Vojo','Wolf','Xeno',
'Yann','Zaid','Alec','Beau','Cade','Dean','Evan','Frey',
'Gage','Hale','Iain','Jake','Kane','Luke','Matt','Nash',
'Omar','Pike','Raja','Shay','Trev','Vane','Xion','Yago',
'Zeke','Abel','Bink','Clex','Drax','Elen','Frex','Gron',
'Hexx','Ilex','Jinn','Klex','Lorx','Nrex','Oxel','Prex',
'Trex','Alva','Brex','Ceyx','Drey','Elox','Frox','Grex',
'Ilox','Jrex','Krex','Lrex','Mrex','Nrox','Orex','Prox',
'Trox','Abol','Brek','Cren','Drek','Erek','Frek','Grek',
'Hrek','Trek','Krek','Lrek','Mrek','Nrek','Orek','Prek',
'Srek','Urek','Vrek','Wrek','Xrek','Yrek','Zrek','Kata',
'Sora','Mira','Lyra','Vera','Nora','Dara','Zara','Lara',
'Kira','Yara','Tara','Sara','Cara','Fara','Gara','Hara',
'Jara','Alix','Blix','Clix','Elix','Flix','Glix','Hlix',
'Krix','Lrix','Mrix','Nrix','Orix','Prix','Trix','Urix',
'Vrix','Wrix','Xrix','Yrix','Zrix','Nova','Rena','Sela',
'Tela','Vela','Xela','Yela','Zela','Aela','Bela','Cela',
'Dela','Fela','Gela','Hela','Iela','Jela','Kela','Lela',
'Mela','Nela','Pela','Rela','Axia','Bria','Cria','Dria',
'Eria','Fria','Gria','Iria','Jria','Kria','Lria','Mria',
'Nria','Oria','Pria','Sria','Tria','Uria','Vria','Wria',
'Xria','Yria','Zria','Alox','Blox','Clox','Elox','Flox',
'Glox','Ilox','Jlox','Klox','Mlox','Nlox','Olox','Plox',
'Rlox','Slox','Tlox','Ulox','Vlox','Wlox','Xlox','Ylox',
'Zlox','Amox','Emox','Fmox','Gmox','Hmox','Imox','Jmox',
'Kmox','Lmox','Nmox','Omox','Pmox','Rmox','Smox','Tmox',
'Umox','Vmox','Wmox','Xmox','Ymox','Zmox','Anox','Bnox',
'Cnox','Dnox','Enox','Fnox','Gnox','Hnox','Inox','Jnox',
'Lnox','Mnox','Nnox','Onox','Pnox','Qnox','Rnox','Snox',
'Tnox','Unox','Vnox','Wnox','Xnox','Ynox','Znox','Raxx',
'Vexx','Zoxx','Paxx','Daxx','Taxx',
];
// 500 SLA-flavoured dystopian surnames
const SURNAMES = [
'Ashcroft','Blackmore','Carnage','Deathridge','Eviscer','Flayer','Gravemore','Hellgate',
'Ironside','Junkyard','Killmore','Lacemore','Meatridge','Noosemore','Overkill','Painmore',
'Quickdeath','Razormore','Slaughton','Tombmore','Undead','Vilemore','Woundmore','Xenocide',
'Yarmore','Zeromore','Abattoir','Bloodmore','Crushmore','Darkmore','Edgemore','Fleshmore',
'Gutmore','Hackmore','Icepick','Jawmore','Knifemore','Lungmore','Maggotmore','Nailmore',
'Orbmore','Pilemore','Quarrymore','Rippmore','Slagmore','Tearmore','Ulcermore','Venommore',
'Waxmore','Xoremore','Yardmore','Zoremore','Akemore','Balemore','Coldmore','Dirtmore',
'Endmore','Foulmore','Grimmore','Holemore','Inkmore','Jailmore','Ketmore','Loremore',
'Moldmore','Numbmore','Odmore','Pitmore','Quemore','Rotmore','Snagmore','Tormore',
'Ulmore','Vastmore','Wiremore','Xetmore','Yoremore','Zetmore','Aldrich','Burnwick',
'Cruelton','Darkwood','Edgerton','Frostmire','Greystone','Hollowmore','Ironwood','Jadwick',
'Killwick','Lostwick','Mirewick','Nightwick','Oddwick','Painwick','Quailwick','Rustwick',
'Skelwick','Thornwick','Umwick','Vilewick','Wormwick','Xawick','Yellwick','Zombwick',
'Ashwick','Bladewick','Corewick','Deadwick','Eelwick','Fluxwick','Grimewick','Hatewick',
'Icewick','Jawwick','Knoxwick','Lashwick','Morwick','Nailwick','Ogrewick','Poxwick',
'Quakwick','Rifwick','Slumwick','Tombwick','Urgwick','Vexwick','Waxwick','Xerwick',
'Yardwick','Zorewick','Abwick','Brixwick','Cruxwick','Dregwick','Exwick','Forwick',
'Gustwick','Hiltwick','Ivorwick','Jestwick','Kadewick','Lorewick','Munkwick','Navewick',
'Odinwick','Prodwick','Quadewick','Rendwick','Smogwick','Tuskwick','Ulnawick','Varkwick',
'Wispwick','Xellwick','Yostwick','Zonawick','Aldmore','Brenmore','Cassmore','Dacemore',
'Erinmore','Fennmore','Galemore','Holtmore','Isakmore','Jacemore','Kaelmore','Lindmore',
'Marcmore','Nealmore','Orinmore','Pennmore','Randmore','Sagemore','Tanemore','Ulanmore',
'Vennmore','Wardmore','Xordmore','Yulmore','Zackmore','Amosmore','Boydmore','Colemore',
'Dalemore','Earlmore','Fordmore','Glenmore','Hankmore','Ivanmore','Joelmore','Kentmore',
'Lanemore','Markmore','Neilmore','Owenmore','Paulmore','Reedmore','Sethmore','Toddmore',
'Umarmore','Wademore','Yorkmore','Zionmore','Arnomore','Brammore','Cruzmore','Deonmore',
'Erikmore','Finnmore','Gregmore','Hugomore','Ilanmore','Judemore','Karlmore','Larsmore',
'Milomore','Nilsmore','Ottomore','Petrmore','Rolfmore','Svenmore','Theomore','Ulfmore',
'Vitomore','Wulfmore','Xvimore','Yuriore','Zenomore','Adanmore','Blasmore','Ciromore',
'Dainmore','Egonmore','Falkmore','Gabemore','Heinmore','Ingomore','Jaromore','Knutmore',
'Leonmore','Maikmore','Nikomore','Olafmore','Pavomore','Rudimore','Simomore','Timomore',
'Urosmore','Vojomore','Wolfmore','Xenomore','Yannmore','Zaidmore','Alecmore','Beaumore',
'Cademore','Deanmore','Evanmore','Freymore','Gagemore','Halemore','Iainmore','Jakemore',
'Kanemore','Lukemore','Mattmore','Nashmore','Omarmore','Pikemore','Rajamore','Shaywick',
'Trevwick','Ulanwick','Vanewick','Wadewick','Xionwick','Yagowick','Zekewick','Abelwick',
'Binkwick','Clexwick','Draxwick','Elenwick','Frexwick','Gronwick','Hexwick','Ilexwick',
'Jinnwick','Klexwick','Lorxwick','Mnexwick','Nrexwick','Oxelwick','Prexwick','Qluxwick',
'Rexwick','Srexwick','Trexwick','Urexwick','Vrexwick','Wrexwick','Xrexwick','Yrexwick',
'Zrexwick','Alvwick','Brixton','Cexton','Drexton','Eloxton','Froxton','Grexton',
'Hrexton','Iloxton','Jrexton','Krexton','Lrexton','Mrexton','Nroxton','Orexton',
'Proxton','Qrexton','Rroxton','Sroxton','Troxton','Uroxton','Vroxton','Wroxton',
'Xroxton','Yroxton','Zroxton','Abolt','Brekon','Crendon','Drekon','Erekon',
'Frekon','Grekon','Hrekon','Irekon','Jrekon','Krekon','Lrekon','Mrekon',
'Nrekon','Orekon','Prekon','Qrekon','Rrekon','Srekon','Trekon','Urekon',
'Vrekon','Wrekon','Xrekon','Yrekon','Zrekon','Katawick','Sorawick','Mirawick',
'Lyrawick','Verawick','Norawick','Darawick','Zarawick','Larawick','Kirawick','Yarawick',
'Tarawick','Sarawick','Parawick','Barawick','Carawick','Farawick','Garawick','Harawick',
'Jarawick','Alixmore','Blixmore','Clixmore','Dlixmore','Elixmore','Flixmore','Glixmore',
'Hlixmore','Ilixmore','Jlixmore','Krixmore','Lrixmore','Mrixmore','Nrixmore','Orixmore',
'Prixmore','Qrixmore','Rrixmore','Srixmore','Trixmore','Urixmore','Vrixmore','Wrixmore',
'Xrixmore','Yrixmore','Zrixmore','Novawick','Renawick','Selawick','Telawick','Velawick',
'Welawick','Xelawick','Yelawick','Zelawick','Aelawick','Belawick','Celawick','Delawick',
'Eelawick','Felawick','Gelawick','Helawick','Ielawick','Jelawick','Kelawick','Lelawick',
'Melawick','Nelawick','Oelawick','Pelawick','Qelawick','Relawick','Briawick','Criawick',
'Driawick','Eriawick','Friawick','Griawick','Hriawick','Iriawick','Jriawick','Kriawick',
'Lriawick','Mriawick','Nriawick','Oriawick','Priawick','Sriawick','Triawick','Uriawick',
'Vriawick','Wriawick','Xriawick','Yriawick','Zriawick','Aloxwick','Bloxwick','Cloxwick',
'Eloxwick','Floxwick','Gloxwick','Hloxwick','Iloxwick','Jloxwick','Kloxwick','Mloxwick',
'Nloxwick','Oloxwick','Ploxwick','Rloxwick','Sloxwick','Tloxwick','Uloxwick','Vloxwick',
'Wloxwick','Xloxwick','Yloxwick','Zloxwick','Amoxwick','Cmoxwick','Dmoxwick','Emoxwick',
'Fmoxwick','Gmoxwick','Hmoxwick','Imoxwick','Jmoxwick','Kmoxwick','Lmoxwick','Mmoxwick',
'Nmoxwick','Omoxwick',
];
// 250 serial killer / threat identities for the Mutant / Serial Killer archetype
const SERIAL_KILLER_NAMES = [
'Jack The Dissector','The Canker Street Carver','Mother Carnage','The Pale Butcher',
'Severin Voss','The Apartment Bleeder','Grimshaw The Peeler','Sister Mortis',
'The 700 Block Ripper','Creed The Hollow','Magda Filch','The Downpipe Strangler',
'Elden Rake','The Meatpacking Saint','Nurse Lacewick','The Sump Killer',
'Doktor Vein','The Canal Street Feeder','Abner Retch','The Sleeping God',
'Kira The Opener','The Soft Tissue Sculptor','Father Gristle','The Smiling Patient',
'Oswin Craw','The Tooth Collector','Lady Carnifax','The Quiet Orderly',
'Brother Splint','The Ceiling Crawler','Yelena Dross','The Lunch Hour Killer',
'Morten Peel','The Chimney Bather','Sera The Hollow','The Duct Work Ghoul',
'Aldric Vane','The Body Farm Pilgrim','Sister Phlegm','The Corridor Creeper',
'Thane Grippe','The Overflow Wraith','Magda Vesper','The Two-Floor Terror',
'Reko The Saint','The Basement Surgeon','Brother Ache','The Office Block Phantom',
'Drace Mortum','The Substation Bleeder','Ilsa Craven','The Stairwell Whisper',
'Venan Pitch','The Loading Bay Killer','Mother Osse','The Transfer Tube Slasher',
'Kyne Reverie','The Uptown Dissector','Father Rend','The Walkway Predator',
'Shrake The Cutter','The Overflow District Flayer','Madra Vile','The Underpass Surgeon',
'Solen Craw','The Airlock Garrotter','Sister Bile','The Power Cell Butcher',
'Hektor Gnash','The Freight Elevator Killer','Brother Worm','The Recycling Centre Ripper',
'Treva Mordax','The Tenement Saint','Doktor Puss','The Night Shift Killer',
'Anya Vex','The Boiler Room Phantom','Father Crust','The Condemned Block Terror',
'Samir Dread','The Rain Collector Killer','Mother Slake','The Long Corridor Predator',
'Brix The Harvester','The Sub-Level Butcher','Sister Flay','The Cargo Hold Ripper',
'Ulan Mort','The Work Camp Killer','Brother Gore','The Pressure Lock Garrotter',
'Essa Writhe','The Tunnel System Surgeon','Doktor Scratch','The Generator Room Killer',
'Lorn Vile','The Factory Floor Flayer','Mother Dreg','The Skyway Predator',
'Kesh The Taker','The Water Tank Butcher','Father Ache','The Plaza Level Terror',
'Nira Spite','The Underworld Saint','Sister Plague','The Transit Hub Ripper',
'Drex The Silent','The Processing Plant Killer','Brother Peel','The Maintenance Shaft Ghoul',
'Cela Void','The Archive Level Predator','Doktor Husk','The Night Canteen Killer',
'Taun Fester','The Hab Block Surgeon','Mother Vex','The Overground Stalker',
'Skar The Tender','The Pit Level Flayer','Father Necrox','The Downward Killer',
'Zeal Mortis','The Sector 6 Ripper','Sister Drip','The Market Square Butcher',
'Helm Lacerate','The Pipe Run Garrotter','Brother Wither','The Off-World Killer',
'Elka Dissect','The Street Drain Predator','Doktor Flay','The Hab Level Surgeon',
'Orn The Carver','The Data Corridor Killer','Mother Grippe','The Weight Room Ripper',
'Caith Dread','The Sewer Saint','Father Craw','The Port Level Butcher',
'Vrex The Bleeder','The Sub-Street Stalker','Sister Mire','The Pump Station Killer',
'Akel Mortum','The Discharge Flayer','Brother Crust','The Skyview Garrotter',
'Pelz The Hollow','The Hunger Level Killer','Doktor Dreg','The Night Ward Surgeon',
'Thad Carnage','The Precinct Ripper','Mother Spite','The Slab Level Butcher',
'Cren Vilemore','The Under-City Predator','Father Peel','The Clearance Level Killer',
'Sora Dissect','The Waterway Stalker','Sister Bile','The Drop Zone Terror',
'Lux The Opener','The Sector 8 Ripper','Brother Husk','The Med Bay Killer',
'Ezra Mortax','The Night Crawler','Doktor Worm','The Pressure Dock Butcher',
'Hesk Carnifax','The Enclosed Space Killer','Mother Lacerate','The Underplex Ripper',
'Trix The Flayer','The Shift Change Predator','Father Spite','The Block Eight Killer',
'Vera Dissect','The Grille Work Garrotter','Sister Fester','The Chasm Level Terror',
'Orm The Carver','The Night Feed Killer','Brother Vile','The Inmate Level Flayer',
'Keld Mortum','The Quiet Hours Stalker','Doktor Spine','The Slow Kill Surgeon',
'Mace Carnage','The Sector Twelve Ripper','Mother Wither','The Grey Level Killer',
'Dusk The Bleeder','The Contract Flayer','Father Gnash','The Contract Saint',
'Rax Mortis','The Night Peel Killer','Sister Dreg','The Midnight Carver',
'Udo The Hollow','The Long Night Stalker','Brother Vex','The Archive Ripper',
'Noka Dissect','The Still Room Killer','Doktor Craven','The Sleep Level Butcher',
'Phet The Opener','The Grease Level Predator','Mother Craw','The Skin Trade Killer',
'Axel Mortax','The Null Level Ripper','Sister Spine','The Dawn Shift Butcher',
'Kyre Carnifax','The Cold Room Surgeon','Father Fester','The Lockdown Garrotter',
'Iden The Flayer','The Dead Zone Stalker','Brother Lacerate','The Sleep Cycle Killer',
'Vrax Mortum','The Zero Level Ripper','Doktor Spite','The Hab Roof Predator',
'Sela Dissect','The Dark Hours Flayer','Mother Husk','The Corridor Eight Killer',
'Rend The Bleeder','The Processing Ripper','Father Worm','The Sub Nineteen Butcher',
'Axia Carnage','The Weight Level Garrotter','Sister Craw','The Last Level Stalker',
'Keth Mortis','The Overflow Carver',
];
// 35 unique street gang handles (from 250 gang entries in the name reference)
const GANG_HANDLES = [
'Splice','Raze','Dagger','Bleed','Gouge','Hack','Spike','Crux',
'Lash','Grind','Stitch','Bite','Smash','Crack','Tear','Fray',
'Stomp','Slash','Wreck','Burn','Strip','Crush','Pound','Snap',
'Twist','Kick','Rip','Gnaw','Maul','Cleave','Gut','Shred',
'Slice','Stab','Punch',
];
// ============================================================================
// Archetype definitions
// ============================================================================
/**
* Roll an integer between min and max (inclusive).
*/
function roll(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* Pick a random element from an array.
*/
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
const ARCHETYPES = {
civilian: {
label: 'Civilian / Citizen',
icon: 'fas fa-user',
description: 'An ordinary denizen of the World of Progress — corp worker, street person, or shopkeeper.',
defaultSpecies: ['human', 'ebon'],
stats: {
strength: [20, 35],
speed: [20, 35],
intellect: [25, 40],
combat: [15, 30],
sanity: [30, 45],
fear: [30, 45],
body: [20, 35],
},
armor: [0, 1],
stress: [3, 6],
resolve: [3, 6],
skills: [
{ name: 'Streetwise', rank: 'Trained', bonus: 10 },
{ name: 'Evaluate', rank: 'Trained', bonus: 5 },
],
weapons: [
{ name: 'Pocket Knife', damage: '1d5', ammo: 0, shots: 0, useAmmo: false, description: 'A small folding knife, more tool than weapon.' },
],
backgrounds: [
'Debt-ridden corp drone grinding out a living in the lower sectors.',
'A recently downsized block resident with nothing left to lose.',
'Street vendor scraping by selling reconditioned tech.',
'Off-shift factory worker from SubUrban housing block Sub-G4.',
'Former Shivers recruit who washed out of basic training.',
'Cafeteria worker at a mid-tier Cloak Division facility.',
'Sector maintenance technician with a drinking problem.',
'A disillusioned clerk who has seen too many BPN reports.',
],
operativeType: 'Civilian',
portraitCategory: null, // use species
},
shiver: {
label: 'Shiver Operative',
icon: 'fas fa-shield-alt',
description: 'SLA Industries paramilitary police — armed response, patrol units, or Sector Rangers.',
defaultSpecies: ['human', 'human', 'human', 'ebon', 'frother'],
stats: {
strength: [30, 45],
speed: [30, 42],
intellect: [25, 38],
combat: [35, 50],
sanity: [25, 40],
fear: [25, 40],
body: [30, 45],
},
armor: [4, 6],
stress: [4, 8],
resolve: [4, 7],
skills: [
{ name: 'Firearms', rank: 'Trained', bonus: 10 },
{ name: 'Unarmed Combat', rank: 'Trained', bonus: 5 },
{ name: 'Streetwise', rank: 'Trained', bonus: 5 },
{ name: 'Interrogation', rank: 'Trained', bonus: 0 },
],
weapons: [
{ name: 'Blitzer SMG', damage: '1d10+3', ammo: 30, shots: 1, useAmmo: true, ammoType: 'SMG', description: 'Standard-issue SLA Shiver automatic weapon. Fires STD rounds.' },
{ name: 'GAU Automatic', damage: '1d10+2', ammo: 15, shots: 1, useAmmo: true, ammoType: 'Pistol', description: 'Compact sidearm issued to all Shiver operatives.' },
],
backgrounds: [
'Third-year patrol officer assigned to a mid-tier sector. Follows orders without question.',
'Armed response unit activated after a Carrien incursion, still twitchy from the last call.',
'Veteran Shiver with 12 years service, completely loyal to the Division.',
'Sector Ranger on anti-gang operations. Carries two extra magazines at all times.',
'Recently promoted to Sergeant, keen to make an impression on the squad.',
'Tasked with escorting a BPN team. Privately unhappy about the assignment.',
'Under-resourced patrol officer stretched across three blocks with a part-time partner.',
'Former Corporate Security secondee, integrating awkwardly with the Shiver unit.',
],
operativeType: 'Shiver',
portraitCategory: null,
},
carrien: {
label: 'Carrien',
icon: 'fas fa-skull-crossbones',
description: 'A feral Carrien — fast, savage, barely human. A pack threat from the deep undercity.',
defaultSpecies: ['human'],
stats: {
strength: [38, 55],
speed: [40, 58],
intellect: [5, 14],
combat: [38, 55],
sanity: [3, 10],
fear: [3, 10],
body: [32, 50],
},
armor: [0, 2],
stress: [2, 3],
resolve: [1, 2],
skills: [
{ name: 'Melee Combat', rank: 'Expert', bonus: 15 },
],
weapons: [
{ name: 'Claws & Teeth', damage: '1d10+3', ammo: 0, shots: 0, useAmmo: false, description: 'Ragged nails, broken teeth, and the desperate strength of a cornered animal.' },
],
backgrounds: [
'Feral pack-runner. Attacks on smell and movement.',
'A former citizen, now unrecognisable after years below-sector.',
'Scout for a larger Carrien pack moving through the sector walls.',
'Driven above-sector by something even it fears deeper in the undercity.',
'Territorial. Will not retreat. Will not negotiate.',
'Freshly turned — still wearing the remnants of corp worker clothes.',
],
operativeType: 'Carrien',
portraitCategory: 'animal_unknown', // Carrien → non-human animal pool
isCreatureType: true,
},
gang: {
label: 'Street Gang',
icon: 'fas fa-people-group',
description: 'A block gang cell — generates multiple members at once with a shared street name and minimal stats.',
isGangGroup: true, // triggers group-creation mode
defaultSpecies: ['human'],
// Per-member stats (combat-focused only — the rest defaults)
stats: {
strength: [25, 42],
speed: [28, 42],
intellect: [15, 25],
combat: [28, 45],
sanity: [12, 25],
fear: [12, 25],
body: [28, 45],
},
armor: [0, 3],
stress: [2, 4],
resolve: [2, 4],
skills: [
{ name: 'Melee Combat', rank: 'Trained', bonus: 10 },
],
// Gang weapons — each member gets ONE random pick from this list
gangWeapons: [
{ name: 'Power Blade', damage: '1d10+2', ammo: 0, shots: 0, useAmmo: false, description: 'A crude energy-edged blade.' },
{ name: 'Vibro-blade', damage: '1d10+1', ammo: 0, shots: 0, useAmmo: false, description: 'A buzzing, fast-edged street weapon.' },
{ name: 'Length of Pipe', damage: '1d6+3', ammo: 0, shots: 0, useAmmo: false, description: 'Blunt force trauma from a section of maintenance pipe.' },
{ name: 'Meat Cleaver', damage: '1d6+4', ammo: 0, shots: 0, useAmmo: false, description: 'Repurposed kitchen hardware.' },
{ name: 'Scrap Pistol', damage: '1d8', ammo: 6, shots: 1, useAmmo: true, ammoType: 'Pistol', description: 'Cobbled together from salvaged parts. May jam.' },
{ name: 'Sawn-off Shotgun', damage: '2d6', ammo: 2, shots: 1, useAmmo: true, ammoType: 'Shotgun', description: 'Close-range devastation. Extremely loud.' },
{ name: 'Knife', damage: '1d5+1', ammo: 0, shots: 0, useAmmo: false, description: 'A short, ugly blade worn on the hip.' },
],
operativeType: 'Gang Member',
portraitCategory: null,
},
corporate: {
label: 'Corporate Agent',
icon: 'fas fa-briefcase',
description: 'Suits, executives, corporate security, and Power Corp operatives.',
defaultSpecies: ['human', 'human', 'ebon', 'human', 'human'],
stats: {
strength: [22, 36],
speed: [22, 36],
intellect: [35, 52],
combat: [28, 42],
sanity: [32, 48],
fear: [32, 48],
body: [22, 36],
},
armor: [1, 4],
stress: [3, 7],
resolve: [4, 8],
skills: [
{ name: 'Persuasion', rank: 'Trained', bonus: 10 },
{ name: 'Evaluate', rank: 'Expert', bonus: 10 },
{ name: 'Intimidation', rank: 'Trained', bonus: 5 },
{ name: 'Concealment', rank: 'Trained', bonus: 5 },
],
weapons: [
{ name: 'Concealed Sidearm', damage: '1d8', ammo: 8, shots: 1, useAmmo: true, ammoType: 'Pistol', description: 'A compact, high-quality pistol. Discreetly holstered.' },
],
backgrounds: [
'Mid-level executive overseeing a sensitive data review. Carries a deadlock chip.',
'Corporate Security agent embedded in a BPN team to monitor SLA Operatives.',
'White Corporation representative with interests in the local sector economy.',
'Blue Corporation efficiency consultant. Currently under non-disclosure contract.',
'Power Corp negotiator — calm, well-dressed, and absolutely ruthless.',
'Corporate fixer tasked with cleaning up a situation that never officially happened.',
'Loyal company man with three promotions and no conscience.',
'Financial auditor dispatched to oversee a "restructuring" of sector resources.',
],
operativeType: 'Corporate',
portraitCategory: null,
},
mutant: {
label: 'Mutant / Serial Killer',
icon: 'fas fa-biohazard',
description: 'Mutated threats, deranged killers, and Manchine-adjacent horrors from the deep sectors.',
defaultSpecies: ['human', 'human', 'human'],
stats: {
strength: [40, 58],
speed: [22, 38],
intellect: [8, 22],
combat: [40, 58],
sanity: [5, 18],
fear: [5, 18],
body: [42, 60],
},
armor: [0, 4],
stress: [2, 4],
resolve: [1, 3],
skills: [
{ name: 'Melee Combat', rank: 'Expert', bonus: 10 },
{ name: 'Intimidation', rank: 'Expert', bonus: 5 },
],
weapons: [
{ name: 'Improvised Weapon', damage: '1d10+4', ammo: 0, shots: 0, useAmmo: false, description: 'A brutal tool of violence. Could be a length of pipe, a modified saw, or something worse.' },
],
backgrounds: [
'A deep-sector mutant driven to violence by prolonged exposure to unstable compounds.',
'Serial killer active in the lower blocks for 18 months. Three kills confirmed, others suspected.',
'Manchine-touched survivor of a containment breach. No longer fully human.',
'A former SLA worker driven mad by a classified experiment gone catastrophically wrong.',
'Something that used to be a person, before the sector chewed it up and spat it back out.',
'Subject of an illegal gene-therapy programme. Currently at large and extremely dangerous.',
'A traumatised war vet whose grip on reality dissolved years ago.',
'DarkNight insurgent pushed past the point of sanity by their conditioning.',
],
operativeType: 'Threat',
portraitCategory: 'mutant_unknown', // Serial killer / mutant pool
},
};
// ============================================================================
// Carrien Salvaged Weapons & Attacks Database
// ============================================================================
const CARRIEN_WEAPONS = [
{ name: 'Feral Claws & Bite', damage: '1d10+3', ammo: 0, shots: 0, useAmmo: false, description: 'Sharp dirt-clogged nails and jagged teeth. Aggressive close-quarters rending.' },
{ name: 'Rust-Machete', damage: '1d8+3', ammo: 0, shots: 0, useAmmo: false, description: 'A heavy piece of salvaged structural plating ground into a brutal, jagged cleaver.' },
{ name: 'Scrap-Iron Club', damage: '1d8+4', ammo: 0, shots: 0, useAmmo: false, description: 'A rusted concrete-reinforcement bar (rebar). High blunt force damage.' },
{ name: 'Rusted Harpoon', damage: '1d10+2', ammo: 0, shots: 0, useAmmo: false, description: 'A sharp, barbed sewage tip wired to a long pole. Allows reach/thrust attacks.' },
{ name: 'Spiked Chain', damage: '1d8+2', ammo: 0, shots: 0, useAmmo: false, description: 'A heavy industrial drive chain wrapped with jagged scrap and rusted bolts.' },
{ name: 'Feral Acid Spit', damage: '1d6', ammo: 0, shots: 0, useAmmo: false, description: 'A burning chemical runoff secretion. Direct corrosive biological projectile.' },
{ name: 'Scrap Blunderbuss', damage: '2d6', ammo: 1, shots: 1, useAmmo: true, ammoType: 'Shotgun', description: 'A pipe jammed with gunpowder and packed with gravel, bolts, and broken glass.' },
{ name: 'Undercity Cleaver', damage: '1d6+4', ammo: 0, shots: 0, useAmmo: false, description: 'An enormous rusted butcher blade scavenged from abandoned sector packaging plants.' },
{ name: 'Feral Shock Prod', damage: '1d8', ammo: 5, shots: 1, useAmmo: true, ammoType: 'Battery', description: 'A broken sector perimeter baton held together with copper wire. Emits electrical discharge.' },
{ name: 'Sewer Crossbow', damage: '1d10', ammo: 1, shots: 1, useAmmo: true, ammoType: 'Bolt', description: 'A heavy leaf-spring mechanical bow firing sharp scrap iron bolts.' },
{ name: 'Pneumatic Nail-Gun', damage: '1d8', ammo: 10, shots: 1, useAmmo: true, ammoType: 'Nails', description: 'Repurposed industrial nail-gun powered by a scrap gas canister.' },
{ name: 'Pipe-Bomb', damage: '2d10', ammo: 1, shots: 1, useAmmo: true, ammoType: 'Grenade', description: 'A metal pipe capped at both ends, filled with fertilizer explosive and matchheads.' },
{ name: 'Scavenged GA Pistol', damage: '1d10+2', ammo: 15, shots: 1, useAmmo: true, ammoType: 'Pistol', description: 'A standard Shiver sidearm, dirty, worn, but functional.' },
{ name: 'Razor-Wire Whip', damage: '1d6+3', ammo: 0, shots: 0, useAmmo: false, description: 'Coils of rusty barbed wire wrapped around a handle.' }
];
// ============================================================================
// SLA Industries Carrien Breeds Database
// ============================================================================
const CARRIEN_BREEDS = {
feral: {
name: 'Sewer Feral',
speciesLabel: 'Feral Carrien',
description: 'The standard, starved undercity scavenger. Highly aggressive and operates in packs.',
statMods: { strength: 0, speed: 0, intellect: 0, combat: 0, body: 0 },
skills: [{ name: 'Stealth', rank: 'Trained', bonus: 10 }],
allowedWeapons: ['Feral Claws & Bite', 'Scrap-Iron Club', 'Rust-Machete', 'Spiked Chain'],
dispositions: ['Feral', 'Starving', 'Aggressive', 'Territorial'],
threat: 'Moderate',
trait: {
name: 'Pack Tactics',
effects: 'Gain a bonus to melee attacks when another allied Carrien is close to the target.',
package: 'Carrien Breed Trait'
},
skulls: [
'a cracked pig skull tied with greasy wire',
'a yellowed dog skull with broken teeth',
'a mud-covered goat skull with one horn snapped off',
'a foul scavenger bird skull with trailing industrial ribbons'
],
heightRange: [1.5, 1.7],
weightRange: [50, 70],
ageRange: [3, 9]
},
scout: {
name: 'Undercity Scout',
speciesLabel: 'Feral Carrien',
description: 'A nimble, silent pack-runner specialized in exploring sector cracks and locating prey.',
statMods: { strength: -5, speed: 10, intellect: 2, combat: 5, body: -5 },
skills: [
{ name: 'Stealth', rank: 'Expert', bonus: 15 },
{ name: 'Observation', rank: 'Trained', bonus: 10 }
],
allowedWeapons: ['Sewer Crossbow', 'Feral Acid Spit', 'Rust-Machete'],
dispositions: ['Feral', 'Suspicious', 'Alert', 'Skittish'],
threat: 'Moderate',
trait: {
name: 'Undercity Camouflage',
effects: 'Virtually invisible in dark sewers and tunnels. Gain a +15 bonus to Stealth checks in dim light.',
package: 'Carrien Breed Trait'
},
skulls: [
'a sleek, elongated rodent skull',
'a small feral cat skull with copper wire whiskers',
'a bird skull with scrap industrial goggles wired over the eye sockets',
'a split badger skull bound in dirty insulation tape'
],
heightRange: [1.4, 1.6],
weightRange: [42, 60],
ageRange: [4, 10]
},
greater: {
name: 'Greater Hulk',
speciesLabel: 'Greater Carrien',
description: 'A massive, heavily muscled alpha breed that towers over standard feral pack-runners.',
statMods: { strength: 15, speed: -4, intellect: -2, combat: 10, body: 15 },
skills: [
{ name: 'Melee Combat', rank: 'Expert', bonus: 20 },
{ name: 'Intimidation', rank: 'Trained', bonus: 15 }
],
allowedWeapons: ['Undercity Cleaver', 'Rust-Machete', 'Rusted Harpoon', 'Scrap-Iron Club'],
dispositions: ['Aggressive', 'Hostile', 'Territorial', 'Feral'],
threat: 'High',
trait: {
name: 'Hulking Charge',
effects: 'If this creature moves at least 10 meters before a Melee hit, the target must make a Strength check or be knocked prone.',
package: 'Carrien Breed Trait'
},
skulls: [
'a huge, blood-stained bull skull with broad cracked horns',
'a massive armored boar skull with protruding rusted iron tusks',
'a thick-plated stormer head skull scavenged from a corporate landfill',
'a heavy industrial helmet welded over a split canine skull'
],
heightRange: [2.1, 2.4],
weightRange: [130, 185],
ageRange: [6, 12]
},
ursa: {
name: 'Ursa Raider',
speciesLabel: 'Ursa Carrien',
description: 'An evolved, highly intelligent strain of Carrien capable of complex tactics and mechanical salvage.',
statMods: { strength: 5, speed: 4, intellect: 12, combat: 8, body: 5 },
skills: [
{ name: 'Melee Combat', rank: 'Trained', bonus: 10 },
{ name: 'Firearms', rank: 'Trained', bonus: 10 }
],
allowedWeapons: ['Scrap Blunderbuss', 'Sewer Crossbow', 'Feral Shock Prod', 'Rust-Machete'],
dispositions: ['Cunning', 'Hostile', 'Disciplined', 'Aggressive'],
threat: 'High',
trait: {
name: 'Scrap Mastery',
effects: 'Expertly utilizes salvaged firearms. Weapons cannot jam due to critical failure, and reload speed is halved.',
package: 'Carrien Breed Trait'
},
skulls: [
'a human skull with SLA operative logo painted in white grease',
'a refined metal-reinforced dog skull with riveted copper plates',
'a split ram skull with thick coiled horns wrapped in barbed wire',
'a vintage industrial gas mask bolted directly to a pristine pig skull'
],
heightRange: [1.7, 1.9],
weightRange: [75, 95],
ageRange: [5, 14]
},
domesticated: {
name: 'Domesticated Operative',
speciesLabel: 'Advanced Carrien',
description: 'An advanced Carrien captured, trained, and conditioned by SLA to act as specialized contract support.',
statMods: { strength: 2, speed: 5, intellect: 15, combat: 10, body: 2 },
skills: [
{ name: 'Firearms', rank: 'Expert', bonus: 15 },
{ name: 'Unarmed Combat', rank: 'Trained', bonus: 10 }
],
allowedWeapons: ['Scavenged GA Pistol', 'Feral Shock Prod', 'Rust-Machete'],
dispositions: ['Disciplined', 'Professional', 'Cold', 'Alert'],
threat: 'Moderate',
trait: {
name: 'SLA Conditioning',
effects: 'Conditioned against mental fracture. Gain +15 to Resolve, and immune to Panic checks triggered by combat damage.',
package: 'Carrien Breed Trait'
},
skulls: [
'a sleek fiberglass replica skull branded with the SLA Industries seal',
'a pristine porcelain canine skull mask',
'a subdued tactical helmet with an integrated visor built into a small goat skull',
'no skull mask — wearing a standard tactical balaclava with carbon fiber cheek guards'
],
heightRange: [1.6, 1.8],
weightRange: [65, 85],
ageRange: [6, 16]
},
mutant: {
name: 'Mutant Scourge',
speciesLabel: 'Mutant Carrien',
description: 'A terrifying Carrien warped by high-level toxic sludge. Unpredictable and wildly dangerous.',
statMods: { strength: 10, speed: 6, intellect: -5, combat: 12, body: 10 },
skills: [
{ name: 'Melee Combat', rank: 'Expert', bonus: 15 },
{ name: 'Intimidation', rank: 'Expert', bonus: 15 }
],
allowedWeapons: ['Feral Claws & Bite', 'Feral Acid Spit', 'Feral Shock Prod', 'Spiked Chain'],
dispositions: ['Unstable', 'Hostile', 'Manic', 'Demented'],
threat: 'Extreme',
trait: {
name: 'Corrosive Backlash',
effects: 'When damaged by a melee slashing or piercing attack, deals 1d6 corrosive acid splash damage to the attacker.',
package: 'Carrien Breed Trait'
},
skulls: [
'a glowing, irradiated wolf skull showing weeping chemical pustules',
'a melted, half-fused mutant skull with wire staples',
'a hideous double-jawed dog skull mask',
'two bird skulls wired side-by-side to cover a bloated mutant face'
],
heightRange: [1.8, 2.1],
weightRange: [90, 130],
ageRange: [2, 6]
}
};
// ============================================================================
// Dossier & Biography generator for SLA Industries Sheet Integration
// ============================================================================
function buildDossierData(archetypeKey, species, name, background, breed = null) {
const cap = (s) => String(s ?? "").charAt(0).toUpperCase() + String(s ?? "").slice(1);
const speciesLabel = cap(species === 'human' && archetypeKey === 'carrien' ? 'Advanced Carrien' : species);
if (archetypeKey === 'carrien' && breed) {
return {
role: { value: breed.name },
archetype: { value: 'Carrien Threat' },
species: { value: breed.speciesLabel },
affiliation: { value: pick(['Hostile Sewer Swarm', 'Deep Undercity Feral Pack', 'Waste Sector Warrens', 'Undercity Marauders']) },
department: { value: pick(['Lower Sewer Grid', 'Pipeline Access Shafts', 'Abandoned Drainage Hubs']) },
sector: { value: pick(['Cannibal Sector 1', 'Cannibal Sector 2', 'Lower Sewer Levels', 'Deep Undercity']) },
disposition: { value: pick(breed.dispositions) },
threat: { value: breed.threat },
summary: { value: background }
};
}
const roles = {
civilian: ['Hab-block Clerk', 'Street Tech Merchant', 'Recycling Loader', 'Transit Gatekeeper', 'Off-duty Laborer', 'Dossier Runner'],
shiver: ['Precinct Patrol Officer', 'Armed Response Trooper', 'Cordon Watch Sergeant', 'Sector Patrol Ranger', 'Security Escort Detail'],
carrien: ['Pack Stalker', 'Undercity Runner', 'Tunnel Ambusher', 'Nest Scout', 'Nest Alpha'],
gang: ['Street Turf Lookout', 'Block Crew Muscle', 'Corner Runner', 'Street Debt Collector', 'Back-alley Brawler'],
corporate: ['Departmental Fixer', 'Karma Lead Scientist', 'Sponsor Asset Overseer', 'Case Auditor', 'Blue Corp Special Envoy'],
mutant: ['Canker Street Carver', 'Block Eight Ripper', 'Substation Bleeder', 'Duct Work Phantom', 'Basement Surgeon']
}[archetypeKey] ?? ['Dossier Subject'];
const affiliations = {
civilian: ['Mort Civil Sector', 'Block Hab Committee', 'Public Transit Utility', 'Independent Trader'],
shiver: ['SLA Industries (Shiver Division)', 'Precinct 88 Wardens', 'Sector Cordon Detail'],
carrien: ['Hostile Sewer Swarm', 'Deep Undercity Feral Pack', 'Waste Sector Marauders'],
gang: ['Chrome Jacks', 'Sump Dogs', 'Red Wire Crew', 'Murder Mile Kings', 'Ash Runners'],
corporate: ['SLA Industries Operations', 'MediCore Containment Division', 'Karma Special Projects', 'White Corp Administration'],
mutant: ['Independent / Disavowed', 'Active Outcast / Hostile', 'Unregistered Threat Profile']
}[archetypeKey] ?? ['Independent'];
const departments = {
civilian: ['District Admin', 'Sub-Level Hab Blocks', 'Transit Operations', 'Sector Maintenance Detail'],
shiver: ['Beat Patrol Squad B', 'High-Risk Tactical Unit', 'Precinct Watch Desk', 'Anti-Crime Division'],
carrien: ['Lower Pumping Station Nest', 'Maintenance Pipeline Grid', 'Deep Waste Warrens'],
gang: ['East Cut Corner Crew', 'Recycling Stack Block', 'Downtown Turf Detail', 'Monorail Corridor Raiders'],
corporate: ['Oversight Compliance Unit', 'Asset Reclamation Office', 'Research Project Containment', 'External Affairs Desk'],
mutant: ['Mutated Containment Breaker', 'Active Pattern Serial Cell', 'Feral Underlevel Scourge']
}[archetypeKey] ?? ['None'];
const sectors = {
civilian: ['Mort City Central', 'Downtown Mort', 'SubUrban District 4', 'Sector Nine Residential'],
shiver: ['Mort City Cordon', 'Downtown Precincts', 'Lower Transit Junctions', 'Perimeter Gates'],
carrien: ['Cannibal Sectors', 'Transit Underlevels', 'Deep Waste District', 'Sewer Runoff Tunnels'],
gang: ['Cannibal Sectors', 'Transit Underlevels', 'Mort Lower Tunnels', 'Industrial Belt Edge'],
corporate: ['Corporate High-SCL Arc', 'Downtown Executive Plaza', 'Bonded Warehouse Sectors'],
mutant: ['Cannibal Sectors', 'Transit Underlevels', 'Abandoned Tenement Block 8', 'Waste District']
}[archetypeKey] ?? ['Mort City'];
const dispositions = {
civilian: ['Nervous', 'Suspicious', 'Quiet', 'Helpful', 'Terrified'],
shiver: ['Professional', 'Alert', 'Disciplined', 'Aggressive', 'Suspicious'],
carrien: ['Feral', 'Hostile', 'Territorial', 'Starving', 'Aggressive'],
gang: ['Aggressive', 'Hostile', 'Suspicious', 'Fanatical', 'Cornered'],
corporate: ['Professional', 'Cold', 'Polite', 'Ruthless', 'Calculating'],
mutant: ['Unstable', 'Hostile', 'Fanatical', 'Manic', 'Demented']
}[archetypeKey] ?? ['Neutral'];
const threats = {
civilian: ['Low'],
shiver: ['Moderate', 'High'],
carrien: ['Moderate', 'High'],
gang: ['Moderate'],
corporate: ['Moderate', 'High'],
mutant: ['High', 'Extreme']
}[archetypeKey] ?? ['Moderate'];
const role = pick(roles);
const affiliation = pick(affiliations);
const department = pick(departments);
const sector = pick(sectors);
const disposition = pick(dispositions);
const threat = pick(threats);
return {
role: { value: role },
archetype: { value: ARCHETYPES[archetypeKey]?.label ?? cap(archetypeKey) },
species: { value: speciesLabel },
affiliation: { value: affiliation },
department: { value: department },
sector: { value: sector },
disposition: { value: disposition },
threat: { value: threat },
summary: { value: background }
};
}
const SPECIES_OPTIONS = [
{ value: 'random', label: 'Random (archetype-appropriate)' },
{ value: 'human', label: 'Human' },
{ value: 'ebon', label: 'Ebon' },
{ value: 'frother', label: 'Frother' },
{ value: 'shaktar', label: 'Shaktar' },
{ value: 'stormer', label: 'Stormer' },
{ value: 'waister', label: 'Waister' },
{ value: 'wraith', label: 'Wraith' },
];
// ============================================================================
// Name generation
// ============================================================================
function generateName(species, archetypeKey, breed = null) {
// Serial killers / mutants get a pre-built infamous identity
if (archetypeKey === 'mutant') {
return pick(SERIAL_KILLER_NAMES);
}
// Carrien names are quick animalistic gutter tags
if (archetypeKey === 'carrien') {
const carrienNames = ['Gnash', 'Skitter', 'Slink', 'Bite', 'Rip', 'Tear', 'Gouge', 'Mire', 'Slime', 'Pox', 'Sewer', 'Drip', 'Claw', 'Feral', 'Snarl', 'Howl', 'Gore', 'Barrow', 'Sump', 'Drain', 'Grime', 'Scrap', 'Spit', 'Rust', 'Fang', 'Shred', 'Mangle', 'Carcass', 'Vulture', 'Rot'];
return pick(carrienNames);
}
// Stormer names are alphanumeric designations
if (species === 'stormer') {
const designations = ['Storm','Bravo','Vector','Apex','Delta','Echo','Grid','Havoc','Iron','Jackal','Kilo','Lima'];
return `${pick(designations)}-${roll(1,9)}${roll(0,9)}`;
}
// All other species: pick from the 485-name first-name pool + 500 surnames
const firstName = pick(FIRST_NAMES);
const surname = pick(SURNAMES);
return `${firstName} ${surname}`;
}
// ============================================================================
// Stat generation
// ============================================================================
function generateStats(archetype) {
const def = ARCHETYPES[archetype];
const stats = {};
for (const [key, [min, max]] of Object.entries(def.stats)) {
stats[key] = { value: roll(min, max), mod: 0 };
}
return stats;
}
// ============================================================================
// Item creation helpers
// ============================================================================
function makeSkillItemData(skill) {
return {
name: skill.name,
type: 'skill',
system: {
rank: skill.rank,
bonus: skill.bonus ?? 0,
sla: { category: '', skillFamily: '', source: 'Generated' },
description: '',
},
};
}
function makeWeaponItemData(weapon) {
return {
name: weapon.name,
type: 'weapon',
system: {
damage: weapon.damage ?? '1d10',
ammo: weapon.ammo ?? 0,
shots: weapon.shots ?? 1,
curShots: weapon.ammo ?? 0,
shotsPerFire: 1,
useAmmo: weapon.useAmmo ?? false,
ammoType: weapon.ammoType ?? '',
ammoCalibre: '',
ammoTag: 'STD',
ammoLoadedType: 'STD',
ammoReserve: weapon.ammo ?? 0,
ammoReserveStd: weapon.ammo ?? 0,
ammoReserveAp: 0,
ammoReserveHe: 0,
ammoReserveHeap: 0,
wound: '',
critDmg: '',
woundEffect: '',
description: weapon.description ?? '',
},
};
}
// ============================================================================
// Main generation function
// ============================================================================
/**
* Generate one NPC actor document.
* @param {string} archetypeKey - Key from ARCHETYPES
* @param {string} speciesChoice - 'random' or a specific species
* @param {PortraitManager} manager
* @param {string|null} folderId
* @returns {Promise<Actor>}
*/
async function generateNpc(archetypeKey, speciesChoice, manager, folderId) {
const def = ARCHETYPES[archetypeKey];
const isCreature = def?.isCreatureType ?? (archetypeKey === 'mutant');
// Resolve species
const species = speciesChoice === 'random'
? pick(def.defaultSpecies)
: speciesChoice;
// Resolve breed details if Carrien
let breed = null;
let carrienSkills = [...def.skills];
if (archetypeKey === 'carrien') {
const breedKey = pick(Object.keys(CARRIEN_BREEDS));
breed = CARRIEN_BREEDS[breedKey];
}
// Generate name
const name = generateName(species, archetypeKey, breed);
// Generate stats
const stats = generateStats(archetypeKey);
// Apply breed-specific stat modifiers and skills if Carrien
if (breed) {
for (const [statKey, mod] of Object.entries(breed.statMods)) {
if (stats[statKey]) {
stats[statKey].value = Math.max(3, stats[statKey].value + mod);
}
}
if (breed.skills) {
for (const bs of breed.skills) {
if (!carrienSkills.some(s => s.name === bs.name)) {
carrienSkills.push(bs);
}
}
}
}
// Health = body stat (simplified; Foundry system will recalculate in some builds)
const bodyVal = stats.body?.value ?? 30;
const healthVal = Math.max(5, Math.floor(bodyVal / 2));
// Armor
const armorVal = roll(def.armor[0], def.armor[1]);
// Stress / Resolve
const stressMax = roll(def.stress[0], def.stress[1]);
const resolveMax = roll(def.resolve[0], def.resolve[1]);
// Pick portrait
let portraitPath = null;
const portraitCat = def.portraitCategory;
if (manager.portraits.length > 0) {
// Broad type map: species → human | mutant | animal (matches portrait-manager.mjs)
const SPECIES_TO_TYPE = {
human: 'human', ebon: 'human', stormer: 'human',
frother: 'mutant',
shaktar: 'animal', waister: 'animal', wraith: 'animal',
};
let portrait;
if (portraitCat) {
// Archetype has an explicit override (carrien → animal_unknown, mutant → mutant_unknown)
portrait = manager.getRandomPortrait({ category: portraitCat });
} else if (isCreature) {
portrait = manager.getRandomPortrait({ category: 'animal_unknown' });
} else {
// Map species to broad type, pick gendered or unknown pool
const broadType = SPECIES_TO_TYPE[species] ?? 'human';
// Try gendered first (both male + female), then fall back to unknown
portrait = manager.getRandomPortrait({
category: [`${broadType}_male`, `${broadType}_female`, `${broadType}_unknown`],
});
}
portraitPath = portrait?.path ?? null;
}
// Generate detailed dossier variables (gender, age, height, weight, SCL, credits, background description)
let gender = pick(['Male', 'Female', 'Undetermined']);
let age = roll(18, 55);
let height = `${(roll(160, 195) / 100).toFixed(2)}m`;
let weight = `${roll(60, 95)}kg`;
let scl = '10';
let credits = roll(20, 150);
let background = pick(def.backgrounds);
if (archetypeKey === 'carrien' && breed) {
gender = pick(['Male', 'Female', 'Undetermined']);
age = roll(breed.ageRange[0], breed.ageRange[1]);
height = `${(roll(breed.heightRange[0] * 100, breed.heightRange[1] * 100) / 100).toFixed(2)}m`;
weight = `${roll(breed.weightRange[0], breed.weightRange[1])}kg`;
scl = 'None';
credits = 0;
const skull = pick(breed.skulls);
background = `A feral Carrien of the ${breed.name} breed. Standing roughly ${height} and weighing ${weight}, this creature represents the primal terror of Mort's underlevels. It wears ${skull} to mask its expressive, human-like features. It is currently acting with a ${breed.threat.toLowerCase()}-threat profile. Behavior is noted as extremely ${pick(breed.dispositions).toLowerCase()} and aggressive when cornered.`;
} else if (archetypeKey === 'shiver') {
scl = pick(['9', '9', '9', '8']); // 8 is Sergeant
credits = roll(100, 450);
} else if (archetypeKey === 'corporate') {
scl = pick(['7', '7', '6', '5']);
credits = roll(1000, 5000);
} else if (archetypeKey === 'mutant') {
scl = 'Outlaw';
credits = roll(0, 20);
height = `${(roll(180, 240) / 100).toFixed(2)}m`;
weight = `${roll(85, 150)}kg`;
} else if (archetypeKey === 'civilian') {
scl = '10';
credits = roll(10, 150);
}
// Generate dossier data
const dossier = buildDossierData(archetypeKey, species, name, background, breed);
// Actor type: mutant/creature uses 'creature', everything else uses 'character'
const actorType = isCreature ? 'creature' : 'character';
// Build actor data
const actorData = {
name,
type: actorType,
img: portraitPath ?? 'icons/svg/mystery-man.svg',
folder: folderId ?? null,
system: {
health: { value: healthVal, min: 0, max: healthVal },
biography: background,
notes: `Archetype: ${def.label}\nSpecies: ${species}\nOperative Type: ${def.operativeType}`,
// System-compatible dossier parameters
details: {
age: String(age),
height: String(height),
weight: String(weight),
scl: String(scl),
credits: Number(credits)
},
bio: {
species: species.charAt(0).toUpperCase() + species.slice(1),
scl: String(scl),
background: background,
appearance: breed ? `Wears ${pick(breed.skulls)}. Slouched, feral posture.` : `Civilian clothing, typical of Lower Mort sectors.`,
notes: `Threat Profile: ${dossier.threat.value}`
},
finance: {
credits: Number(credits),
unis: 0,
lad: 0
},
gender: gender,
age: age,
height: height,
weight: weight,
wealth: Number(credits),
stats: {
...stats,
armor: { value: armorVal, mod: armorVal, damageReduction: archetypeKey === 'shiver' ? 1 : (archetypeKey === 'mutant' ? 2 : 0), enabled: true },
combat: { ...stats.combat, enabled: true },
instinct: { ...stats.instinct, enabled: true },
speed: { ...stats.speed, enabled: true },
strength: { ...stats.strength, enabled: true },
sanity: { ...stats.sanity, enabled: true },
fear: { ...stats.fear, enabled: true },
body: { ...stats.body, enabled: true }
},
other: {
stress: { value: 2, min: 2, max: stressMax },
resolve: { value: resolveMax, min: 0, max: resolveMax },
},
sla: {
operativeType: { value: def.operativeType },
species: { value: species.charAt(0).toUpperCase() + species.slice(1) },
scl: String(scl),
credits: Number(credits)
},
slaNpc: dossier
},
prototypeToken: {
name,
disposition: CONST.TOKEN_DISPOSITIONS.HOSTILE,
texture: { src: portraitPath ?? 'icons/svg/mystery-man.svg' },
sight: { enabled: false },
actorLink: false,
randomImg: false,
},
};
// Create actor
const actor = await Actor.create(actorData);
if (!actor) return null;
// Add armor stat update (armor is a stat on the character)
await actor.update({ 'system.stats.armor.value': armorVal });
// Resolve weapons
let npcWeapons = [...def.weapons];
if (archetypeKey === 'carrien' && breed) {
// Carriens get 1 to 2 random scrap weapons/attacks based on breed!
const allowed = breed.allowedWeapons;
const w1Name = pick(allowed);
const weapon1 = CARRIEN_WEAPONS.find(w => w.name === w1Name) || CARRIEN_WEAPONS[0];
let weapon2 = null;
if (Math.random() > 0.4 && allowed.length > 1) {
let w2Name = pick(allowed);
while (w2Name === w1Name) {
w2Name = pick(allowed);
}
weapon2 = CARRIEN_WEAPONS.find(w => w.name === w2Name);
}
npcWeapons = weapon2 ? [weapon1, weapon2] : [weapon1];
}
// Create embedded item documents (skills + weapons + breed traits!)
const validItemTypes = CONFIG.Item?.documentClass?.TYPES ?? [];
const hasType = (t) => validItemTypes.includes(t);
const itemsToCreate = [];
if (hasType('skill')) {
itemsToCreate.push(...carrienSkills.map(s => makeSkillItemData(s)));
}
if (hasType('weapon')) {
itemsToCreate.push(...npcWeapons.map(w => makeWeaponItemData(w)));
}
if (archetypeKey === 'carrien' && breed?.trait) {
// Zero-Engine specialty
if (hasType('specialty')) {
itemsToCreate.push({
name: breed.trait.name,
type: 'specialty',
img: 'icons/svg/star.svg',
system: {
effects: breed.trait.effects,
package: breed.trait.package,
category: 'general',
prerequisites: '',
healthMod: 0,
resolveMod: 0,
isActive: true
}
});
}
// S5S / Sla-Industries trait
if (hasType('trait')) {
itemsToCreate.push({
name: breed.trait.name,
type: 'trait',
img: 'icons/svg/statue.svg',
system: {
description: breed.trait.effects,
category: 'combat',
source: 'Carrien Breed'
}
});
}
// SLA-Mothership ability
if (hasType('ability')) {
itemsToCreate.push({
name: breed.trait.name,
type: 'ability',
img: 'icons/svg/bolt.svg',
system: {
text: breed.trait.effects,
roll: '',
sla: {
abilityType: 'special',
discipline: '',
fluxCost: 0,
tier: 'basic',
attack: false,
notes: [breed.trait.effects]
}
}
});
}
}
if (itemsToCreate.length > 0) {
await actor.createEmbeddedDocuments('Item', itemsToCreate);
}
return actor;
}
// ============================================================================
// Gang group generation — creates a batch of lightweight grunt actors
// ============================================================================
/**
* Generate a street gang cell of `count` members.
* Each member gets one name, minimal combat stats, and one random weapon.
* @param {number} count
* @param {PortraitManager} manager
* @param {string|null} folderId
* @returns {Promise<Actor[]>}
*/
async function generateGangMembers(count, manager, folderId) {
const def = ARCHETYPES.gang;
const created = [];
// Track used handles in this batch to avoid duplicates
const usedHandles = new Set();
for (let i = 0; i < count; i++) {
// Pick a unique handle for this member
let handle;
const availableHandles = GANG_HANDLES.filter(h => !usedHandles.has(h));
if (availableHandles.length > 0) {
handle = pick(availableHandles);
usedHandles.add(handle);
} else {
// Pool exhausted — append a number
handle = `${pick(GANG_HANDLES)}-${i + 1}`;
}
// Roll minimal combat-relevant stats only
const combatVal = roll(def.stats.combat[0], def.stats.combat[1]);
const bodyVal = roll(def.stats.body[0], def.stats.body[1]);
const speedVal = roll(def.stats.speed[0], def.stats.speed[1]);
const strengthVal = roll(def.stats.strength[0],def.stats.strength[1]);
const healthVal = Math.max(4, Math.floor(bodyVal / 3));
const armorVal = roll(def.armor[0], def.armor[1]);
// Gang members are street humans — draw from human pool
let portraitPath = null;
if (manager.portraits.length > 0) {
const p = manager.getRandomPortrait({ category: ['human_male', 'human_female', 'human_unknown'] });
portraitPath = p?.path ?? null;
}
// Pick a random weapon from the gang weapons list
const weapon = pick(def.gangWeapons);
// Generate background & dossier data
const background = `Street gang member. No past worth mentioning.`;
const dossier = buildDossierData('gang', 'human', handle, background);
const actorData = {
name: handle,
type: 'character',
img: portraitPath ?? 'icons/svg/mystery-man.svg',
folder: folderId ?? null,
system: {
health: { value: healthVal, min: 0, max: healthVal },
biography: background,
notes: `Archetype: Street Gang\nArmor: ${armorVal}\nCombat: ${combatVal}`,
stats: {
strength: { value: strengthVal, mod: 0, enabled: true },
speed: { value: speedVal, mod: 0, enabled: true },
intellect: { value: roll(15, 25), mod: 0, enabled: true },
combat: { value: combatVal, mod: 0, enabled: true },
sanity: { value: roll(12, 22), mod: 0, enabled: true },
fear: { value: roll(12, 22), mod: 0, enabled: true },
body: { value: bodyVal, mod: 0, enabled: true },
armor: { value: armorVal, mod: armorVal, damageReduction: 0, enabled: true },
},
other: {
stress: { value: 2, min: 2, max: roll(2, 4) },
resolve: { value: 2, min: 0, max: roll(2, 4) },
},
sla: {
operativeType: { value: 'Gang Member' },
species: { value: 'Human' },
},
slaNpc: dossier
},
prototypeToken: {
name,
disposition: CONST.TOKEN_DISPOSITIONS.HOSTILE,
texture: { src: portraitPath ?? 'icons/svg/mystery-man.svg' },
sight: { enabled: false },
actorLink: false,
randomImg: false,
},
};
const actor = await Actor.create(actorData);
if (!actor) continue;
// Embed the weapon item if valid for this system
const validItemTypes = CONFIG.Item?.documentClass?.TYPES ?? [];
if (validItemTypes.includes('weapon')) {
await actor.createEmbeddedDocuments('Item', [ makeWeaponItemData(weapon) ]);
}
created.push(actor);
}
return created;
}
// ============================================================================
// UI helpers
// ============================================================================
/**
* Show/hide the gang-size row and the standard count/species rows
* depending on whether the 'gang' archetype is selected.
* @param {HTMLElement} root
* @param {string} archetypeKey
*/
function _toggleGangRow(root, archetypeKey) {
const isGang = ARCHETYPES[archetypeKey]?.isGangGroup ?? false;
const gangRow = root.querySelector('.gen-gang-size-row');
const countRow = root.querySelector('.gen-count-species-row');
const speciesRow = root.querySelector('.gen-species-field');
if (gangRow) gangRow.style.display = isGang ? '' : 'none';
if (countRow) countRow.style.display = isGang ? 'none' : '';
if (speciesRow) speciesRow.style.display = isGang ? 'none' : '';
}
// ============================================================================
// NpcGeneratorDialog — FormApplication / ApplicationV2 adaptive dialog
// ============================================================================
function buildGeneratorClass() {
if (foundry.applications?.api?.ApplicationV2) {
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
return class NpcGeneratorDialog extends HandlebarsApplicationMixin(ApplicationV2) {
constructor(manager, opts = {}) {
super(opts);
this.manager = manager;
this._busy = false;
this._lastLog = [];
}
static DEFAULT_OPTIONS = {
id: 'npc-generator-dialog',
tag: 'form',
classes: ['npc-portrait-pack', 'npc-generator'],
window: {
title: 'SLA Industries NPC Generator',
resizable: true,
},
position: { width: 480, height: 'auto' },
form: {
handler: NpcGeneratorDialog._onGenerate,
submitOnChange: false,
closeOnSubmit: false,
},
};
static PARTS = {
form: {
template: `modules/${MODULE_ID}/templates/npc-generator.hbs`,
}
};
async _prepareContext(_opts) {
return {
archetypes: Object.entries(ARCHETYPES).map(([k, v]) => ({ value: k, label: v.label, icon: v.icon, description: v.description })),
species: SPECIES_OPTIONS,
log: this._lastLog,
};
}
_onRender(context, opts) {
super._onRender?.(context, opts);
const root = this.element;
// Archetype card selection → update description + toggle gang-size row
root.querySelectorAll('[name="archetype"]').forEach(radio => {
radio.addEventListener('change', () => {
const key = radio.value;
const desc = ARCHETYPES[key]?.description ?? '';
const el = root.querySelector('.archetype-description');
if (el) el.textContent = desc;
// Toggle selected class
root.querySelectorAll('.archetype-card').forEach(c => c.classList.remove('selected'));
radio.closest('.archetype-card')?.classList.add('selected');
// Show gang-size row only when gang is selected
_toggleGangRow(root, key);
});
});
// Set initial state
const checked = root.querySelector('[name="archetype"]:checked');
if (checked) _toggleGangRow(root, checked.value);
}
static async _onGenerate(event, form, formData) {
if (this._busy) return;
this._busy = true;
this._lastLog = [];
const data = foundry.utils.expandObject(formData.object);
const archetype = data.archetype ?? 'civilian';
const species = data.species ?? 'random';
const folderId = data.folderId || null;
const def = ARCHETYPES[archetype];
let created = [];
if (def?.isGangGroup) {
// Street gang — batch creation of grunts
const gangSize = Math.max(2, Math.min(20, parseInt(data.gangSize ?? 5)));
created = await generateGangMembers(gangSize, this.manager, folderId);
for (const a of created) {
this._lastLog.push(`✓ ${a.name} (Gang Member)`);
}
} else {
// Individual NPC(s)
const count = Math.max(1, Math.min(10, parseInt(data.count ?? 1)));
for (let i = 0; i < count; i++) {
const actor = await generateNpc(archetype, species, this.manager, folderId);
if (actor) {
created.push(actor);
this._lastLog.push(`✓ Created: ${actor.name} (${def?.label ?? archetype})`);
} else {
this._lastLog.push(`✗ Failed to create NPC ${i + 1}`);
}
}
}
this._busy = false;
ui.notifications.info(`NPC Generator: ${created.length} actor${created.length !== 1 ? 's' : ''} created.`);
await this.render();
}
};
}
// Foundry v11/v12 — FormApplication fallback
return class NpcGeneratorDialog extends FormApplication {
constructor(manager) {
super();
this.manager = manager;
this._busy = false;
this._lastLog = [];
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: 'npc-generator-dialog',
title: 'SLA Industries NPC Generator',
template: `modules/${MODULE_ID}/templates/npc-generator.hbs`,
width: 480,
height: 'auto',
resizable: true,
classes: ['npc-portrait-pack', 'npc-generator'],
});
}
getData() {
return {
archetypes: Object.entries(ARCHETYPES).map(([k, v]) => ({ value: k, label: v.label, icon: v.icon, description: v.description })),
species: SPECIES_OPTIONS,
log: this._lastLog,
};
}
activateListeners(html) {
super.activateListeners(html);
const root = html[0];
// Update description text + toggle gang row when archetype changes
html.find('[name="archetype"]').on('change', (ev) => {
const key = ev.currentTarget.value;
const desc = ARCHETYPES[key]?.description ?? '';
html.find('.archetype-description').text(desc);
_toggleGangRow(root, key);
// Toggle selected class
html.find('.archetype-card').removeClass('selected');
$(ev.currentTarget).closest('.archetype-card').addClass('selected');
});
// Set initial state
const checked = root.querySelector('[name="archetype"]:checked');
if (checked) _toggleGangRow(root, checked.value);
}
async _updateObject(_event, formData) {
if (this._busy) return;
this._busy = true;
this._lastLog = [];
const archetype = formData.archetype ?? 'civilian';
const species = formData.species ?? 'random';
const folderId = formData.folderId || null;
const def = ARCHETYPES[archetype];
let created = [];
if (def?.isGangGroup) {
const gangSize = Math.max(2, Math.min(20, parseInt(formData.gangSize ?? 5)));
created = await generateGangMembers(gangSize, this.manager, folderId);
for (const a of created) {
this._lastLog.push(`✓ ${a.name} (Gang Member)`);
}
} else {
const count = Math.max(1, Math.min(10, parseInt(formData.count ?? 1)));
for (let i = 0; i < count; i++) {
const actor = await generateNpc(archetype, species, this.manager, folderId);
if (actor) {
created.push(actor);
this._lastLog.push(`✓ Created: ${actor.name} (${def?.label ?? archetype})`);
} else {
this._lastLog.push(`✗ Failed to create NPC ${i + 1}`);
}
}
}
this._busy = false;
ui.notifications.info(`NPC Generator: ${created.length} actor${created.length !== 1 ? 's' : ''} created.`);
this.render(true);
}
};
}
export const NpcGeneratorDialog = buildGeneratorClass();
// Export archetype data for external use
export { ARCHETYPES, SPECIES_OPTIONS, generateNpc };