Based on sla-blueprint-news. On Create, in a zero-engine world, also spawns playable Zero Engine npc actors for the briefing: a threat group (named leader + mooks) matched to the BPN opposition (gangs, Carrien, corpsec, Stormers, black-ops...) plus the named cast — each with attributes, hit points, armour, damage, threat and embedded weapons. Stat blocks mirror the system's own NPC Creator (SLA_NPC_TYPES / _slaWeaponData). De-collided ids/globals/buttons so it coexists with the base module. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
172 lines
11 KiB
JavaScript
172 lines
11 KiB
JavaScript
/**
|
||
* Zero Engine adapter for Blueprint News 0 Engine.
|
||
*
|
||
* Turns a generated BPN into real Zero Engine (system id `zero-engine`) NPC
|
||
* actors — gangs, Carrien, corp security, and the named cast — with
|
||
* attributes, hit points, armour, damage, threat level and embedded weapons.
|
||
*
|
||
* The actor + weapon shapes mirror the Zero Engine system's own NPC Creator
|
||
* (SLA_NPC_TYPES / _slaWeaponData / _createNPC in zero-engine.mjs) so the
|
||
* results are identical to what the system would build itself. Initiative is
|
||
* rolled by the Zero Engine in combat (driven by Agility + Mobility), which
|
||
* these actors have.
|
||
*/
|
||
|
||
/* Weapon presets — copied from the Zero Engine system's _slaWeaponData. */
|
||
const ZERO_WEAPONS = {
|
||
pistol: { name: "Blitzer 10mm Pistol", weaponType: "Pistol", category: "ranged", damage: 2, range: "Short", rof: 2, ap: 0, magazine: 15, ammo: 15, fireModes: ["single"], cost: 300, img: "icons/weapons/guns/gun-pistol-flintlock-metal.webp" },
|
||
rifle: { name: "M300 Assault Rifle", weaponType: "Rifle", category: "ranged", damage: 3, range: "Long", rof: 3, ap: 1, magazine: 30, ammo: 30, fireModes: ["single", "burst", "auto"], cost: 600, img: "icons/weapons/guns/rifle-bayonet.webp" },
|
||
smg: { name: "Blitzer SMG", weaponType: "SMG", category: "ranged", damage: 2, range: "Short", rof: 3, ap: 0, magazine: 30, ammo: 30, fireModes: ["single", "auto"], cost: 450, img: "icons/weapons/guns/gun-pistol-flintlock-metal.webp" },
|
||
heavy_melee: { name: "Power Blade", weaponType: "Blade", category: "melee", damage: 3, range: "Engaged", rof: 1, ap: 2, magazine: 0, ammo: 0, fireModes: [], cost: 400, img: "icons/weapons/swords/greatsword.webp" },
|
||
knife: { name: "Combat Knife", weaponType: "Knife", category: "melee", damage: 2, range: "Engaged", rof: 2, ap: 0, magazine: 0, ammo: 0, fireModes: [], cost: 80, img: "icons/weapons/daggers/dagger-straight-sharp.webp" },
|
||
baton: { name: "Shiver Baton", weaponType: "Baton", category: "melee", damage: 2, range: "Engaged", rof: 2, ap: 0, magazine: 0, ammo: 0, fireModes: [], cost: 50, img: "icons/weapons/clubs/club-simple-black.webp" },
|
||
claws: { name: "Claws", weaponType: "Natural", category: "melee", damage: 3, range: "Engaged", rof: 2, ap: 0, magazine: 0, ammo: 0, fireModes: [], cost: 0, img: "icons/skills/melee/unarmed-punch-fist.webp" },
|
||
blade: { name: "Mono-Edge Blade", weaponType: "Blade", category: "melee", damage: 3, range: "Engaged", rof: 2, ap: 2, magazine: 0, ammo: 0, fireModes: [], cost: 350, img: "icons/weapons/swords/sword-katana-gleaming.webp" },
|
||
heavy_weapon: { name: "Heavy Autocannon", weaponType: "HW", category: "ranged", damage: 5, range: "Long", rof: 2, ap: 3, magazine: 20, ammo: 20, fireModes: ["burst", "auto"], cost: 2500, img: "icons/weapons/guns/gun-blunderbuss.webp" }
|
||
};
|
||
|
||
function weaponItem(type) {
|
||
const base = ZERO_WEAPONS[type] || ZERO_WEAPONS.pistol;
|
||
const { img, ...sys } = base;
|
||
return {
|
||
name: base.name,
|
||
type: "weapon",
|
||
img: img || "icons/svg/item.svg",
|
||
system: { ...sys, description: "", equipped: true, gearBonus: 0, initiativeMod: 0, ammoType: "standard", autoAmmoUse: 8 }
|
||
};
|
||
}
|
||
|
||
/* Stat profiles — the Zero Engine SLA_NPC_TYPES set, plus a Stormer profile
|
||
the base set lacks. attrs clamp 2–5, armour 0–6, hp scales with threat. */
|
||
const ZERO_PROFILES = {
|
||
civilian: { label: "Civilian", threat: 1, attrs: { strength: 2, agility: 2, wits: 2, empathy: 2 }, skills: { observation: 1 }, hp: 4, armor: 0, weapons: [] },
|
||
gang_grunt: { label: "Gang Grunt", threat: 1, attrs: { strength: 3, agility: 2, wits: 2, empathy: 2 }, skills: { melee: 2, force: 1 }, hp: 5, armor: 1, weapons: ["pistol", "knife"] },
|
||
shiver: { label: "Shiver (Patrol)", threat: 2, attrs: { strength: 3, agility: 3, wits: 2, empathy: 2 }, skills: { marksmanship: 2, melee: 1, observation: 1 }, hp: 6, armor: 2, weapons: ["pistol", "baton"] },
|
||
operative_green: { label: "SLA Operative (Green)", threat: 2, attrs: { strength: 2, agility: 3, wits: 2, empathy: 2 }, skills: { marksmanship: 2, mobility: 1, observation: 1 }, hp: 5, armor: 2, weapons: ["pistol", "knife"] },
|
||
carrien: { label: "Carrien", threat: 2, attrs: { strength: 3, agility: 4, wits: 1, empathy: 1 }, skills: { melee: 3, mobility: 2 }, hp: 7, armor: 1, weapons: ["claws"] },
|
||
operative_veteran: { label: "SLA Operative (Vet)", threat: 3, attrs: { strength: 3, agility: 3, wits: 3, empathy: 2 }, skills: { marksmanship: 3, melee: 2, stealth: 2, observation: 2 }, hp: 8, armor: 3, weapons: ["rifle", "pistol", "knife"] },
|
||
stormer: { label: "Stormer", threat: 3, attrs: { strength: 4, agility: 3, wits: 2, empathy: 1 }, skills: { melee: 3, force: 2, stamina: 2 }, hp: 11, armor: 3, weapons: ["heavy_melee", "smg"] },
|
||
dark_finder: { label: "Dark Finder", threat: 4, attrs: { strength: 4, agility: 3, wits: 3, empathy: 2 }, skills: { marksmanship: 4, melee: 3, stealth: 3, observation: 3 }, hp: 10, armor: 4, weapons: ["rifle", "pistol", "blade"] },
|
||
manchine: { label: "Manchine", threat: 5, attrs: { strength: 5, agility: 4, wits: 3, empathy: 1 }, skills: { melee: 4, marksmanship: 3, force: 4, stamina: 4 }, hp: 14, armor: 5, weapons: ["heavy_weapon", "claws"] }
|
||
};
|
||
|
||
/* BPN threat archetype → encounter group (a leader + a number of mooks). */
|
||
const THREAT_GROUPS = {
|
||
ganger: { leader: "gang_grunt", mook: "gang_grunt", mooks: [3, 5] },
|
||
raider: { leader: "operative_green", mook: "gang_grunt", mooks: [3, 5] },
|
||
killer: { leader: "operative_veteran", mook: "operative_green", mooks: [1, 2] },
|
||
corpsec: { leader: "operative_veteran", mook: "shiver", mooks: [3, 4] },
|
||
security: { leader: "shiver", mook: "shiver", mooks: [3, 4] },
|
||
riot: { leader: "gang_grunt", mook: "gang_grunt", mooks: [5, 6] },
|
||
blackops: { leader: "dark_finder", mook: "operative_veteran", mooks: [2, 3] },
|
||
stormer: { leader: "manchine", mook: "stormer", mooks: [1, 2] },
|
||
carrien: { leader: "carrien", mook: "carrien", mooks: [3, 5] },
|
||
"retrieval-crew": { leader: "operative_veteran", mook: "operative_green", mooks: [2, 3] }
|
||
};
|
||
|
||
/* Map a cast member's role text to a sensible non-combat profile. */
|
||
function contactProfile(role = "") {
|
||
const r = String(role).toLowerCase();
|
||
if (r.includes("shiver") || r.includes("security") || r.includes("enforcer")) return "shiver";
|
||
if (r.includes("operative") || r.includes("fixer") || r.includes("runner") || r.includes("financier")) return "operative_green";
|
||
if (r.includes("scientist") || r.includes("tech") || r.includes("medic") || r.includes("clerk") || r.includes("controller")) return "civilian";
|
||
return "civilian";
|
||
}
|
||
|
||
/** Build a Zero Engine npc actor payload from a profile, scaled to a target
|
||
* threat. Mirrors the system's _createNPC (derivedStats, skills, flux). */
|
||
function npcActorData(profileKey, { threat, name, biography } = {}) {
|
||
const profile = ZERO_PROFILES[profileKey] || ZERO_PROFILES.gang_grunt;
|
||
const t = Math.max(1, Math.min(6, threat ?? profile.threat));
|
||
const delta = t - profile.threat;
|
||
const attrs = {};
|
||
for (const [k, v] of Object.entries(profile.attrs)) attrs[k] = Math.max(2, Math.min(5, v + delta));
|
||
const hp = Math.max(1, profile.hp + delta * 2);
|
||
const armor = Math.max(0, Math.min(6, profile.armor + delta));
|
||
const resolve = Math.max(2, attrs.wits + attrs.empathy);
|
||
|
||
return {
|
||
actor: {
|
||
name: name || `${profile.label} #${Math.floor(Math.random() * 900 + 100)}`,
|
||
type: "npc",
|
||
system: {
|
||
biography: biography || "",
|
||
threat: t,
|
||
attributes: { ...attrs },
|
||
health: { value: hp, max: hp },
|
||
derivedStats: { health: { value: hp, max: hp }, resolve: { value: resolve, max: resolve } },
|
||
skills: { ...profile.skills },
|
||
armor,
|
||
damage: Math.max(2, attrs.strength),
|
||
flux: profile.flux ? { value: profile.flux, max: profile.flux } : { value: 0, max: 0 }
|
||
}
|
||
},
|
||
// 'claws' is a natural attack reflected by the damage stat — not an item.
|
||
weapons: (profile.weapons || []).filter((w) => w !== "claws").map(weaponItem),
|
||
label: profile.label
|
||
};
|
||
}
|
||
|
||
function randInt(lo, hi) { return Math.floor(Math.random() * (hi - lo + 1)) + lo; }
|
||
|
||
/** True when actor creation is possible (running under the Zero Engine). */
|
||
export function zeroEngineActive() {
|
||
return game.system?.id === "zero-engine";
|
||
}
|
||
|
||
/**
|
||
* Create the BPN's NPC actors in a folder. Returns { created, folder } or
|
||
* { created: 0, skipped: true } when not on the Zero Engine.
|
||
*/
|
||
export async function createZeroActors(bpn) {
|
||
if (!zeroEngineActive()) return { created: 0, skipped: true };
|
||
|
||
const base = `${bpn.colour.label} BPN — ${bpn.shortTitle}`;
|
||
const folder = await Folder.create({ name: base, type: "Actor", color: bpn.colour.accent });
|
||
const fid = folder.id;
|
||
|
||
const builds = [];
|
||
|
||
// 1) The threat group: a named leader plus a number of mooks.
|
||
const threatKey = bpn.npcSeam?.threatArchetype || "ganger";
|
||
const group = THREAT_GROUPS[threatKey] || THREAT_GROUPS.ganger;
|
||
const leaderProfile = ZERO_PROFILES[group.leader] || ZERO_PROFILES.gang_grunt;
|
||
const leaderName = bpn.gm?.threatLead || `${leaderProfile.label} Leader`;
|
||
builds.push(npcActorData(group.leader, {
|
||
threat: leaderProfile.threat + 1,
|
||
name: leaderName,
|
||
biography: `Threat lead for ${base}.\n${bpn.opposition}\n${bpn.gm?.threatCountLabel ?? ""}`.trim()
|
||
}));
|
||
const mookCount = randInt(group.mooks[0], group.mooks[1]);
|
||
const mookProfile = ZERO_PROFILES[group.mook] || ZERO_PROFILES.gang_grunt;
|
||
for (let i = 0; i < mookCount; i++) {
|
||
builds.push(npcActorData(group.mook, {
|
||
name: `${mookProfile.label} ${i + 1}`,
|
||
biography: `Part of the opposition for ${base}.`
|
||
}));
|
||
}
|
||
|
||
// 2) The named cast (skip the threat lead — already built above as leader).
|
||
for (const npc of (bpn.cast || [])) {
|
||
if (npc.role === "Designated threat") continue;
|
||
builds.push(npcActorData(contactProfile(npc.role), {
|
||
name: npc.name,
|
||
biography: `${npc.role} · ${npc.disposition}\n${npc.description}\nSignificance: ${npc.significance}`
|
||
}));
|
||
}
|
||
|
||
// Create actors, then embed their weapons.
|
||
let created = 0;
|
||
for (const b of builds) {
|
||
try {
|
||
const actor = await Actor.create({ ...b.actor, folder: fid });
|
||
if (actor && b.weapons.length) await actor.createEmbeddedDocuments("Item", b.weapons);
|
||
created++;
|
||
} catch (err) {
|
||
console.warn("Blueprint News 0E | actor create failed:", err);
|
||
}
|
||
}
|
||
|
||
return { created, folder, threatLabel: leaderProfile.label, mookCount, mookLabel: mookProfile.label };
|
||
}
|