sync: wg-voidships-builder v1.4.9

This commit is contained in:
2025-09-19 15:59:30 +02:00
parent c11e029b92
commit 20345f1605
4 changed files with 288 additions and 130 deletions
+57 -12
View File
@@ -2,9 +2,16 @@
"id": "wg-voidships-builder", "id": "wg-voidships-builder",
"title": "Wrath & Glory: Voidships — Builder", "title": "Wrath & Glory: Voidships — Builder",
"description": "Składanie okrętów jako Actorów przez dodawanie wyposażenia (gear) z kompendium.", "description": "Składanie okrętów jako Actorów przez dodawanie wyposażenia (gear) z kompendium.",
"version": "1.4.8", "version": "1.4.9",
"compatibility": { "minimum": "12", "verified": "13" }, "compatibility": {
"authors": [{ "name": "Gizmo & GPT-5 Thinking" }], "minimum": "12",
"verified": "13"
},
"authors": [
{
"name": "Gizmo & GPT-5 Thinking"
}
],
"scripts": [ "scripts": [
"scripts/voidships.js", "scripts/voidships.js",
"scripts/voidship-actor.js" "scripts/voidship-actor.js"
@@ -12,15 +19,47 @@
"templates": [ "templates": [
"templates/voidship-sheet.hbs" "templates/voidship-sheet.hbs"
], ],
"packs": [ "packs": [
{ "label": "W&G Voidship Hulls (Empty)", "name": "wg-voidship-hulls", "path": "packs/wg-voidship-hulls.db", "type": "Actor", "system": "wrath-and-glory" }, {
{ "label": "W&G Voidship Items", "name": "wg-voidship-items", "path": "packs/wg-voidship-items.db", "type": "Item", "system": "wrath-and-glory" }, "label": "W&G Voidship Hulls (Empty)",
{ "label": "W&G Voidships — Tables", "name": "wg-voidships-tables", "path": "packs/wg-voidships-tables.db", "type": "RollTable" }, "name": "wg-voidship-hulls",
{ "label": "W&G Voidship Macros", "name": "wg-voidship-macros", "path": "packs/wg-voidship-macros.db", "type": "Macro" }, "path": "packs/wg-voidship-hulls.db",
"type": "Actor",
{ "label": "W&G Voidship Archeotech", "name": "wg-voidship-archeotech", "path": "packs/wg-voidship-archeotech.db", "type": "Item", "system": "wrath-and-glory" }, "system": "wrath-and-glory"
{ "label": "W&G Voidship Xenos", "name": "wg-voidship-xenos", "path": "packs/wg-voidship-xenos.db", "type": "Item", "system": "wrath-and-glory" } },
{
"label": "W&G Voidship Items",
"name": "wg-voidship-items",
"path": "packs/wg-voidship-items.db",
"type": "Item",
"system": "wrath-and-glory"
},
{
"label": "W&G Voidships — Tables",
"name": "wg-voidships-tables",
"path": "packs/wg-voidships-tables.db",
"type": "RollTable"
},
{
"label": "W&G Voidship Macros",
"name": "wg-voidship-macros",
"path": "packs/wg-voidship-macros.db",
"type": "Macro"
},
{
"label": "W&G Voidship Archeotech",
"name": "wg-voidship-archeotech",
"path": "packs/wg-voidship-archeotech.db",
"type": "Item",
"system": "wrath-and-glory"
},
{
"label": "W&G Voidship Xenos",
"name": "wg-voidship-xenos",
"path": "packs/wg-voidship-xenos.db",
"type": "Item",
"system": "wrath-and-glory"
}
], ],
"packFolders": [ "packFolders": [
{ {
@@ -37,7 +76,13 @@
"folders": [] "folders": []
} }
], ],
"relationships": { "systems": [{ "id": "wrath-and-glory" }] }, "relationships": {
"systems": [
{
"id": "wrath-and-glory"
}
]
},
"readme": "journal/README.md", "readme": "journal/README.md",
"license": "LICENSE" "license": "LICENSE"
} }
@@ -6,9 +6,11 @@
{"_id":"HULLPLATE0000001","name":"Reinforced Hull Plating","type":"gear","img":"icons/svg/armor.svg","flags":{"wg-voidships-builder":{"component":{"kind":"armour","subtype":"plating","tags":["hull","armour","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":1,"power":0},"mods":{"armour":2,"integrity":4,"manoeuvre":-1,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Additional armour plates and bracing.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]} {"_id":"HULLPLATE0000001","name":"Reinforced Hull Plating","type":"gear","img":"icons/svg/armor.svg","flags":{"wg-voidships-builder":{"component":{"kind":"armour","subtype":"plating","tags":["hull","armour","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":1,"power":0},"mods":{"armour":2,"integrity":4,"manoeuvre":-1,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Additional armour plates and bracing.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]}
{"_id":"CARGOHOLD0000001","name":"Cargo Hold & Lighter Bay","type":"gear","img":"icons/svg/crate.svg","flags":{"wg-voidships-builder":{"component":{"kind":"cargo-bay","subtype":"standard","tags":["cargo-bay","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":-5,"power":1},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":-5,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"General-purpose cargo hold with lighter handling.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]} {"_id":"CARGOHOLD0000001","name":"Cargo Hold & Lighter Bay","type":"gear","img":"icons/svg/crate.svg","flags":{"wg-voidships-builder":{"component":{"kind":"cargo-bay","subtype":"standard","tags":["cargo-bay","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":-5,"power":1},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":-5,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"General-purpose cargo hold with lighter handling.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]}
{"_id":"AUXPLASMA0000001","name":"Auxiliary Plasma Generator","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"component":{"kind":"plasma","subtype":"aux-generator","tags":["aux-plasma","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":2,"power":-6},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":6}}}},"system":{"quantity":1,"rarity":"Common","description":"Auxiliary plasma reactor increasing available power.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{"power":0},"mods":{}}},"effects":[]} {"_id":"AUXPLASMA0000001","name":"Auxiliary Plasma Generator","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"component":{"kind":"plasma","subtype":"aux-generator","tags":["aux-plasma","internal"]},"voidOnly":true,"voidship":{"slot":"Internal","use":{"space":2,"power":-6},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":6}}}},"system":{"quantity":1,"rarity":"Common","description":"Auxiliary plasma reactor increasing available power.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{"power":0},"mods":{}}},"effects":[]}
{"_id":"MACROPROW0000001","name":"Macrobattery (Prow)","type":"gear","img":"icons/svg/sword.svg","flags":{"wg-voidships-builder":{"component":{"kind":"weapon","subtype":"macrobattery","tags":["weapon","prow"]},"voidOnly":true,"voidship":{"slot":"Prow","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Prow macrobattery.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]}
{"_id":"MACRODORS0000001","name":"Macrobattery (Dorsal)","type":"gear","img":"icons/svg/sword.svg","flags":{"wg-voidships-builder":{"component":{"kind":"weapon","subtype":"macrobattery","tags":["weapon","dorsal"]},"voidOnly":true,"voidship":{"slot":"Dorsal","use":{"space":2,"power":2},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}}},"system":{"quantity":1,"rarity":"Common","description":"Dorsal macrobattery.","value":0,"keywords":[],"effect":"","test":"","voidship":{"use":{},"mods":{}}},"effects":[]}
{"_id":"ITEMREACTCRU0001","name":"Plasma Reactor (Cruiser) — Mars-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"cruiser","tags":["reactor","plasma"]}}},"system":{"description":"Standardowy reaktor plazmowy klasy krążownik (Mars-Pattern). Generuje moc dla wszystkich systemów okrętu.","voidship":{"slot":"Internal","use":{"space":20,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":60}}},"effects":[]} {"_id":"ITEMREACTCRU0001","name":"Plasma Reactor (Cruiser) — Mars-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"cruiser","tags":["reactor","plasma"]}}},"system":{"description":"Standardowy reaktor plazmowy klasy krążownik (Mars-Pattern). Generuje moc dla wszystkich systemów okrętu.","voidship":{"slot":"Internal","use":{"space":20,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":60}}},"effects":[]}
{"_id":"ITEMREACTLCR0001","name":"Plasma Reactor (Light Cruiser) — Mars-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"light-cruiser","tags":["reactor","plasma"]}}},"system":{"description":"Reaktor plazmowy dla lekkich krążowników (Mars-Pattern).","voidship":{"slot":"Internal","use":{"space":18,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":55}}},"effects":[]} {"_id":"ITEMREACTLCR0001","name":"Plasma Reactor (Light Cruiser) — Mars-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"light-cruiser","tags":["reactor","plasma"]}}},"system":{"description":"Reaktor plazmowy dla lekkich krążowników (Mars-Pattern).","voidship":{"slot":"Internal","use":{"space":18,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":55}}},"effects":[]}
{"_id":"ITEMREACTFRI0001","name":"Plasma Reactor (Frigate) — Jovian-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"frigate","tags":["reactor","plasma"]}}},"system":{"description":"Reaktor plazmowy okrętów klasy fregata (Jovian-Pattern).","voidship":{"slot":"Internal","use":{"space":15,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":45}}},"effects":[]} {"_id":"ITEMREACTFRI0001","name":"Plasma Reactor (Frigate) — Jovian-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"frigate","tags":["reactor","plasma"]}}},"system":{"description":"Reaktor plazmowy okrętów klasy fregata (Jovian-Pattern).","voidship":{"slot":"Internal","use":{"space":15,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":45}}},"effects":[]}
{"_id":"ITEMREACTESC0001","name":"Plasma Reactor (Escort) — Jovian-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"escort","tags":["reactor","plasma"]}}},"system":{"description":"Lżejszy reaktor plazmowy dla eskort.","voidship":{"slot":"Internal","use":{"space":12,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":40}}},"effects":[]} {"_id":"ITEMREACTESC0001","name":"Plasma Reactor (Escort) — Jovian-Pattern","type":"gear","img":"icons/svg/energy.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"reactor","subtype":"escort","tags":["reactor","plasma"]}}},"system":{"description":"Lżejszy reaktor plazmowy dla eskort.","voidship":{"slot":"Internal","use":{"space":12,"power":0},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":40}}},"effects":[]}
{"_id":"ITEMMACROUNI0001","name":"Macrobattery (Universal) — Mars-Pattern","type":"gear","img":"icons/svg/dice-target.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"weapon","subtype":"macro","tags":["weapon","macro"]}}},"system":{"description":"Uniwersalna makrobateria. Może być montowana na dowolnym stanowisku broni.","voidship":{"allowedSlots":["prow","dorsal","port","starboard"],"use":{"space":8,"power":4},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}},"effects":[]}
{"_id":"ITEMWPMACBRD0001","name":"Macrobattery (Broadside) — Mars-Pattern","type":"gear","img":"icons/svg/dice-target.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"weapon","subtype":"macro-broadside","tags":["weapon","macro"]}}},"system":{"description":"Makrobateria burtowa. Montaż wyłącznie na burtach.","voidship":{"allowedSlots":["port","starboard"],"use":{"space":9,"power":4},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}},"effects":[]}
{"_id":"ITEMTORPPROW0001","name":"Torpedo Tubes — Jovian-Pattern","type":"gear","img":"icons/svg/anchor.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"weapon","subtype":"torpedo","tags":["weapon","torpedo"]}}},"system":{"description":"Wyrzutnie torpedowe montowane w dziobie.","voidship":{"allowedSlots":["prow"],"use":{"space":10,"power":5},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}},"effects":[]}
{"_id":"ITEMLANCEDSL0001","name":"Lance Battery — Starflare-Pattern","type":"gear","img":"icons/svg/sun.svg","flags":{"wg-voidships-builder":{"voidOnly":true,"component":{"kind":"weapon","subtype":"lance","tags":["weapon","lance"]}}},"system":{"description":"Bateria lanc. Preferowane stanowiska: dorsal/port/starboard.","voidship":{"allowedSlots":["dorsal","port","starboard"],"use":{"space":10,"power":6},"mods":{"armour":0,"integrity":0,"manoeuvre":0,"detection":0,"speed":0,"space":0,"power":0}}},"effects":[]}
+94 -68
View File
@@ -4,6 +4,47 @@
const VB_MOD = "wg-voidships-builder"; const VB_MOD = "wg-voidships-builder";
const VB_DEBUG = true; // Debug on const VB_DEBUG = true; // Debug on
function dbg(...args) { if (VB_DEBUG) console.debug("[VB]", ...args); } 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 ---------------- // ---------------- Handlebars helpers ----------------
Hooks.once("init", () => { Hooks.once("init", () => {
@@ -311,88 +352,73 @@ async _onDrop(event) {
if (raw?.type === "Item") return this._onDropItem(event, raw); if (raw?.type === "Item") return this._onDropItem(event, raw);
dbg("drop.ignored", 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) { async _onDropItem(event, data) {
const VB_MOD = "wg-voidships-builder"; event.preventDefault();
dbg("drop.item.raw", foundry.utils.deepClone(data));
// 1) spróbuj fromUuid // 1) Rozwiąż źródło dropu (UUID/pack/data) możliwie bezpiecznie
let itemDoc = null; let doc = null;
if (data?.uuid) {
try { itemDoc = await fromUuid(data.uuid); } catch (e) { dbg("fromUuid.error", e); }
}
// 2) inline data
let itemData = itemDoc?.toObject?.() ?? data?.data ?? null;
// 3) ostatecznie spróbuj Foundry helpera
if (!itemData) {
try { try {
const i = await Item.implementation.fromDropData(data); if (data?.uuid) doc = await fromUuid(data.uuid);
itemData = i?.toObject?.() ?? null; else if (data?.pack && data?._id) {
} catch (e) { const pack = game.packs.get(data.pack);
ui.notifications.error("Drop failed: cannot resolve item data (no UUID/DATA)."); if (pack) doc = await pack.getDocument(data._id);
dbg("fromDropData.error", e, data); return; } else if (data?.data || data?.type) {
// Foundry potrafi zrzucać raw "data"
doc = await Item.implementation.fromDropData(data);
} }
} } catch (e) { /* fall-through */ }
if (!itemData) { ui.notifications.error("Drop failed: no item data."); return; }
// 4) Czy to komponent? akceptujemy po flagach lub po system.voidship // 2) Wyprowadź "raw" do stworzenia osadzonego itemu
const fmod = itemData.flags?.[VB_MOD] || {}; const raw = doc?.toObject?.(false) ?? data?.data ?? data;
const meta = fmod.component || {}; if (!raw?.system) {
const vflag = fmod.voidship || null; ui.notifications.error("Drop failed: cannot resolve item data (no UUID/DATA).");
const vsys = itemData.system?.voidship || null; return false;
const tags = Array.isArray(meta.tags) ? meta.tags : []; }
const isComponent = !!(vflag || vsys || tags.length);
// 3) Sprawdź, czy to komponent voidship (wymagamy system.voidship)
const isComponent = !!raw.system?.voidship || !!raw.flags?.["wg-voidships-builder"]?.component;
if (!isComponent) { if (!isComponent) {
ui.notifications.warn("This item is not a voidship component."); ui.notifications.warn("This item is not a voidship component.");
dbg("notComponent", { name: itemData.name, flags: itemData.flags }); return false;
return;
} }
// 5) wyznacz slot + liczby (preferuj to co już jest w flagach; jeśli było tylko w system.voidship przenieś do flag) // 4) Upewnij się, że istnieje sekcja voidship
const inferSlotFromTags = (tags=[]) => { raw.system.voidship = raw.system.voidship || {};
const low = (Array.isArray(tags)?tags:[]).map(t => String(t).toLowerCase()); const vsh = raw.system.voidship;
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 vf = vflag || vsys || {}; // 5) Wyciągnij allowedSlots (preferuj z system.voidship.allowedSlots; ewentualnie z flags.component.allowedSlots)
const slot = vf.slot || meta.slot || inferSlotFromTags(meta.tags); const fromFlags = foundry.utils.getProperty(raw, "flags.wg-voidships-builder.component.allowedSlots");
const vfixed = { const allowed = normalizeAllowedSlots(vsh.allowedSlots ?? fromFlags);
slot,
use: { // 6) Jeżeli to broń (ma ograniczenia slotów), wybierz pierwszy wolny pasujący i wpisz do vsh.slot.
space: Number(vf?.use?.space ?? 0), if (allowed.length > 0) {
power: Number(vf?.use?.power ?? 0) const hull = this.actor.getFlag("wg-voidships-builder", "hull") || {};
}, const hullSlots = hull.slots || {};
mods: { const used = countUsedWeaponSlots(this.actor);
armour: Number(vf?.mods?.armour ?? 0),
integrity: Number(vf?.mods?.integrity ?? 0), const chosen = pickFirstFreeSlot(allowed, hullSlots, used);
manoeuvre: Number(vf?.mods?.manoeuvre ?? 0), if (!chosen) {
detection: Number(vf?.mods?.detection ?? 0), ui.notifications.warn("No free matching weapon slot for this component.");
speed: Number(vf?.mods?.speed ?? 0), return false;
space: Number(vf?.mods?.space ?? 0), }
power: Number(vf?.mods?.power ?? 0) vsh.slot = chosen; // przypisz ostateczny slot montażu
} else {
// Nie-broń: zostaw istniejący slot albo nadaj "Internal"
vsh.slot = vsh.slot || "Internal";
} }
};
// 6) Nie dotykamy schema W&G zapisujemy w FLAGS // 7) Domyślnie traktuj jako "equipped" (zamontowane), jeśli schema na to pozwala
itemData.flags = foundry.utils.mergeObject(itemData.flags || {}, { if (raw.system.equipped === undefined) raw.system.equipped = true;
[VB_MOD]: foundry.utils.mergeObject(fmod, { voidship: vfixed }, { inplace: false })
}, { inplace: false });
// 7) dopnij equipped (żeby mody się naliczały od razu) // 8) Utwórz item na aktorze
itemData.system = foundry.utils.mergeObject(itemData.system || {}, { equipped: true }, { inplace: false }); await this.actor.createEmbeddedDocuments("Item", [raw]);
// 9) Przelicz i odśwież
dbg("drop.finalized", { name: itemData.name, vfixed });
try {
const created = await this.actor.createEmbeddedDocuments("Item", [itemData]);
dbg("drop.created", created?.map(c => ({ id: c.id, name: c.name })));
this.render(true); this.render(true);
} catch (e) { return true;
ui.notifications.error("Failed to add component to this voidship (see console).");
dbg("drop.create.error", e);
}
} }
+130 -45
View File
@@ -1,15 +1,19 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
sync_foundry_module.py (wersja z dry-run + argumentami CLI) sync_foundry_module.py (z dry-run, argumentami CLI i preflightem git/module.json)
Funkcje: Kroki:
1) Znajduje ...\AppData\(Local|LocalLow|Roaming)\FoundryVTT\Data\modules dla podanego usera [PRE] W katalogu źródłowym:
(lub używa pełnego override). - Wczytaj module.json, pobierz 'id' i 'version'
2) Czyści zawartość wskazanego modułu (z potwierdzeniem lub bez; w dry-run tylko listuje). - Zwiększ ostatnią liczbę w 'version' o 1, zapisz module.json
3) Kopiuje zawartość z SOURCE_BASE_DIR\<MODULE_SUBDIR_NAME> (w dry-run tylko plan). - git add -A; git commit -m "sync: <id> v<version>"; git push
(w dry-run wyłącznie plan, bez modyfikacji i bez gita)
Argumenty CLI nadpisują stałe z sekcji KONFIGURACJA. [2] Wyczyść zawartość docelowego modułu (z potwierdzeniem lub bez; w dry-run tylko lista)
[3] Skopiuj zawartość źródła do celu (w dry-run: plan)
Wymagania: Python 3.8+, git w PATH, uprawnienia do zapisu.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,6 +21,8 @@ import os
import sys import sys
import shutil import shutil
import argparse import argparse
import json
import subprocess
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@@ -27,11 +33,11 @@ from typing import List, Optional, Tuple
TARGET_USER: str = "mtuszowski" # nazwa folderu w C:\Users\... TARGET_USER: str = "mtuszowski" # nazwa folderu w C:\Users\...
APPDATA_KIND: Optional[str] = None # None | "Local" | "LocalLow" | "Roaming" APPDATA_KIND: Optional[str] = None # None | "Local" | "LocalLow" | "Roaming"
TARGET_MODULES_DIR_OVERRIDE: Optional[str] = None # pełna ścieżka do ...\FoundryVTT\Data\modules TARGET_MODULES_DIR_OVERRIDE: Optional[str] = None # pełna ścieżka do ...\FoundryVTT\Data\modules
MODULE_SUBDIR_NAME: str = "wg-voidships" MODULE_SUBDIR_NAME: str = "wg-voidships-builder"
CONFIRM_DELETE: bool = True CONFIRM_DELETE: bool = True
SOURCE_BASE_DIR: str = r"C:\vtt_work" SOURCE_BASE_DIR: str = r"C:\vtt_work"
RELATIVE_PATTERN: str = r"FoundryVTT\Data\modules" RELATIVE_PATTERN: str = r"FoundryVTT\Data\modules"
DRY_RUN: bool = False # <- nowa stała DRY_RUN: bool = False # globalny tryb symulacji (nic nie zmienia)
# ========================== # ==========================
# KONIEC KONFIGURACJI # KONIEC KONFIGURACJI
@@ -104,14 +110,12 @@ def ensure_dir(path: Path) -> None:
def iter_dir_contents(dir_path: Path) -> List[Path]: def iter_dir_contents(dir_path: Path) -> List[Path]:
"""Zwraca listę *bez* samego katalogu: wszystkie pliki/katalogi pierwszego poziomu."""
if not dir_path.exists(): if not dir_path.exists():
return [] return []
return list(dir_path.iterdir()) return list(dir_path.iterdir())
def list_recursive(dir_path: Path) -> List[Path]: def list_recursive(dir_path: Path) -> List[Path]:
"""Rekurencyjna lista wszystkich elementów wewnątrz katalogu (głębiej niż 1)."""
items: List[Path] = [] items: List[Path] = []
if not dir_path.exists(): if not dir_path.exists():
return items return items
@@ -125,15 +129,9 @@ def list_recursive(dir_path: Path) -> List[Path]:
def clear_dir_contents(dir_path: Path, dry_run: bool) -> Tuple[int, int]: def clear_dir_contents(dir_path: Path, dry_run: bool) -> Tuple[int, int]:
"""
Usuwa zawartość katalogu (pozostawia go). W dry_run tylko listuje.
Zwraca (liczba_plików, liczba_katalogów) do usunięcia/usuniętych.
"""
ensure_dir(dir_path) ensure_dir(dir_path)
files = 0 files = 0
dirs = 0 dirs = 0
# Zbierz listę do raportu
to_delete = iter_dir_contents(dir_path) to_delete = iter_dir_contents(dir_path)
if dry_run: if dry_run:
@@ -143,20 +141,13 @@ def clear_dir_contents(dir_path: Path, dry_run: bool) -> Tuple[int, int]:
for child in to_delete: for child in to_delete:
tag = "[plik]" if child.is_file() else "[katalog]" if child.is_dir() else "[inne]" tag = "[plik]" if child.is_file() else "[katalog]" if child.is_dir() else "[inne]"
print(f" {tag} {norm(child)}") print(f" {tag} {norm(child)}")
# policz rekurencyjnie dla katalogów
for child in to_delete: for child in to_delete:
if child.is_file() or child.is_symlink(): if child.is_file() or child.is_symlink():
files += 1 files += 1
elif child.is_dir(): elif child.is_dir():
# policz wszystkie elementy w środku informacyjnie
nested = list_recursive(child)
dirs += 1 dirs += 1
for n in nested:
# nie drukujemy każdego pliku zagnieżdżonego — wystarczy powyższa lista top-level
pass
return files, dirs return files, dirs
# tryb wykonawczy
for child in to_delete: for child in to_delete:
if child.is_file() or child.is_symlink(): if child.is_file() or child.is_symlink():
files += 1 files += 1
@@ -168,10 +159,6 @@ def clear_dir_contents(dir_path: Path, dry_run: bool) -> Tuple[int, int]:
def plan_copy(src: Path, dst: Path) -> List[Tuple[Path, Path, str]]: def plan_copy(src: Path, dst: Path) -> List[Tuple[Path, Path, str]]:
"""
Tworzy plan (źródło, cel, status) dla wszystkich elementów w src.
Status: "overwrite" jeśli cel istnieje (plik/katalog), inaczej "create".
"""
plan: List[Tuple[Path, Path, str]] = [] plan: List[Tuple[Path, Path, str]] = []
if not src.is_dir(): if not src.is_dir():
raise FileNotFoundError(f"Katalog źródłowy nie istnieje: {norm(src)}") raise FileNotFoundError(f"Katalog źródłowy nie istnieje: {norm(src)}")
@@ -179,13 +166,11 @@ def plan_copy(src: Path, dst: Path) -> List[Tuple[Path, Path, str]]:
for root, dirs, files in os.walk(src): for root, dirs, files in os.walk(src):
root_p = Path(root) root_p = Path(root)
rel_root = root_p.relative_to(src) rel_root = root_p.relative_to(src)
# katalogi
for d in dirs: for d in dirs:
s = root_p / d s = root_p / d
t = dst / rel_root / d t = dst / rel_root / d
status = "overwrite" if t.exists() else "create" status = "overwrite" if t.exists() else "create"
plan.append((s, t, status)) plan.append((s, t, status))
# pliki
for f in files: for f in files:
s = root_p / f s = root_p / f
t = dst / rel_root / f t = dst / rel_root / f
@@ -195,10 +180,6 @@ def plan_copy(src: Path, dst: Path) -> List[Tuple[Path, Path, str]]:
def copy_dir_contents(src: Path, dst: Path, dry_run: bool) -> Tuple[int, int]: def copy_dir_contents(src: Path, dst: Path, dry_run: bool) -> Tuple[int, int]:
"""
Kopiuje zawartość katalogu src do dst. W dry_run tylko listuje plan.
Zwraca (liczba_plików, liczba_katalogów) skopiowanych/planowanych.
"""
ensure_dir(dst) ensure_dir(dst)
plan = plan_copy(src, dst) plan = plan_copy(src, dst)
@@ -213,7 +194,6 @@ def copy_dir_contents(src: Path, dst: Path, dry_run: bool) -> Tuple[int, int]:
dirs = sum(1 for s, _, _ in plan if s.is_dir()) dirs = sum(1 for s, _, _ in plan if s.is_dir())
return files, dirs return files, dirs
# wykonanie
files = 0 files = 0
dirs = 0 dirs = 0
for s, t, _ in plan: for s, t, _ in plan:
@@ -238,8 +218,109 @@ def copy_dir_contents(src: Path, dst: Path, dry_run: bool) -> Tuple[int, int]:
return files, dirs return files, dirs
# ============ PRE-FLIGHT: module.json + git ============
def read_manifest(manifest_path: Path) -> dict:
if not manifest_path.is_file():
raise FileNotFoundError(f"Brak pliku manifestu: {norm(manifest_path)}")
try:
with manifest_path.open("r", encoding="utf-8") as f:
return json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Niepoprawny JSON w {norm(manifest_path)}: {e}") from e
def bump_version(last_version: str) -> str:
"""
Zwiększa o 1 ostatnią *liczbę* w wersji rozdzielanej kropkami.
Przykłady: 1 -> 2, 1.2 -> 1.3, 1.2.9 -> 1.2.10
"""
parts = last_version.strip().split(".")
if not parts:
raise ValueError(f"Niepoprawna wersja: '{last_version}'")
try:
n = int(parts[-1])
except ValueError:
raise ValueError(f"Ostatni segment wersji nie jest liczbą: '{last_version}'")
parts[-1] = str(n + 1)
return ".".join(parts)
def write_manifest(manifest_path: Path, data: dict) -> None:
tmp = manifest_path.with_suffix(".json.tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
tmp.replace(manifest_path) # atomowa podmiana na tym samym FS
def run_git_commands(repo_dir: Path, commit_msg: str, dry_run: bool) -> None:
"""
Wykonuje: git add -A; git commit -m "<msg>"; git push
W dry_run — tylko wyświetla plan.
"""
if dry_run:
print("[DRY-RUN] git add -A")
print(f"[DRY-RUN] git commit -m {commit_msg!r}")
print("[DRY-RUN] git push")
return
def run(cmd: List[str]) -> None:
r = subprocess.run(cmd, cwd=str(repo_dir), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if r.returncode != 0:
raise RuntimeError(f"Błąd podczas uruchamiania: {' '.join(cmd)}\nWyjście:\n{r.stdout}")
else:
if r.stdout.strip():
print(r.stdout)
# add, commit (może nie utworzyć commita jeśli brak zmian), push
run(["git", "add", "-A"])
# commit powinien się powieść — jeśli nie ma zmian, chcemy FAIL
r = subprocess.run(["git", "commit", "-m", commit_msg], cwd=str(repo_dir),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if r.returncode != 0:
# typowy komunikat: "nothing to commit, working tree clean" -> traktujemy jako błąd wg wymagań
raise RuntimeError(f"Błąd podczas git commit:\n{r.stdout}")
else:
if r.stdout.strip():
print(r.stdout)
run(["git", "push"])
def preflight_source(src_dir: Path, dry_run: bool) -> Tuple[str, str]:
"""
Wczytuje module.json, bumpuje wersję, zapisuje (jeśli nie dry_run),
i wykonuje git add/commit/push. Zwraca (module_id, new_version).
"""
manifest_path = src_dir / "module.json"
manifest = read_manifest(manifest_path)
if "id" not in manifest or "version" not in manifest:
raise KeyError(f"W {norm(manifest_path)} brakuje pola 'id' lub 'version'.")
module_id = str(manifest["id"])
old_version = str(manifest["version"])
new_version = bump_version(old_version)
print(f"[PRE] module.json: id={module_id}, version={old_version} -> {new_version}")
if dry_run:
print("[DRY-RUN] Nie zapisuję module.json i nie wykonuję git.")
else:
manifest["version"] = new_version
write_manifest(manifest_path, manifest)
print(f"[PRE] Zapisano zaktualizowany {norm(manifest_path)}")
commit_msg = f"sync: {module_id} v{new_version}"
run_git_commands(src_dir, commit_msg, dry_run=False)
return module_id, new_version
# ================= CLI / MAIN =================
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Synchronizacja modułu FoundryVTT (z dry-run).") p = argparse.ArgumentParser(description="Synchronizacja modułu FoundryVTT (preflight: module.json + git).")
p.add_argument("--user", dest="user", default=TARGET_USER, p.add_argument("--user", dest="user", default=TARGET_USER,
help=f"Nazwa użytkownika Windows (domyślnie: {TARGET_USER})") help=f"Nazwa użytkownika Windows (domyślnie: {TARGET_USER})")
p.add_argument("--appdata-kind", dest="appdata_kind", choices=APPDATA_VARIANTS + (None,), p.add_argument("--appdata-kind", dest="appdata_kind", choices=APPDATA_VARIANTS + (None,),
@@ -251,7 +332,7 @@ def parse_args() -> argparse.Namespace:
p.add_argument("--module-subdir-name", dest="module_subdir_name", p.add_argument("--module-subdir-name", dest="module_subdir_name",
default=MODULE_SUBDIR_NAME, default=MODULE_SUBDIR_NAME,
help=f"Nazwa podkatalogu modułu (domyślnie: {MODULE_SUBDIR_NAME})") help=f"Nazwa podkatalogu modułu (domyślnie: {MODULE_SUBDIR_NAME})")
# confirm-delete jako przełącznik dwu-stanowy
cd_group = p.add_mutually_exclusive_group() cd_group = p.add_mutually_exclusive_group()
cd_group.add_argument("--confirm-delete", dest="confirm_delete", action="store_true", cd_group.add_argument("--confirm-delete", dest="confirm_delete", action="store_true",
help="Włącz potwierdzenie kasowania (jeśli było wyłączone).") help="Włącz potwierdzenie kasowania (jeśli było wyłączone).")
@@ -266,12 +347,11 @@ def parse_args() -> argparse.Namespace:
default=RELATIVE_PATTERN, default=RELATIVE_PATTERN,
help=f"Wzorzec relatywny od AppData (domyślnie: {RELATIVE_PATTERN})") help=f"Wzorzec relatywny od AppData (domyślnie: {RELATIVE_PATTERN})")
# dry-run jako przełącznik dwu-stanowy
dr_group = p.add_mutually_exclusive_group() dr_group = p.add_mutually_exclusive_group()
dr_group.add_argument("--dry-run", dest="dry_run", action="store_true", dr_group.add_argument("--dry-run", dest="dry_run", action="store_true",
help="Tryb symulacji: tylko listuje co by zrobił.") help="Tryb symulacji: tylko plan (bez modyfikacji i bez gita).")
dr_group.add_argument("--no-dry-run", dest="dry_run", action="store_false", dr_group.add_argument("--no-dry-run", dest="dry_run", action="store_false",
help="Tryb wykonawczy: wprowadza zmiany na dysku.") help="Tryb wykonawczy: wprowadza zmiany i robi git.")
p.set_defaults(dry_run=DRY_RUN) p.set_defaults(dry_run=DRY_RUN)
return p.parse_args() return p.parse_args()
@@ -289,7 +369,14 @@ def main() -> int:
relative_pattern = args.relative_pattern relative_pattern = args.relative_pattern
dry_run = args.dry_run dry_run = args.dry_run
# 1) Ustal katalog modules # Ścieżki
src_dir = Path(source_base) / module_subdir
# [PRE] module.json + git w katalogu źródłowym
print(f"[PRE] Katalog źródłowy: {norm(src_dir)}")
preflight_source(src_dir, dry_run=dry_run)
# 1) Ustal katalog modules (cel)
if modules_override: if modules_override:
modules_dir = Path(modules_override) modules_dir = Path(modules_override)
if not modules_dir.is_dir(): if not modules_dir.is_dir():
@@ -302,7 +389,7 @@ def main() -> int:
target_module_dir = modules_dir / module_subdir target_module_dir = modules_dir / module_subdir
print(f"[INFO] Docelowy katalog modułu: {norm(target_module_dir)}") print(f"[INFO] Docelowy katalog modułu: {norm(target_module_dir)}")
# 2) Kasowanie zawartości (albo plan kasowania w dry-run) # 2) Kasowanie zawartości (albo plan w dry-run)
if confirm_delete and not dry_run: if confirm_delete and not dry_run:
ans = input(f"Czy na pewno wyczyścić zawartość '{norm(target_module_dir)}'? [y/N]: ").strip().lower() ans = input(f"Czy na pewno wyczyścić zawartość '{norm(target_module_dir)}'? [y/N]: ").strip().lower()
if ans not in ("y", "yes", "t", "tak"): if ans not in ("y", "yes", "t", "tak"):
@@ -315,9 +402,7 @@ def main() -> int:
del_files, del_dirs = clear_dir_contents(target_module_dir, dry_run=dry_run) del_files, del_dirs = clear_dir_contents(target_module_dir, dry_run=dry_run)
print(f"[INFO] {'Do usunięcia' if dry_run else 'Usunięto'}: plików={del_files}, katalogów={del_dirs}") print(f"[INFO] {'Do usunięcia' if dry_run else 'Usunięto'}: plików={del_files}, katalogów={del_dirs}")
# 3) Kopiowanie (albo plan kopiowania w dry-run) # 3) Kopiowanie (albo plan kopiowania)
src_dir = Path(source_base) / module_subdir
print(f"[INFO] Katalog źródłowy: {norm(src_dir)}")
print("[AKCJA] Kopiowanie zawartości..." if not dry_run else "[SYMULACJA] Plan kopiowania...") print("[AKCJA] Kopiowanie zawartości..." if not dry_run else "[SYMULACJA] Plan kopiowania...")
cp_files, cp_dirs = copy_dir_contents(src_dir, target_module_dir, dry_run=dry_run) cp_files, cp_dirs = copy_dir_contents(src_dir, target_module_dir, dry_run=dry_run)
print(f"[INFO] {'Do skopiowania' if dry_run else 'Skopiowano'}: plików={cp_files}, katalogów={cp_dirs}") print(f"[INFO] {'Do skopiowania' if dry_run else 'Skopiowano'}: plików={cp_files}, katalogów={cp_dirs}")