// W&G Voidships Builder — Voidship Actor Sheet (vehicle+flag emulation) // v1.1.0 — Foundry V11–V13 safe /* eslint-env es2021 */ const VB_MOD = "wg-voidships-builder"; const VB_DEBUG = true; // Debug on function dbg(...args) { if (VB_DEBUG) console.debug("[VB]", ...args); } // --- SLOT HELPERS: normalizacja i przydział "pierwszy wolny pasujący" --- const VB_WEAPON_SLOTS = ["prow", "dorsal", "port", "starboard", "keel"]; function normalizeSlotName(s) { const v = String(s || "").trim().toLowerCase(); return VB_WEAPON_SLOTS.includes(v) ? v : null; } function normalizeAllowedSlots(allowed) { if (!allowed) return []; if (Array.isArray(allowed)) return allowed.map(normalizeSlotName).filter(Boolean); return String(allowed).split(/[|,; ]+/g).map(normalizeSlotName).filter(Boolean); } function getVSH(it) { // canonical: flags; fallback: legacy system.voidship (skopiujemy do flags przy dropie) return it.getFlag(VB_MOD, "voidship") || it.system?.voidship || null; } function countUsedWeaponSlots(actor) { const used = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 }; for (const it of actor.items ?? []) { const v = getVSH(it); const eq = (it.system?.equipped ?? true); const slot = normalizeSlotName(v?.slot); if (eq && slot && (slot in used)) used[slot] += 1; } return used; } function pickFirstFreeSlot(allowed, hullSlots, used) { const order = ["prow", "dorsal", "port", "starboard", "keel"]; for (const s of order) { if (!allowed.includes(s)) continue; const cap = Number(hullSlots?.[s] ?? 0); const u = Number(used?.[s] ?? 0); if (u < cap) return s; } return null; } // ---------------- Handlebars helpers ---------------- Hooks.once("init", () => { if (!Handlebars.helpers.signed) { Handlebars.registerHelper("signed", (v) => { const n = Number(v || 0); if (!Number.isFinite(n) || n === 0) return "±0"; return (n > 0 ? "+" : "−") + Math.abs(n); }); } if (!Handlebars.helpers.eq) { Handlebars.registerHelper("eq", (a, b) => a === b); } if (!Handlebars.helpers.json) { Handlebars.registerHelper("json", (ctx) => { try { return JSON.stringify(ctx, null, 2); } catch { return String(ctx); } }); } }); class VoidshipSheet extends ActorSheet { static get defaultOptions() { const opts = super.defaultOptions; return foundry.utils.mergeObject(opts, { classes: ["wg-voidships", "sheet", "actor", "voidship", "themed", "theme-light"], template: `modules/${VB_MOD}/templates/voidship-sheet.hbs`, width: 720, height: 680, tabs: [{ navSelector: ".tabs", contentSelector: ".sheet-body", initial: "wg" }], dragDrop: [{ dragSelector: ".voidship-sheet", dropSelector: ".voidship-sheet" }] }); } /** Allow viewing for everyone (adjust if you need perms) */ _canUserView(user) { return !!user; } get isVoidship() { return this.actor?.getFlag(VB_MOD, "isVoidship") === true; } // ---- math helpers ---- _sumModsFromItems(items) { const mods = { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 }; let useSpace = 0, usePower = 0; const slotsUsed = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 }; for (const it of items) { const vs = it.system?.voidship; if (!vs) continue; const equipped = (it.system?.equipped ?? true); const slotKey = String(vs.slot || "").toLowerCase(); if (equipped && ["prow", "dorsal", "port", "starboard", "keel"].includes(slotKey)) { slotsUsed[slotKey] = (slotsUsed[slotKey] || 0) + 1; } // capacity usage (always counts, bo to realne zużycie – jeśli wolisz tylko equipped, to obejmij warunkiem) const use = vs.use || {}; if (Number.isFinite(Number(use.space))) useSpace += Number(use.space); if (Number.isFinite(Number(use.power))) usePower += Number(use.power); // mods liczą się tylko jeśli equipped if (!equipped) continue; const m = vs.mods || {}; for (const k of Object.keys(mods)) { const val = Number(m[k]); if (Number.isFinite(val)) mods[k] += val; } } return { mods, use: { space: useSpace, power: usePower }, slotsUsed }; } // ---- data aggregation for template ---- async getData(options) { const data = await super.getData(options); const actor = this.actor; const VB_MOD = "wg-voidships-builder"; // Flagowe źródła (przeżywają migracje W&G) // czytaj flagi wprost z actor.flags[namespace] (albo pojedyncze klucze) const flagsNs = actor.flags?.[VB_MOD] || {}; const defaultHull = { class: actor.name, size: "Cruiser", slots: { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 } }; const defaultBase = { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 }; const defaultCrew = { command: 0, pilot: 0, sensors: 0, engineering: 0, gunnery: 0, security: 0, flightDeck: 0, passengers: 0 }; const crew = actor.getFlag(VB_MOD, "crew") ?? flagsNs.crew ?? defaultCrew; const rtRaw = actor.getFlag(VB_MOD, "rtRaw") ?? flagsNs.rtRaw ?? {}; const rt = rtRaw.rt || {}; const rtCard = { speed: rt.speed ?? rtRaw.combat?.speed ?? 0, manoeuvrability: rt.manoeuvrability ?? rtRaw.mnvr ?? 0, detection: rt.detection ?? 0, hullIntegrity: rt.hullIntegrity ?? rtRaw.combat?.wounds?.max ?? 0, armour: rt.armour ?? 0, space: rt.space ?? 0, power: rt.power ?? 0, weapons: { prow: rt.weapons?.prow ?? 0, dorsal: rt.weapons?.dorsal ?? 0, port: rt.weapons?.port ?? 0, starboard: rt.weapons?.starboard ?? 0, keel: rt.weapons?.keel ?? 0 }, turret: rt.turret ?? 0, notes: rtRaw.notes ?? "", source: rt.source ?? rtRaw.source ?? "" }; // --- komponenty z itemów (flags canonical) --- const items = actor.items.contents || []; const components = []; const slotsUsed = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 }; const mods = { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0 }; const useTotal = { space: 0, power: 0 }; for (const it of items) { const v = getVSH(it); if (!v) continue; const c = foundry.utils.duplicate(v); c.name = it.name; c.itemId = it.id; c.equipped = (it.system?.equipped ?? true); c.slot = normalizeSlotName(c.slot) || "Internal"; c.allowedSlots = normalizeAllowedSlots(c.allowedSlots || foundry.utils.getProperty(it.flags, `${VB_MOD}.component.allowedSlots`)); // defaults c.use = { space: 0, power: 0, ...(c.use || {}) }; c.mods = { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0, ...(c.mods || {}) }; components.push(c); if (c.equipped) { // sumuj mody for (const k of Object.keys(mods)) { const val = Number(c.mods[k] || 0); if (Number.isFinite(val)) mods[k] += val; } // sumuj zużycie/produkcję const us = Number(c.use.space || 0); const up = Number(c.use.power || 0); if (Number.isFinite(us)) useTotal.space += us; if (Number.isFinite(up)) useTotal.power += up; // slot usage (tylko jeśli to broń z legalnym slotem) if (VB_WEAPON_SLOTS.includes(c.slot)) { slotsUsed[c.slot] = (slotsUsed[c.slot] || 0) + 1; } } } const base = actor.getFlag(VB_MOD, "base") || { armour:0, integrity:0, manoeuvre:0, detection:0, speed:0, space:0, power:0 }; // total = base + mods const totals = { armour: (Number(base.armour)||0) + mods.armour, integrity: (Number(base.integrity)||0) + mods.integrity, manoeuvre: (Number(base.manoeuvre)||0) + mods.manoeuvre, detection: (Number(base.detection)||0) + mods.detection, speed: (Number(base.speed)||0) + mods.speed, space: (Number(base.space)||0) + mods.space, power: (Number(base.power)||0) + mods.power }; totals.spaceFree = totals.space - useTotal.space; totals.powerFree = totals.power - useTotal.power; // warnings const hull = actor.getFlag(VB_MOD, "hull") || { slots: {} }; const warnings = {}; for (const s of VB_WEAPON_SLOTS) { if ((slotsUsed[s]||0) > (hull.slots?.[s]||0)) { warnings.slots = "One or more weapon slot groups exceed hull limits."; break; } } if (totals.spaceFree < 0) warnings.capacity = "Not enough Space for installed components."; if (totals.powerFree < 0) warnings.capacity = "Not enough Power for installed components."; // zaszyj data.hull = hull; data.base = base; data.components = components; data.slotsUsed = slotsUsed; data.totals = totals; data.mods = mods; data.useTotal = useTotal; // extra debug data.debug = { usedWeaponSlots: slotsUsed, itemsCount: items.length, componentsCount: components.length, itemsNoVSH: items.filter(i => !getVSH(i)).map(i => ({id:i.id, name:i.name})) }; return data; } // ---- listeners & UI ---- activateListeners(html) { super.activateListeners(html); html.find("[data-action='mark-voidship']").on("click", async () => { await this.actor.setFlag(VB_MOD, "isVoidship", true); ui.notifications.info("Marked as Voidship."); this.render(true); }); html.find("[data-action='unmark-voidship']").on("click", async () => { await this.actor.unsetFlag(VB_MOD, "isVoidship"); ui.notifications.info("Unmarked Voidship."); this.render(true); }); html.find("[data-action='configure-base']").on("click", async () => { const cur = this.actor.getFlag(VB_MOD, "base") || {}; const content = `
`; new Dialog({ title: "Voidship — Base Stats", content, buttons: { ok: { label: "Save", callback: async (dlg) => { const form = dlg[0].querySelector("form"); const num = (n) => Number(form.querySelector(`[name="${n}"]`)?.value || 0); await this.actor.setFlag(VB_MOD, "base", { armour: num("armour"), integrity: num("integrity"), manoeuvre: num("manoeuvre"), detection: num("detection"), speed: num("speed"), space: num("space"), power: num("power") }); this.render(true); } }, cancel: { label: "Cancel" } }, default: "ok" }, { jQuery: true }).render(true); }); // Components controls html.find("[data-action='comp-toggle']").on("click", async (ev) => { const row = ev.currentTarget.closest("tr"); const id = row?.dataset?.itemId; const item = this.actor.items.get(id); if (!item) return; const equipped = !(item.system?.equipped ?? true); await item.update({ "system.equipped": equipped }); this.render(true); }); html.find("[data-action='comp-edit']").on("click", async (ev) => { const row = ev.currentTarget.closest("tr"); const id = row?.dataset?.itemId; const item = this.actor.items.get(id); if (item) item.sheet?.render(true); }); html.find("[data-action='comp-remove']").on("click", async (ev) => { const row = ev.currentTarget.closest("tr"); const id = row?.dataset?.itemId; if (!id) return; await this.actor.deleteEmbeddedDocuments("Item", [id]); this.render(true); }); } async _onDropItem(event, data) { event.preventDefault(); // 1) Rozwiąż źródło (uuid/pack/data) let doc = null; try { if (data?.uuid) doc = await fromUuid(data.uuid); else if (data?.pack && data?._id) { const pack = game.packs.get(data.pack); if (pack) doc = await pack.getDocument(data._id); } else if (data) { doc = await Item.implementation.fromDropData(data); } } catch (e) { /* ignore */ } const raw = doc?.toObject?.(false) ?? data?.data ?? data; if (!raw?.type) { ui.notifications.error("Drop failed: cannot resolve item data (no UUID/DATA)."); return false; } // 2) Czy to komponent? const flagsIn = foundry.utils.getProperty(raw, `flags.${VB_MOD}`) || {}; const legacySys = foundry.utils.getProperty(raw, "system.voidship") || null; const hasCompTag = !!flagsIn.component; const hasLegacy = !!legacySys; if (!hasCompTag && !hasLegacy) { ui.notifications.warn("This item is not a voidship component."); return false; } // 3) Skonstruuj flags.${VB_MOD}.voidship (kanoniczne) const vshIn = flagsIn.voidship || legacySys || {}; const flagsOut = foundry.utils.duplicate(flagsIn); flagsOut.voidship = foundry.utils.mergeObject({ slot: vshIn.slot || null, use: { space: 0, power: 0, ...(vshIn.use || {}) }, mods: { armour: 0, integrity: 0, manoeuvre: 0, detection: 0, speed: 0, space: 0, power: 0, ...(vshIn.mods || {}) }, allowedSlots: normalizeAllowedSlots(vshIn.allowedSlots || flagsIn?.component?.allowedSlots || []) }, {}, { inplace: false }); // 4) Auto-przydział slotu dla broni (jeśli allowedSlots != pusty) const allowed = flagsOut.voidship.allowedSlots || []; if (allowed.length > 0) { const hull = this.actor.getFlag(VB_MOD, "hull") || {}; const hullSlots = hull.slots || {}; const used = countUsedWeaponSlots(this.actor); const chosen = pickFirstFreeSlot(allowed, hullSlots, used); if (!chosen) { ui.notifications.warn("No free matching weapon slot for this component."); return false; } flagsOut.voidship.slot = chosen; } else { // nie-broń flagsOut.voidship.slot = flagsOut.voidship.slot || "Internal"; } // 5) wpisz flags i domyślne "equipped" raw.flags = raw.flags || {}; raw.flags[VB_MOD] = flagsOut; if (raw.system?.equipped === undefined) { raw.system = raw.system || {}; raw.system.equipped = true; } // 6) Utwórz na aktorze await this.actor.createEmbeddedDocuments("Item", [raw]); this.render(true); return true; } } // Register the sheet Hooks.once("init", () => { Actors.registerSheet(VB_MOD, VoidshipSheet, { types: ["vehicle"], makeDefault: false, label: "Voidship (W&G)" }); game.wgVoidships = game.wgVoidships || {}; game.wgVoidships.createVoidship = async function (name = "New Voidship") { const actor = await Actor.create({ name, type: "vehicle", folder: null }); await actor.setFlag(VB_MOD, "isVoidship", true); ui.notifications.info(`Voidship created: ${name}`); return actor; }; console.log(`${VB_MOD}: VoidshipSheet registered`); });