1470 lines
65 KiB
JavaScript
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 };
|