- Enforce sidebar directory restrictions for non-GM players across actors, scenes, journal, tables, compendium, and combat - Major CSS refactor in mosh.css (layout, sizing, colour passes) - Actor sheet JS: extended item handling and sheet render logic - Actor data model updates in actor.js - Item sheet additions in item-sheet.js - Updated chat templates: itemRoll, modifyActor, modifyItem, reload, rollCheck, rollTable for improved formatting and clarity - Actor/creature sheet HTML restructured - Compendium pack DB files rotated (LevelDB log/manifest housekeeping) Co-authored-by: slaguru666 <timevans666@gmail.com>
276 lines
11 KiB
JavaScript
276 lines
11 KiB
JavaScript
import { SLADrugSystem } from "../sla-drug-system.js";
|
|
|
|
function isAmmoPurchaseItem(itemData = {}) {
|
|
const system = itemData?.system ?? {};
|
|
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());
|
|
}
|
|
|
|
/**
|
|
* Extend the basic ItemSheet with some very simple modifications
|
|
* @extends {ItemSheet}
|
|
*/
|
|
export class MothershipItemSheet extends foundry.appv1.sheets.ItemSheet {
|
|
|
|
/** @override */
|
|
static get defaultOptions() {
|
|
var options = {
|
|
classes: ["mosh", "sheet", "item"],
|
|
width: 600,
|
|
height: 500,
|
|
tabs: [{ navSelector: ".sheet-tabs", contentSelector: ".sheet-body", initial: "description" }]
|
|
};
|
|
|
|
return foundry.utils.mergeObject(super.defaultOptions, options);
|
|
}
|
|
|
|
/** @override */
|
|
get template() {
|
|
const path = "systems/sla-mothership/templates/item";
|
|
// Return a single sheet for all item types.
|
|
return `${path}/item-${this.item.type}-sheet.html`;
|
|
// Alternatively, you could use the following return statement to do a
|
|
// unique item sheet by type, like `weapon-sheet.html`.
|
|
|
|
// return `${path}/${this.item.system.type}-sheet.html`;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
async getData() {
|
|
const data = await super.getData();
|
|
const sheetData = data.data ?? data;
|
|
const superData = sheetData.system ?? (sheetData.system = {});
|
|
const itemType = this.item.type;
|
|
|
|
superData.sla ??= {};
|
|
superData.settings ??= {};
|
|
|
|
if (itemType === "weapon") {
|
|
superData.ranges ??= { short: 0, medium: 0, long: 0, value: "" };
|
|
superData.ammoTag ??= "STD";
|
|
superData.ammoLoadedType ??= superData.ammoTag ?? "STD";
|
|
superData.ammoReserveStd ??= Math.max(0, Number(superData.ammo ?? 0) || 0);
|
|
superData.ammoReserveAp ??= 0;
|
|
superData.ammoReserveHe ??= 0;
|
|
superData.ammoReserveHeap ??= 0;
|
|
superData.ammoReserve ??= Number(superData.ammoReserveStd ?? 0) + Number(superData.ammoReserveAp ?? 0) + Number(superData.ammoReserveHe ?? 0) + Number(superData.ammoReserveHeap ?? 0);
|
|
superData.ammo ??= superData.ammoReserve;
|
|
superData.ammoBaseCost ??= 0;
|
|
superData.sla.fireModes ??= deriveDefaultFireModes(superData);
|
|
superData.sla.currentFireMode ??= getFireModes(superData)[0]?.label ?? "Single";
|
|
superData.shotsPerFire = getActiveFireMode(superData)?.shots ?? Math.max(Number(superData.shotsPerFire ?? 1) || 1, 1);
|
|
|
|
if (superData.ranges.value === "" && Number(superData.ranges.medium ?? 0) > 0) {
|
|
superData.ranges.value = `${superData.ranges.short}/${superData.ranges.medium}/${superData.ranges.long}`;
|
|
superData.ranges.medium = 0;
|
|
}
|
|
}
|
|
|
|
if (itemType === "ability") {
|
|
superData.sla.abilityType ??= "ebb";
|
|
superData.sla.skillRef ??= "";
|
|
superData.sla.fluxCost ??= 1;
|
|
superData.sla.tier ??= "basic";
|
|
superData.sla.panicSave ??= "fear";
|
|
superData.sla.rollOnUse ??= true;
|
|
superData.sla.tiers ??= [];
|
|
}
|
|
|
|
if (itemType === "drug") {
|
|
superData.quantity ??= 1;
|
|
superData.weight ??= 0;
|
|
superData.cost ??= 0;
|
|
superData.sla.drugId ??= "";
|
|
superData.sla.category ??= "Drug";
|
|
superData.sla.rating ??= 0;
|
|
superData.sla.durationLabel ??= "";
|
|
superData.sla.addictionFactor ??= 0;
|
|
const drugDefinition = await SLADrugSystem.getDrugDefinitionFromItem(this.item);
|
|
sheetData.drugDefinition = drugDefinition;
|
|
if (drugDefinition) {
|
|
superData.sla.drugId = drugDefinition.id;
|
|
superData.sla.category = drugDefinition.category ?? "Drug";
|
|
superData.sla.rating = Number(drugDefinition.rating ?? 0);
|
|
superData.sla.durationLabel = String(drugDefinition.durationLabel ?? "");
|
|
superData.sla.addictionFactor = Number(drugDefinition.addiction?.threshold ?? 0);
|
|
}
|
|
}
|
|
|
|
superData.settings.useCalm = game.settings.get("sla-mothership", "useCalm");
|
|
superData.settings.hideWeight = game.settings.get("sla-mothership", "hideWeight");
|
|
superData.settings.firstEdition = game.settings.get("sla-mothership", "firstEdition");
|
|
superData.settings.androidPanic = game.settings.get("sla-mothership", "androidPanic");
|
|
|
|
sheetData.enriched = [];
|
|
sheetData.enriched.description = await foundry.applications.ux.TextEditor.implementation.enrichHTML(superData.description ?? "", { async: true });
|
|
|
|
return sheetData;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
setPosition(options = {}) {
|
|
const position = super.setPosition(options);
|
|
const sheetBody = this.element.find(".sheet-body");
|
|
const bodyHeight = position.height - 192;
|
|
sheetBody.css("height", bodyHeight);
|
|
return position;
|
|
}
|
|
|
|
/* -------------------------------------------- */
|
|
|
|
/** @override */
|
|
activateListeners(html) {
|
|
super.activateListeners(html);
|
|
|
|
// Everything below here is only needed if the sheet is editable
|
|
if (!this.options.editable) return;
|
|
|
|
// Roll handlers, click handlers, etc. would go here.
|
|
}
|
|
|
|
async _updateObject(event, formData) {
|
|
const updateData = foundry.utils.expandObject(formData);
|
|
const currentQty = Math.max(0, Number(this.item.system?.quantity ?? 0) || 0);
|
|
if (Object.prototype.hasOwnProperty.call(updateData?.system ?? {}, "weight")) {
|
|
const parsed = Number(updateData.system.weight ?? 0) || 0;
|
|
updateData.system.weight = Math.round(parsed * 10) / 10;
|
|
}
|
|
await this.item.update(updateData, { diff: false });
|
|
|
|
const nextQtyRaw = updateData?.system?.quantity;
|
|
const nextQty = nextQtyRaw === undefined ? currentQty : Math.max(0, Number(nextQtyRaw ?? 0) || 0);
|
|
const increase = Math.max(0, nextQty - currentQty);
|
|
const actor = this.item.parent;
|
|
if (!increase || actor?.documentName !== "Actor") return;
|
|
|
|
const unitCost = Number(updateData?.system?.cost ?? this.item.system?.cost ?? 0) || 0;
|
|
const total = Math.round(unitCost * increase * 100) / 100;
|
|
if (!total) return;
|
|
|
|
const currentCredits = Number(actor.system?.credits?.value ?? 0) || 0;
|
|
const newCredits = Math.round((currentCredits - total) * 100) / 100;
|
|
const updateActorData = { "system.credits.value": newCredits };
|
|
|
|
if (isAmmoPurchaseItem(this.item.toObject())) {
|
|
const stamp = formatSlaTimestamp();
|
|
const currentLedger = String(actor.system?.sla?.ammoLedger?.value ?? "").trim();
|
|
const entry = `[${stamp}] PURCHASE | ${this.item.name ?? "Ammo"} | ${increase} round${increase === 1 ? "" : "s"} | -${total} cR | balance ${newCredits} cR`;
|
|
const ledgerLines = [entry, ...currentLedger.split("\n").filter(Boolean)].slice(0, 60);
|
|
updateActorData["system.sla.ammoLedger.value"] = ledgerLines.join("\n");
|
|
}
|
|
|
|
await actor.update(updateActorData);
|
|
ui.notifications.info(`${actor.name}: deducted ${total} cR for ${increase} more ${this.item.name ?? "item"}. Credits now ${newCredits} cR.`);
|
|
await actor.addSlaActivityLog?.(`Purchased +${increase} ${this.item.name ?? "item"} for ${total} cR. Credits now ${newCredits} cR.`);
|
|
}
|
|
}
|
|
|
|
function getFireModes(system) {
|
|
const raw = String(system?.sla?.fireModes ?? "").trim();
|
|
const rofText = String(system?.sla?.rofText ?? system?.rofText ?? "").toLowerCase();
|
|
const shotsPerFire = Math.max(1, Number(system?.shotsPerFire ?? 1) || 1);
|
|
const special = String(system?.sla?.special ?? "").toLowerCase();
|
|
const name = String(system?.name ?? "");
|
|
const modes = [];
|
|
for (const chunk of raw.split(/[\n,]+/)) {
|
|
const entry = chunk.trim();
|
|
if (!entry) continue;
|
|
const match = entry.match(/^(.+?)(?:\s*[:=|-]\s*|\s*\(\s*)(\d+)\)?$/);
|
|
if (!match) {
|
|
const inferred = inferWeaponModeShots(entry, shotsPerFire, rofText);
|
|
if (!inferred) continue;
|
|
modes.push({
|
|
label: entry.replace(/\s+/g, " ").trim().replace(/\bfull auto\b/i, "Auto"),
|
|
shots: inferred
|
|
});
|
|
continue;
|
|
}
|
|
modes.push({
|
|
label: match[1].trim(),
|
|
shots: Math.max(1, Number(match[2]) || 1)
|
|
});
|
|
}
|
|
const staleSingleMode = modes.length === 1
|
|
&& modes[0].label.toLowerCase() === "single"
|
|
&& modes[0].shots === 1
|
|
&& (special.includes("auto") || special.includes("burst") || rofText.includes("burst") || rofText.includes("auto") || inferAutomaticWeaponShots(name) > 1);
|
|
|
|
return modes.length && !staleSingleMode
|
|
? modes
|
|
: deriveWeaponModeDefaults(Math.max(shotsPerFire, inferAutomaticWeaponShots(name)), special, rofText, name);
|
|
}
|
|
|
|
function getActiveFireMode(system) {
|
|
const modes = getFireModes(system);
|
|
const current = String(system?.sla?.currentFireMode ?? "").trim().toLowerCase();
|
|
return modes.find((mode) => mode.label.toLowerCase() === current) ?? modes[0];
|
|
}
|
|
|
|
function deriveDefaultFireModes(system) {
|
|
const shotsPerFire = Math.max(1, Number(system?.shotsPerFire ?? 1) || 1);
|
|
const special = String(system?.sla?.special ?? "").toLowerCase();
|
|
const rofText = String(system?.sla?.rofText ?? system?.rofText ?? "").toLowerCase();
|
|
const modes = deriveWeaponModeDefaults(shotsPerFire, special, rofText);
|
|
|
|
return modes
|
|
.filter((mode, index, list) => list.findIndex((entry) => entry.label === mode.label && entry.shots === mode.shots) === index)
|
|
.map((mode) => `${mode.label}:${mode.shots}`)
|
|
.join(", ");
|
|
}
|
|
|
|
function inferWeaponModeShots(label = "", shotsPerFire = 1, rofText = "") {
|
|
const normalized = String(label ?? "").toLowerCase();
|
|
if (normalized.includes("single") || normalized.includes("semi")) return 1;
|
|
if (normalized.includes("double") || normalized.includes("2 shots")) return Math.max(2, shotsPerFire);
|
|
if (normalized.includes("burst")) return Math.max(3, shotsPerFire);
|
|
if (normalized.includes("auto")) {
|
|
if (rofText.includes("full auto")) return Math.max(6, shotsPerFire);
|
|
return Math.max(3, shotsPerFire);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function deriveWeaponModeDefaults(shotsPerFire = 1, special = "", rofText = "", name = "") {
|
|
const modes = [{ label: "Single", shots: 1 }];
|
|
const text = `${special} ${rofText}`.toLowerCase();
|
|
const effectiveShots = Math.max(shotsPerFire, inferAutomaticWeaponShots(name));
|
|
|
|
if (text.includes("2 shots") || text.includes("2/round")) {
|
|
modes.push({ label: "Double", shots: 2 });
|
|
}
|
|
if (text.includes("burst") || (special.includes("auto") && effectiveShots >= 3)) {
|
|
modes.push({ label: "Burst", shots: Math.max(3, effectiveShots) });
|
|
} else if (effectiveShots === 2) {
|
|
modes.push({ label: "Double", shots: 2 });
|
|
} else if (effectiveShots > 2) {
|
|
modes.push({ label: "Burst", shots: effectiveShots });
|
|
}
|
|
if (text.includes("full auto") || special.includes("auto")) {
|
|
modes.push({ label: "Auto", shots: Math.max(text.includes("full auto") ? 6 : effectiveShots, 3) });
|
|
}
|
|
|
|
return modes;
|
|
}
|
|
|
|
function inferAutomaticWeaponShots(name = "") {
|
|
const text = String(name ?? "").toLowerCase();
|
|
if (/lmg|machine gun|minigun/.test(text)) return 6;
|
|
if (/assault rifle|fen\s*ar|smg|machine pistol|auto-shotgun|reaper/.test(text)) return 3;
|
|
return 1;
|
|
}
|