Files
sla_industries_brp/sla-industries-brp/module/actor/actor.mjs
T

943 lines
36 KiB
JavaScript

import { BRPactorItemDrop } from './actor-itemDrop.mjs'
import { BRPID } from '../brpid/brpid.mjs';
import { SLAEbbSystem } from "../apps/sla-ebb-system.mjs";
import { SLASeedImporter } from "../apps/sla-seed-importer.mjs";
import { SLASkillPoints } from "../apps/sla-skill-points.mjs";
export class BRPActor extends Actor {
prepareData() {
super.prepareData();
}
prepareBaseData() {
this.system.brpidFlagItems = {}
for (const i of this.items) {
if (i.flags.brp?.brpidFlag?.id) {
const parts = i.flags.brp?.brpidFlag?.id.match(/^([^\.]+)\.([^\.]+)\.([^\.]+)$/)
if (parts) {
if (typeof this.system.brpidFlagItems[parts[1]] === 'undefined') {
this.system.brpidFlagItems[parts[1]] = {}
}
if (typeof this.system.brpidFlagItems[parts[1]][parts[2]] === 'undefined') {
this.system.brpidFlagItems[parts[1]][parts[2]] = {}
}
this.system.brpidFlagItems[parts[1]][parts[2]][parts[3]] = i
}
}
}
}
prepareDerivedData() {
const actorData = this;
const systemData = actorData.system;
const flags = actorData.flags.boilerplate || {};
//Prepare data for different actor types
this._prepareCharacterData(actorData);
this._prepareNpcData(actorData);
}
//Prepare Character specific data
async _prepareCharacterData(actorData) {
if (actorData.type !== 'character') return;
const systemData = actorData.system;
systemData.sla = systemData.sla ?? {};
if (!Number.isFinite(Number(systemData.sla.credits))) {
systemData.sla.credits = 0;
} else {
systemData.sla.credits = Number(systemData.sla.credits);
}
this._prepStats(actorData)
this._prepDerivedStats(actorData)
systemData.health.value = systemData.health.max;
//Initialise Powers IDs
systemData.magic = "";
systemData.mutation = "";
systemData.psychic = "";
systemData.sorcery = "";
systemData.super = "";
//Initialise health statuses and other values
systemData.dead = false;
systemData.severed = false;
systemData.unconscious = false;
systemData.injured = false;
systemData.incapacitated = false;
systemData.bleeding = false
systemData.enc = 0;
systemData.av1 = 0;
systemData.av2 = 0;
systemData.avr1 = "";
systemData.avr2 = "";
systemData.psCurr = 0;
systemData.psMax = 0;
//Set Hit Location AP to zero
for (let itm of actorData.items) {
if (itm.type === 'hit-location') {
itm.system.av1 = 0
itm.system.av2 = 0
itm.system.avr1 = ""
itm.system.avr2 = ""
itm.system.enc = 0
itm.system.ppCurr = 0
itm.system.pSCurr = 0
itm.system.mnplmod = 0
itm.system.percmod = 0
itm.system.physmod = 0
itm.system.stealthmod = 0
}
}
//Calculate Skill Category Bonuses
for (let itm of actorData.items) {
if (itm.type === 'skillcat') {
let bonus = 0;
if (game.settings.get('sla-industries-brp', 'skillBonus') === "1" && itm.system.stat != 'none') {
bonus = Math.ceil(systemData.stats[itm.system.stat].total / 2);
} else if (game.settings.get('sla-industries-brp', 'skillBonus') === "2") {
for (let [key, value] of Object.entries(itm.system.attrib)) {
if (key === 'edu' && !game.settings.get('sla-industries-brp', 'useEDU')) { continue }
switch (value) {
case "0": //No adjustment
break;
case "1": //Primary
bonus += this._categoryprimary(systemData.stats[key].total)
break;
case "2": //Secondary
bonus += this._categorysecondary(systemData.stats[key].total)
break;
case "3": //Negative
bonus += this._categorynegative(systemData.stats[key].total)
break;
case "4": //Negative Secondary
bonus += this._categorynegsec(systemData.stats[key].total)
break;
}
}
}
itm.system.bonus = bonus
itm.system.total = itm.system.bonus + itm.system.mod
let key = itm.flags.brp.brpidFlag.id
systemData.skillcategory[key] = itm.system.total;
}
}
//Calcualte/adjust scores for items (itm)
const skillCreationCap = SLASkillPoints.getCreationCapForActor(actorData)
for (let itm of actorData.items) {
//Does the item have transferrable effects
if (['gear', 'armour', 'weapon','wound'].includes(itm.type)) {
if (itm.transferredEffects.length > 0) {
itm.system.hasEffects = true;
} else {
itm.system.hasEffects = false;
}
}
//If skill, magic or psychic calculate the total score and record the category bonus
if (itm.type === 'skill' || itm.type === 'magic' || itm.type === 'psychic') {
const rawTotal = itm.system.base + itm.system.xp + itm.system.effects + itm.system.profession + itm.system.personality + itm.system.personal + itm.system.culture
itm.system.total = (systemData.creationMode && itm.type === "skill")
? Math.min(skillCreationCap, rawTotal)
: rawTotal
itm.system.catBonus = systemData.skillcategory[itm.system.category]
//If Allegiance the set total and potential Ally status
} else if (itm.type === 'allegiance') {
itm.system.total = itm.system.allegPoints + itm.system.xp
itm.system.potAlly = false
//If Passion the set total
} else if (itm.type === 'passion') {
itm.system.total = itm.system.base + itm.system.xp
//If Personality Trait the set total
} else if (itm.type === 'persTrait') {
itm.system.total = Math.max(0, Math.min(100, itm.system.base + itm.system.xp))
itm.system.opptotal = 100 - itm.system.total
//If Reputation set total
} else if (itm.type === 'reputation') {
itm.system.total = itm.system.base
//If gear/weapon, calculates the encumbrance
} else if (['gear', 'weapon'].includes(itm.type)) {
if (itm.system.equipStatus === 'carried') {
itm.system.actlEnc = itm.system.quantity * itm.system.enc
systemData.enc = Number(systemData.enc + itm.system.actlEnc)
} else { itm.system.actlEnc = 0 }
if (itm.system.equipStatus != 'stored') {
if (itm.system.pSCurr > 0) { systemData.psCurr = systemData.psCurr + itm.system.pSCurr }
if (itm.system.pSMax > 0) { systemData.psMax = systemData.psMax + itm.system.pSMax }
}
//Does the item have transferrable effects _TODO is this duplicate of 126-131, if so delete
if (itm.transferredEffects.length > 0) {
itm.system.hasEffects = true;
} else {
itm.system.hasEffects = false;
}
//If armour
} else if (itm.type === 'armour') {
const hitLoc = game.settings.get('sla-industries-brp', 'useHPL') ? actorData.items.get(itm.system.hitlocID) : null
//Calc encumbrance based on carry status and if using HPL
if (itm.system.equipStatus === 'carried') {
if (game.settings.get('sla-industries-brp', 'useHPL') && hitLoc?.system?.fractionENC) {
itm.system.actlEnc = Math.round(itm.system.quantity * itm.system.enc / hitLoc.system.fractionENC * 10) / 10
} else {
itm.system.actlEnc = Number(itm.system.quantity * itm.system.enc)
}
//If not carried then zero ENC
} else {
itm.system.actlEnc = 0
}
systemData.enc = systemData.enc + itm.system.actlEnc
if (itm.system.equipStatus != 'stored') {
if (itm.system.pSCurr > 0) { systemData.psCurr = systemData.psCurr + itm.system.pSCurr }
if (itm.system.pSMax > 0) { systemData.psMax = systemData.psMax + itm.system.pSMax }
if (game.settings.get('sla-industries-brp', 'useHPL') && hitLoc?.system) {
hitLoc.system.ppCurr += itm.system.ppCurr
hitLoc.system.pSCurr += Number(itm.system.pSCurr)
}
}
//Add the Armour Point Score to the Hit Location if worn
if (itm.system.equipStatus === 'worn') {
if (game.settings.get('sla-industries-brp', 'useHPL') && hitLoc?.system) {
hitLoc.system.av1 += itm.system.av1
hitLoc.system.mnplmod += itm.system.mnplmod
hitLoc.system.percmod += itm.system.percmod
hitLoc.system.physmod += itm.system.physmod
hitLoc.system.stealthmod += itm.system.stealthmod
if (itm.system.av2 > 0) {
hitLoc.system.av2 += itm.system.av2
}
if (itm.system.avr1 != "") {
hitLoc.system.avr1 += "+" + itm.system.avr1
}
if (itm.system.avr2 != "") {
hitLoc.system.avr2 += "+" + itm.system.avr2
}
} else {
systemData.av1 += itm.system.av1
systemData.av2 += itm.system.av2
if (itm.system.avr1 != "") {
systemData.avr1 += "+" + itm.system.avr1
}
if (itm.system.avr2 != "") {
systemData.avr2 += "+" + itm.system.avr2
}
}
}
if (game.settings.get('sla-industries-brp', 'useHPL') && hitLoc?.system) {
hitLoc.system.enc += itm.system.actlEnc
}
//If power then record the ID against the category
} else if (itm.type === 'power') {
systemData[itm.system.category] = itm._id;
} else if (itm.type === 'profession') {
systemData.profession = itm.name
systemData.professionId = itm._id;
//If hit locations calculation HPL max and current and statuses
} else if (itm.type === 'hit-location' && game.settings.get('sla-industries-brp', 'useHPL')) {
itm.system.injured = false;
itm.system.maxHP = Math.max((Math.ceil(systemData.health.max / itm.system.fractionHP) + itm.system.adj), 0);
itm.system.currHP = itm.system.maxHP
//Loop through items (wnd) to find wounds
for (let wnd of actorData.items) {
if (wnd.type === 'wound' && wnd.system.locId === itm._id) {
itm.system.currHP = itm.system.currHP - wnd.system.value
}
}
if (itm.system.currHP < 1 && itm.system.locType === 'limb') {
itm.system.injured = true
} else if (itm.system.currHP < 1 && itm.system.locType === 'abdomen') {
itm.system.injured = true
systemData.injured = true
} else if (itm.system.currHP < 1 && itm.system.locType === 'chest') {
itm.system.incapacitated = true
systemData.incapacitated = true
} else if (itm.system.currHP < 1 && itm.system.locType === 'head') {
itm.system.unconscious = true
systemData.unconscious = true
}
if (itm.system.bleeding) { systemData.bleeding = true }
if (itm.system.unconscious) { systemData.unconscious = true }
if (itm.system.incapacitated) { systemData.incapacitated = true }
if (itm.system.severed) { systemData.severed = true }
if (itm.system.dead) { systemData.dead = true }
//If wound calculate total HPL
} else if (itm.type === 'wound') {
systemData.health.value = systemData.health.value - itm.system.value
}
}
//Calculate allegiance target
let allegiances = actorData.items.filter(itm => itm.type === 'allegiance')
allegiances.sort(function (a, b) {
let x = a.system.total;
let y = b.system.total;
if (x < y) { return 1 };
if (x > y) { return -1 };
return 0;
});
//If there is one allegiance
let ally = ""
if (allegiances.length === 1 && allegiances[0].system.total >= 20) {
actorData.items.get(allegiances[0]._id).system.potAlly = true
} else if (allegiances.length > 1 && (allegiances[0].system.total - allegiances[1].system.total) >= 20) {
actorData.items.get(allegiances[0]._id).system.potAlly = true
}
//Round ENC to 2 decimals and then adjust fatigue
systemData.enc = (systemData.enc).toFixed(2)
systemData.fatigue.max = Math.ceil(systemData.stats.str.total + systemData.stats.con.total - systemData.enc + systemData.fatigue.mod + (systemData.fatigue.effects ?? 0));
//Derive Health Statuses from total HP
if (systemData.health.value < 1) {
systemData.dead = true;
} else if (systemData.health.value < 3) {
systemData.unconscious = true;
}
}
//Prepare NPC specific data.
async _prepareNpcData(actorData) {
if (actorData.type !== 'npc') return;
const systemData = actorData.system;
this._prepStats(actorData)
this._prepDerivedStats(actorData)
for (let [key, stat] of Object.entries(actorData.system.baseStats)) {
stat.label = game.i18n.localize(CONFIG.BRP.stats[key]) ?? key;
stat.labelShort = game.i18n.localize(CONFIG.BRP.statsAbbreviations[key]) ?? key;
//Mark all stats as visible except for EDU where it's not being used
stat.visible = true;
if (key === 'edu' && !game.settings.get('sla-industries-brp', 'useEDU')) { stat.visible = false }
}
let damage = 0
systemData.fatigue.max = Math.ceil(systemData.stats.str.total + systemData.stats.con.total + systemData.fatigue.mod + (systemData.fatigue.effects ?? 0));
for (let itm of actorData.items) {
if (['skill', 'psychic', 'magic', 'passion', 'reputation'].includes(itm.type)) {
itm.system.total = itm.system.base + (itm.system.effects ?? 0);
} else if (['allegiance'].includes(itm.type)) {
itm.system.total = itm.system.allegPoints;
} else if (['persTrait'].includes(itm.type)) {
itm.system.total = itm.system.base;
itm.system.opptotal = 100 - itm.system.base;
}
else if (itm.type === 'hit-location' && game.settings.get('sla-industries-brp', 'useHPL')) {
itm.system.injured = false;
itm.system.maxHP = Math.max((Math.ceil(systemData.health.max / itm.system.fractionHP) + itm.system.adj), 0);
damage = damage + Math.max(itm.system.maxHP - itm.system.currHP, 0)
}
}
//If using HPL then set current health
if (game.settings.get('sla-industries-brp', 'useHPL')) {
systemData.health.value = systemData.health.max - damage
}
}
getRollData() {
const data = super.getRollData();
// Prepare character roll data.
this._getCharacterRollData(data);
this._getNpcRollData(data);
return data;
}
// Prepare character roll data.
_getCharacterRollData(data) {
if (this.type !== 'character') return;
// Copy the ability scores to the top level
if (data.stats) {
for (let [k, v] of Object.entries(data.stats)) {
data[k] = foundry.utils.deepClone(v);
}
}
}
// Prepare NPC roll data
_getNpcRollData(data) {
if (this.type !== 'npc') return;
}
//Prepare Stats
_prepStats(actorData) {
// Loop through ability scores, calculating total and derived scores
actorData.system.redistInc = 0
actorData.system.redistDec = 0
for (let [key, stat] of Object.entries(actorData.system.stats)) {
stat.label = game.i18n.localize(CONFIG.BRP.stats[key]) ?? key;
stat.labelShort = game.i18n.localize(CONFIG.BRP.statsAbbreviations[key]) ?? key;
stat.labelDeriv = game.i18n.localize(CONFIG.BRP.statsDerived[key]) ?? key;
stat.total = Number(stat.base) + Number(stat.redist) + Number(stat.culture) + Number(stat.exp) + Number(stat.effects) + Number(stat.age);
stat.deriv = stat.total * 5;
if (stat.redist < 0) { actorData.system.redistDec = actorData.system.redistDec + stat.redist }
if (stat.redist > 0) { actorData.system.redistInc = actorData.system.redistInc + stat.redist }
//Mark all stats as visible except for EDU where it's not being used
stat.visible = true;
if (key === 'edu' && !game.settings.get('sla-industries-brp', 'useEDU')) { stat.visible = false }
}
if (actorData.type === 'npc')
for (let [key, stat] of Object.entries(actorData.system.baseStats)) {
stat.labelShort = game.i18n.localize(CONFIG.BRP.statsAbbreviations[key]) ?? key;
//Mark all stats as visible except for EDU where it's not being used
stat.visible = true;
if (key === 'edu' && !game.settings.get('sla-industries-brp', 'useEDU')) { stat.visible = false }
}
actorData.system.redistTotal = actorData.system.redistInc + actorData.system.redistDec
}
// Calculate derived scores
_prepDerivedStats(actorData) {
let systemData = actorData.system
if (actorData.type === 'character') {
const hpMod = Number(game.settings.get('sla-industries-brp', 'hpMod') ?? 1);
const pulpMultiplier = game.settings.get('sla-industries-brp', 'pulpSla') ? 2 : 1;
systemData.health.max = Math.ceil((systemData.stats.con.total + systemData.stats.siz.total) * hpMod * pulpMultiplier / 2) + systemData.health.mod + (systemData.health.effects ?? 0);
} else {
let health = 0
let formula = ""
let statCount = 0
if (systemData.hp.stat1 != 'none') {
health += systemData.stats[systemData.hp.stat1].total
statCount++
formula = game.i18n.localize(CONFIG.BRP.statsAbbreviations[systemData.hp.stat1])
}
if (systemData.hp.stat2 != 'none') {
health += systemData.stats[systemData.hp.stat2].total
statCount++
if (statCount === 2) { formula = formula + " + " }
formula = formula + game.i18n.localize(CONFIG.BRP.statsAbbreviations[systemData.hp.stat2])
}
health = Math.ceil(health * systemData.hp.multi)
if (statCount === 1) {
formula = "(" + formula + " * " + systemData.hp.multi + ")"
} else if (statCount === 2) {
formula = "((" + formula + ") * " + systemData.hp.multi + ")"
}
if (systemData.health.mod < 0) {
formula = formula + " " + systemData.health.mod
} else if (systemData.health.mod > 0) {
formula = formula + " + " + systemData.health.mod
}
systemData.hp.formula = formula
systemData.health.max = health + (systemData.health.mod ?? 0) + (systemData.health.effects ?? 0);
}
systemData.health.mjrwnd = Math.ceil(systemData.health.max / 2);
systemData.power.max = systemData.stats.pow.total + systemData.power.mod + (systemData.power.effects ?? 0);
if (actorData.type === 'character' && !SLAEbbSystem.isEbbSpecies(actorData)) {
// Flux is SLA EBB-only and only available to Ebon / Brain Waster characters.
systemData.power.max = 0;
systemData.power.value = 0;
} else {
systemData.power.value = Math.max(0, Math.min(Number(systemData.power.value ?? 0), Number(systemData.power.max ?? 0)));
}
const derivedSanMax = Math.max(1, Number(systemData.stats.pow.total ?? 0) * 5);
const currentSanMax = Number(systemData.sanity?.max ?? 0);
const currentSan = Number(systemData.sanity?.value ?? 0);
const needsSanInit = (!Number.isFinite(currentSanMax) || currentSanMax <= 0 || (currentSanMax === 5 && currentSan === 5 && derivedSanMax > 5));
const needsLegacySanMigration = (Number.isFinite(currentSanMax) && currentSanMax > 0 && currentSanMax < derivedSanMax && currentSanMax <= 10);
if (needsSanInit || needsLegacySanMigration) {
// Migrate legacy small SAN pools (e.g. 3/5) to POWx5 while preserving any existing SAN loss.
const legacyLoss = (Number.isFinite(currentSanMax) && Number.isFinite(currentSan))
? Math.max(0, Number(currentSanMax) - Number(currentSan))
: 0;
systemData.sanity.max = derivedSanMax;
systemData.sanity.value = Math.max(0, derivedSanMax - legacyLoss);
} else {
systemData.sanity.max = Math.max(1, currentSanMax);
systemData.sanity.value = Math.max(0, Math.min(Number(currentSan ?? 0), Number(systemData.sanity.max ?? 0)));
}
if (game.settings.get('sla-industries-brp', 'useRes5')) {
const derivedCoolMax = Math.max(1, Number(systemData.stats.pow.total ?? 0) * 2);
systemData.res5.max = derivedCoolMax;
if (!Number.isFinite(Number(systemData.res5.value))) {
systemData.res5.value = derivedCoolMax;
}
systemData.res5.value = Math.max(0, Math.min(Number(systemData.res5.value ?? 0), Number(systemData.res5.max ?? 0)));
}
systemData.xpBonus = Math.ceil(systemData.stats.int.total / 2);
systemData.dmgBonus = this._damageBonus(systemData.stats.str.total + systemData.stats.siz.total)
//Set Resource Labels
if (game.settings.get('sla-industries-brp', 'ppLabelLong')) {
systemData.power.label = game.settings.get('sla-industries-brp', 'ppLabelLong')
} else {
systemData.power.label = game.i18n.localize('BRP.pp')
}
if (game.settings.get('sla-industries-brp', 'ppLabelShort')) {
systemData.power.labelAbbr = game.settings.get('sla-industries-brp', 'ppLabelShort')
} else {
systemData.power.labelAbbr = game.i18n.localize('BRP.ppShort')
}
if (game.settings.get('sla-industries-brp', 'hpLabelLong')) {
systemData.health.label = game.settings.get('sla-industries-brp', 'hpLabelLong')
} else {
systemData.health.label = game.i18n.localize('BRP.health')
}
if (game.settings.get('sla-industries-brp', 'hpLabelShort')) {
systemData.health.labelAbbr = game.settings.get('sla-industries-brp', 'hpLabelShort')
} else {
systemData.health.labelAbbr = game.i18n.localize('BRP.hp')
}
if (game.settings.get('sla-industries-brp', 'fpLabelLong')) {
systemData.fatigue.label = game.settings.get('sla-industries-brp', 'fpLabelLong')
} else {
systemData.fatigue.label = game.i18n.localize('BRP.fatigue')
}
if (game.settings.get('sla-industries-brp', 'fpLabelShort')) {
systemData.fatigue.labelAbbr = game.settings.get('sla-industries-brp', 'fpLabelShort')
} else {
systemData.fatigue.labelAbbr = game.i18n.localize('BRP.fp')
}
if (game.settings.get('sla-industries-brp', 'res5LabelLong')) {
systemData.res5.label = game.settings.get('sla-industries-brp', 'res5LabelLong')
} else {
systemData.res5.label = game.i18n.localize('BRP.res5')
}
if (game.settings.get('sla-industries-brp', 'res5LabelShort')) {
systemData.res5.labelAbbr = game.settings.get('sla-industries-brp', 'res5LabelShort')
} else {
systemData.res5.labelAbbr = game.i18n.localize('BRP.res5Abbr')
}
}
//Used for Rolling NPCs when token dropped
get hasRollableCharacteristics() {
for (const [, value] of Object.entries(this.system.baseStats)) {
if (isNaN(Number(value.random))) return true
if (isNaN(Number(value.average))) return true
}
return false
}
//Roll Random Stats
async rollCharacteristicsValue() {
const stats = {}
for (const [key, value] of Object.entries(this.system.baseStats)) {
if (value.random && !value.random.startsWith('@')) {
const r = new Roll(value.random)
await r.evaluate()
if (r.total) {
stats[`system.stats.${key}.base`] = Math.floor(
r.total
)
}
}
}
await this.update(stats)
await this.updateVitals()
}
//Roll Average Stats
async averageCharacteristicsValue() {
const stats = {}
for (const [key, value] of Object.entries(this.system.baseStats)) {
if (value.average && !value.average.startsWith('@')) {
const r = new Roll(value.average)
await r.evaluate()
if (r.total) {
stats[`system.stats.${key}.base`] = Math.floor(
r.total
)
}
}
}
await this.update(stats)
await this.updateVitals()
}
//Update Current HP, FP etc and HPL when Rolled or Average Roll
async updateVitals() {
let checkProp = {
'system.health.value': this.system.health.max,
'system.power.value': this.system.power.max,
'system.fatigue.value': this.system.fatigue.max,
'system.sanity.value': this.system.sanity.max
}
await this.update(checkProp)
if (game.settings.get('sla-industries-brp', 'useHPL')) {
let hitLocs = this.items.filter(itm => itm.type === 'hit-location').map(itm => {
return { _id: itm.id, 'system.currHP': itm.system.maxHP }
})
await Item.updateDocuments(hitLocs, { parent: this })
}
}
//Create a new actor - When creating an actor set basics including tokenlink, bars, displays sight
static async create(data, options = {}) {
//If dropping from compendium check to see if the actor already exists in game.actors and if it does then get the game.actors details rather than create a copy
if (options.fromCompendium) {
let tempActor = await (game.actors.filter(actr => actr.flags?.brp?.brpidFlag?.id === data.flags.brp.brpidFlag.id && actr.flags?.brp?.brpidFlag?.priority === data.flags.brp.brpidFlag.priority))[0]
if (tempActor) { return tempActor }
}
if (data.type === 'character') {
data.prototypeToken = foundry.utils.mergeObject({
actorLink: true,
disposition: 1,
displayName: CONST.TOKEN_DISPLAY_MODES.ALWAYS,
displayBars: CONST.TOKEN_DISPLAY_MODES.ALWAYS,
sight: {
enabled: true
},
detectionModes: [{
id: 'basicSight',
range: 30,
enabled: true
}]
}, data.prototypeToken || {})
}
let actor = await super.create(data, options)
//Add BRPID based on actor name if the game setting is flagged.
if (game.settings.get('sla-industries-brp', "actorBRPID")) {
let tempID = await BRPID.guessId(actor)
if (tempID) {
await actor.update({ 'flags.brp.brpidFlag.id': tempID })
const html = $(actor.sheet.element).find('header.window-header a.header-button.edit-brpid-warning,header.window-header a.header-button.edit-brpid-exisiting')
if (html.length) {
html.css({
color: (tempID ? 'orange' : 'red')
})
}
actor.render()
}
}
//Set up a general hit location for a new actor if using HPL
if (actor.type === 'character' && game.settings.get('sla-industries-brp', 'useHPL')) {
let brpid = "i.hit-location.general"
const itemData = [{
name: game.i18n.localize('BRP.general'),
type: 'hit-location',
img: 'systems/sla-industries-brp/assets/Icons/arm-bandage.svg',
flags: { brp: { brpidFlag: { id: "i.hit-location.general" } } },
system: {
"fractionHP": 1,
"fractionENC": 1,
"lowRoll": 0,
"highRoll": 0,
"locType": "general"
}
}];
const newItem = await Item.createDocuments(itemData, { parent: actor });
}
//Add Starter Traits if toggled on
if (actor.type === 'character' && game.settings.get('sla-industries-brp', 'starterTraits') && game.settings.get('sla-industries-brp', 'usePersTrait')) {
let newTraits = []
let traitList = (await game.system.api.brpid.fromBRPIDRegexBest({ brpidRegExp: new RegExp('^i.persTrait'), type: 'i' })).filter(itm => itm.system.basic)
for (let itm of traitList) {
if (actor.items.filter(nitm => nitm.flags.brp.brpidFlag.id === itm.flags.brp.brpidFlag.id).length < 1) {
newTraits.push(itm);
}
}
await Item.createDocuments(newTraits, { parent: actor });
}
//Add Skill Categories
if (actor.type === 'character') {
let newSkillCats = []
let skillCatList = (await game.system.api.brpid.fromBRPIDRegexBest({ brpidRegExp: new RegExp('^i.skillcat'), type: 'i' }))
for (let itm of skillCatList) {
if (actor.items.filter(nitm => nitm.flags.brp.brpidFlag.id === itm.flags.brp.brpidFlag.id).length < 1) {
newSkillCats.push(itm)
}
}
await Item.createDocuments(newSkillCats, { parent: actor })
// SLA baseline: every new character gets full SLA skill roster.
await BRPActor.ensureSLASkillRoster(actor, {
includeEbb: SLAEbbSystem.isEbbSpecies(actor),
pruneEbbForNonEligible: true
})
await SLASkillPoints.initializeForActor({
actor,
trainingPackage: null,
resetAllocations: false,
creationMode: true
})
}
return actor
}
static async ensureSLASkillRoster(actor, { includeEbb = null, pruneEbbForNonEligible = true } = {}) {
if (!actor || actor.type !== "character") return { created: 0, removed: 0, missing: [] }
if (includeEbb === null || includeEbb === undefined) {
includeEbb = SLAEbbSystem.isEbbSpecies(actor)
}
const seed = await SLASeedImporter.loadSeed("skills.json").catch(() => null)
if (!seed?.skills?.length) {
// Fallback for non-seeded worlds.
if (game.settings.get('sla-industries-brp', 'starterSkills')) {
let newSkills = []
let skillList = (await game.system.api.brpid.fromBRPIDRegexBest({ brpidRegExp: new RegExp('^i.skill'), type: 'i' })).filter(itm => itm.system.basic)
for (let itm of skillList) {
if (actor.items.filter(nitm => nitm.flags.brp.brpidFlag.id === itm.flags.brp.brpidFlag.id).length < 1) {
itm.system.base = await BRPactorItemDrop._calcBase(itm, actor)
newSkills.push(itm)
}
}
await Item.createDocuments(newSkills, { parent: actor })
await BRPActor.charBaseSkillScores(actor)
return { created: newSkills.length, removed: 0, missing: [] }
}
return { created: 0, removed: 0, missing: [] }
}
const existing = new Map()
for (const item of actor.items.filter((i) => i.type === "skill")) {
const brpid = item.flags?.[game.system.id]?.brpidFlag?.id ?? item.flags?.brp?.brpidFlag?.id
if (brpid) existing.set(brpid, item)
}
const createData = []
const missing = []
const iconUpdates = []
for (const skillRow of seed.skills) {
const skillName = String(skillRow?.name ?? "").trim()
if (!skillName) continue
const isEbbSkill = BRPActor._isEbbSkillName(skillName)
if (isEbbSkill && !includeEbb) continue
const brpid = await SLASeedImporter.resolveSkillBrpid(skillName)
if (!brpid || brpid === "none") {
missing.push(skillName)
continue
}
if (existing.has(brpid)) continue
const sourceDoc = (await game.system.api.brpid.fromBRPIDBest({ brpid }))[0]
if (!sourceDoc || sourceDoc.type !== "skill") {
missing.push(skillName)
continue
}
const data = sourceDoc.toObject()
delete data._id
delete data.folder
delete data.sort
delete data.ownership
delete data.pack
data.system = data.system ?? {}
if (!BRPactorItemDrop._isSkillCategoryBrpid?.(data.system.category)) {
const fallbackCategory = await BRPactorItemDrop._getFallbackSkillCategoryBrpid?.(actor)
if (fallbackCategory) data.system.category = fallbackCategory
}
// Normalize specialty placeholders (e.g. Craft (Specialty)) even if source flags are missing.
if (!data.system.specialism && /\(\s*specialty\s*\)/i.test(skillName)) {
const mainName = String(data.system.mainName || skillName.replace(/\(\s*specialty\s*\)/i, "").trim()).trim()
data.system.specialism = true
data.system.mainName = mainName
data.system.specName = String(data.system.specName || "Specialty").trim()
data.system.chosen = true
data.name = `${mainName} (${data.system.specName})`
}
// Reserve one specialty slot when a skill uses specialties.
if (data.system.specialism && !data.system.chosen) {
const mainName = String(data.system.mainName || skillName).trim()
const specName = String(data.system.specName || "Specialty").trim()
data.system.mainName = mainName
data.system.specName = specName
data.system.chosen = true
data.name = `${mainName} (${specName})`
}
const skillIcon = await SLASeedImporter.getSkillIconPath(skillName)
if (skillIcon) {
data.img = skillIcon
}
data.system.base = await BRPactorItemDrop._calcBase(data, actor)
createData.push(data)
}
if (createData.length) {
await actor.createEmbeddedDocuments("Item", createData)
}
for (const skillItem of actor.items.filter((item) => item.type === "skill")) {
const iconName = String(skillItem.system?.mainName || skillItem.name || "").trim()
if (!iconName) continue
const iconPath = await SLASeedImporter.getSkillIconPath(iconName)
if (!iconPath || skillItem.img === iconPath) continue
iconUpdates.push({ _id: skillItem.id, img: iconPath })
}
if (iconUpdates.length) {
await actor.updateEmbeddedDocuments("Item", iconUpdates)
}
let removed = 0
if (!includeEbb && pruneEbbForNonEligible) {
const ebbSkillIds = actor.items
.filter((item) => item.type === "skill")
.filter((item) => BRPActor._isEbbSkillName(String(item.system?.mainName || item.name || "")))
.map((item) => item.id)
if (ebbSkillIds.length) {
await actor.deleteEmbeddedDocuments("Item", ebbSkillIds)
removed = ebbSkillIds.length
}
}
if (createData.length || removed > 0) {
await BRPActor.charBaseSkillScores(actor)
}
return { created: createData.length, removed, missing }
}
static async syncAllCharacterSkillRosters({ notify = true } = {}) {
const actors = game.actors?.filter((a) => a.type === "character") ?? []
let created = 0
let removed = 0
const missing = new Set()
const failed = []
for (const actor of actors) {
try {
const result = await BRPActor.ensureSLASkillRoster(actor, {
includeEbb: SLAEbbSystem.isEbbSpecies(actor),
pruneEbbForNonEligible: true
})
created += Number(result?.created ?? 0)
removed += Number(result?.removed ?? 0)
for (const row of result?.missing ?? []) missing.add(String(row))
} catch (err) {
failed.push(String(actor?.name ?? actor?.id ?? "unknown"))
console.warn("sla-industries-brp | SLA roster sync failed for actor", {
actor: actor?.name,
actorId: actor?.id,
err
})
}
}
const summary = { actors: actors.length, created, removed, missing: [...missing], failed }
if (notify && (created > 0 || removed > 0 || summary.missing.length > 0)) {
const parts = []
if (created > 0) parts.push(`${created} added`)
if (removed > 0) parts.push(`${removed} removed`)
if (summary.missing.length > 0) parts.push(`${summary.missing.length} unresolved refs`)
if (summary.failed.length > 0) parts.push(`${summary.failed.length} actor failures`)
ui.notifications.info(`SLA skill roster sync: ${parts.join(", ")}.`)
}
return summary
}
static _isEbbSkillName(name = "") {
const norm = String(name ?? "").trim().toLowerCase()
return (
norm === "biofeedback" ||
norm === "formulate" ||
norm === "ebb (core)" ||
norm.startsWith("ebb ")
)
}
//Recalculate all skill base scores
static async charBaseSkillScores(actor) {
let change = []
for (let itm of actor.items) {
if (itm.type != 'skill') { continue }
let baseScore = await BRPactorItemDrop._calcBase(itm, actor)
change.push({ _id: itm._id, "system.base": baseScore })
}
await Item.updateDocuments(change, { parent: actor })
}
// Primary Skills Bonus
_categoryprimary(statvalue) {
var bonus = statvalue - 10;
return bonus;
}
// Secondary Skills Bonus
_categorysecondary(statvalue) {
var bonus = (statvalue - 10) / 2
if (bonus < 0) {
bonus = Math.ceil(bonus);
} else {
bonus = Math.floor(bonus);
}
return bonus;
}
// Negative Skills Bonus
_categorynegative(statvalue) {
var bonus = 10 - statvalue;
return bonus;
}
// Negative Secondary Skills Bonus
_categorynegsec(statvalue) {
var bonus = (10 - statvalue) / 2
if (bonus < 0) {
bonus = Math.ceil(bonus);
} else {
bonus = Math.floor(bonus);
}
return bonus;
}
//Damage Bonus
_damageBonus(value) {
let dmgBonus = {}
if (value < 13) {
dmgBonus.full = '-1D6'
dmgBonus.half = '-1D3'
} else if (value < 17) {
dmgBonus.full = '-1D4'
dmgBonus.half = '-1D2'
} else if (value < 25) {
dmgBonus.full = '+0'
dmgBonus.half = '+0'
} else if (value < 33) {
dmgBonus.full = '+1D4'
dmgBonus.half = '+1D2'
} else if (value < 41) {
dmgBonus.full = '+1D6'
dmgBonus.half = '+1D3'
} else {
dmgBonus.full = "+" + (Math.ceil((value - 40) / 16) + 1) + "D6"
dmgBonus.half = "+" + (Math.ceil((value - 40) / 16) + 1) + "D3"
}
return dmgBonus
}
}