import { SLAMothershipGenerator } from "../windows/sla-character-generator.js"; import { SLADrugSystem } from "../sla-drug-system.js"; import { buildSlaTraitRows, getSlaTraitOptionsByType, validateSlaTraitRows } from "../traits/sla-traits.js"; function isEbbSkillName(name = "") { const clean = String(name ?? "").trim(); return clean.startsWith("Ebb ") || ["Biofeedback", "Formulate", "Ebb (Core)"].includes(clean); } function sortSkillCollection(skills = []) { return [...skills].sort((left, right) => { const leftCategory = String(left?.system?.sla?.category ?? "").trim().toLowerCase(); const rightCategory = String(right?.system?.sla?.category ?? "").trim().toLowerCase(); if (leftCategory !== rightCategory) return leftCategory.localeCompare(rightCategory); return String(left?.name ?? "").localeCompare(String(right?.name ?? "")); }); } function dedupeSkillCollection(skills = []) { const byName = new Map(); for (const skill of skills) { const key = String(skill?.name ?? "").trim().toLowerCase(); if (!key) continue; const current = byName.get(key); const currentBonus = Number(current?.system?.bonus ?? 0) || 0; const nextBonus = Number(skill?.system?.bonus ?? 0) || 0; if (!current || nextBonus > currentBonus) { byName.set(key, skill); } } return Array.from(byName.values()); } function normalizeTraitSkillKey(value = "") { return String(value ?? "") .toLowerCase() .replace(/[^a-z0-9]+/g, ""); } function roundCarryWeight(value) { return Math.round((Number(value ?? 0) || 0) * 10) / 10; } function encToKg(enc) { return roundCarryWeight((Number(enc ?? 0) || 0) * 1.5); } function resolveItemWeight(itemDoc) { const system = itemDoc?.system ?? {}; const explicitWeight = Number(system.weight ?? 0); const encValue = Number(system?.sla?.enc ?? 0); if ((itemDoc?.type === "weapon" || itemDoc?.type === "armor") && encValue > 0) { if (explicitWeight <= 0 || Math.abs(explicitWeight - encValue) < 0.001) { return encToKg(encValue); } } return roundCarryWeight(explicitWeight); } function getCarryState(itemDoc) { const system = itemDoc?.system ?? {}; if (system.carryState) return String(system.carryState); if (itemDoc?.type === "armor" && system.equipped) return "equipped"; return "carried"; } function buildCarryStateMeta(itemDoc) { const state = getCarryState(itemDoc); return { state, label: state === "equipped" ? "Equipped" : state === "stowed" ? "Stowed" : "Carried", cssClass: state === "equipped" ? "equipped" : state === "stowed" ? "stowed" : "carried", countsWeight: state !== "stowed" }; } function calculateCarryCapacity(actorSystem = {}) { const strength = Math.max(0, Number(actorSystem?.stats?.strength?.value ?? 0) || 0); const body = Math.max(0, Number(actorSystem?.stats?.body?.value ?? 0) || 0); return roundCarryWeight(Math.max(10, (strength * 0.6) + (body * 0.4))); } function buildTraitSkillPreview(traitRows = [], skillName = "") { const key = normalizeTraitSkillKey(skillName); const communicationSkills = new Set(["command", "fasttalk", "intimidate", "persuade", "streetwise"]); const effects = []; let flatMod = 0; const add = (amount, label) => { const value = Number(amount ?? 0) || 0; if (!value) return; flatMod += value; effects.push(`${label}: ${value >= 0 ? "+" : ""}${value}%`); }; for (const row of traitRows) { const rank = Math.max(1, Number(row?.slaTrait?.rank ?? 1) || 1); switch (String(row?.slaTrait?.key ?? "")) { case "good-hearing": if (key === "listen") add(10 * rank, row.name); break; case "poor-hearing": if (key === "listen") add(-10 * rank, row.name); break; case "good-vision": if (key === "spothidden") add(10 * rank, row.name); break; case "poor-vision": if (key === "spothidden") add(-10 * rank, row.name); break; case "attractive": if (communicationSkills.has(key)) add(10 * rank, row.name); break; case "unattractive": if (communicationSkills.has(key)) add(-10 * rank, row.name); break; case "depression": if (communicationSkills.has(key)) add(-10 * rank, row.name); break; default: break; } } return { flatMod, effects, summary: effects.join(" | "), modLabel: flatMod ? `${flatMod > 0 ? "+" : ""}${flatMod}` : "" }; } function decorateSkillWithTraitPreview(skill, traitRows = []) { skill.system ??= {}; const baseBonus = Number(skill.system.bonus ?? 0) || 0; const preview = buildTraitSkillPreview(traitRows, skill.name); skill.system.displayBonus = baseBonus + preview.flatMod; skill.system.traitMod = preview.flatMod; skill.system.traitEffects = preview.effects; skill.slaTraitPreview = preview; return skill; } function getPurchasableItemTotalCost(itemData = {}) { const system = itemData?.system ?? itemData?.data ?? {}; const unitCost = Number(system.cost ?? 0) || 0; const quantity = Math.max(1, Number(system.quantity ?? 1) || 1); return Math.round(unitCost * quantity * 100) / 100; } function isAmmoPurchaseItem(itemData = {}) { const system = itemData?.system ?? itemData?.data ?? {}; const sla = system?.sla ?? {}; const name = String(itemData?.name ?? "").trim().toLowerCase(); const category = String(sla.category ?? "").trim().toLowerCase(); const calibre = String(sla.calibre ?? "").trim(); const ammoTag = String(sla.ammoTag ?? "").trim(); return category === "ammunition" || Boolean((calibre && ammoTag) || name.startsWith("ammo:")); } function formatSlaTimestamp() { return new Intl.DateTimeFormat(undefined, { dateStyle: "short", timeStyle: "short" }).format(new Date()); } const SLA_SPECIES_RULES = { Human: [ { key: "adaptable", label: "Adaptable", summary: "Once per session, reroll one Stat check and take the better result.", optional: false, default: true } ], Frother: [ { key: "berserkState", label: "Berserk State", summary: "On a failed Sanity Save, the Frother may enter Berserk instead of gaining Stress: +10 to Combat and Strength checks, but risks attacking the nearest target.", optional: true, default: true }, { key: "drugMetabolism", label: "Drug Metabolism", summary: "No addiction roll for standard combat drugs, and one combat drug per scene can be taken without a Body Save penalty.", optional: true, default: true } ], Ebon: [ { key: "ebbSensitivity", label: "Ebb Sensitivity", summary: "Automatically senses nearby Ebb use and rolls Sanity Saves at Disadvantage against reality-warping events.", optional: false, default: true }, { key: "fluxPool", label: "Flux Pool", summary: "Flux begins at Intellect / 4 with a minimum reserve of 20, and recovers at 1 point per hour of rest.", optional: false, default: true } ], "Brain Waster": [ { key: "ebbAggression", label: "Ebb Aggression", summary: "Damaging Ebb abilities add +1d5 damage.", optional: false, default: true }, { key: "unnervingPresence", label: "Unnerving Presence", summary: "Nearby allies roll Fear Saves at Disadvantage when the Brain Waster channels Ebb.", optional: true, default: true } ], "Wraith Raider": [ { key: "predatorSenses", label: "Predator Senses", summary: "Advantage on Speed-based perception, ambush detection, and initiative checks. Never surprised in natural or urban environments unless by supernatural means.", optional: false, default: true }, { key: "naturalWeapons", label: "Natural Weapons", summary: "Claws deal Strength / 10 + 1d5 damage at Adjacent range and count as primary species weapons.", optional: false, default: true } ], Shaktar: [ { key: "honourBound", label: "Honour Bound", summary: "Breaking a code of honour forces a Sanity Save or the Shaktar gains 1d3 Stress.", optional: true, default: true }, { key: "naturalWeapons", label: "Natural Weapons", summary: "Claws deal Strength / 10 damage and grappling or restraining Strength checks gain Advantage.", optional: false, default: true } ], "Stormer 313 Malice": [ { key: "prometheusRegeneration", label: "Prometheus Regeneration", summary: "At the end of every fourth combat round, recover 1d5 Health. Once wounds are healed, continue recovering 1 Health per round.", optional: true, default: true }, { key: "biogeneticAggression", label: "Biogenetic Aggression", summary: "Disadvantage on Intellect checks involving diplomacy, deception detection, or abstract reasoning.", optional: false, default: true }, { key: "financeChipMandatory", label: "Finance Chip Mandatory", summary: "The Karma finance chip is built in and cannot be removed.", optional: false, default: true } ], "Stormer 711 Xeno": [ { key: "chameleonChitin", label: "Chameleon Chitin", summary: "Spend 1 action to become near-invisible while stationary. Detection is at Disadvantage until the Xeno moves.", optional: true, default: true }, { key: "prometheusRegeneration", label: "Prometheus Regeneration", summary: "At the end of every fourth combat round, recover 1d5 Health.", optional: true, default: true }, { key: "inhumanAppearance", label: "Inhuman Appearance", summary: "Social Intellect checks against other species are at Disadvantage because the Xeno is deeply unnerving.", optional: true, default: true } ], "Advanced Carrien": [ { key: "socialPrejudice", label: "Social Prejudice", summary: "Track district or sponsor prejudice penalties until a source-grounded Carrien conversion replaces this draft entry.", optional: true, default: true } ] }; /** * Extend the basic ActorSheet with some very simple modifications * @extends {ActorSheet} */ export class MothershipActorSheet extends foundry.appv1.sheets.ActorSheet { constructor(...args) { super(...args); this._slaTraitPickerSelection ??= { advantage: "", disadvantage: "" }; } /** @override */ static get defaultOptions() { var options = { classes: ["mosh", "sheet", "actor", "character"], template: "systems/sla-mothership/templates/actor/actor-sheet.html", width: 820, height: 820, tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "skills" }], submitOnChange: true } return foundry.utils.mergeObject(super.defaultOptions, options); } /* -------------------------------------------- */ /** @override */ async getData() { const data = await super.getData(); data.dtypes = ["String", "Number", "Boolean"]; const superData = data.data.system; for (let attr of Object.values(data.data.system.attributes)) { attr.isCheckbox = attr.dtype === "Boolean"; } // Prepare items. if (this.actor.type == 'character') { this._prepareCharacterItems(data); } if (data.data.system.settings == null) { data.data.system.settings = {}; } data.data.system.settings.useCalm = game.settings.get("sla-mothership", "useCalm"); data.data.system.settings.hideWeight = game.settings.get("sla-mothership", "hideWeight"); data.data.system.settings.firstEdition = game.settings.get("sla-mothership", "firstEdition"); data.data.system.settings.androidPanic = game.settings.get("sla-mothership", "androidPanic"); superData.sla ??= {}; superData.sla.operativeType ??= { value: "" }; superData.sla.species ??= { value: "" }; superData.sla.speciesNotes ??= { value: "" }; superData.sla.trainingPackage ??= { value: "" }; superData.sla.packageSummary ??= { value: "" }; superData.sla.packageSkills ??= { value: "" }; superData.sla.packageWealth ??= { value: "" }; superData.sla.flux ??= { value: 0, min: 0, max: 0, label: "Flux" }; superData.sla.fluxStage ??= { value: "" }; superData.sla.bpn ??= { value: "" }; superData.sla.scl ??= { value: "" }; superData.sla.employer ??= { value: "" }; superData.sla.sector ??= { value: "" }; superData.sla.height ??= { value: "" }; superData.sla.weightText ??= { value: "" }; superData.sla.lad ??= false; superData.sla.ebbRating ??= { value: 0 }; superData.sla.ammoSpendTotal ??= { value: 0 }; superData.sla.ammoLedger ??= { value: "" }; superData.sla.fluxNotes ??= { value: "" }; superData.sla.ebbNotes ??= { value: "" }; superData.sla.speciesRules ??= {}; superData.sla.sessionAbilityUsed ??= false; superData.sla.contacts ??= []; superData.sla.activityLog ??= { value: "" }; const activeTheme = this.actor.getFlag("sla-mothership", "sheetTheme") || "modern"; data.slaSheetTheme = activeTheme; data.data.slaSheetTheme = activeTheme; data.data.enriched = []; data.data.enriched.notes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(superData.notes, {async: true}); data.data.enriched.biography = await foundry.applications.ux.TextEditor.implementation.enrichHTML(superData.biography, {async: true}); data.data.isEbbUser = this.actor.isSlaEbbUser?.() ?? false; data.data.slaFlux = this.actor.getSlaFluxState?.() ?? { label: "Stable", description: "", cssClass: "stable", thresholds: "25% / 50% / 75% / 100%" }; data.data.slaFluxGuidance = this.actor.getSlaFluxGuidance?.() ?? []; const fluxValue = Number(superData.sla.flux?.value ?? 0); const fluxMax = Math.max(0, Number(superData.sla.flux?.max ?? 0)); data.data.slaFluxPercent = fluxMax > 0 ? Math.min(100, Math.max(0, Math.round((fluxValue / fluxMax) * 100))) : 0; // Vevaphon morph data data.data.isVevaphon = this.actor.isSlaVevaphon?.() ?? false; if (data.data.isVevaphon) { const instab = Math.max(0, Math.min(12, Number(superData.sla.vevaphonInstability?.value ?? 0) || 0)); data.data.vevaphonInstabilityPercent = Math.round((instab / 12) * 100); data.data.vevaphonInstabilityElevated = instab >= 6; data.data.vevaphonInstabilityCritical = instab >= 10; data.data.vevaphonInstabilityClass = instab >= 10 ? "instability-critical" : instab >= 6 ? "instability-elevated" : "instability-nominal"; data.data.vevaphonInstabilityDesc = instab >= 10 ? "Instability critical. Full reversion risk on every shift." : instab >= 6 ? "Instability elevated. Sanity saves required when morphing mid-scene." : "Instability nominal. Morphing is available without additional risk."; data.data.vevaphonMorphPanic = [ { roll: 1, label: "Cascade Shift", desc: "Random morph form, +1 Instability." }, { roll: 2, label: "Identity Bleed", desc: "Forgets name and callsign until end of scene." }, { roll: 3, label: "Partial Lock", desc: "One limb freezes. −10 physical rolls using that limb." }, { roll: 4, label: "Tissue Rejection", desc: "1d5 damage, armour does not apply." }, { roll: 5, label: "Threat Imprint", desc: "Locked on nearest visible target." }, { roll: 6, label: "Full Reversion", desc: "Base form, lose morph bonuses, −1 Instability." } ]; } data.data.slaSpeciesTraitList = String(superData.sla.speciesNotes?.value ?? "").split(/\n+/).map((entry) => entry.trim()).filter(Boolean); data.data.slaPackageSkillList = String(superData.sla.packageSkills?.value ?? "").split(/\s*,\s*/).map((entry) => entry.trim()).filter(Boolean); data.data.slaSpeciesRules = getSpeciesRules(String(superData.sla.species?.value ?? "").trim(), superData.sla.speciesRules); data.data.slaEnabledSpeciesRules = data.data.slaSpeciesRules.filter((rule) => rule.enabled); data.data.slaEnabledSpeciesRuleCount = data.data.slaEnabledSpeciesRules.length; data.data.slaPositiveTraitOptions = getSlaTraitOptionsByType("advantage"); data.data.slaNegativeTraitOptions = getSlaTraitOptionsByType("disadvantage"); data.data.slaTraitPickerSelection = { advantage: String(this._slaTraitPickerSelection?.advantage ?? "").trim(), disadvantage: String(this._slaTraitPickerSelection?.disadvantage ?? "").trim() }; data.data.slaPrometheusStatus = this.actor.getSlaPrometheusStatus?.() ?? { enabled: false, inCombat: false, currentRound: 0, roundsUntilPulse: null, pulseThisRound: false }; // Contacts const rawContacts = Array.isArray(superData.sla.contacts) ? superData.sla.contacts : []; data.data.slaContacts = rawContacts.map((c) => ({ id: String(c.id ?? foundry.utils.randomID()), name: String(c.name ?? "Unknown"), role: String(c.role ?? ""), notes: String(c.notes ?? ""), disposition: ["ally", "neutral", "hostile", "unknown"].includes(c.disposition) ? c.disposition : "neutral" })); data.data.slaContactCount = data.data.slaContacts.length; const traitRows = buildSlaTraitRows(data.data.persTraits ?? []); data.data.persTraitsEnhanced = traitRows; data.data.traitCount = traitRows.length; data.data.traitValidation = validateSlaTraitRows(traitRows); data.data.skills = sortSkillCollection(dedupeSkillCollection((data.data.skills ?? []).map((skill) => { const breakdown = this.actor.getSkillBreakdown?.(skill) ?? {}; skill.system ??= {}; skill.system.sla ??= {}; skill.system.sla.category ||= breakdown.category ?? ""; skill.slaBreakdown = breakdown; skill.isEbbSkill = isEbbSkillName(skill.name); skill.isCombatSkill = Boolean(skill.system.sla.combat); return decorateSkillWithTraitPreview(skill, traitRows); }))); data.data.ebbSkills = sortSkillCollection(dedupeSkillCollection((data.data.ebbSkills ?? []).map((skill) => { const breakdown = this.actor.getSkillBreakdown?.(skill) ?? {}; skill.system ??= {}; skill.system.sla ??= {}; skill.system.sla.category ||= breakdown.category ?? ""; skill.slaBreakdown = breakdown; skill.isEbbSkill = true; skill.isCombatSkill = Boolean(skill.system.sla.combat); return decorateSkillWithTraitPreview(skill, traitRows); }))); const drugOverview = await SLADrugSystem.getActorOverview(this.actor); data.data.drugAlerts = drugOverview.alerts; data.data.drugRows = (drugOverview.rows ?? []).map((row) => { const sourceItem = this.actor.items.get(row.itemId); const carryMeta = sourceItem ? buildCarryStateMeta(sourceItem) : { state: "carried", label: "Carried", cssClass: "carried", countsWeight: true }; const totalWeight = sourceItem ? roundCarryWeight(resolveItemWeight(sourceItem) * Math.max(1, Number(sourceItem.system?.quantity ?? row.quantity ?? 1) || 1)) : 0; return { ...row, slaCarry: { ...carryMeta, totalWeight } }; }); data.data.slaStatModifierTitles = SLADrugSystem.getStatModifierTitles(this.actor); const ammoItems = Array.from(this.actor.items ?? []).filter((item) => item.type === "item" && String(item.system?.sla?.category ?? "").trim().toLowerCase() === "ammunition" ); data.data.slaAmmoInventory = { stackCount: ammoItems.length, roundCount: ammoItems.reduce((total, item) => total + (Number(item.system?.quantity ?? 0) || 0), 0) }; data.data.slaCarry = { capacity: roundCarryWeight(Number(superData.weight?.capacity ?? 0) || 0), current: roundCarryWeight(Number(superData.weight?.current ?? 0) || 0), equipped: roundCarryWeight(Number(superData.weight?.equipped ?? 0) || 0), carried: roundCarryWeight(Number(superData.weight?.carried ?? 0) || 0), stowed: roundCarryWeight(Number(superData.weight?.stowed ?? 0) || 0), available: roundCarryWeight(Number(superData.weight?.available ?? 0) || 0), over: Boolean(superData.weight?.over), state: String(superData.weight?.state ?? "Nominal") }; //SKILL XP BUTTONS superData.xp.html = ''; if (superData.xp.html == '') { for (let i = 1; i <= 15; i++) { if (i > superData.xp.value) { if (i % 5) { superData.xp.html += '
'; } else { //If a special one let trainLevel = 'Start a new session? This will reset the trauma response session ability for this operative.
", defaultYes: true }); if (!confirmed) return; await this.actor.update({ "system.sla.sessionAbilityUsed": false }); ui.notifications.info(`${this.actor.name}: session ability reset for new session.`); this.render(false); }); // Rest button — short or long rest dialog html.find('.sla-rest-button').click(async (ev) => { ev.preventDefault(); const restType = await new Promise((resolve) => { new Dialog({ title: "Take a Rest", content: `What kind of rest is ${this.actor.name} taking?
Short Rest: Removes non-permanent conditions. No stat recovery.
Long Rest: Removes non-permanent conditions and fully restores Flux (if Ebb user).
`, buttons: { short: { icon: '', label: "Short Rest", callback: () => resolve("short") }, long: { icon: '', label: "Long Rest", callback: () => resolve("long") }, cancel: { icon: '', label: "Cancel", callback: () => resolve(null) } }, default: "short" }).render(true); }); if (!restType) return; const updates = {}; // Remove temporary conditions (items of type "condition") const conditionIds = this.actor.items .filter((item) => item.type === "condition") .map((item) => item.id); if (conditionIds.length > 0) { await this.actor.deleteEmbeddedDocuments("Item", conditionIds); } if (restType === "long") { // Restore Flux to max for Ebb users const isEbb = this.actor.isSlaEbbUser?.() ?? false; if (isEbb) { const fluxMax = Math.max(0, Number(this.actor.system?.sla?.flux?.max ?? 0)); updates["system.sla.flux.value"] = fluxMax; await this.actor.updateSlaFluxState?.(fluxMax, { save: true }); } } if (Object.keys(updates).length > 0) { await this.actor.update(updates); } const condMsg = conditionIds.length > 0 ? ` Cleared ${conditionIds.length} condition(s).` : ""; const fluxMsg = restType === "long" && (this.actor.isSlaEbbUser?.() ?? false) ? " Flux restored." : ""; ui.notifications.info(`${this.actor.name}: ${restType === "long" ? "Long" : "Short"} rest complete.${condMsg}${fluxMsg}`); await this.actor.addSlaActivityLog?.(`${restType === "long" ? "Long" : "Short"} rest.${condMsg}${fluxMsg}`); this.render(false); }); // Contact add button html.find('.sla-contact-add').click(async (ev) => { ev.preventDefault(); await this._slaContactDialog(null); }); // Contact edit button html.find('.sla-contact-edit').click(async (ev) => { ev.preventDefault(); const contactId = String(ev.currentTarget.dataset.contactId ?? "").trim(); if (!contactId) return; await this._slaContactDialog(contactId); }); // Contact delete button html.find('.sla-contact-delete').click(async (ev) => { ev.preventDefault(); const contactId = String(ev.currentTarget.dataset.contactId ?? "").trim(); if (!contactId) return; const confirmed = await Dialog.confirm({ title: "Delete Contact", content: "Remove this contact from the operative's network?
", defaultYes: false }); if (!confirmed) return; const contacts = Array.isArray(this.actor.system?.sla?.contacts) ? [...this.actor.system.sla.contacts] : []; const updated = contacts.filter((c) => c.id !== contactId); await this.actor.update({ "system.sla.contacts": updated }); this.render(false); }); html.find('.sla-panic-button').click(async ev => { await this.actor.rollTable("panicCheck", null, null, null, null, null, null); await this.actor.addSlaActivityLog?.("Panic check triggered."); }); html.find('.sla-wound-button').click(ev => { this.actor.chooseSlaWoundTable?.(); }); html.find('.trait-validate').click(() => { const traitRows = buildSlaTraitRows(this.actor.items.filter((item) => item.type === "persTrait")); const report = validateSlaTraitRows(traitRows); const balance = Number(report?.rankBalance?.net ?? 0); if (report.ok) { ui.notifications.info(`Trait validation OK. Balance ${balance >= 0 ? "+" : ""}${balance}.`); } else { ui.notifications.warn(`Trait validation issues: ${report.errors.join(" ")}`); } this.render(false); }); const captureTraitSelection = (ev) => { ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation(); const type = String(ev.currentTarget?.dataset?.type ?? "").trim().toLowerCase(); if (!type) return; const value = String(ev.currentTarget?.value ?? "").trim(); this._slaTraitPickerSelection ??= {}; this._slaTraitPickerSelection[type] = value; const pickerRow = ev.currentTarget.closest('.sla-trait-picker-row'); if (pickerRow?.dataset) { pickerRow.dataset.selectedTrait = value; } const addButton = pickerRow?.querySelector?.(`.sla-trait-add[data-type="${type}"]`); if (addButton?.dataset) { addButton.dataset.selectedTrait = value; } }; html.find('.sla-trait-select').on('change input', captureTraitSelection); html.find('.sla-trait-add').click(async (ev) => { ev.preventDefault(); ev.stopPropagation(); ev.stopImmediatePropagation(); const type = String(ev.currentTarget.dataset.type ?? "").trim().toLowerCase(); const pickerRow = ev.currentTarget.closest('.sla-trait-picker-row'); const select = pickerRow?.querySelector(`.sla-trait-select[data-type="${type}"]`) ?? html.find(`.sla-trait-select[data-type="${type}"]`).get(0); const selectedKey = String( select?.value ?? pickerRow?.dataset?.selectedTrait ?? ev.currentTarget?.dataset?.selectedTrait ?? this._slaTraitPickerSelection?.[type] ?? "" ).trim(); if (!selectedKey) { ui.notifications.warn(`Choose a ${type === "advantage" ? "positive" : "negative"} trait first.`); return; } const options = type === "advantage" ? getSlaTraitOptionsByType("advantage") : getSlaTraitOptionsByType("disadvantage"); const selected = options.find((entry) => entry.key === selectedKey); if (!selected) return; const existing = this.actor.items.find((item) => item.type === "persTrait" && String(item.name ?? "").trim().toLowerCase() === selected.label.toLowerCase()); if (existing) { ui.notifications.warn(`${selected.label} is already on this operative.`); return; } await this.actor.createEmbeddedDocuments("Item", [{ name: selected.label, type: "persTrait", img: type === "advantage" ? "systems/sla-mothership/images/icons/ui/conditions/moral_compass.png" : "systems/sla-mothership/images/icons/ui/conditions/anxiety.png", system: { base: 1, xp: 0, oppName: "", basic: false, improve: false, oppimprove: false, description: "", gmDescription: "", sla: { traitType: type, source: "SLA Trait Selector" } } }]); this._slaTraitPickerSelection ??= {}; this._slaTraitPickerSelection[type] = ""; if (pickerRow?.dataset) { pickerRow.dataset.selectedTrait = ""; } if (ev.currentTarget?.dataset) { ev.currentTarget.dataset.selectedTrait = ""; } if (select) select.value = ""; }); html.find('.sla-health-reset').click(async (ev) => { ev.preventDefault(); const maxHealth = Math.max(0, Number(this.actor.system?.health?.max ?? 0) || 0); await this.actor.update({ "system.health.value": maxHealth }); ui.notifications.info(`Health reset to ${maxHealth}.`); this.render(false); }); html.find('.weapon-reload-all').click(async (ev) => { ev.preventDefault(); await this.actor.reloadAllCompatibleWeapons?.(); this.render(false); }); // Clicking on Armor html.find('.armor-roll').click(ev => { //roll panic check this.actor.chooseCover(); }); // Drag events for macros. if (this.actor.isOwner) { let handler = ev => this._onDragStart(ev); html.find('li.dropitem').each((i, li) => { if (li.classList.contains("inventory-header")) return; li.setAttribute("draggable", true); li.addEventListener("dragstart", handler, false); }); } } /* -------------------------------------------- */ async _onDrop(event) { const data = TextEditor.getDragEventData(event); if (data?.type !== "Item") return super._onDrop(event); const droppedItem = await Item.implementation.fromDropData(data).catch(() => null); if (!droppedItem) return super._onDrop(event); if (droppedItem.parent?.uuid === this.actor.uuid) { return super._onDrop(event); } const shouldDebitPurchase = !droppedItem.parent || droppedItem.parent.documentName !== "Actor"; const itemData = droppedItem.toObject(); delete itemData._id; delete itemData.folder; const created = await this.actor.createEmbeddedDocuments("Item", [itemData], { slaPurchaseDebit: true, slaPurchaseSource: data.uuid ?? droppedItem.uuid ?? "" }); if (shouldDebitPurchase) { await this._applySlaPurchaseDebit(created); } return created; } async _applySlaPurchaseDebit(createdDocs = []) { const documents = Array.isArray(createdDocs) ? createdDocs : [createdDocs]; const total = Math.round(documents.reduce((sum, doc) => sum + getPurchasableItemTotalCost(doc?.toObject?.() ?? doc), 0) * 100) / 100; if (!total) return; const currentCredits = Number(this.actor.system?.credits?.value ?? 0) || 0; const newCredits = Math.round((currentCredits - total) * 100) / 100; const updateData = { "system.credits.value": newCredits }; const itemNames = documents.map((doc) => doc?.name).filter(Boolean).join(", "); const ammoDocs = documents .map((doc) => doc?.toObject?.() ?? doc) .filter((doc) => isAmmoPurchaseItem(doc)); if (ammoDocs.length) { const stamp = formatSlaTimestamp(); const currentLedger = String(this.actor.system?.sla?.ammoLedger?.value ?? "").trim(); const purchaseEntries = ammoDocs.map((doc) => { const system = doc?.system ?? doc?.data ?? {}; const quantity = Math.max(1, Number(system.quantity ?? 1) || 1); const unitCost = Number(system.cost ?? 0) || 0; const lineCost = Math.round(unitCost * quantity * 100) / 100; return `[${stamp}] PURCHASE | ${doc?.name ?? "Ammo"} | ${quantity} round${quantity === 1 ? "" : "s"} | -${lineCost} cR | balance ${newCredits} cR`; }); const ledgerLines = [...purchaseEntries, ...currentLedger.split("\n").filter(Boolean)].slice(0, 60); updateData["system.sla.ammoLedger.value"] = ledgerLines.join("\n"); } await this.actor.update(updateData); ui.notifications.info(`${this.actor.name}: deducted ${total} cR for ${itemNames || "purchased item"}. Credits now ${newCredits} cR.`); await this.actor.addSlaActivityLog?.(`Purchased ${itemNames || "item"} for ${total} cR. Credits now ${newCredits} cR.`); } async _applySlaQuantityIncreaseDebit(itemDoc, increase = 0) { const added = Math.max(0, Number(increase ?? 0) || 0); if (!added || !itemDoc) return; const unitCost = Number(itemDoc.system?.cost ?? 0) || 0; const total = Math.round(unitCost * added * 100) / 100; if (!total) return; const currentCredits = Number(this.actor.system?.credits?.value ?? 0) || 0; const newCredits = Math.round((currentCredits - total) * 100) / 100; const updateData = { "system.credits.value": newCredits }; if (isAmmoPurchaseItem(itemDoc)) { const stamp = formatSlaTimestamp(); const currentLedger = String(this.actor.system?.sla?.ammoLedger?.value ?? "").trim(); const entry = `[${stamp}] PURCHASE | ${itemDoc.name ?? "Ammo"} | ${added} round${added === 1 ? "" : "s"} | -${total} cR | balance ${newCredits} cR`; const ledgerLines = [entry, ...currentLedger.split("\n").filter(Boolean)].slice(0, 60); updateData["system.sla.ammoLedger.value"] = ledgerLines.join("\n"); } await this.actor.update(updateData); ui.notifications.info(`${this.actor.name}: deducted ${total} cR for ${added} more ${itemDoc.name ?? "item"}. Credits now ${newCredits} cR.`); await this.actor.addSlaActivityLog?.(`Purchased +${added} ${itemDoc.name ?? "item"} for ${total} cR. Credits now ${newCredits} cR.`); } /* -------------------------------------------- */ /** * Handle creating a new Owned Item for the actor using initial data defined in the HTML dataset * @param {Event} event The originating click event * @private */ _onItemCreate(event) { event.preventDefault(); const header = event.currentTarget; // Get the type of item to create. const type = header.dataset.type; // Grab any data associated with this control. var data; data = foundry.utils.duplicate(header.dataset); // Initialize a default name. const name = type === "persTrait" ? "New Trait" : `New ${type.capitalize()}`; // Prepare the item object. const itemData = { name: name, type: type, data: data }; // Remove the type from the dataset since it's in the itemData.type prop. delete itemData.data["type"]; // Finally, create the item! return this.actor.createEmbeddedDocuments("Item", [itemData]); } /** * Handle creating a new Owned skill for the actor using initial data defined in the HTML dataset * @param {Event} event The originating click event * @private */ _onSkillCreate(event) { event.preventDefault(); const header = event.currentTarget; // Get the type of item to create. const type = header.dataset.type; // Grab any data associated with this control. var data; data = foundry.utils.duplicate(header.dataset); //const data = duplicate(header.dataset); // Initialize a default name. const name = `New Skill`; // Prepare the item object. const itemData = { name: name, type: type, system: data }; let d = new foundry.applications.api.DialogV2({ window: {title: `New Skill`}, classes: ["macro-popup-dialog"], content: `