// 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); // string "prow|dorsal|port" albo "prow,dorsal" return String(allowed) .split(/[|,; ]+/g) .map(normalizeSlotName) .filter(Boolean); } function countUsedWeaponSlots(actor) { const used = { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 }; for (const it of actor.items ?? []) { const vs = it.system?.voidship; if (!vs) continue; const slot = normalizeSlotName(vs.slot); const eq = (it.system?.equipped ?? true); if (eq && slot && (slot in used)) used[slot] += 1; } return used; } function pickFirstFreeSlot(allowed, hullSlots, used) { // Kolejność zgodnie z wymaganiem: "pierwszy wolny pasujący" 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 hull = actor.getFlag(VB_MOD, "hull") ?? flagsNs.hull ?? defaultHull; const base = actor.getFlag(VB_MOD, "base") ?? flagsNs.base ?? defaultBase; 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: czytamy Z FLAG ===== const items = actor.items.contents; const inferSlotFromTags = (tags=[]) => { const low = (Array.isArray(tags)?tags:[]).map(t => String(t).toLowerCase()); const pref = ["prow","dorsal","port","starboard","keel","aetherics","internal"]; const hit = pref.find(p => low.includes(p)); return hit ? (hit[0].toUpperCase()+hit.slice(1)) : "Internal"; }; const components = []; for (const i of items) { const fmod = i.flags?.[VB_MOD] || {}; const vflag = fmod.voidship || null; // <-- TU trzymamy liczby const meta = fmod.component || {}; // kind/subtype/tags // Dopuszczamy też stare itemy z system.voidship (jeśli coś przetrwa) const vsys = i.system?.voidship || null; // komponent, jeśli ma vflag LUB meta.tags LUB vsys const isComponent = !!(vflag || vsys || (Array.isArray(meta.tags) && meta.tags.length)); if (!isComponent) continue; const vf = vflag || vsys || {}; const use = { space: Number(vf?.use?.space ?? 0), power: Number(vf?.use?.power ?? 0) }; const mods = { armour: Number(vf?.mods?.armour ?? 0), integrity: Number(vf?.mods?.integrity ?? 0), manoeuvre: Number(vf?.mods?.manoeuvre ?? 0), detection: Number(vf?.mods?.detection ?? 0), speed: Number(vf?.mods?.speed ?? 0), space: Number(vf?.mods?.space ?? 0), power: Number(vf?.mods?.power ?? 0) }; let slot = vf.slot || meta.slot || inferSlotFromTags(meta.tags); const equipped = (i.system?.equipped ?? true) && (vf.disabled !== true); components.push({ itemId: i.id, name: i.name, slot, equipped, use, mods, tags: meta.tags || [] }); } // Zliczanie slotów (tylko equipped) const slotsUsed = components.reduce((acc, c) => { if (!c.equipped) return acc; const key = String(c.slot || "Internal").toLowerCase(); if (["prow","dorsal","port","starboard","keel"].includes(key)) acc[key] = (acc[key] || 0) + 1; return acc; }, { prow: 0, dorsal: 0, port: 0, starboard: 0, keel: 0 }); // Capacity: bazowe + mody z komponentów (equipped) vs użycie (equipped) const modsSum = components.reduce((acc, c) => { if (!c.equipped) return acc; for (const k of Object.keys(acc)) acc[k] += Number(c.mods[k] || 0); return acc; }, { armour:0, integrity:0, manoeuvre:0, detection:0, speed:0, space:0, power:0 }); const useSum = components.reduce((acc, c) => { if (!c.equipped) return acc; acc.space += Number(c.use.space || 0); acc.power += Number(c.use.power || 0); return acc; }, { space: 0, power: 0 }); const totals = { armour: (Number(base.armour)||0) + modsSum.armour, integrity: (Number(base.integrity)||0) + modsSum.integrity, manoeuvre: (Number(base.manoeuvre)||0) + modsSum.manoeuvre, detection: (Number(base.detection)||0) + modsSum.detection, speed: (Number(base.speed)||0) + modsSum.speed, space: (Number(base.space)||0) + modsSum.space, power: (Number(base.power)||0) + modsSum.power }; totals.spaceFree = totals.space - useSum.space; totals.powerFree = totals.power - useSum.power; // Ostrzeżenia const warnings = {}; const overSlots = (slotsUsed.prow > (hull.slots?.prow || 0)) || (slotsUsed.dorsal > (hull.slots?.dorsal || 0)) || (slotsUsed.port > (hull.slots?.port || 0)) || (slotsUsed.starboard > (hull.slots?.starboard || 0)) || (slotsUsed.keel > (hull.slots?.keel || 0)); if (overSlots) warnings.slots = "One or more weapon slot groups exceed hull limits."; if (totals.spaceFree < 0 || totals.powerFree < 0) warnings.capacity = "Not enough Space/Power for installed components."; // wstrzyknij do template data.hull = hull; data.base = base; data.crew = crew; data.rtCard = rtCard; data.components = components; data.slotsUsed = slotsUsed; data.totals = totals; data.mods = modsSum; // ← DODANE: żeby HBS miał liczby modów data.warnings = warnings; dbg("sheet.getData", { base, hull, totals, slotsUsed, componentsCount: components.length }); 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); }); } // --- robust drop handlers: flag-only storage (survives W&G) --- async _onDrop(event) { event.preventDefault(); let raw = {}; try { raw = JSON.parse(event.dataTransfer?.getData("text/plain") || "{}"); } catch { ui.notifications.error("Drop failed: cannot parse drop payload."); return; } if (raw?.type === "Item") return this._onDropItem(event, raw); dbg("drop.ignored", raw); } /** Drop itemów na arkusz: tylko komponenty voidship. * - Jeżeli item ma allowedSlots (broń): przydziel "pierwszy wolny pasujący" i zapisz do system.voidship.slot * - Jeżeli nie ma allowedSlots: zachowaj istniejący system.voidship.slot (albo slot "Internal" dla nie-broni) */ async _onDropItem(event, data) { event.preventDefault(); // 1) Rozwiąż źródło dropu (UUID/pack/data) możliwie bezpiecznie 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?.data || data?.type) { // Foundry potrafi zrzucać raw "data" doc = await Item.implementation.fromDropData(data); } } catch (e) { /* fall-through */ } // 2) Wyprowadź "raw" do stworzenia osadzonego itemu const raw = doc?.toObject?.(false) ?? data?.data ?? data; if (!raw?.system) { ui.notifications.error("Drop failed: cannot resolve item data (no UUID/DATA)."); return false; } // 3) Sprawdź, czy to komponent voidship (wymagamy system.voidship) const isComponent = !!raw.system?.voidship || !!raw.flags?.["wg-voidships-builder"]?.component; if (!isComponent) { ui.notifications.warn("This item is not a voidship component."); return false; } // 4) Upewnij się, że istnieje sekcja voidship raw.system.voidship = raw.system.voidship || {}; const vsh = raw.system.voidship; // 5) Wyciągnij allowedSlots (preferuj z system.voidship.allowedSlots; ewentualnie z flags.component.allowedSlots) const fromFlags = foundry.utils.getProperty(raw, "flags.wg-voidships-builder.component.allowedSlots"); const allowed = normalizeAllowedSlots(vsh.allowedSlots ?? fromFlags); // 6) Jeżeli to broń (ma ograniczenia slotów), wybierz pierwszy wolny pasujący i wpisz do vsh.slot. if (allowed.length > 0) { const hull = this.actor.getFlag("wg-voidships-builder", "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; } vsh.slot = chosen; // przypisz ostateczny slot montażu } else { // Nie-broń: zostaw istniejący slot albo nadaj "Internal" vsh.slot = vsh.slot || "Internal"; } // 7) Domyślnie traktuj jako "equipped" (zamontowane), jeśli schema na to pozwala if (raw.system.equipped === undefined) raw.system.equipped = true; // 8) Utwórz item na aktorze await this.actor.createEmbeddedDocuments("Item", [raw]); // 9) Przelicz i odśwież 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`); });