Files
sla_industries_MS/module/windows/sla-npc-generator.js
T
tevans 24f513baf8 NPC Desk: archetype accents, live preview card, badge icons, custom SVG emblems
- sla-npc-generator.js: archetype-specific accent styling for Shiver, DarkNight, Tek, Ebb, Media, Internal Affairs, gangs, civilians, predators, corp assets; live preview card (identity, strapline, disposition/threat posture, stat readout, loadout kit) updating on archetype/quality/species/disposition/threat/loadout/affiliation change; badge icon helper switched from Font Awesome classes to custom SVG path mapping
- sla-npc-generator.html: preview card markup with badge header; SVG badge renderer replacing icon-class approach
- mosh.css: archetype accent strip styles; preview card layout; image-based badge styling; badge set rules under npc-badges
- icons/ui/npc-badges/: 10 new custom SVGs — shiver, darknight, tek, ebb, internal, media, gang, predator, civilian, corp
2026-04-15 18:27:24 +01:00

854 lines
37 KiB
JavaScript

import { SLAMothershipGenerator } from "./sla-character-generator.js";
const SYSTEM_ID = "sla-mothership";
const ROOT_ACTOR_FOLDER = "SLA Mothership";
const NPC_FOLDER = "NPCs";
const NPC_ARCHETYPES = {
"Civilian Witness": {
summary: "Clerks, residents, dock workers, minor officials, and bystanders with enough identity to question or endanger.",
stats: { combat: 18, instinct: 32, speed: 24, loyalty: 46, sanity: 36, armor: 0, damageReduction: 0, health: 8, hits: 1 },
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: false },
skills: [
{ name: "Persuade", bonus: 20 },
{ name: "Spot Hidden", bonus: 25 },
{ name: "Drive (Civilian)", bonus: 20 },
{ name: "Bureaucracy", bonus: 15 }
],
loadouts: {
None: [],
Civilian: ["Street Clothes / Leather Jacket", "UV / Multi-Spectrum Torch", "Oyster Card (ITB Card)"]
},
defaultLoadout: "Civilian",
defaultRole: "Witness / Local",
defaultAffiliation: "Mort Civilian",
defaultDepartment: "Local District"
},
"Street Ganger": {
summary: "Street muscle, lookouts, turf enforcers, and volatile crew members from Mort's lower tiers.",
stats: { combat: 34, instinct: 34, speed: 30, loyalty: 24, sanity: 24, armor: 0, damageReduction: 0, health: 10, hits: 2 },
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: false, armor: false },
skills: [
{ name: "Brawl", bonus: 25 },
{ name: "Streetwise", bonus: 25 },
{ name: "Intimidate", bonus: 20 },
{ name: "Dodge", bonus: 20 },
{ name: "Firearm (Pistol)", bonus: 20 }
],
loadouts: {
None: [],
"Street Melee": ["Collapsible Baton", "Standard Combat Knife", "Street Clothes / Leather Jacket"],
Sidearm: ["FEN 603 Auto-Pistol", "Standard Combat Knife", "Street Clothes / Leather Jacket"]
},
defaultLoadout: "Sidearm",
defaultRole: "Street Gang Enforcer",
defaultAffiliation: "Local Street Gang",
defaultDepartment: "Turf Crew"
},
"Shiver Patrol": {
summary: "Street cops, precinct enforcers, and cordon teams good for patrol scenes, arrests, and fast escalation.",
stats: { combat: 44, instinct: 38, speed: 34, loyalty: 58, sanity: 36, armor: 3, damageReduction: 1, health: 12, hits: 2 },
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
skills: [
{ name: "Firearm (Pistol)", bonus: 30 },
{ name: "Firearm (SMG)", bonus: 25 },
{ name: "Athletics", bonus: 20 },
{ name: "Command", bonus: 20 },
{ name: "Spot Hidden", bonus: 25 }
],
loadouts: {
None: [],
Patrol: ["FEN 401 Shiver Pistol", "Collapsible Baton", "Shiver Flak Vest"],
"SMG Team": ["FEN 209 Machine Pistol", "Collapsible Baton", "Full Shiver Armour"]
},
defaultLoadout: "Patrol",
defaultRole: "Shiver Patrol Officer",
defaultAffiliation: "SLA Industries",
defaultDepartment: "Shiver Precinct"
},
"Corp Operative": {
summary: "Standard corporate assets, contract staff, or in-house operatives for Mort departments and sponsor interests.",
stats: { combat: 40, instinct: 40, speed: 32, loyalty: 50, sanity: 40, armor: 2, damageReduction: 0, health: 11, hits: 2 },
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
skills: [
{ name: "Firearm (Pistol)", bonus: 25 },
{ name: "Command", bonus: 20 },
{ name: "Spot Hidden", bonus: 20 },
{ name: "Bureaucracy", bonus: 20 },
{ name: "Persuade", bonus: 15 }
],
loadouts: {
None: [],
Sidearm: ["FEN 603 Auto-Pistol", "Street Clothes / Leather Jacket"],
"Field Agent": ["FEN 209 Machine Pistol", "Shiver Flak Vest", "Klippo Multi-Band Communicator"]
},
defaultLoadout: "Field Agent",
defaultAffiliationType: "Corporation",
defaultRole: "Corporate Operative",
defaultAffiliation: "SLA Industries",
defaultDepartment: "Operations",
defaultSector: "Mort City"
},
Media: {
summary: "Reporters, propaganda staff, image handlers, and camera teams who shape the public story while staying very close to the danger.",
stats: { combat: 20, instinct: 42, speed: 28, loyalty: 44, sanity: 42, armor: 1, damageReduction: 0, health: 9, hits: 1 },
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
skills: [
{ name: "Persuade", bonus: 30 },
{ name: "Fast Talk", bonus: 25 },
{ name: "SLA Info", bonus: 25 },
{ name: "Spot Hidden", bonus: 20 },
{ name: "Intimidate", bonus: 15 }
],
loadouts: {
None: [],
Press: ["Klippo Multi-Band Communicator", "UV / Multi-Spectrum Torch", "Street Clothes / Leather Jacket"],
"Embedded Crew": ["FEN 603 Auto-Pistol", "Klippo Multi-Band Communicator", "Street Clothes / Leather Jacket"]
},
defaultLoadout: "Press",
defaultAffiliationType: "Corporation",
defaultRole: "Media Correspondent",
defaultAffiliation: "SLA Media",
defaultDepartment: "Editorial / Field Unit",
defaultSector: "Downtown Mort"
},
"Internal Affairs": {
summary: "Cold, suspicious investigators and auditors used for leak hunts, corruption probes, and quiet pressure on operatives or departments.",
stats: { combat: 34, instinct: 48, speed: 28, loyalty: 56, sanity: 44, armor: 2, damageReduction: 0, health: 10, hits: 2 },
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
skills: [
{ name: "Bureaucracy", bonus: 30 },
{ name: "Psychology", bonus: 25 },
{ name: "Intimidate", bonus: 25 },
{ name: "Command", bonus: 20 },
{ name: "Firearm (Pistol)", bonus: 20 }
],
loadouts: {
None: [],
Audit: ["FEN 603 Auto-Pistol", "Klippo Multi-Band Communicator", "Street Clothes / Leather Jacket"],
Pressure: ["FEN 401 Shiver Pistol", "Shiver Flak Vest", "Klippo Multi-Band Communicator"]
},
defaultLoadout: "Audit",
defaultAffiliationType: "Department",
defaultRole: "Internal Affairs Investigator",
defaultAffiliation: "SLA Industries",
defaultDepartment: "Internal Affairs",
defaultSector: "Cannibal Sectors"
},
"DarkNight Agent": {
summary: "Hard, covert, and unpleasant professionals for pressure scenes, black-bag work, and very bad conversations.",
stats: { combat: 52, instinct: 48, speed: 42, loyalty: 42, sanity: 44, armor: 4, damageReduction: 2, health: 13, hits: 2 },
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
skills: [
{ name: "Stealth", bonus: 30 },
{ name: "Dodge", bonus: 25 },
{ name: "Spot Hidden", bonus: 30 },
{ name: "Firearm (SMG)", bonus: 30 },
{ name: "Intimidate", bonus: 25 }
],
loadouts: {
None: [],
"Strike Agent": ["FEN 209 Machine Pistol", "Standard Combat Knife", "Stealth Suit"],
Breach: ["FEN AR Assault Rifle", "Fragmentation Grenade", "Crackshot Armour"]
},
defaultLoadout: "Strike Agent",
defaultAffiliationType: "Squad",
defaultRole: "DarkNight Agent",
defaultAffiliation: "DarkNight",
defaultDepartment: "Black Unit",
defaultSector: "Cannibal Sectors"
},
"Tek Specialist": {
summary: "Engineers, locks-and-systems staff, data handlers, and machine-facing support assets.",
stats: { combat: 26, instinct: 42, speed: 28, loyalty: 46, sanity: 40, armor: 1, damageReduction: 0, health: 9, hits: 1 },
enabled: { combat: true, instinct: true, speed: true, loyalty: true, sanity: true, armor: true },
skills: [
{ name: "Computer Systems", bonus: 30 },
{ name: "Tech (Computers & AI)", bonus: 25 },
{ name: "Tech (Electronics)", bonus: 25 },
{ name: "Spot Hidden", bonus: 20 },
{ name: "Firearm (Pistol)", bonus: 15 }
],
loadouts: {
None: [],
Utility: ["FEN 603 Auto-Pistol", "Klippo Multi-Band Communicator", "UV / Multi-Spectrum Torch"],
"Field Tek": ["FEN 603 Auto-Pistol", "BOOPA Medical Kit (Standard)", "Klippo Multi-Band Communicator"]
},
defaultLoadout: "Utility",
defaultAffiliationType: "Department",
defaultRole: "Tek Specialist",
defaultAffiliation: "Tek Division",
defaultDepartment: "Systems",
defaultSector: "Industrial Edge"
},
"Ebb Operative": {
summary: "Psychic intervention assets, occult handlers, and dangerous minds kept near the edge of tolerable doctrine.",
stats: { combat: 30, instinct: 46, speed: 24, loyalty: 30, sanity: 46, armor: 0, damageReduction: 0, health: 9, hits: 2 },
enabled: { combat: true, instinct: true, speed: true, loyalty: false, sanity: true, armor: false },
skills: [
{ name: "Biofeedback", bonus: 25 },
{ name: "Formulate", bonus: 25 },
{ name: "Ebb (Core)", bonus: 25 },
{ name: "Spot Hidden", bonus: 20 },
{ name: "Firearm (Pistol)", bonus: 15 }
],
loadouts: {
None: [],
Focus: ["FEN 603 Auto-Pistol", "BOOPA Medical Kit (Standard)", "Klippo Multi-Band Communicator"]
},
defaultLoadout: "Focus",
defaultAffiliationType: "Department",
defaultRole: "Ebb Handler",
defaultAffiliation: "SLA Industries",
defaultDepartment: "Ebb / Black",
defaultSector: "Mort City"
},
"Predator / Beast": {
summary: "Mutants, monsters, feral biogen threats, or engineered horrors that need only enough structure to fight and stalk.",
stats: { combat: 46, instinct: 56, speed: 40, loyalty: 12, sanity: 10, armor: 2, damageReduction: 1, health: 14, hits: 3 },
enabled: { combat: true, instinct: true, speed: true, loyalty: false, sanity: false, armor: true },
skills: [
{ name: "Brawl", bonus: 30 },
{ name: "Athletics", bonus: 25 },
{ name: "Dodge", bonus: 20 },
{ name: "Spot Hidden", bonus: 25 }
],
loadouts: {
None: []
},
defaultLoadout: "None",
defaultAffiliationType: "Independent",
defaultRole: "Hunting Threat",
defaultAffiliation: "Hostile Specimen",
defaultDepartment: "Wild / Uncontrolled",
defaultSector: "Waste District"
}
};
const QUALITY_MODS = {
Rabble: { stat: -8, skill: -10, health: -2, hits: 0 },
Standard: { stat: 0, skill: 0, health: 0, hits: 0 },
Veteran: { stat: 6, skill: 5, health: 1, hits: 0 },
Elite: { stat: 10, skill: 10, health: 2, hits: 1 },
Boss: { stat: 14, skill: 15, health: 4, hits: 1 }
};
const DISPOSITIONS = ["Neutral", "Professional", "Hostile", "Nervous", "Fanatical", "Cornered", "Corrupt", "Helpful"];
const THREAT_TIERS = ["Low", "Moderate", "High", "Extreme"];
const AFFILIATION_TYPES = ["Department", "Gang", "Corporation", "Cult", "Squad", "Civic", "Independent"];
const ROLE_SNIPPETS = {
"Civilian Witness": ["Transit clerk", "Dock handler", "Apartment resident", "Witness under pressure", "Small trader"],
"Street Ganger": ["Lookout", "Enforcer", "Runner", "Debt collector", "Back-alley bruiser"],
"Shiver Patrol": ["Patrol officer", "Checkpoint lead", "Riot response cop", "Beat sergeant", "Scene controller"],
"Corp Operative": ["Case handler", "Recovery agent", "Sponsor fixer", "Quiet escort", "Internal security operative"],
Media: ["Field correspondent", "Embedded camera lead", "Image handler", "Narrative producer", "Live scene reporter"],
"Internal Affairs": ["Audit investigator", "Leak hunter", "Corruption examiner", "Interrogation lead", "Compliance agent"],
"DarkNight Agent": ["Pressure asset", "Black-bag specialist", "Interdiction hunter", "Silent breach lead", "Cleanup professional"],
"Tek Specialist": ["Systems breach tech", "Maintenance lead", "Field engineer", "Data recovery tech", "Signals operator"],
"Ebb Operative": ["Control asset", "Psi observer", "Occult investigator", "Flux-sensitive handler", "Discreet interventionist"],
"Predator / Beast": ["Tunnel stalker", "Waste-zone hunter", "Escaped specimen", "Territory alpha", "Feral ambusher"]
};
const AFFILIATION_SNIPPETS = {
Department: ["Mort Department", "Ops Con", "Shiver Precinct", "Tek Division", "Cannibal Sector Authority"],
Gang: ["Chrome Jacks", "Sump Dogs", "Red Wire Crew", "Murder Mile Kings", "Ash-Sector Runners"],
Corporation: ["SLA Industries", "FEN", "Wraithen Security", "MediCore", "Tek Division"],
Cult: ["The True Noise", "Church of White Fire", "The Bent Halo", "The Last Broadcast", "Ash Choir"],
Squad: ["Blue BPN Hold Team", "Black Transit Unit", "Sweep Detail", "Strike Cell Seven", "Special Recovery Team"],
Civic: ["Transit Maintenance", "Block Committee", "Waste Processing", "Public Utility", "Relocation Office"],
Independent: ["Freelance crew", "Mercenary pair", "Broker network", "Private fixer", "Unaffiliated contact"]
};
const DEPARTMENT_SNIPPETS = {
"Civilian Witness": ["Hab Block", "Transit", "Utility", "Street Market", "Dock Quarter"],
"Street Ganger": ["East Cut", "Underline", "Recycling Stack", "Canal Row", "Tunnel Nine"],
"Shiver Patrol": ["Precinct 88", "Riot Control", "Street Division", "Transit Policing", "Evidence Escort"],
"Corp Operative": ["Internal Security", "Investigations", "Sponsor Liaison", "Logistics Recovery", "Operations"],
Media: ["Editorial Desk", "Live Scene Unit", "Mort Broadcast", "Image Control", "Public Feed Team"],
"Internal Affairs": ["Compliance Review", "Leak Office", "Audit Section", "Disciplinary Unit", "Special Oversight"],
"DarkNight Agent": ["Silent Entry", "Black Archive", "Pressure Cell", "Removal Team", "Interdiction"],
"Tek Specialist": ["Signals", "Maintenance", "Surveillance", "Field Systems", "Machine Control"],
"Ebb Operative": ["Occult Oversight", "Black Psi", "Flux Watch", "Intervention Cell", "Containment"],
"Predator / Beast": ["Den", "Nest", "Territory", "Spore Cluster", "Hunting Ground"]
};
export class SLANpcGeneratorApp extends FormApplication {
constructor(options = {}) {
super(options);
this.formState = null;
}
static get defaultOptions() {
return foundry.utils.mergeObject(super.defaultOptions, {
id: "sla-npc-generator",
classes: ["mosh", "sheet", "sla-generator", "sla-npc-generator"],
template: "systems/sla-mothership/templates/dialogs/sla-npc-generator.html",
width: 780,
height: "auto",
submitOnChange: false,
closeOnSubmit: false
});
}
get title() {
return "SLA NPC Desk";
}
async getData() {
await SLAMothershipGenerator.ensureSeedData();
const seed = await SLAMothershipGenerator.loadSeedData();
const speciesOptions = seed.species.species.map((entry) => entry.name);
const state = this.formState ?? this.defaultState(speciesOptions);
this.formState = state;
const archetype = NPC_ARCHETYPES[state.archetype] ?? NPC_ARCHETYPES["Street Ganger"];
const loadoutOptions = Object.keys(archetype.loadouts ?? { None: [] });
return {
state,
accentKey: getArchetypeAccentKey(state.archetype),
archetypes: Object.entries(NPC_ARCHETYPES).map(([name, entry]) => ({ name, summary: entry.summary })),
speciesOptions,
qualityOptions: Object.keys(QUALITY_MODS),
dispositionOptions: DISPOSITIONS,
threatOptions: THREAT_TIERS,
affiliationTypes: AFFILIATION_TYPES,
loadoutOptions,
summary: archetype.summary,
preview: buildNpcPreview(state, archetype)
};
}
defaultState(speciesOptions = []) {
const archetype = "Street Ganger";
const config = NPC_ARCHETYPES[archetype];
return {
name: SLAMothershipGenerator.randomName(),
archetype,
quality: "Standard",
species: speciesOptions.includes("Human") ? "Human" : (speciesOptions[0] ?? "Human"),
affiliationType: config.defaultAffiliationType ?? "Gang",
affiliation: config.defaultAffiliation,
department: config.defaultDepartment,
role: config.defaultRole,
sector: config.defaultSector ?? "Mort City",
disposition: "Hostile",
threat: "Moderate",
loadout: config.defaultLoadout ?? "None",
assignPortrait: true,
openSheet: true,
notes: ""
};
}
activateListeners(html) {
super.activateListeners(html);
html.find(".sla-npc-random-name").on("click", () => {
html.find("[name='name']").val(SLAMothershipGenerator.randomName());
});
html.find(".sla-npc-randomize").on("click", async () => {
const state = this.collectState(html);
const randomized = this.randomizeState(state);
this.formState = randomized;
await this.render(false);
});
html.find("[name='archetype']").on("change", async () => {
const current = this.collectState(html);
this.formState = this.rethemeStateForArchetype(current, current.archetype);
await this.render(false);
});
const rerenderSelectors = [
"[name='quality']",
"[name='species']",
"[name='disposition']",
"[name='threat']",
"[name='loadout']",
"[name='affiliationType']"
];
for (const selector of rerenderSelectors) {
html.find(selector).on("change", async () => {
this.formState = this.collectState(html);
await this.render(false);
});
}
}
collectState(html) {
return {
name: String(html.find("[name='name']").val() ?? "").trim(),
archetype: String(html.find("[name='archetype']").val() ?? "Street Ganger"),
quality: String(html.find("[name='quality']").val() ?? "Standard"),
species: String(html.find("[name='species']").val() ?? "Human"),
affiliationType: String(html.find("[name='affiliationType']").val() ?? "Department"),
affiliation: String(html.find("[name='affiliation']").val() ?? "").trim(),
department: String(html.find("[name='department']").val() ?? "").trim(),
role: String(html.find("[name='role']").val() ?? "").trim(),
sector: String(html.find("[name='sector']").val() ?? "").trim(),
disposition: String(html.find("[name='disposition']").val() ?? "Neutral"),
threat: String(html.find("[name='threat']").val() ?? "Moderate"),
loadout: String(html.find("[name='loadout']").val() ?? "None"),
assignPortrait: Boolean(html.find("[name='assignPortrait']").prop("checked")),
openSheet: Boolean(html.find("[name='openSheet']").prop("checked")),
notes: String(html.find("[name='notes']").val() ?? "").trim()
};
}
randomizeState(current) {
const state = { ...current };
const archetypeName = state.archetype || randomFrom(Object.keys(NPC_ARCHETYPES)) || "Street Ganger";
const archetype = NPC_ARCHETYPES[archetypeName] ?? NPC_ARCHETYPES["Street Ganger"];
const themed = this.rethemeStateForArchetype(state, archetypeName);
themed.affiliation = randomFrom(AFFILIATION_SNIPPETS[themed.affiliationType] ?? [archetype.defaultAffiliation]) ?? archetype.defaultAffiliation;
themed.department = randomFrom(DEPARTMENT_SNIPPETS[archetypeName] ?? [archetype.defaultDepartment]) ?? archetype.defaultDepartment;
themed.role = randomFrom(ROLE_SNIPPETS[archetypeName] ?? [archetype.defaultRole]) ?? archetype.defaultRole;
themed.sector = randomFrom(getSectorOptionsForArchetype(archetypeName, archetype.defaultSector)) ?? (archetype.defaultSector ?? "Mort City");
themed.disposition = randomFrom(DISPOSITIONS) ?? "Neutral";
themed.threat = randomFrom(THREAT_TIERS) ?? "Moderate";
themed.loadout = randomFrom(Object.keys(archetype.loadouts ?? { None: [] })) ?? "None";
themed.name = SLAMothershipGenerator.randomName();
return themed;
}
rethemeStateForArchetype(current, archetypeName) {
const archetype = NPC_ARCHETYPES[archetypeName] ?? NPC_ARCHETYPES["Street Ganger"];
const next = { ...current, archetype: archetypeName };
next.affiliationType = archetype.defaultAffiliationType ?? current.affiliationType ?? "Department";
next.affiliation = archetype.defaultAffiliation ?? "";
next.department = archetype.defaultDepartment ?? "";
next.role = archetype.defaultRole ?? "";
next.sector = archetype.defaultSector ?? "Mort City";
next.loadout = archetype.defaultLoadout ?? "None";
next.disposition = getDefaultDispositionForArchetype(archetypeName);
next.threat = getDefaultThreatForArchetype(archetypeName);
next.species = getDefaultSpeciesForArchetype(archetypeName, current.species);
return next;
}
async _updateObject(_event, formData) {
const expanded = foundry.utils.expandObject(formData);
const state = {
name: String(expanded.name ?? "").trim(),
archetype: String(expanded.archetype ?? "Street Ganger"),
quality: String(expanded.quality ?? "Standard"),
species: String(expanded.species ?? "Human"),
affiliationType: String(expanded.affiliationType ?? "Department"),
affiliation: String(expanded.affiliation ?? "").trim(),
department: String(expanded.department ?? "").trim(),
role: String(expanded.role ?? "").trim(),
sector: String(expanded.sector ?? "").trim(),
disposition: String(expanded.disposition ?? "Neutral"),
threat: String(expanded.threat ?? "Moderate"),
loadout: String(expanded.loadout ?? "None"),
assignPortrait: Boolean(expanded.assignPortrait),
openSheet: Boolean(expanded.openSheet),
notes: String(expanded.notes ?? "").trim()
};
this.formState = state;
const result = await generateSlaNpc(state);
if (result?.actor && state.openSheet) {
result.actor.sheet?.render(true);
}
ui.notifications.info(`SLA NPC ready: ${result.actor.name}`);
this.render(false);
}
static open(options = {}) {
return new SLANpcGeneratorApp(options).render({ force: true });
}
}
export async function generateSlaNpc(state = {}) {
await SLAMothershipGenerator.ensureSeedData();
const seed = await SLAMothershipGenerator.loadSeedData();
const archetype = NPC_ARCHETYPES[state.archetype] ?? NPC_ARCHETYPES["Street Ganger"];
const quality = QUALITY_MODS[state.quality] ?? QUALITY_MODS.Standard;
const speciesName = String(state.species ?? "Human").trim() || "Human";
const resolvedName = String(state.name ?? "").trim() || SLAMothershipGenerator.randomName();
const folder = await ensureNpcFolder();
const actor = await Actor.create({
name: resolvedName,
type: "creature",
folder: folder.id
});
const stats = applyQualityToStats(archetype.stats, quality);
const enabled = archetype.enabled ?? {};
const description = buildNpcDescription({ state, archetype, speciesName });
const biography = buildNpcBiography({ state, archetype, speciesName });
await actor.update({
img: SLAMothershipGenerator.resolveSpeciesImage(speciesName),
"system.description": description,
"system.biography": biography,
"system.notes": String(state.notes ?? "").trim(),
"system.health.value": stats.health,
"system.health.max": stats.health,
"system.hits.value": 0,
"system.hits.max": stats.hits,
"system.stats.combat.value": stats.combat,
"system.stats.instinct.value": stats.instinct,
"system.stats.speed.value": stats.speed,
"system.stats.loyalty.value": stats.loyalty,
"system.stats.sanity.value": stats.sanity,
"system.stats.armor.mod": stats.armor,
"system.stats.armor.damageReduction": stats.damageReduction,
"system.stats.combat.enabled": Boolean(enabled.combat),
"system.stats.instinct.enabled": Boolean(enabled.instinct),
"system.stats.speed.enabled": Boolean(enabled.speed),
"system.stats.loyalty.enabled": Boolean(enabled.loyalty),
"system.stats.sanity.enabled": Boolean(enabled.sanity),
"system.stats.armor.enabled": Boolean(enabled.armor),
"system.slaNpc.archetype.value": state.archetype,
"system.slaNpc.species.value": speciesName,
"system.slaNpc.affiliationType.value": state.affiliationType,
"system.slaNpc.affiliation.value": String(state.affiliation ?? "").trim(),
"system.slaNpc.department.value": String(state.department ?? "").trim(),
"system.slaNpc.role.value": String(state.role ?? "").trim(),
"system.slaNpc.sector.value": String(state.sector ?? "").trim(),
"system.slaNpc.disposition.value": String(state.disposition ?? "").trim(),
"system.slaNpc.threat.value": String(state.threat ?? "").trim(),
"system.slaNpc.summary.value": archetype.summary
});
const itemPayloads = [];
itemPayloads.push(...buildNpcSkillItems(archetype.skills ?? [], quality, seed.skills.skills));
itemPayloads.push(...buildLoadoutItems(archetype, state.loadout));
if (state.archetype === "Ebb Operative" && SLAMothershipGenerator.isEbbSpecies(speciesName)) {
itemPayloads.push(...SLAMothershipGenerator.buildEbbSkillItems(speciesName));
itemPayloads.push(...SLAMothershipGenerator.buildEbbItems(speciesName));
}
if (state.archetype === "Predator / Beast") {
itemPayloads.push(...SLAMothershipGenerator.buildSpeciesItems(speciesName));
}
const uniquePayloads = uniqueEmbeddedItems(itemPayloads);
if (uniquePayloads.length) {
await actor.createEmbeddedDocuments("Item", uniquePayloads);
}
if (state.assignPortrait && game.npcPortraitPack?.assignPortraitToActor) {
await game.npcPortraitPack.assignPortraitToActor(actor);
}
return { actor };
}
async function ensureNpcFolder() {
let root = game.folders.find((folder) => folder.type === "Actor" && folder.name === ROOT_ACTOR_FOLDER && !folder.folder);
if (!root) {
root = await Folder.create({ name: ROOT_ACTOR_FOLDER, type: "Actor", color: "#163447" });
}
let folder = game.folders.find((entry) => entry.type === "Actor" && entry.name === NPC_FOLDER && entry.folder?.id === root.id);
if (!folder) {
folder = await Folder.create({ name: NPC_FOLDER, type: "Actor", folder: root.id, color: "#5b3349" });
}
return folder;
}
function applyQualityToStats(baseStats, quality) {
return {
combat: clamp98((baseStats.combat ?? 10) + (quality.stat ?? 0)),
instinct: clamp98((baseStats.instinct ?? 10) + (quality.stat ?? 0)),
speed: clamp98((baseStats.speed ?? 10) + (quality.stat ?? 0)),
loyalty: clamp98((baseStats.loyalty ?? 10) + (quality.stat ?? 0)),
sanity: clamp98((baseStats.sanity ?? 10) + (quality.stat ?? 0)),
armor: Math.max(0, Number(baseStats.armor ?? 0)),
damageReduction: Math.max(0, Number(baseStats.damageReduction ?? 0)),
health: Math.max(1, Number(baseStats.health ?? 8) + Number(quality.health ?? 0)),
hits: Math.max(1, Number(baseStats.hits ?? 1) + Number(quality.hits ?? 0))
};
}
function buildNpcSkillItems(skillDefs, quality, allSkillData = []) {
return (skillDefs ?? [])
.map((entry) => {
const baseBonus = Number(entry.bonus ?? 10) + Number(quality.skill ?? 0);
const worldItem = findWorldItem(entry.name, "skill");
if (worldItem) {
const embedded = toEmbeddedData(worldItem);
embedded.system.bonus = clamp98(baseBonus);
embedded.system.rank = rankFromBonus(embedded.system.bonus);
embedded.system.sla ??= {};
embedded.system.sla.baseBonus = Number(worldItem.system?.bonus ?? 0) || 0;
embedded.system.sla.packageBonus = Math.max(0, embedded.system.bonus - Number(embedded.system.sla.baseBonus ?? 0));
return embedded;
}
const data = allSkillData.find((skill) => normalizeText(skill.name) === normalizeText(entry.name)) ?? {};
return {
name: entry.name,
type: "skill",
img: "icons/svg/upgrade.svg",
system: {
description: "",
rank: rankFromBonus(baseBonus),
bonus: clamp98(baseBonus),
prerequisite_ids: [],
sla: {
category: data.categoryRef ?? "",
skillFamily: data.categoryRef ?? "",
source: "SLA NPC Desk"
}
}
};
})
.filter(Boolean);
}
function buildLoadoutItems(archetype, loadoutName) {
const loadout = archetype.loadouts?.[loadoutName] ?? [];
return loadout
.map((name) => findWorldItem(name))
.filter(Boolean)
.map((item) => toEmbeddedData(item));
}
function buildNpcDescription({ state, archetype, speciesName }) {
return [
`${state.role || archetype.defaultRole}.`,
`${state.affiliation || archetype.defaultAffiliation}${state.department ? ` / ${state.department}` : ""}.`,
`${state.disposition || "Neutral"} presence with ${state.threat || "Moderate"} threat pressure.`,
`${speciesName} profile.`
].join(" ");
}
function buildNpcBiography({ state, archetype, speciesName }) {
return `
<p><strong>Role:</strong> ${escapeHtml(state.role || archetype.defaultRole)}</p>
<p><strong>Affiliation:</strong> ${escapeHtml(state.affiliation || archetype.defaultAffiliation)}</p>
<p><strong>Department / Cell:</strong> ${escapeHtml(state.department || archetype.defaultDepartment)}</p>
<p><strong>Species:</strong> ${escapeHtml(speciesName)}</p>
<p><strong>Disposition:</strong> ${escapeHtml(state.disposition || "Neutral")} | <strong>Threat:</strong> ${escapeHtml(state.threat || "Moderate")}</p>
<p><strong>Sector:</strong> ${escapeHtml(state.sector || "Mort City")}</p>
<p>${escapeHtml(archetype.summary)}</p>
`.trim();
}
function buildNpcPreview(state, archetype) {
const quality = QUALITY_MODS[state.quality] ?? QUALITY_MODS.Standard;
const loadoutNames = archetype.loadouts?.[state.loadout] ?? [];
const statline = applyQualityToStats(archetype.stats, quality);
return {
iconPath: getArchetypeIconPath(state.archetype),
iconLabel: getArchetypeBadgeShort(state.archetype),
badge: getArchetypeBadge(state.archetype),
strap: getArchetypeStrapline(state.archetype),
identity: `${state.role || archetype.defaultRole} / ${state.species}`,
brief: `${state.affiliation || archetype.defaultAffiliation}${state.department ? ` / ${state.department}` : ""} operating in ${state.sector || archetype.defaultSector || "Mort City"}.`,
posture: `${state.disposition || "Neutral"} disposition with ${state.threat || "Moderate"} threat expectations.`,
statline: [
`Combat ${statline.combat}`,
`Instinct ${statline.instinct}`,
`Speed ${statline.speed}`,
`Health ${statline.health}`,
`Wounds ${statline.hits}`
],
loadout: loadoutNames.length ? loadoutNames : ["No preset issue kit selected."],
notes: archetype.summary
};
}
function clamp98(value) {
return Math.max(1, Math.min(98, Number(value ?? 0) || 0));
}
function rankFromBonus(bonus) {
if (bonus >= 40) return "Master";
if (bonus >= 25) return "Expert";
return "Trained";
}
function findWorldItem(name, type = null) {
return game.items.find((item) => normalizeText(item.name) === normalizeText(name) && (!type || item.type === type)) ?? null;
}
function toEmbeddedData(document) {
return foundry.utils.duplicate(document.toObject());
}
function uniqueEmbeddedItems(items = []) {
const seen = new Set();
const out = [];
for (const item of items) {
const key = `${item.type}::${normalizeText(item.name)}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
function normalizeText(value) {
return String(value ?? "").trim().toLowerCase();
}
function randomFrom(list = []) {
if (!list.length) return null;
return list[Math.floor(Math.random() * list.length)] ?? null;
}
function getDefaultSpeciesForArchetype(archetypeName, currentSpecies = "Human") {
if (archetypeName === "Ebb Operative") return "Ebon";
if (archetypeName === "Predator / Beast") return "Advanced Carrien";
return currentSpecies || "Human";
}
function getDefaultDispositionForArchetype(archetypeName) {
switch (archetypeName) {
case "Civilian Witness": return "Nervous";
case "Street Ganger": return "Hostile";
case "Shiver Patrol": return "Professional";
case "Corp Operative": return "Professional";
case "Media": return "Corrupt";
case "Internal Affairs": return "Professional";
case "DarkNight Agent": return "Hostile";
case "Tek Specialist": return "Professional";
case "Ebb Operative": return "Cornered";
case "Predator / Beast": return "Fanatical";
default: return "Neutral";
}
}
function getDefaultThreatForArchetype(archetypeName) {
switch (archetypeName) {
case "Civilian Witness": return "Low";
case "Street Ganger": return "Moderate";
case "Shiver Patrol": return "Moderate";
case "Corp Operative": return "Moderate";
case "Media": return "Low";
case "Internal Affairs": return "Moderate";
case "DarkNight Agent": return "High";
case "Tek Specialist": return "Moderate";
case "Ebb Operative": return "High";
case "Predator / Beast": return "Extreme";
default: return "Moderate";
}
}
function getSectorOptionsForArchetype(archetypeName, fallback = "Mort City") {
switch (archetypeName) {
case "Street Ganger":
return ["Cannibal Sectors", "Transit Underlevels", "Waste District", "Mort City"];
case "Shiver Patrol":
return ["Mort City", "Downtown Mort", "Transit Underlevels", "Cannibal Sectors"];
case "Corp Operative":
return ["Mort City", "Downtown Mort", "Industrial Edge", "Cannibal Sectors"];
case "Media":
return ["Downtown Mort", "Mort City", "Cannibal Sectors", "Transit Underlevels"];
case "Internal Affairs":
return ["Cannibal Sectors", "Mort City", "Downtown Mort", "Industrial Edge"];
case "DarkNight Agent":
return ["Cannibal Sectors", "Transit Underlevels", "Mort City", "Waste District"];
case "Tek Specialist":
return ["Industrial Edge", "Mort City", "Transit Underlevels", "Downtown Mort"];
case "Ebb Operative":
return ["Mort City", "Cannibal Sectors", "Downtown Mort"];
case "Predator / Beast":
return ["Waste District", "Cannibal Sectors", "Transit Underlevels", "Industrial Edge"];
default:
return [fallback, "Mort City", "Downtown Mort"];
}
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function getArchetypeAccentKey(archetypeName) {
switch (archetypeName) {
case "Shiver Patrol": return "shiver";
case "DarkNight Agent": return "darknight";
case "Tek Specialist": return "tek";
case "Ebb Operative": return "ebb";
case "Media": return "media";
case "Internal Affairs": return "internal";
case "Street Ganger": return "gang";
case "Predator / Beast": return "predator";
case "Civilian Witness": return "civilian";
case "Corp Operative": return "corp";
default: return "corp";
}
}
function getArchetypeBadge(archetypeName) {
switch (archetypeName) {
case "Shiver Patrol": return "Shiver Patrol";
case "DarkNight Agent": return "DarkNight";
case "Tek Specialist": return "Tek Division";
case "Ebb Operative": return "Ebb Control";
case "Media": return "Media Feed";
case "Internal Affairs": return "Internal Affairs";
case "Street Ganger": return "Street Threat";
case "Predator / Beast": return "Hostile Specimen";
case "Civilian Witness": return "Civilian Contact";
case "Corp Operative": return "Corporate Asset";
default: return archetypeName;
}
}
function getArchetypeBadgeShort(archetypeName) {
switch (archetypeName) {
case "Shiver Patrol": return "SV";
case "DarkNight Agent": return "DN";
case "Tek Specialist": return "TK";
case "Ebb Operative": return "EB";
case "Media": return "MD";
case "Internal Affairs": return "IA";
case "Street Ganger": return "SG";
case "Predator / Beast": return "HX";
case "Civilian Witness": return "CV";
case "Corp Operative": return "CO";
default: return "NPC";
}
}
function getArchetypeIconPath(archetypeName) {
const root = "systems/sla-mothership/icons/ui/npc-badges";
switch (archetypeName) {
case "Shiver Patrol": return `${root}/shiver.svg`;
case "DarkNight Agent": return `${root}/darknight.svg`;
case "Tek Specialist": return `${root}/tek.svg`;
case "Ebb Operative": return `${root}/ebb.svg`;
case "Media": return `${root}/media.svg`;
case "Internal Affairs": return `${root}/internal.svg`;
case "Street Ganger": return `${root}/gang.svg`;
case "Predator / Beast": return `${root}/predator.svg`;
case "Civilian Witness": return `${root}/civilian.svg`;
case "Corp Operative": return `${root}/corp.svg`;
default: return `${root}/corp.svg`;
}
}
function getArchetypeStrapline(archetypeName) {
switch (archetypeName) {
case "Shiver Patrol": return "Precinct issue / patrol doctrine / fast response";
case "DarkNight Agent": return "Black operations / pressure asset / covert response";
case "Tek Specialist": return "Systems breach / maintenance / machine-facing support";
case "Ebb Operative": return "Occult intervention / flux risk / psychic control";
case "Media": return "Image control / live coverage / narrative pressure";
case "Internal Affairs": return "Audit pressure / disciplinary review / quiet threat";
case "Street Ganger": return "Turf violence / local intimidation / disposable muscle";
case "Predator / Beast": return "Ferality / pursuit / close-quarters threat";
case "Civilian Witness": return "Collateral witness / local knowledge / brittle pressure";
case "Corp Operative": return "Corporate recovery / sponsor discipline / field oversight";
default: return "Operational contact / generated dossier";
}
}