From b05a1be778aca0260fefc81023918aa3c7973cf7 Mon Sep 17 00:00:00 2001 From: "Tim Evans (slaguru666)" Date: Wed, 8 Apr 2026 09:29:07 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20initial=20deploy=20=E2=80=94=20SLA=20I?= =?UTF-8?q?ndustries=20Foundry=20VTT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 + data/furniture-tables.json | 295 + data/room-tables.json | 158 + data/ship-profiles.json | 300 + lang/en.json | 8 + module.json | 31 + module/scifi-deckplan-generator.mjs | 6628 +++++++++++++++++ ...deckplan-generator.mjs.bak-20260324-134812 | 6114 +++++++++++++++ ...deckplan-generator.mjs.bak-20260324-143610 | 6566 ++++++++++++++++ styles/scifi-deckplan-generator.css | 214 + templates/scifi-deckplan-generator.html | 304 + 11 files changed, 20623 insertions(+) create mode 100644 .gitignore create mode 100644 data/furniture-tables.json create mode 100644 data/room-tables.json create mode 100644 data/ship-profiles.json create mode 100644 lang/en.json create mode 100644 module.json create mode 100644 module/scifi-deckplan-generator.mjs create mode 100644 module/scifi-deckplan-generator.mjs.bak-20260324-134812 create mode 100644 module/scifi-deckplan-generator.mjs.bak-20260324-143610 create mode 100644 styles/scifi-deckplan-generator.css create mode 100644 templates/scifi-deckplan-generator.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8255ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.DS_Store +._* +*.swp +worlds/ diff --git a/data/furniture-tables.json b/data/furniture-tables.json new file mode 100644 index 0000000..109940f --- /dev/null +++ b/data/furniture-tables.json @@ -0,0 +1,295 @@ +{ + "scifi": { + "bridge": [ + { "key": "console", "w": 2, "h": 1, "shape": "r", "qty": [1, 3], "placement": "wall", "label": "", "occlusion": "none", "densityMod": 0.8 }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [1, 2], "placement": "center", "label": "", "occlusion": "none", "densityMod": 0.8 }, + { "key": "viewscreen", "w": 3, "h": 1, "shape": "r", "qty": [0, 1], "placement": "far-wall", "label": "VIEWSCREEN", "occlusion": "sight" } + ], + "engineering": [ + { "key": "workbench", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "sight" }, + { "key": "console", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "none" }, + { "key": "conduit", "w": 1, "h": 1, "shape": "r", "qty": [2, 4], "placement": "scatter", "label": "", "occlusion": "none" }, + { "key": "floor-hatch", "w": 1, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "HATCH", "occlusion": "none", "isHatch": true }, + { "key": "junction-box", "w": 1, "h": 1, "shape": "r", "qty": [1, 3], "placement": "corners", "label": "JUNCT", "occlusion": "sight" } + ], + "crew-quarters": [ + { "key": "bunk", "w": 1, "h": 2, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "sight" }, + { "key": "locker", "w": 1, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "sight" } + ], + "medical": [ + { "key": "table", "w": 1, "h": 2, "shape": "r", "qty": [1, 2], "placement": "center", "label": "MED TABLE", "occlusion": "sight" }, + { "key": "cabinet", "w": 1, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "sight" } + ], + "mess-hall": [ + { "key": "table", "w": 2, "h": 1, "shape": "r", "qty": [1, 3], "placement": "center", "label": "", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [2, 6], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "cargo": [ + { "key": "crate", "w": 1, "h": 1, "shape": "r", "qty": [3, 8], "placement": "scatter", "label": "", "occlusion": "solid", "densityMod": 2.0 }, + { "key": "barrel", "w": 1, "h": 1, "shape": "e", "qty": [1, 4], "placement": "scatter", "label": "", "occlusion": "sight", "densityMod": 1.5 }, + { "key": "container", "w": 2, "h": 1, "shape": "r", "qty": [0, 3], "placement": "wall", "label": "CARGO", "occlusion": "solid", "densityMod": 1.5 } + ], + "armory": [ + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [1, 3], "placement": "wall", "label": "WEAPON RACK", "occlusion": "sight" }, + { "key": "crate", "w": 1, "h": 1, "shape": "r", "qty": [1, 2], "placement": "scatter", "label": "", "occlusion": "solid" } + ], + "reactor": [ + { "key": "core", "w": 2, "h": 2, "shape": "e", "qty": [1, 1], "placement": "center", "label": "REACTOR", "occlusion": "solid" }, + { "key": "coolant-loop", "w": 1, "h": 2, "shape": "r", "qty": [2, 4], "placement": "wall", "label": "COOLANT", "occlusion": "sight" }, + { "key": "control-rod", "w": 1, "h": 1, "shape": "r", "qty": [2, 4], "placement": "wall", "label": "ROD", "occlusion": "sight" }, + { "key": "rad-shield", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "RAD", "occlusion": "solid" } + ], + "escape-pods": [ + { "key": "escape-pod", "w": 1, "h": 2, "shape": "e", "qty": [2, 4], "placement": "wall", "label": "POD", "occlusion": "sight" }, + { "key": "release-ctrl", "w": 1, "h": 1, "shape": "r", "qty": [1, 1], "placement": "wall", "label": "RELEASE", "occlusion": "none" } + ], + "weapon-bay": [ + { "key": "torpedo-rack", "w": 2, "h": 1, "shape": "r", "qty": [1, 3], "placement": "wall", "label": "TORP", "occlusion": "solid" }, + { "key": "targeting-sys", "w": 2, "h": 1, "shape": "r", "qty": [1, 1], "placement": "wall", "label": "TARGET", "occlusion": "sight" }, + { "key": "ammo-store", "w": 1, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "AMMO", "occlusion": "solid" } + ], + "navigation": [ + { "key": "star-chart", "w": 2, "h": 2, "shape": "r", "qty": [1, 1], "placement": "center", "label": "CHART", "occlusion": "none" }, + { "key": "console", "w": 2, "h": 1, "shape": "r", "qty": [2, 4], "placement": "wall", "label": "", "occlusion": "none" } + ], + "fuel-bay": [ + { "key": "tank", "w": 1, "h": 2, "shape": "e", "qty": [2, 4], "placement": "wall", "label": "", "occlusion": "sight" } + ], + "lab": [ + { "key": "bench", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "none" }, + { "key": "equipment", "w": 1, "h": 1, "shape": "r", "qty": [2, 4], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "brig": [ + { "key": "cell-bar", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "CELL", "occlusion": "sight" }, + { "key": "cot", "w": 1, "h": 2, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "none" } + ], + "storage": [ + { "key": "shelf", "w": 1, "h": 1, "shape": "r", "qty": [2, 5], "placement": "wall", "label": "", "occlusion": "sight" }, + { "key": "crate", "w": 1, "h": 1, "shape": "r", "qty": [1, 3], "placement": "scatter", "label": "", "occlusion": "solid" } + ], + "ops": [ + { "key": "console", "w": 2, "h": 1, "shape": "r", "qty": [2, 4], "placement": "wall", "label": "", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [1, 3], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "observation": [ + { "key": "viewport", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "far-wall", "label": "VIEWPORT", "occlusion": "none" }, + { "key": "recliner", "w": 1, "h": 1, "shape": "r", "qty": [1, 4], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "airlock": [ + { "key": "eva-locker", "w": 1, "h": 2, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "EVA SUIT", "occlusion": "sight" }, + { "key": "decon-tank", "w": 1, "h": 1, "shape": "e", "qty": [0, 1], "placement": "center", "label": "DECON", "occlusion": "none" }, + { "key": "pressure-ctrl", "w": 1, "h": 1, "shape": "r", "qty": [1, 1], "placement": "wall", "label": "PRESS", "occlusion": "none" } + ], + "cryo-bay": [ + { "key": "cryo-pod", "w": 1, "h": 2, "shape": "r", "qty": [2, 6], "placement": "wall", "label": "CRYO", "occlusion": "sight" }, + { "key": "revival-unit", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "wall", "label": "REVIVAL", "occlusion": "sight" } + ], + "life-support": [ + { "key": "o2-tank", "w": 1, "h": 2, "shape": "e", "qty": [1, 3], "placement": "wall", "label": "O2", "occlusion": "sight" }, + { "key": "scrubber", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "SCRUB", "occlusion": "sight" }, + { "key": "water-reclaim", "w": 1, "h": 2, "shape": "r", "qty": [0, 1], "placement": "wall", "label": "H2O", "occlusion": "sight" }, + { "key": "floor-hatch", "w": 1, "h": 1, "shape": "r", "qty": [1, 1], "placement": "wall", "label": "HATCH", "occlusion": "none", "isHatch": true } + ], + "docking-bay": [ + { "key": "docking-clamp", "w": 2, "h": 1, "shape": "r", "qty": [1, 3], "placement": "wall", "label": "CLAMP", "occlusion": "sight" }, + { "key": "fuel-coupling", "w": 1, "h": 1, "shape": "e", "qty": [1, 2], "placement": "wall", "label": "FUEL", "occlusion": "none" }, + { "key": "gantry-node", "w": 1, "h": 1, "shape": "r", "qty": [2, 4], "placement": "corners", "label": "", "occlusion": "none" } + ], + "maintenance": [ + { "key": "floor-hatch", "w": 1, "h": 1, "shape": "r", "qty": [1, 1], "placement": "wall", "label": "HATCH", "occlusion": "none", "isHatch": true }, + { "key": "access-panel", "w": 1, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "ACCESS", "occlusion": "none", "isHatch": true }, + { "key": "junction-box", "w": 1, "h": 1, "shape": "r", "qty": [0, 2], "placement": "corners", "label": "JUNCT", "occlusion": "sight" }, + { "key": "power-conduit", "w": 3, "h": 1, "shape": "r", "qty": [0, 2], "placement": "wall", "label": "PWR", "occlusion": "none" }, + { "key": "tool-cabinet", "w": 1, "h": 2, "shape": "r", "qty": [0, 2], "placement": "wall", "label": "", "occlusion": "sight" } + ], + "nav-suite": [ + { "key": "star-chart", "w": 2, "h": 2, "shape": "r", "qty": [1, 1], "placement": "center", "label": "CHART", "occlusion": "none" }, + { "key": "console", "w": 2, "h": 1, "shape": "r", "qty": [2, 4], "placement": "wall", "label": "", "occlusion": "none" } + ], + "armory-small": [ + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "WEAPONS", "occlusion": "sight" }, + { "key": "crate", "w": 1, "h": 1, "shape": "r", "qty": [0, 2], "placement": "scatter", "label": "", "occlusion": "solid" } + ], + "gravity-ctrl": [ + { "key": "console", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "GRAV CTRL", "occlusion": "none" }, + { "key": "generator", "w": 2, "h": 2, "shape": "e", "qty": [0, 1], "placement": "center", "label": "FIELD GEN", "occlusion": "solid" } + ], + "comms-array": [ + { "key": "antenna-ctrl", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "COMMS", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [1, 2], "placement": "scatter", "label": "", "occlusion": "none" } + ] + }, + "dungeon": { + "boss-chamber": [ + { "key": "throne", "w": 2, "h": 2, "shape": "r", "qty": [1, 1], "placement": "far-wall", "label": "THRONE", "occlusion": "solid" }, + { "key": "pillar", "w": 1, "h": 1, "shape": "r", "qty": [2, 4], "placement": "corners", "label": "", "occlusion": "solid" } + ], + "throne-room": [ + { "key": "throne", "w": 2, "h": 2, "shape": "r", "qty": [1, 1], "placement": "far-wall", "label": "THRONE", "occlusion": "solid" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [4, 8], "placement": "wall", "label": "", "occlusion": "none" } + ], + "vault": [ + { "key": "chest", "w": 1, "h": 1, "shape": "r", "qty": [1, 3], "placement": "scatter", "label": "CHEST", "occlusion": "none" }, + { "key": "pile", "w": 1, "h": 1, "shape": "e", "qty": [1, 4], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "shrine": [ + { "key": "altar", "w": 2, "h": 1, "shape": "r", "qty": [1, 1], "placement": "far-wall", "label": "ALTAR", "occlusion": "sight" }, + { "key": "candle", "w": 1, "h": 1, "shape": "r", "qty": [2, 4], "placement": "corners", "label": "", "occlusion": "none" } + ], + "ritual-room": [ + { "key": "altar", "w": 2, "h": 1, "shape": "r", "qty": [1, 1], "placement": "center", "label": "ALTAR", "occlusion": "sight" }, + { "key": "circle", "w": 3, "h": 3, "shape": "e", "qty": [0, 1], "placement": "center", "label": "", "occlusion": "none" }, + { "key": "candle", "w": 1, "h": 1, "shape": "r", "qty": [4, 6], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "barracks": [ + { "key": "bunk", "w": 1, "h": 2, "shape": "r", "qty": [2, 4], "placement": "wall", "label": "", "occlusion": "sight" }, + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "ARMS", "occlusion": "sight" } + ], + "guardroom": [ + { "key": "table", "w": 2, "h": 1, "shape": "r", "qty": [1, 1], "placement": "center", "label": "", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [2, 4], "placement": "scatter", "label": "", "occlusion": "none" }, + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [0, 1], "placement": "wall", "label": "ARMS", "occlusion": "sight" } + ], + "library": [ + { "key": "shelf", "w": 1, "h": 2, "shape": "r", "qty": [2, 6], "placement": "wall", "label": "", "occlusion": "sight" }, + { "key": "table", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "center", "label": "", "occlusion": "none" } + ], + "trophy-room": [ + { "key": "plinth", "w": 1, "h": 1, "shape": "r", "qty": [2, 4], "placement": "scatter", "label": "TROPHY", "occlusion": "none" } + ], + "alchemy": [ + { "key": "bench", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "none" }, + { "key": "cauldron", "w": 1, "h": 1, "shape": "e", "qty": [1, 2], "placement": "center", "label": "", "occlusion": "none" } + ], + "forge": [ + { "key": "anvil", "w": 1, "h": 1, "shape": "r", "qty": [1, 1], "placement": "center", "label": "ANVIL", "occlusion": "solid" }, + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "sight" } + ], + "antechamber": [ + { "key": "bench", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "wall", "label": "", "occlusion": "none" } + ], + "laboratory": [ + { "key": "bench", "w": 2, "h": 1, "shape": "r", "qty": [1, 3], "placement": "wall", "label": "", "occlusion": "none" }, + { "key": "shelf", "w": 1, "h": 2, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "sight" }, + { "key": "cauldron", "w": 1, "h": 1, "shape": "e", "qty": [0, 2], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "crypt": [ + { "key": "sarcophagus", "w": 1, "h": 2, "shape": "r", "qty": [1, 4], "placement": "wall", "label": "TOMB", "occlusion": "solid" } + ], + "torture": [ + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [1, 2], "placement": "center", "label": "RACK", "occlusion": "solid" }, + { "key": "table", "w": 1, "h": 2, "shape": "r", "qty": [0, 1], "placement": "wall", "label": "", "occlusion": "sight" } + ], + "prison": [ + { "key": "cot", "w": 1, "h": 2, "shape": "r", "qty": [1, 3], "placement": "wall", "label": "", "occlusion": "none" } + ], + "cistern": [ + { "key": "pool", "w": 3, "h": 3, "shape": "e", "qty": [0, 1], "placement": "center", "label": "WATER", "occlusion": "none" } + ], + "armoury": [ + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [2, 4], "placement": "wall", "label": "ARMS", "occlusion": "sight" }, + { "key": "chest", "w": 1, "h": 1, "shape": "r", "qty": [0, 2], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "store": [ + { "key": "barrel", "w": 1, "h": 1, "shape": "e", "qty": [2, 5], "placement": "scatter", "label": "", "occlusion": "none" }, + { "key": "crate", "w": 1, "h": 1, "shape": "r", "qty": [1, 4], "placement": "scatter", "label": "", "occlusion": "solid" } + ] + }, + "building": { + "office": [ + { "key": "desk", "w": 2, "h": 1, "shape": "r", "qty": [1, 3], "placement": "wall", "label": "", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [1, 3], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "meeting": [ + { "key": "table", "w": 3, "h": 2, "shape": "r", "qty": [1, 1], "placement": "center", "label": "", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [4, 8], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "lobby": [ + { "key": "desk", "w": 2, "h": 1, "shape": "r", "qty": [1, 1], "placement": "far-wall", "label": "RECEPTION", "occlusion": "none" }, + { "key": "sofa", "w": 2, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "none" } + ], + "exec-office": [ + { "key": "desk", "w": 2, "h": 1, "shape": "r", "qty": [1, 1], "placement": "far-wall", "label": "", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [1, 3], "placement": "scatter", "label": "", "occlusion": "none" }, + { "key": "sofa", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "wall", "label": "", "occlusion": "none" } + ], + "bedroom": [ + { "key": "bed", "w": 2, "h": 3, "shape": "r", "qty": [1, 1], "placement": "wall", "label": "", "occlusion": "sight" }, + { "key": "wardrobe", "w": 1, "h": 2, "shape": "r", "qty": [0, 1], "placement": "wall", "label": "", "occlusion": "sight" } + ], + "kitchen": [ + { "key": "counter", "w": 3, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "none" }, + { "key": "table", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "center", "label": "", "occlusion": "none" } + ], + "living": [ + { "key": "sofa", "w": 3, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "none" }, + { "key": "table", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "center", "label": "", "occlusion": "none" } + ], + "common-room": [ + { "key": "table", "w": 2, "h": 1, "shape": "r", "qty": [2, 4], "placement": "scatter", "label": "", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [4, 8], "placement": "scatter", "label": "", "occlusion": "none" }, + { "key": "fireplace", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "far-wall", "label": "HEARTH", "occlusion": "sight" } + ], + "bar": [ + { "key": "counter", "w": 3, "h": 1, "shape": "r", "qty": [1, 2], "placement": "far-wall", "label": "BAR", "occlusion": "sight" }, + { "key": "stool", "w": 1, "h": 1, "shape": "r", "qty": [2, 4], "placement": "wall", "label": "", "occlusion": "none" } + ], + "dining": [ + { "key": "table", "w": 3, "h": 2, "shape": "r", "qty": [1, 1], "placement": "center", "label": "", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [4, 8], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "ballroom": [ + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [6, 12], "placement": "wall", "label": "", "occlusion": "none" } + ], + "library": [ + { "key": "shelf", "w": 1, "h": 2, "shape": "r", "qty": [3, 6], "placement": "wall", "label": "", "occlusion": "sight" }, + { "key": "table", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "center", "label": "", "occlusion": "none" } + ], + "warehouse": [ + { "key": "shelf", "w": 1, "h": 3, "shape": "r", "qty": [3, 7], "placement": "wall", "label": "", "occlusion": "sight", "densityMod": 1.5 }, + { "key": "crate", "w": 1, "h": 1, "shape": "r", "qty": [2, 6], "placement": "scatter", "label": "", "occlusion": "solid", "densityMod": 2.0 } + ], + "stairwell": [ + { "key": "landing", "w": 2, "h": 2, "shape": "r", "qty": [1, 1], "placement": "center", "label": "STAIRS", "occlusion": "none" } + ], + "courtyard": [ + { "key": "tree", "w": 1, "h": 1, "shape": "e", "qty": [1, 3], "placement": "scatter", "label": "", "occlusion": "none" }, + { "key": "bench", "w": 2, "h": 1, "shape": "r", "qty": [0, 2], "placement": "wall", "label": "", "occlusion": "none" } + ], + "garden": [ + { "key": "bed", "w": 2, "h": 1, "shape": "r", "qty": [1, 3], "placement": "scatter", "label": "", "occlusion": "none" }, + { "key": "tree", "w": 1, "h": 1, "shape": "e", "qty": [1, 2], "placement": "corners", "label": "", "occlusion": "none" } + ], + "armoury": [ + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [2, 4], "placement": "wall", "label": "ARMS", "occlusion": "sight" } + ], + "server": [ + { "key": "rack", "w": 1, "h": 3, "shape": "r", "qty": [2, 6], "placement": "wall", "label": "SERVER", "occlusion": "sight", "densityMod": 1.5 } + ], + "living-room": [ + { "key": "sofa", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "wall", "label": "", "occlusion": "none" }, + { "key": "chair", "w": 1, "h": 1, "shape": "r", "qty": [1, 3], "placement": "scatter", "label": "", "occlusion": "none" } + ], + "entrance-hall": [ + { "key": "table", "w": 2, "h": 1, "shape": "r", "qty": [0, 1], "placement": "center", "label": "", "occlusion": "none" } + ], + "surgery": [ + { "key": "table", "w": 1, "h": 2, "shape": "r", "qty": [1, 1], "placement": "center", "label": "OPERATING TABLE", "occlusion": "sight" }, + { "key": "cabinet", "w": 1, "h": 1, "shape": "r", "qty": [1, 2], "placement": "wall", "label": "", "occlusion": "sight" } + ], + "padded-cell": [ + { "key": "cot", "w": 1, "h": 2, "shape": "r", "qty": [0, 1], "placement": "wall", "label": "", "occlusion": "none" } + ], + "basement": [ + { "key": "crate", "w": 1, "h": 1, "shape": "r", "qty": [2, 5], "placement": "scatter", "label": "", "occlusion": "solid" }, + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [0, 2], "placement": "wall", "label": "", "occlusion": "sight" } + ], + "boiler-room": [ + { "key": "boiler", "w": 2, "h": 2, "shape": "e", "qty": [1, 1], "placement": "center", "label": "BOILER", "occlusion": "solid" } + ], + "attic": [ + { "key": "crate", "w": 1, "h": 1, "shape": "r", "qty": [3, 7], "placement": "scatter", "label": "", "occlusion": "solid", "densityMod": 2.0 }, + { "key": "rack", "w": 1, "h": 2, "shape": "r", "qty": [0, 2], "placement": "wall", "label": "", "occlusion": "sight" } + ] + } +} diff --git a/data/room-tables.json b/data/room-tables.json new file mode 100644 index 0000000..5bdc977 --- /dev/null +++ b/data/room-tables.json @@ -0,0 +1,158 @@ +{ + "rooms": [ + { "key": "bridge", "label": "BRIDGE", "placement": "bow", "weight": 1, "unique": true, "minSize": [4, 4] }, + { "key": "engineering", "label": "ENGINEERING", "placement": "stern", "weight": 1, "unique": true, "minSize": [4, 4] }, + { "key": "cargo", "label": "CARGO BAY", "placement": "any", "weight": 8, "unique": false, "minSize": [3, 3], "numbered": true }, + { "key": "crew-quarters", "label": "CREW QUARTERS", "placement": "any", "weight": 10, "unique": false, "minSize": [2, 3], "numbered": true }, + { "key": "medical", "label": "MEDICAL BAY", "placement": "any", "weight": 3, "unique": true, "minSize": [3, 3] }, + { "key": "armory", "label": "ARMOURY", "placement": "any", "weight": 2, "unique": true, "minSize": [2, 3] }, + { "key": "brig", "label": "BRIG", "placement": "any", "weight": 2, "unique": true, "minSize": [2, 2] }, + { "key": "airlock", "label": "AIRLOCK", "placement": "edge", "weight": 4, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "mess-hall", "label": "MESS HALL", "placement": "any", "weight": 3, "unique": true, "minSize": [3, 3] }, + { "key": "ops", "label": "OPS", "placement": "any", "weight": 3, "unique": true, "minSize": [3, 3] }, + { "key": "comms", "label": "COMMS", "placement": "bow", "weight": 2, "unique": true, "minSize": [2, 2] }, + { "key": "lab", "label": "RESEARCH LAB", "placement": "any", "weight": 3, "unique": false, "minSize": [3, 3], "numbered": true }, + { "key": "reactor", "label": "REACTOR CORE", "placement": "stern", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "escape-pods", "label": "ESCAPE PODS", "placement": "edge", "weight": 2, "unique": true, "minSize": [2, 3] }, + { "key": "weapon-bay", "label": "WEAPON BAY", "placement": "stern", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "navigation", "label": "NAVIGATION", "placement": "bow", "weight": 2, "unique": true, "minSize": [2, 2] }, + { "key": "fuel-bay", "label": "FUEL STORAGE", "placement": "stern", "weight": 2, "unique": true, "minSize": [2, 3] }, + { "key": "storage", "label": "STORAGE", "placement": "any", "weight": 10, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "docking-bay", "label": "DOCKING BAY", "placement": "bow", "weight": 2, "unique": true, "minSize": [5, 4] }, + { "key": "life-support", "label": "LIFE SUPPORT", "placement": "any", "weight": 2, "unique": true, "minSize": [3, 3] }, + { "key": "maintenance", "label": "MAINTENANCE", "placement": "any", "weight": 5, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "observation", "label": "OBSERVATION", "placement": "bow", "weight": 2, "unique": true, "minSize": [3, 3] }, + { "key": "void", "label": "VOID", "placement": "any", "weight": 1, "unique": false, "minSize": [2, 2] } + ], + "sla_overrides": { + "bridge": { "label": "COMMAND CENTRE" }, + "armory": { "label": "ORDNANCE LOCKER" }, + "brig": { "label": "HOLDING CELLS" }, + "crew-quarters": { "label": "OPERATIVE BARRACKS" }, + "lab": { "label": "EBB LAB" }, + "escape-pods": { "label": "EMERGENCY BAYS" }, + "navigation": { "label": "NAV STATION" } + }, + "dungeon": { + "rooms": [ + { "key": "entrance", "label": "ENTRANCE", "placement": "edge", "weight": 1, "unique": true, "minSize": [3, 3], "doorType": "none" }, + { "key": "boss-chamber", "label": "BOSS CHAMBER", "placement": "far", "weight": 1, "unique": true, "minSize": [5, 5], "doorType": "locked" }, + { "key": "throne-room", "label": "THRONE ROOM", "placement": "far", "weight": 1, "unique": true, "minSize": [4, 4], "doorType": "locked" }, + { "key": "vault", "label": "VAULT", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 3], "doorType": "locked" }, + { "key": "shrine", "label": "SHRINE", "placement": "any", "weight": 2, "unique": true, "minSize": [3, 3], "doorType": "none" }, + { "key": "torture", "label": "TORTURE CHAMBER", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 3], "doorType": "locked" }, + { "key": "barracks", "label": "BARRACKS", "placement": "any", "weight": 4, "unique": false, "minSize": [3, 3], "numbered": true }, + { "key": "guardroom", "label": "GUARD ROOM", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 3], "numbered": true, "doorType": "none" }, + { "key": "prison", "label": "PRISON CELLS", "placement": "any", "weight": 2, "unique": true, "minSize": [3, 3], "doorType": "locked" }, + { "key": "armoury", "label": "ARMOURY", "placement": "any", "weight": 2, "unique": true, "minSize": [2, 3], "doorType": "locked" }, + { "key": "store", "label": "STOREROOM", "placement": "any", "weight": 6, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "library", "label": "LIBRARY", "placement": "any", "weight": 2, "unique": true, "minSize": [3, 3] }, + { "key": "laboratory", "label": "LABORATORY", "placement": "any", "weight": 2, "unique": true, "minSize": [3, 3] }, + { "key": "trophy-room", "label": "TROPHY ROOM", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "alchemy", "label": "ALCHEMIST WORKSHOP", "placement": "any", "weight": 2, "unique": true, "minSize": [3, 3] }, + { "key": "forge", "label": "FORGE", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "antechamber", "label": "ANTECHAMBER", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "crypt", "label": "CRYPT", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "ritual-room", "label": "RITUAL CHAMBER", "placement": "any", "weight": 2, "unique": true, "minSize": [3, 3] }, + { "key": "quarters", "label": "QUARTERS", "placement": "any", "weight": 5, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "cistern", "label": "CISTERN", "placement": "any", "weight": 2, "unique": true, "minSize": [3, 3] }, + { "key": "collapsed", "label": "COLLAPSED HALL", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 2] }, + { "key": "trap-room", "label": "TRAP ROOM", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] }, + { "key": "void", "label": "", "placement": "any", "weight": 4, "unique": false, "minSize": [2, 2] } + ] + }, + "building": { + "subtypes": { + "office": { + "rooms": [ + { "key": "lobby", "label": "LOBBY", "placement": "entrance", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "office", "label": "OFFICE", "placement": "any", "weight": 10, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "meeting", "label": "MEETING ROOM", "placement": "any", "weight": 4, "unique": false, "minSize": [3, 3], "numbered": true }, + { "key": "server", "label": "SERVER ROOM", "placement": "any", "weight": 2, "unique": true, "minSize": [2, 2], "doorType": "locked" }, + { "key": "reception", "label": "RECEPTION", "placement": "entrance", "weight": 2, "unique": true, "minSize": [2, 3] }, + { "key": "breakroom", "label": "BREAK ROOM", "placement": "any", "weight": 3, "unique": true, "minSize": [2, 2] }, + { "key": "bathroom", "label": "BATHROOM", "placement": "any", "weight": 4, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "storage", "label": "STORAGE", "placement": "any", "weight": 4, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "stairwell", "label": "STAIRWELL", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "exec-office", "label": "EXECUTIVE SUITE", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 3], "doorType": "locked" }, + { "key": "void", "label": "", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] } + ] + }, + "warehouse": { + "rooms": [ + { "key": "loading", "label": "LOADING DOCK", "placement": "entrance", "weight": 1, "unique": true, "minSize": [4, 3] }, + { "key": "warehouse", "label": "WAREHOUSE FLOOR", "placement": "any", "weight": 8, "unique": false, "minSize": [4, 4], "numbered": true }, + { "key": "office", "label": "SITE OFFICE", "placement": "any", "weight": 2, "unique": true, "minSize": [2, 3] }, + { "key": "security", "label": "SECURITY ROOM", "placement": "entrance", "weight": 1, "unique": true, "minSize": [2, 2], "doorType": "locked" }, + { "key": "coldstore", "label": "COLD STORE", "placement": "any", "weight": 2, "unique": true, "minSize": [3, 3], "doorType": "locked" }, + { "key": "bathroom", "label": "BATHROOM", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 2] }, + { "key": "storage", "label": "STORAGE", "placement": "any", "weight": 4, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "stairwell", "label": "STAIRWELL", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "void", "label": "", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 2] } + ] + }, + "residential": { + "rooms": [ + { "key": "living", "label": "LIVING ROOM", "placement": "entrance", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "kitchen", "label": "KITCHEN", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 3] }, + { "key": "bedroom", "label": "BEDROOM", "placement": "any", "weight": 6, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "bathroom", "label": "BATHROOM", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "dining", "label": "DINING ROOM", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 3] }, + { "key": "study", "label": "STUDY", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] }, + { "key": "stairwell", "label": "STAIRWELL", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "cellar", "label": "CELLAR", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 2], "doorType": "locked" }, + { "key": "void", "label": "", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] } + ] + }, + "tavern": { + "rooms": [ + { "key": "common-room", "label": "COMMON ROOM", "placement": "entrance", "weight": 1, "unique": true, "minSize": [4, 4] }, + { "key": "bar", "label": "BAR", "placement": "entrance", "weight": 1, "unique": true, "minSize": [2, 3] }, + { "key": "kitchen", "label": "KITCHEN", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 3] }, + { "key": "room", "label": "GUEST ROOM", "placement": "any", "weight": 6, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "cellar", "label": "CELLAR", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 2], "doorType": "locked" }, + { "key": "stable", "label": "STABLE", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "courtyard", "label": "COURTYARD", "placement": "any", "weight": 1, "unique": true, "minSize": [4, 4] }, + { "key": "stairwell", "label": "STAIRWELL", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "privy", "label": "PRIVY", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] }, + { "key": "storeroom", "label": "STOREROOM", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 2] }, + { "key": "void", "label": "", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] } + ] + }, + "mansion": { + "rooms": [ + { "key": "foyer", "label": "FOYER", "placement": "entrance", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "ballroom", "label": "BALLROOM", "placement": "any", "weight": 1, "unique": true, "minSize": [5, 5] }, + { "key": "dining", "label": "DINING ROOM", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 4] }, + { "key": "library", "label": "LIBRARY", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "study", "label": "STUDY", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 3], "doorType": "locked" }, + { "key": "bedroom", "label": "BEDROOM", "placement": "any", "weight": 4, "unique": false, "minSize": [2, 3], "numbered": true }, + { "key": "servant", "label": "SERVANTS QUARTERS", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "kitchen", "label": "KITCHEN", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 3] }, + { "key": "garden", "label": "GARDEN ROOM", "placement": "any", "weight": 1, "unique": true, "minSize": [4, 4] }, + { "key": "armoury", "label": "ARMOURY", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 3], "doorType": "locked" }, + { "key": "stairwell", "label": "STAIRWELL", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2], "numbered": true }, + { "key": "vault", "label": "VAULT", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 2], "doorType": "locked" }, + { "key": "bathroom", "label": "BATHROOM", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 2] }, + { "key": "void", "label": "", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] } + ] + }, + "horror": { + "rooms": [ + { "key": "entrance-hall", "label": "ENTRANCE HALL", "placement": "entrance", "weight": 1, "unique": true, "minSize": [3, 3] }, + { "key": "living-room", "label": "LIVING ROOM", "placement": "any", "weight": 3, "unique": false, "minSize": [3, 3] }, + { "key": "kitchen", "label": "KITCHEN", "placement": "any", "weight": 2, "unique": true, "minSize": [2, 3] }, + { "key": "bedroom", "label": "BEDROOM", "placement": "any", "weight": 3, "unique": false, "minSize": [2, 3], "numbered": true }, + { "key": "bathroom", "label": "BATHROOM", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] }, + { "key": "basement", "label": "BASEMENT", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 4], "doorType": "locked" }, + { "key": "surgery", "label": "SURGERY ROOM", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 4] }, + { "key": "padded-cell", "label": "PADDED CELL", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2], "doorType": "locked", "numbered": true }, + { "key": "boiler-room", "label": "BOILER ROOM", "placement": "any", "weight": 1, "unique": true, "minSize": [2, 3] }, + { "key": "attic", "label": "ATTIC", "placement": "any", "weight": 1, "unique": true, "minSize": [3, 4] }, + { "key": "collapse", "label": "COLLAPSED AREA", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] }, + { "key": "void", "label": "", "placement": "any", "weight": 2, "unique": false, "minSize": [2, 2] } + ] + } + } + } +} diff --git a/data/ship-profiles.json b/data/ship-profiles.json new file mode 100644 index 0000000..f1733a5 --- /dev/null +++ b/data/ship-profiles.json @@ -0,0 +1,300 @@ +{ + "profiles": [ + { + "key": "hauler", + "label": "Class-C Hauler", + "columns": 36, + "rows": 18, + "decks": 2, + "mode": "bsp", + "hullShape": "tapered", + "splitBias": "horizontal", + "theme": "industrial", + "roomCount": 14, + "minRoomSize": 3, + "maxRoomSize": 8 + }, + { + "key": "frigate", + "label": "Patrol Frigate", + "columns": 28, + "rows": 14, + "decks": 3, + "mode": "bsp", + "hullShape": "hammerhead", + "splitBias": "horizontal", + "theme": "steel", + "roomCount": 10, + "minRoomSize": 3, + "maxRoomSize": 7 + }, + { + "key": "research", + "label": "Research Vessel", + "columns": 24, + "rows": 16, + "decks": 2, + "mode": "corridor", + "hullShape": "tapered", + "theme": "clean-corporate", + "roomCount": 12, + "minRoomSize": 3, + "maxRoomSize": 6 + }, + { + "key": "station-sector", + "label": "Space Station Sector", + "columns": 30, + "rows": 30, + "decks": 1, + "mode": "corridor", + "hullShape": "cross", + "theme": "steel", + "roomCount": 18, + "minRoomSize": 3, + "maxRoomSize": 8 + }, + { + "key": "derelict", + "label": "Derelict Wreck", + "columns": 28, + "rows": 18, + "decks": 2, + "mode": "bsp", + "hullShape": "l-shape", + "splitBias": "balanced", + "theme": "derelict", + "roomCount": 12, + "minRoomSize": 3, + "maxRoomSize": 7 + }, + { + "key": "custom", + "label": "Custom", + "columns": 32, + "rows": 20, + "decks": 1, + "mode": "bsp", + "hullShape": "rectangle", + "splitBias": "balanced", + "theme": "steel", + "roomCount": 12, + "minRoomSize": 3, + "maxRoomSize": 8 + }, + { + "key": "small-dungeon", + "label": "Small Dungeon", + "columns": 24, + "rows": 18, + "decks": 1, + "mode": "dungeon", + "hullShape": "rectangle", + "theme": "stone", + "roomCount": 8, + "minRoomSize": 3, + "maxRoomSize": 6, + "loopFactor": 20, + "corridorWidth": 1, + "secretDoorDensity": "minimal" + }, + { + "key": "medium-dungeon", + "label": "Medium Dungeon", + "columns": 30, + "rows": 24, + "decks": 2, + "mode": "dungeon", + "hullShape": "rectangle", + "theme": "stone", + "roomCount": 14, + "minRoomSize": 3, + "maxRoomSize": 7, + "loopFactor": 28, + "corridorWidth": 1, + "secretDoorDensity": "sparse" + }, + { + "key": "crypt-dungeon", + "label": "Undead Crypt", + "columns": 28, + "rows": 20, + "decks": 2, + "mode": "dungeon", + "hullShape": "ring", + "theme": "crypt", + "roomCount": 10, + "minRoomSize": 3, + "maxRoomSize": 6, + "loopFactor": 22, + "corridorWidth": 1, + "secretDoorDensity": "sparse" + }, + { + "key": "cave-system", + "label": "Cave System", + "columns": 34, + "rows": 28, + "decks": 1, + "mode": "dungeon", + "hullShape": "cross", + "theme": "cave", + "roomCount": 16, + "minRoomSize": 3, + "maxRoomSize": 7, + "loopFactor": 35, + "corridorWidth": 2, + "secretDoorDensity": "minimal" + }, + { + "key": "stronghold", + "label": "Bandit Stronghold", + "columns": 26, + "rows": 22, + "decks": 2, + "mode": "dungeon", + "hullShape": "l-shape", + "theme": "wood", + "roomCount": 12, + "minRoomSize": 3, + "maxRoomSize": 6, + "loopFactor": 24, + "corridorWidth": 1, + "secretDoorDensity": "minimal" + }, + { + "key": "sewer-complex", + "label": "Sewer Complex", + "columns": 32, + "rows": 26, + "decks": 1, + "mode": "dungeon", + "hullShape": "rectangle", + "theme": "sewer", + "roomCount": 14, + "minRoomSize": 3, + "maxRoomSize": 7, + "loopFactor": 30, + "corridorWidth": 2, + "secretDoorDensity": "sparse" + }, + { + "key": "small-office", + "label": "Small Office", + "columns": 24, + "rows": 18, + "decks": 1, + "mode": "building", + "hullShape": "rect", + "theme": "concrete-office", + "roomCount": 10, + "minRoomSize": 3, + "maxRoomSize": 6, + "splitBias": "balanced", + "buildingSubtype": "office" + }, + { + "key": "warehouse", + "label": "Warehouse", + "columns": 32, + "rows": 20, + "decks": 1, + "mode": "building", + "hullShape": "rect", + "theme": "concrete-office", + "roomCount": 8, + "minRoomSize": 4, + "maxRoomSize": 8, + "splitBias": "horizontal", + "buildingSubtype": "warehouse" + }, + { + "key": "townhouse", + "label": "Townhouse", + "columns": 22, + "rows": 20, + "decks": 3, + "mode": "building", + "hullShape": "rect", + "theme": "wood-floor", + "roomCount": 9, + "minRoomSize": 3, + "maxRoomSize": 6, + "splitBias": "vertical", + "buildingSubtype": "residential" + }, + { + "key": "tavern", + "label": "Tavern", + "columns": 26, + "rows": 18, + "decks": 2, + "mode": "building", + "hullShape": "t-shape", + "theme": "wood-floor", + "roomCount": 11, + "minRoomSize": 3, + "maxRoomSize": 7, + "splitBias": "balanced", + "buildingSubtype": "tavern" + }, + { + "key": "mansion", + "label": "Mansion", + "columns": 30, + "rows": 24, + "decks": 2, + "mode": "building", + "hullShape": "u-shape", + "theme": "carpet", + "roomCount": 14, + "minRoomSize": 3, + "maxRoomSize": 8, + "splitBias": "balanced", + "buildingSubtype": "mansion" + }, + { + "key": "l-shape-office", + "label": "L-Shape Office", + "columns": 28, + "rows": 20, + "decks": 1, + "mode": "building", + "hullShape": "l-shape", + "theme": "concrete-office", + "roomCount": 12, + "minRoomSize": 3, + "maxRoomSize": 7, + "splitBias": "balanced", + "buildingSubtype": "office" + }, + { + "key": "forest-ambush", + "label": "Forest Ambush", + "columns": 34, + "rows": 26, + "decks": 1, + "mode": "wilderness", + "hullShape": "clearing", + "theme": "forest", + "roomCount": 10, + "minRoomSize": 3, + "maxRoomSize": 7, + "corridorWidth": 1 + }, + { + "key": "swamp-expedition", + "label": "Swamp Expedition", + "columns": 36, + "rows": 28, + "decks": 1, + "mode": "wilderness", + "hullShape": "ford", + "theme": "swamp", + "roomCount": 12, + "minRoomSize": 3, + "maxRoomSize": 8, + "corridorWidth": 1 + } + ] +} diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..3667b31 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,8 @@ +{ + "SDPG.SceneButton": "Map Generator", + "SDPG.MenuName": "Sci-Fi & Fantasy Map Generator", + "SDPG.MenuHint": "Generate sci-fi deck plans, dungeons, buildings, and wilderness battlemaps as Foundry scenes.", + "SDPG.AppTitle": "Sci-Fi & Fantasy Map Generator", + "SDPG.ToolbarGroup": "Map Generator", + "SDPG.ToolbarOpen": "Open Sci-Fi & Fantasy Map Generator" +} diff --git a/module.json b/module.json new file mode 100644 index 0000000..61387af --- /dev/null +++ b/module.json @@ -0,0 +1,31 @@ +{ + "id": "scifi-deckplan-generator", + "title": "Sci-Fi & Fantasy Map Generator", + "description": "Procedurally generates sci-fi deck plans, fantasy dungeons, horror buildings, wilderness encounter maps, and tactical battlemaps as Foundry VTT scenes.", + "version": "0.9.1", + "compatibility": { + "minimum": "13", + "verified": "13.350", + "maximum": "13" + }, + "authors": [ + { + "name": "Tim Evans" + } + ], + "esmodules": [ + "module/scifi-deckplan-generator.mjs" + ], + "styles": [ + "styles/scifi-deckplan-generator.css" + ], + "languages": [ + { + "lang": "en", + "name": "English", + "path": "lang/en.json" + } + ], + "manifest": "https://raw.githubusercontent.com/slaguru666/scifi-deckplan-generator/main/module.json", + "download": "https://github.com/slaguru666/scifi-deckplan-generator/releases/latest/download/scifi-deckplan-generator.zip" +} \ No newline at end of file diff --git a/module/scifi-deckplan-generator.mjs b/module/scifi-deckplan-generator.mjs new file mode 100644 index 0000000..6779e6d --- /dev/null +++ b/module/scifi-deckplan-generator.mjs @@ -0,0 +1,6628 @@ +const MODULE_ID = "scifi-deckplan-generator"; +const ROOT_SCENE_FOLDER = "Generated Maps"; +const ASSET_DIR = MODULE_ID; +const GENERATED_WALL_FLAG = `${MODULE_ID}.generatedWall`; +const GENERATED_DRAWING_FLAG = `${MODULE_ID}.generatedDrawing`; +const GENERATED_LIGHT_FLAG = `${MODULE_ID}.generatedLight`; +const GENERATED_TOKEN_FLAG = `${MODULE_ID}.generatedToken`; +const GENERATED_NOTE_FLAG = `${MODULE_ID}.generatedNote`; +const GENERATED_REGION_FLAG = `${MODULE_ID}.generatedRegion`; +const MOVEMENT_NORMAL = CONST.WALL_MOVEMENT_TYPES?.NORMAL ?? CONST.WALL_SENSE_TYPES?.NORMAL ?? 20; +const SENSE_NORMAL = CONST.WALL_SENSE_TYPES?.NORMAL ?? 20; +const SENSE_NONE = CONST.WALL_SENSE_TYPES?.NONE ?? 0; +const DOOR_NONE = CONST.WALL_DOOR_TYPES?.NONE ?? 0; +const DOOR_BASIC = CONST.WALL_DOOR_TYPES?.DOOR ?? 1; +const DOOR_SECRET = CONST.WALL_DOOR_TYPES?.SECRET ?? 2; +const DOOR_STATE_OPEN = CONST.WALL_DOOR_STATES?.OPEN ?? 0; +const DOOR_STATE_CLOSED = CONST.WALL_DOOR_STATES?.CLOSED ?? 1; +const DOOR_STATE_LOCKED = CONST.WALL_DOOR_STATES?.LOCKED ?? 2; + +const CIRCULAR_ROOM_TYPES = new Set([ + "reactor", + "cistern", + "shrine", + "observation", + "ritual-room", + "forge", + "boiler-room", + "pool" +]); + +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +const GENERATION_MODES = [ + { value: "bsp", label: "BSP Room Layout (Ship/Station)" }, + { value: "corridor", label: "Corridor Graph (Ship/Station)" }, + { value: "geomorph", label: "Geomorph Tile Stitching" }, + { value: "dungeon", label: "Random Dungeon" }, + { value: "building", label: "Random Building" }, + { value: "wilderness", label: "Outdoor Wilderness" } +]; + +const GRID_SIZES = [ + { value: 50, label: "50 px" }, + { value: 75, label: "75 px" }, + { value: 100, label: "100 px" } +]; + +const CHAMFER_SIZES = [ + { value: 1, label: "1 Cell" }, + { value: 2, label: "2 Cells" } +]; + +const GRID_MODE_OPTIONS = [ + { value: "square", label: "Square Grid" }, + { value: "hex-row", label: "Hex Grid (Odd Rows)" } +]; + +const CORNER_STYLE_OPTIONS = [ + { value: "square", label: "Square" }, + { value: "chamfer", label: "Chamfered" }, + { value: "perturb", label: "Organic" } +]; + +const MODE_THEME_OPTIONS = { + bsp: [ + { value: "steel", label: "Steel" }, + { value: "industrial", label: "Industrial" }, + { value: "derelict", label: "Derelict" }, + { value: "alien-organic", label: "Alien Organic" }, + { value: "clean-corporate", label: "Clean Corporate" }, + { value: "alien-terminal", label: "Alien Terminal" } + ], + corridor: [ + { value: "steel", label: "Steel" }, + { value: "industrial", label: "Industrial" }, + { value: "derelict", label: "Derelict" }, + { value: "alien-organic", label: "Alien Organic" }, + { value: "clean-corporate", label: "Clean Corporate" }, + { value: "alien-terminal", label: "Alien Terminal" } + ], + geomorph: [ + { value: "steel", label: "Steel" }, + { value: "industrial", label: "Industrial" }, + { value: "derelict", label: "Derelict" }, + { value: "clean-corporate", label: "Clean Corporate" }, + { value: "alien-terminal", label: "Alien Terminal" } + ], + dungeon: [ + { value: "stone", label: "Stone Dungeon" }, + { value: "cave", label: "Cave System" }, + { value: "wood", label: "Wooden Stronghold" }, + { value: "crypt", label: "Undead Crypt" }, + { value: "sewer", label: "Sewer / Undercroft" } + ], + building: [ + { value: "concrete-office", label: "Concrete Office" }, + { value: "wood-floor", label: "Wood Floor" }, + { value: "brick", label: "Brick" }, + { value: "carpet", label: "Carpet" }, + { value: "tile", label: "Tile" }, + { value: "derelict-house", label: "Derelict House" }, + { value: "asylum", label: "Asylum / Hospital" }, + { value: "haunted-manor", label: "Haunted Manor" }, + { value: "bloodbath", label: "Bloodbath Scene" } + ], + wilderness: [ + { value: "forest", label: "Temperate Forest" }, + { value: "jungle", label: "Dense Jungle" }, + { value: "plains", label: "Open Plains" }, + { value: "swamp", label: "Swamp / Marsh" }, + { value: "tundra", label: "Frozen Tundra" }, + { value: "badlands", label: "Badlands / Scrub" } + ] +}; + +const MODE_HULL_OPTIONS = { + bsp: [ + { value: "rectangle", label: "Rectangle" }, + { value: "tapered", label: "Tapered Ship" }, + { value: "hammerhead", label: "Hammerhead" }, + { value: "cross", label: "Cross Station" }, + { value: "l-shape", label: "L-Shape" }, + { value: "ring", label: "Ring" } + ], + corridor: [ + { value: "rectangle", label: "Rectangle" }, + { value: "tapered", label: "Tapered Ship" }, + { value: "hammerhead", label: "Hammerhead" }, + { value: "cross", label: "Cross Station" }, + { value: "l-shape", label: "L-Shape" }, + { value: "ring", label: "Ring" } + ], + geomorph: [ + { value: "rectangle", label: "Tile Grid" } + ], + dungeon: [ + { value: "rectangle", label: "Rectangle" }, + { value: "cross", label: "Crossed Halls" }, + { value: "l-shape", label: "L-Shape" }, + { value: "ring", label: "Ring / Crypt Loop" } + ], + building: [ + { value: "rect", label: "Rectangle" }, + { value: "l-shape", label: "L-Shape" }, + { value: "t-shape", label: "T-Shape" }, + { value: "u-shape", label: "U-Shape" } + ], + wilderness: [ + { value: "open", label: "Open Ground" }, + { value: "clearing", label: "Forest Clearing" }, + { value: "ford", label: "River Ford" }, + { value: "camp", label: "Camp Perimeter" } + ] +}; + +const BUILDING_SUBTYPES = [ + { value: "office", label: "Office" }, + { value: "warehouse", label: "Warehouse" }, + { value: "residential", label: "Residential" }, + { value: "tavern", label: "Tavern" }, + { value: "mansion", label: "Mansion" }, + { value: "horror", label: "Horror / Derelict" } +]; + +const COORDINATE_MODES = [ + { value: "none", label: "Off" }, + { value: "border", label: "Border" }, + { value: "full", label: "Full Cell" } +]; + +const SPLIT_BIASES = [ + { value: "balanced", label: "Balanced" }, + { value: "horizontal", label: "Horizontal Bias" }, + { value: "vertical", label: "Vertical Bias" } +]; + +const CORRIDOR_WIDTHS = [ + { value: 1, label: "1 Cell" }, + { value: 2, label: "2 Cells" } +]; + +const CORRIDOR_STYLES = [ + { value: "spine", label: "Spine Corridor" }, + { value: "partitioned", label: "Partitioned (No Corridor)" } +]; + +const FURNITURE_DENSITIES = [ + { value: "off", label: "None" }, + { value: "sparse", label: "Sparse" }, + { value: "normal", label: "Normal" }, + { value: "dense", label: "Dense" } +]; + +const MISSION_TEMPLATE_OPTIONS = [ + { value: "auto", label: "Auto" }, + { value: "scifi", label: "Sci-Fi Ops" }, + { value: "dungeon", label: "Dungeon Delve" }, + { value: "horror", label: "Horror Incident" }, + { value: "wilderness", label: "Wilderness Recon" }, + { value: "sla", label: "SLA Briefing" } +]; + +const ENCOUNTER_FACTION_OPTIONS = [ + { value: "auto", label: "Auto" }, + { value: "corporate", label: "Corporate Security" }, + { value: "marines", label: "Marines / Troopers" }, + { value: "cult", label: "Cult / Fanatics" }, + { value: "undead", label: "Undead" }, + { value: "wildlife", label: "Wildlife / Beasts" }, + { value: "sla", label: "SLA Operatives" } +]; + +const ENCOUNTER_DENSITY_OPTIONS = [ + { value: "light", label: "Light" }, + { value: "standard", label: "Standard" }, + { value: "heavy", label: "Heavy" } +]; + +const FACTION_PRESET_OPTIONS = [ + { value: "none", label: "None" }, + { value: "ship", label: "Crew / Pirates / Automated / Infected" }, + { value: "dungeon", label: "Delvers / Cult / Undead / Guardians" }, + { value: "building", label: "Staff / Security / Residents / Intruders" }, + { value: "sla", label: "Operatives / Civilians / Hostiles / Media" } +]; + +const REGENERATE_TARGET_OPTIONS = [ + { value: "none", label: "Create fresh scenes" }, + { value: "viewed", label: "Viewed scene (single deck)" }, + { value: "viewed-folder", label: "Viewed scene folder" }, + { value: "folder", label: "Selected scene folder" } +]; + +const SECRET_DOOR_DENSITY_OPTIONS = [ + { value: "none", label: "None" }, + { value: "minimal", label: "Minimal (1–2 per map)" }, + { value: "sparse", label: "Sparse (2–4 per map)" }, + { value: "normal", label: "Normal (4–8 per map)" }, + { value: "dense", label: "Dense (hidden network)" } +]; + +const SECRET_DOOR_CHANCE = { + none: 0, + minimal: 0.01, + sparse: 0.04, + normal: 0.10, + dense: 0.20 +}; + +const MAINTENANCE_SHAFT_DENSITY_OPTIONS = [ + { value: "none", label: "None" }, + { value: "sparse", label: "Sparse" }, + { value: "normal", label: "Normal" }, + { value: "dense", label: "Dense" } +]; + +const MAINTENANCE_SHAFT_SOURCES = new Set([ + "engineering", "reactor", "life-support", "maintenance", + "cargo", "storage", "fuel-bay", "docking-bay" +]); + +const MAINTENANCE_SHAFT_DESTINATIONS = new Set([ + "engineering", "reactor", "life-support", "maintenance", "bridge", + "ops", "armory", "weapon-bay", "brig" +]); + +const BULKHEAD_PAIRS = [ + ["reactor", "engineering"], + ["reactor", "crew-quarters"], + ["reactor", "cargo"], + ["brig", "corridor"], + ["airlock", "bridge"], + ["airlock", "crew-quarters"], + ["weapon-bay", "cargo"], + ["docking-bay", "engineering"] +]; + +const REGION_CONFIG = { + "reactor": { + color: "#ff8800", + gmOnly: false, + behaviors: [{ type: "adjustDarknessLevel", darkness: { min: 0, max: 0.3 } }] + }, + "boss-chamber": { + color: "#aa0000", + gmOnly: true, + behaviors: [] + }, + "shrine": { + color: "#8800ff", + gmOnly: false, + behaviors: [] + }, + "ritual-room": { + color: "#440088", + gmOnly: false, + behaviors: [] + }, + "trap-room": { + color: "#cc4400", + gmOnly: true, + behaviors: [] + }, + "medical": { + color: "#0088cc", + gmOnly: false, + behaviors: [] + }, + "surgery": { + color: "#cc0044", + gmOnly: true, + behaviors: [] + } +}; + +const ENCOUNTER_ROOM_TYPES = new Set([ + "boss-chamber", + "guardroom", + "barracks", + "torture", + "surgery", + "padded-cell", + "basement", + "boiler-room", + "prison", + "security", + "armory", + "armoury", + "brig" +]); + +const ENCOUNTER_ROOM_LABELS = { + "boss-chamber": "BOSS", + guardroom: "GUARD", + barracks: "SOLDIER", + torture: "TORTURER", + surgery: "THREAT", + "padded-cell": "INMATE", + basement: "LURKER", + "boiler-room": "THREAT", + prison: "PRISONER", + security: "SECURITY", + armory: "ARMED", + armoury: "ARMED", + brig: "DETAINEE" +}; + +const DECK_NAME_POOL = [ + "Command Deck", + "Habitation Deck", + "Cargo Deck", + "Engineering Deck", + "Maintenance Deck", + "Operations Deck", + "Docking Deck", + "Research Deck" +]; + +const DUNGEON_LEVEL_NAMES = [ + "Upper Level", + "Lower Level", + "Catacombs", + "Deep Vaults", + "Flooded Passages", + "The Abyss", + "Undercroft", + "Burial Halls" +]; + +const BUILDING_LEVEL_NAMES = [ + "Ground Floor", + "Upper Floor", + "Service Level", + "Executive Floor", + "Basement", + "Upper Offices", + "Storage Level", + "Residential Floor" +]; + +const SHIP_NAME_POOL = [ + "Axiom", + "Erebus", + "Helix", + "Kestrel", + "Meridian", + "Oblique", + "Perseverance", + "Tethys", + "Valiance", + "Vortex", + "Waypoint", + "Peregrine" +]; + +const SHIP_SUFFIX_POOL = [ + "MK-I", + "MK-II", + "MK-III", + "MK-IV", + "IX", + "VII", + "Class C", + "Block 12", + "Run 7", + "Gamma" +]; + +const DUNGEON_NAME_POOL = [ + "Blackstone", + "Grimhollow", + "Ebondeep", + "Cindervault", + "Mirehold", + "Wyrmrest", + "Ashcrypt", + "Hollowmere" +]; + +const DUNGEON_SUFFIX_POOL = [ + "Catacombs", + "Vault", + "Crypt", + "Halls", + "Depths", + "Labyrinth", + "Stronghold", + "Sewers" +]; + +const BUILDING_NAME_POOL = [ + "Meridian", + "Halcyon", + "Northbank", + "Greywall", + "Kingsport", + "Crown", + "Glasswater", + "Market" +]; + +const BUILDING_SUFFIX_POOL = [ + "Offices", + "Warehouse", + "House", + "Hall", + "Tower", + "Tavern", + "Mansion", + "Works" +]; + +const WILDERNESS_NAME_POOL = [ + "Ashwood", + "Blackfen", + "Dreadmere", + "Frostline", + "Ironbrush", + "Mirewatch", + "Rookwood", + "Thornfield" +]; + +const WILDERNESS_SUFFIX_POOL = [ + "Approach", + "Hollow", + "Crossing", + "Wilds", + "March", + "Basin", + "Trail", + "Expanse" +]; + +const GEOMORPH_TILES = [ + { + key: "straight-ns", + connectors: { north: true, east: false, south: true, west: false }, + corridors: [{ x: 2, y: 0, width: 2, height: 6 }], + rooms: [{ x: 1, y: 1, width: 4, height: 2 }] + }, + { + key: "straight-ew", + connectors: { north: false, east: true, south: false, west: true }, + corridors: [{ x: 0, y: 2, width: 6, height: 2 }], + rooms: [{ x: 1, y: 1, width: 2, height: 4 }] + }, + { + key: "corner-ne", + connectors: { north: true, east: true, south: false, west: false }, + corridors: [{ x: 2, y: 0, width: 2, height: 3 }, { x: 2, y: 2, width: 4, height: 2 }], + rooms: [{ x: 1, y: 3, width: 3, height: 2 }] + }, + { + key: "corner-sw", + connectors: { north: false, east: false, south: true, west: true }, + corridors: [{ x: 0, y: 2, width: 4, height: 2 }, { x: 2, y: 2, width: 2, height: 4 }], + rooms: [{ x: 2, y: 0, width: 3, height: 2 }] + }, + { + key: "tee-nes", + connectors: { north: true, east: true, south: true, west: false }, + corridors: [{ x: 2, y: 0, width: 2, height: 6 }, { x: 2, y: 2, width: 4, height: 2 }], + rooms: [{ x: 0, y: 1, width: 2, height: 4 }] + }, + { + key: "hub", + connectors: { north: true, east: true, south: true, west: true }, + corridors: [{ x: 2, y: 0, width: 2, height: 6 }, { x: 0, y: 2, width: 6, height: 2 }], + rooms: [{ x: 1, y: 1, width: 4, height: 4 }] + }, + { + key: "sealed-room", + connectors: { north: false, east: false, south: false, west: false }, + corridors: [], + rooms: [{ x: 1, y: 1, width: 4, height: 4 }] + } +]; + +const FACTION_PRESETS = { + ship: [ + { name: "Crew", color: "#4b8ecf", roomTypes: ["bridge", "quarters", "medical", "galley", "observation"] }, + { name: "Pirates", color: "#b45b42", roomTypes: ["cargo", "armory", "brig", "security"] }, + { name: "Automated", color: "#7b93a8", roomTypes: ["engineering", "reactor", "maintenance", "laboratory"] }, + { name: "Infected", color: "#7a3e59", roomTypes: ["airlock", "escape-pods", "hydroponics", "storage"] } + ], + dungeon: [ + { name: "Delvers", color: "#5678a8", roomTypes: ["entrance", "library", "cistern"] }, + { name: "Cult", color: "#8a4d6f", roomTypes: ["ritual-room", "shrine", "alchemy"] }, + { name: "Undead", color: "#8b8f9e", roomTypes: ["crypt", "vault", "throne-room"] }, + { name: "Guardians", color: "#b38742", roomTypes: ["guardroom", "armoury", "boss-chamber", "prison"] } + ], + building: [ + { name: "Staff", color: "#4d8f7a", roomTypes: ["office", "lobby", "foyer", "common-room", "bar"] }, + { name: "Security", color: "#4f6b94", roomTypes: ["security", "loading", "storage", "armory"] }, + { name: "Residents", color: "#a88153", roomTypes: ["bedroom", "living-room", "kitchen", "bathroom"] }, + { name: "Intruders", color: "#9a4a4a", roomTypes: ["basement", "boiler-room", "attic", "surgery"] } + ], + sla: [ + { name: "Operatives", color: "#5b86c5", roomTypes: ["bridge", "security", "armory", "command"] }, + { name: "Civilians", color: "#7aa073", roomTypes: ["quarters", "medical", "galley", "common-room"] }, + { name: "Hostiles", color: "#b85a4f", roomTypes: ["cargo", "brig", "maintenance", "boiler-room"] }, + { name: "Media", color: "#b28b3c", roomTypes: ["observation", "lobby", "office", "lab"] } + ] +}; + +let roomTablesCache = null; +let shipProfilesCache = null; +let furnitureTablesCache = null; +const encounterPackCache = new Map(); + +Hooks.once("init", () => { + game.scifiDeckPlanGenerator = { + open: () => ScifiDeckPlanGeneratorApp.open() + }; + + game.settings.register(MODULE_ID, "lastFormState", { + scope: "client", + config: false, + default: {} + }); + + game.settings.register(MODULE_ID, "savedPresets", { + scope: "world", + config: false, + type: Array, + default: [] + }); + + game.settings.register(MODULE_ID, "gmOverlayVisible", { + scope: "client", + config: false, + type: Boolean, + default: true + }); + + game.settings.registerMenu(MODULE_ID, "openGenerator", { + name: game.i18n.localize("SDPG.MenuName"), + hint: game.i18n.localize("SDPG.MenuHint"), + label: game.i18n.localize("SDPG.SceneButton"), + icon: "fas fa-rocket", + type: ScifiDeckPlanGeneratorApp, + restricted: true + }); +}); + +Hooks.on("renderSceneDirectory", (_app, html) => installSceneDirectoryButton(html)); +Hooks.on("renderSidebarTab", (app, html) => { + const isScenesTab = app?.tabName === "scenes" || app?.options?.id === "scenes"; + if (isScenesTab) installSceneDirectoryButton(html); +}); +Hooks.on("canvasReady", () => applyGmOverlayVisibility(canvas.scene ?? null)); +Hooks.on("drawTile", (tile) => applyGmOverlayPlaceableVisibility(tile)); +Hooks.on("drawNote", (note) => applyGmOverlayPlaceableVisibility(note)); + +Hooks.on("getSceneControlButtons", (controls) => { + if (!game.user?.isGM) return; + + const buttonTool = { + name: "openScifiDeckPlanGenerator", + title: game.i18n.localize("SDPG.ToolbarOpen"), + icon: "fas fa-rocket", + button: true, + onClick: () => ScifiDeckPlanGeneratorApp.open() + }; + const gmOverlayTool = { + name: "toggleScifiDeckPlanGmOverlay", + title: "Toggle GM Overlay", + icon: "fas fa-user-secret", + toggle: true, + active: game.settings.get(MODULE_ID, "gmOverlayVisible") !== false, + onClick: async (active) => { + await setGmOverlayVisible(active); + } + }; + + const revealShaftTool = { + name: "sdpgRevealMaintenanceShafts", + title: "Reveal Maintenance Shafts", + icon: "fas fa-eye", + button: true, + onClick: async () => { + const scene = game.scenes?.viewed; + if (!scene) return; + const shaftDrawings = scene.drawings.filter((d) => d.flags?.[MODULE_ID]?.isMaintenanceShaft && d.hidden); + const shaftWalls = scene.walls.filter((w) => w.flags?.[MODULE_ID]?.isMaintenanceShaft && w.hidden); + if (!shaftDrawings.length && !shaftWalls.length) { + ui.notifications.info("No hidden maintenance shafts found in this scene."); + return; + } + const confirmed = await Dialog.confirm({ + title: "Reveal Maintenance Shafts", + content: `

Reveal ${shaftDrawings.length} shaft sections and ${shaftWalls.length} shaft walls to players?

` + }); + if (!confirmed) return; + if (shaftDrawings.length) { + await scene.updateEmbeddedDocuments("Drawing", shaftDrawings.map((d) => ({ _id: d.id, hidden: false }))); + } + if (shaftWalls.length) { + await scene.updateEmbeddedDocuments("Wall", shaftWalls.map((w) => ({ _id: w.id, hidden: false }))); + } + ui.notifications.info("Maintenance shafts revealed."); + } + }; + + if (Array.isArray(controls)) { + const wallsControls = controls.find((control) => control.name === "walls"); + if (wallsControls) { + wallsControls.tools ??= []; + if (!wallsControls.tools.some((tool) => tool.name === buttonTool.name)) { + wallsControls.tools.push(buttonTool); + } + if (!wallsControls.tools.some((tool) => tool.name === gmOverlayTool.name)) { + wallsControls.tools.push(gmOverlayTool); + } + if (!wallsControls.tools.some((tool) => tool.name === revealShaftTool.name)) { + wallsControls.tools.push(revealShaftTool); + } + return; + } + + controls.push({ + name: MODULE_ID, + title: game.i18n.localize("SDPG.ToolbarGroup"), + icon: "fas fa-rocket", + layer: "walls", + tools: [buttonTool, gmOverlayTool, revealShaftTool] + }); + return; + } + + if (!controls || typeof controls !== "object") return; + const wallsControls = controls.walls; + if (!wallsControls) return; + wallsControls.tools ??= {}; + wallsControls.tools[buttonTool.name] ??= buttonTool; + wallsControls.tools[gmOverlayTool.name] ??= gmOverlayTool; + wallsControls.tools[revealShaftTool.name] ??= revealShaftTool; +}); + +class ScifiDeckPlanGeneratorApp extends HandlebarsApplicationMixin(ApplicationV2) { + #isGenerating = false; + + static DEFAULT_OPTIONS = { + id: MODULE_ID, + tag: "form", + classes: ["scifi-deckplan-generator"], + window: { + title: "SDPG.AppTitle", + resizable: true, + width: 980, + height: 900 + }, + form: { + handler: ScifiDeckPlanGeneratorApp.formHandler, + submitOnChange: false, + closeOnSubmit: true + } + }; + + static PARTS = { + form: { + template: `modules/${MODULE_ID}/templates/scifi-deckplan-generator.html` + } + }; + + static open() { + return new ScifiDeckPlanGeneratorApp().render({ force: true }); + } + + static async formHandler(event, _form, formData) { + return this.generateFromForm(formData.object, event?.target ?? event?.currentTarget ?? null); + } + + async _prepareContext() { + const saved = game.settings.get(MODULE_ID, "lastFormState") ?? {}; + const profilesData = await getShipProfiles(); + const defaults = { + shipName: buildMapName("bsp"), + mode: "bsp", + profile: "hauler", + columns: 32, + rows: 20, + gridSize: 75, + gridMode: "square", + decks: 1, + seed: "", + theme: "steel", + hullShape: "rectangle", + coordinates: "border", + roomCount: 12, + minRoomSize: 3, + maxRoomSize: 8, + cornerStyle: "chamfer", + chamferSize: 1, + circularSpecialRooms: true, + softRoomOutlines: true, + splitBias: "balanced", + corridorStyle: "spine", + loopFactor: 30, + corridorWidth: 1, + buildingSubtype: "office", + secretDoorDensity: "minimal", + furnitureDensity: "normal", + missionTemplate: "auto", + encounterFaction: "auto", + encounterDensity: "standard", + encounterPack: "", + roomPopulationPack: "", + populateRoomsFromCompendium: false, + compactPrintMode: false, + factionPreset: "none", + savedPreset: "", + activateScenes: true, + regenerateInPlace: false, + regenerateTarget: "none", + targetSceneFolder: "", + showRoomLabels: true, + placeLights: true, + generateMissionBrief: true, + generateMissionPacket: false, + seedEncounters: false, + createRoomNotes: true, + createGmOverlay: true, + exportMetadataJson: true, + mapAge: 0, + exportMothershipJson: false + }; + const merged = { ...defaults, ...saved }; + const themeOptions = getThemeOptionsForMode(merged.mode); + const hullOptions = getHullOptionsForMode(merged.mode); + const sceneFolders = buildSceneFolderOptions(); + const actorPacks = buildActorPackOptions(); + const savedPresets = buildSavedPresetOptions(); + + return { + defaults: merged, + modes: mapOptions(GENERATION_MODES, merged.mode), + profiles: mapOptions( + profilesData.profiles.map((profile) => ({ value: profile.key, label: profile.label })), + merged.profile + ), + themes: mapOptions(themeOptions, merged.theme), + hullShapes: mapOptions(hullOptions, merged.hullShape), + gridSizes: mapOptions(GRID_SIZES, Number(merged.gridSize)), + gridModes: mapOptions(GRID_MODE_OPTIONS, merged.gridMode), + chamferSizes: mapOptions(CHAMFER_SIZES, Number(merged.chamferSize)), + coordinates: mapOptions(COORDINATE_MODES, merged.coordinates), + cornerStyles: mapOptions(CORNER_STYLE_OPTIONS, merged.cornerStyle), + splitBiases: mapOptions(SPLIT_BIASES, merged.splitBias), + corridorStyles: mapOptions(CORRIDOR_STYLES, merged.corridorStyle), + corridorWidths: mapOptions(CORRIDOR_WIDTHS, Number(merged.corridorWidth)), + furnitureDensities: mapOptions(FURNITURE_DENSITIES, merged.furnitureDensity), + secretDoorDensities: mapOptions(SECRET_DOOR_DENSITY_OPTIONS, merged.secretDoorDensity), + maintenanceShaftDensities: mapOptions(MAINTENANCE_SHAFT_DENSITY_OPTIONS, merged.maintenanceShaftDensity), + missionTemplates: mapOptions(MISSION_TEMPLATE_OPTIONS, merged.missionTemplate), + encounterFactions: mapOptions(ENCOUNTER_FACTION_OPTIONS, merged.encounterFaction), + encounterDensities: mapOptions(ENCOUNTER_DENSITY_OPTIONS, merged.encounterDensity), + regenerateTargets: mapOptions(REGENERATE_TARGET_OPTIONS, merged.regenerateTarget ?? "none"), + sceneFolders: mapOptions(sceneFolders, merged.targetSceneFolder ?? ""), + actorPacks: mapOptions(actorPacks, merged.encounterPack ?? ""), + roomPopulationPacks: mapOptions(actorPacks, merged.roomPopulationPack ?? ""), + factionPresets: mapOptions(FACTION_PRESET_OPTIONS, merged.factionPreset ?? "none"), + savedPresets: mapOptions(savedPresets, merged.savedPreset ?? ""), + buildingSubtypes: mapOptions(BUILDING_SUBTYPES, merged.buildingSubtype), + profileCards: profilesData.profiles.map((profile) => ({ + label: profile.label, + summary: `${profile.columns}x${profile.rows}, ${profile.decks} level${profile.decks === 1 ? "" : "s"}, ${profile.mode.toUpperCase()}, ${profile.theme}`, + mode: profile.mode + })) + }; + } + + _onRender(context, options) { + super._onRender(context, options); + const root = this.element; + const form = root.matches?.("form") ? root : root.querySelector("form"); + if (!form) return; + + root.querySelector('[data-action="apply-profile"]')?.addEventListener("click", async (event) => { + event.preventDefault(); + const profilesData = await getShipProfiles(); + const selectedKey = form.querySelector('[name="profile"]')?.value; + const profile = profilesData.profiles.find((entry) => entry.key === selectedKey); + if (!profile) return; + setInputValue(form, "mode", profile.mode); + updateModeVisibility(form, profile.mode); + setInputValue(form, "columns", profile.columns); + setInputValue(form, "rows", profile.rows); + setInputValue(form, "decks", profile.decks); + setInputValue(form, "theme", profile.theme); + setInputValue(form, "hullShape", profile.hullShape ?? "rectangle"); + setInputValue(form, "splitBias", profile.splitBias ?? "balanced"); + setInputValue(form, "corridorStyle", profile.corridorStyle ?? "spine"); + setInputValue(form, "roomCount", profile.roomCount ?? 12); + setInputValue(form, "minRoomSize", profile.minRoomSize ?? 3); + setInputValue(form, "maxRoomSize", profile.maxRoomSize ?? 8); + setInputValue(form, "loopFactor", profile.loopFactor ?? 30); + setInputValue(form, "corridorWidth", profile.corridorWidth ?? 1); + setInputValue(form, "buildingSubtype", profile.buildingSubtype ?? "office"); + setInputValue(form, "secretDoorDensity", profile.secretDoorDensity ?? "minimal"); + setInputValue(form, "furnitureDensity", profile.furnitureDensity ?? "normal"); + setInputValue(form, "shipName", profile.label); + updatePreviewPane(form, root).catch(() => {}); + }); + + root.querySelector('[data-action="load-saved-preset"]')?.addEventListener("click", async () => { + await applySavedPresetToForm(form, root); + }); + root.querySelector('[data-action="save-current-preset"]')?.addEventListener("click", async () => { + await saveFormAsPreset(form, root); + }); + root.querySelector('[data-action="delete-saved-preset"]')?.addEventListener("click", async () => { + await deleteSavedPresetFromForm(form, root); + }); + root.querySelector('[data-action="export-png"]')?.addEventListener("click", async () => { + await exportPreviewPng(form, root); + }); + root.querySelector('[data-action="share-seed"]')?.addEventListener("click", async () => { + await shareFormState(form); + }); + root.querySelector('[data-action="import-seed"]')?.addEventListener("click", async () => { + await importSharedFormState(form, root); + }); + + setInputValue(form, "buildingSubtype", context.defaults.buildingSubtype ?? "office"); + updateModeVisibility(form, form.querySelector('[name="mode"]')?.value ?? context.defaults.mode ?? "bsp"); + + form.querySelector('[name="mode"]')?.addEventListener("change", (event) => { + updateModeVisibility(form, event.currentTarget.value); + updatePreviewPane(form, root).catch(() => {}); + }); + form.querySelector(".sdpg-reroll-seed")?.addEventListener("click", () => { + const seedInput = form.querySelector('[name="seed"]'); + if (seedInput) seedInput.value = String(Math.floor(Math.random() * 1_000_000)); + updatePreviewPane(form, root).catch(() => {}); + }); + root.querySelector('[data-action="preview-map"]')?.addEventListener("click", () => { + updatePreviewPane(form, root).catch(() => {}); + }); + form.querySelectorAll("input, select").forEach((element) => { + element.addEventListener("change", () => updatePreviewPane(form, root).catch(() => {})); + }); + updatePreviewPane(form, root).catch(() => {}); + } + + async generateFromForm(formObject) { + if (this.#isGenerating) return []; + this.#isGenerating = true; + const formState = normalizeFormState(formObject); + try { + await game.settings.set(MODULE_ID, "lastFormState", formState); + ui.notifications.info(`Generating ${formState.shipName}...`); + const roomTables = await getRoomTables(); + const deckPlan = buildDeckPlan(formState, roomTables); + const scenes = await createFoundryScenes(deckPlan, formState); + let metadataPath = null; + + if (formState.exportMothershipJson) { + await exportMothershipViewerJson(deckPlan); + } + if (formState.exportMetadataJson) { + metadataPath = await exportMapMetadataJson(deckPlan, formState, scenes); + } + if (formState.generateMissionPacket) { + await createMissionPacket(deckPlan, formState, scenes, metadataPath); + } + + if (formState.activateScenes && scenes.length) { + await scenes[scenes.length - 1].activate(); + await scenes[scenes.length - 1].view(); + } + + ui.notifications.info(`Generated ${scenes.length} map scene${scenes.length === 1 ? "" : "s"} for ${deckPlan.shipName}.`); + return scenes; + } catch (error) { + console.error(`${MODULE_ID} generation failed`, error); + ui.notifications.error(`Map generation failed: ${error?.message ?? error}`); + throw error; + } finally { + this.#isGenerating = false; + this.close({ force: true }).catch(() => {}); + } + } +} + +function installSceneDirectoryButton(html) { + if (!game.user?.isGM) return; + const root = $(html); + if (root.find(".sdpg-launch").length) return; + + const button = $(` + + `); + button.on("click", () => ScifiDeckPlanGeneratorApp.open()); + const deleteButton = $(` + + `); + deleteButton.on("click", () => deleteGeneratedDocuments(game.scenes?.viewed ?? canvas.scene ?? null)); + const annotateButton = $(` + + `); + annotateButton.on("click", () => openSceneAnnotationDialog(game.scenes?.viewed ?? canvas.scene ?? null)); + const gmOverlayButton = $(` + + `); + gmOverlayButton.on("click", async () => { + await setGmOverlayVisible(!(game.settings.get(MODULE_ID, "gmOverlayVisible") !== false)); + }); + + const createSceneButton = root.find("button[data-action='createScene'], a[data-action='createScene'], button.create-document").first(); + if (createSceneButton.length) { + const row = $('
'); + row.append(button); + row.append(gmOverlayButton); + row.append(annotateButton); + row.append(deleteButton); + createSceneButton.closest(".header-actions, .action-buttons, .directory-header, header, .tab").first().after(row); + return; + } + + const header = root.find(".directory-header, header").first(); + if (header.length) { + const row = $('
'); + row.append(button); + row.append(gmOverlayButton); + row.append(annotateButton); + row.append(deleteButton); + header.prepend(row); + } +} + +async function setGmOverlayVisible(visible) { + await game.settings.set(MODULE_ID, "gmOverlayVisible", Boolean(visible)); + applyGmOverlayVisibility(canvas.scene ?? null); + ui.controls?.render?.(true); + ui.notifications.info(`GM overlay ${visible ? "shown" : "hidden"} for your client.`); +} + +function applyGmOverlayVisibility(scene) { + if (!game.user?.isGM || !scene) return; + const visible = game.settings.get(MODULE_ID, "gmOverlayVisible") !== false; + const sceneIsActive = (canvas.scene?.id ?? null) === scene.id; + if (!sceneIsActive) return; + + for (const tile of canvas.tiles?.placeables ?? []) { + applyGmOverlayPlaceableVisibility(tile, visible); + } + + for (const note of canvas.notes?.placeables ?? []) { + applyGmOverlayPlaceableVisibility(note, visible); + } +} + +function applyGmOverlayPlaceableVisibility(placeable, overrideVisible = null) { + if (!game.user?.isGM || !placeable?.document?.flags?.[MODULE_ID]?.gmOverlay) return; + const visible = overrideVisible ?? (game.settings.get(MODULE_ID, "gmOverlayVisible") !== false); + placeable.visible = visible; + placeable.alpha = visible ? 0.95 : 0; + placeable.renderable = visible; +} + +async function deleteGeneratedDocuments(scene) { + if (!scene) { + ui.notifications.warn("No scene active."); + return; + } + + const { wallIds, drawingIds, tileIds, lightIds, tokenIds, noteIds } = collectGeneratedSceneIds(scene); + + if (!wallIds.length && !drawingIds.length && !tileIds.length && !lightIds.length && !tokenIds.length && !noteIds.length) { + ui.notifications.info("No generated elements found in this scene."); + return; + } + + await Dialog.confirm({ + title: "Delete Generated Elements", + content: `

Remove ${wallIds.length} walls, ${drawingIds.length} drawings, ${tileIds.length} tiles, ${lightIds.length} lights, ${tokenIds.length} encounter tokens, and ${noteIds.length} generated notes from ${escapeXml(scene.name)}?

`, + yes: async () => { + await deleteGeneratedDocumentsInternal(scene, { wallIds, drawingIds, tileIds, lightIds, tokenIds, noteIds }); + ui.notifications.info(`Deleted generated elements from ${scene.name}.`); + } + }); +} + +async function openSceneAnnotationDialog(scene) { + if (!scene) { + ui.notifications.warn("No scene active."); + return; + } + const roomDrawings = scene.drawings.contents + .filter((drawing) => drawing.flags?.[MODULE_ID]?.roomId) + .sort((a, b) => (a.flags[MODULE_ID].roomLabel || a.flags[MODULE_ID].roomType || "").localeCompare(b.flags[MODULE_ID].roomLabel || b.flags[MODULE_ID].roomType || "")); + if (!roomDrawings.length) { + ui.notifications.warn("No generated room drawings found on this scene."); + return; + } + const value = await Dialog.prompt({ + title: "Annotate Generated Room", + content: ` +
+ + +
+
+ + +
+ `, + callback: (html) => ({ + roomId: html.find('[name="roomId"]').val(), + note: html.find('[name="annotation"]').val() + }) + }).catch(() => null); + if (!value?.roomId || !String(value.note ?? "").trim()) return; + const drawing = roomDrawings.find((entry) => entry.id === value.roomId); + if (!drawing) return; + const room = drawing.flags[MODULE_ID]; + await scene.createEmbeddedDocuments("Note", [withGeneratedNoteFlag({ + x: round((drawing.x ?? 0) + (drawing.shape?.width ?? 0) / 2), + y: round((drawing.y ?? 0) + (drawing.shape?.height ?? 0) / 2), + iconSize: 36, + text: room.roomLabel || room.roomType || "Room Note", + texture: { src: "icons/svg/book.svg" }, + hidden: true, + flags: { + [MODULE_ID]: { + annotation: true, + gmOverlay: true, + roomId: room.roomId, + roomType: room.roomType, + roomLabel: room.roomLabel, + noteText: String(value.note).trim() + } + } + })]); + ui.notifications.info("Room annotation added."); +} + +function collectGeneratedSceneIds(scene) { + return { + wallIds: scene.walls.contents.filter((wall) => wall.flags?.[MODULE_ID]?.generatedWall).map((wall) => wall.id), + drawingIds: scene.drawings.contents.filter((drawing) => drawing.flags?.[MODULE_ID]?.generatedDrawing).map((drawing) => drawing.id), + tileIds: scene.tiles.contents.filter((tile) => tile.flags?.[MODULE_ID]?.generatedTile).map((tile) => tile.id), + lightIds: scene.lights.contents.filter((light) => light.flags?.[MODULE_ID]?.generatedLight).map((light) => light.id), + tokenIds: scene.tokens.contents.filter((token) => token.flags?.[MODULE_ID]?.generatedToken).map((token) => token.id), + noteIds: scene.notes.contents.filter((note) => note.flags?.[MODULE_ID]?.generatedNote).map((note) => note.id) + }; +} + +async function deleteGeneratedDocumentsInternal(scene, ids = collectGeneratedSceneIds(scene)) { + if (ids.wallIds?.length) await scene.deleteEmbeddedDocuments("Wall", ids.wallIds); + if (ids.drawingIds?.length) await scene.deleteEmbeddedDocuments("Drawing", ids.drawingIds); + if (ids.tileIds?.length) await scene.deleteEmbeddedDocuments("Tile", ids.tileIds); + if (ids.lightIds?.length) await scene.deleteEmbeddedDocuments("AmbientLight", ids.lightIds); + if (ids.tokenIds?.length) await scene.deleteEmbeddedDocuments("Token", ids.tokenIds); + if (ids.noteIds?.length) await scene.deleteEmbeddedDocuments("Note", ids.noteIds); +} + +function normalizeFormState(raw = {}) { + const mode = normalizeChoice(raw.mode, "bsp", GENERATION_MODES.map((entry) => entry.value)); + const seedValue = String(raw.seed ?? "").trim(); + const minRoomSize = clampInt(raw.minRoomSize, 2, 8, 3); + const maxRoomSize = Math.max(minRoomSize, clampInt(raw.maxRoomSize, 4, 12, 8)); + const buildingSubtype = normalizeChoice(raw.buildingSubtype, "office", BUILDING_SUBTYPES.map((entry) => entry.value)); + const generatedName = buildMapName(mode, seedValue || Date.now(), buildingSubtype); + const themeChoices = getThemeOptionsForMode(mode).map((entry) => entry.value); + const hullChoices = getHullOptionsForMode(mode).map((entry) => entry.value); + const compactPrintMode = Boolean(raw.compactPrintMode); + const columns = compactPrintMode ? clampInt(raw.columns, 8, 12, 12) : clampInt(raw.columns, 10, 80, 32); + const rows = compactPrintMode ? clampInt(raw.rows, 6, 8, 8) : clampInt(raw.rows, 6, 50, 20); + const decks = compactPrintMode ? 1 : clampInt(raw.decks, 1, 8, 1); + return { + shipName: String(raw.shipName || generatedName).trim() || generatedName, + mode, + profile: String(raw.profile || "custom"), + savedPreset: String(raw.savedPreset || ""), + columns, + rows, + gridSize: clampInt(raw.gridSize, 50, 100, 75), + gridMode: normalizeChoice(raw.gridMode, "square", GRID_MODE_OPTIONS.map((entry) => entry.value)), + decks, + seed: seedValue || String(Math.floor(Math.random() * 1_000_000)), + theme: normalizeChoice(raw.theme, themeChoices[0], themeChoices), + hullShape: normalizeChoice(raw.hullShape, hullChoices[0], hullChoices), + coordinates: compactPrintMode ? "none" : normalizeChoice(raw.coordinates, "border", COORDINATE_MODES.map((entry) => entry.value)), + roomCount: clampInt(raw.roomCount, 6, 40, 12), + minRoomSize, + maxRoomSize, + cornerStyle: normalizeChoice(raw.cornerStyle, "chamfer", CORNER_STYLE_OPTIONS.map((entry) => entry.value)), + chamferSize: clampInt(raw.chamferSize, 1, 2, 1), + circularSpecialRooms: raw.circularSpecialRooms !== false && raw.circularSpecialRooms !== "false", + softRoomOutlines: raw.softRoomOutlines !== false && raw.softRoomOutlines !== "false", + splitBias: normalizeChoice(raw.splitBias, "balanced", SPLIT_BIASES.map((entry) => entry.value)), + corridorStyle: normalizeChoice(raw.corridorStyle, "spine", CORRIDOR_STYLES.map((entry) => entry.value)), + loopFactor: clampInt(raw.loopFactor, 0, 100, 30), + corridorWidth: clampInt(raw.corridorWidth, 1, 2, 1), + buildingSubtype, + secretDoorDensity: normalizeChoice(raw.secretDoorDensity, "minimal", SECRET_DOOR_DENSITY_OPTIONS.map((e) => e.value)), + maintenanceShaftDensity: normalizeChoice(raw.maintenanceShaftDensity, "sparse", MAINTENANCE_SHAFT_DENSITY_OPTIONS.map((e) => e.value)), + placeRegions: raw.placeRegions !== false && raw.placeRegions !== "false", + furnitureDensity: normalizeChoice(raw.furnitureDensity, "normal", FURNITURE_DENSITIES.map((entry) => entry.value)), + missionTemplate: normalizeChoice(raw.missionTemplate, "auto", MISSION_TEMPLATE_OPTIONS.map((entry) => entry.value)), + encounterFaction: normalizeChoice(raw.encounterFaction, "auto", ENCOUNTER_FACTION_OPTIONS.map((entry) => entry.value)), + encounterDensity: normalizeChoice(raw.encounterDensity, "standard", ENCOUNTER_DENSITY_OPTIONS.map((entry) => entry.value)), + encounterPack: String(raw.encounterPack ?? "").trim(), + roomPopulationPack: String(raw.roomPopulationPack ?? "").trim(), + populateRoomsFromCompendium: Boolean(raw.populateRoomsFromCompendium), + compactPrintMode, + factionPreset: normalizeChoice(raw.factionPreset, "none", FACTION_PRESET_OPTIONS.map((entry) => entry.value)), + activateScenes: Boolean(raw.activateScenes), + regenerateInPlace: Boolean(raw.regenerateInPlace), + regenerateTarget: normalizeChoice(raw.regenerateTarget, "none", REGENERATE_TARGET_OPTIONS.map((entry) => entry.value)), + targetSceneFolder: String(raw.targetSceneFolder ?? "").trim(), + showRoomLabels: Boolean(raw.showRoomLabels), + placeLights: raw.placeLights !== false && raw.placeLights !== "false", + generateMissionBrief: raw.generateMissionBrief !== false && raw.generateMissionBrief !== "false", + generateMissionPacket: Boolean(raw.generateMissionPacket), + seedEncounters: Boolean(raw.seedEncounters), + createRoomNotes: Boolean(raw.createRoomNotes), + createGmOverlay: raw.createGmOverlay !== false && raw.createGmOverlay !== "false", + exportMetadataJson: raw.exportMetadataJson !== false && raw.exportMetadataJson !== "false", + mapAge: clampInt(raw.mapAge, 0, 100, 0), + exportMothershipJson: ["bsp", "corridor"].includes(mode) && Boolean(raw.exportMothershipJson) + }; +} + +function getThemeOptionsForMode(mode) { + return MODE_THEME_OPTIONS[mode] ?? MODE_THEME_OPTIONS.bsp; +} + +function getHullOptionsForMode(mode) { + return MODE_HULL_OPTIONS[mode] ?? MODE_HULL_OPTIONS.bsp; +} + +function updateModeVisibility(form, mode) { + const themeSelect = form.querySelector('[name="theme"]'); + const hullSelect = form.querySelector('[name="hullShape"]'); + syncSelectOptions(themeSelect, getThemeOptionsForMode(mode)); + syncSelectOptions(hullSelect, getHullOptionsForMode(mode)); + + form.querySelectorAll(".sdpg-mode-field").forEach((field) => { + const allowed = (field.dataset.modes ?? "").split(/\s+/).filter(Boolean); + const active = !allowed.length || allowed.includes(mode); + field.classList.toggle("is-hidden", !active); + field.querySelectorAll("input, select").forEach((element) => { + element.disabled = !active; + }); + }); + + const exportToggle = form.querySelector('[name="exportMothershipJson"]'); + if (exportToggle) exportToggle.checked = ["bsp", "corridor"].includes(mode) && exportToggle.checked; +} + +async function updatePreviewPane(form, root) { + const image = root.querySelector("[data-preview-image]"); + const summary = root.querySelector("[data-preview-summary]"); + if (!image || !summary) return; + try { + summary.textContent = "Rendering preview..."; + const formObject = Object.fromEntries(new FormData(form).entries()); + form.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { + formObject[checkbox.name] = checkbox.checked; + }); + const formState = normalizeFormState(formObject); + const roomTables = await getRoomTables(); + const deckPlan = buildDeckPlan(formState, roomTables); + const previewDeck = deckPlan.decks[0]; + const svg = buildDeckSVG(deckPlan, previewDeck, formState); + image.dataset.svg = svg; + image.dataset.filename = `${slugify(deckPlan.shipName)}-preview.png`; + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + summary.textContent = buildPreviewSummary(deckPlan, previewDeck, formState); + } catch (error) { + console.error(`${MODULE_ID} preview failed`, error); + summary.textContent = `Preview failed: ${error?.message ?? error}`; + } +} + +function buildPreviewSummary(deckPlan, deck, formState) { + const hazards = deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)).length; + const wildernessFeatures = formState.mode === "wilderness" + ? `${deck.wilderness?.treeClusters?.length ?? 0} tree clusters, ${deck.wilderness?.waterFeatures?.length ?? 0} water features, ${deck.wilderness?.encounterZones?.length ?? 0} encounter zones` + : `${deck.rooms.length} rooms, ${deck.corridors.length} corridors, ${countDoors(deck.wallSegments)} doors`; + const targetLabel = { + none: "new scenes", + viewed: "viewed scene", + "viewed-folder": "viewed folder", + folder: formState.targetSceneFolder ? "selected folder" : "new scenes" + }[formState.regenerateTarget === "none" && formState.regenerateInPlace ? "viewed" : formState.regenerateTarget] ?? "new scenes"; + return `${deckPlan.shipName} | ${deck.deckName} | ${deckPlan.decks.length} deck${deckPlan.decks.length === 1 ? "" : "s"} | ${formState.theme} | ${formState.gridMode} | ${wildernessFeatures}${hazards ? ` | ${hazards} danger rooms` : ""} | target: ${targetLabel}`; +} + +async function saveFormAsPreset(form, root) { + const name = (await Dialog.prompt({ + title: "Save Preset", + content: `
`, + callback: (html) => html.find('[name="presetName"]').val() + }))?.trim(); + if (!name) return; + const formState = readFormState(form); + formState.savedPreset = name; + const presets = [...(game.settings.get(MODULE_ID, "savedPresets") ?? [])].filter((preset) => preset.name !== name); + presets.push({ name, state: formState }); + presets.sort((a, b) => a.name.localeCompare(b.name)); + await game.settings.set(MODULE_ID, "savedPresets", presets); + await game.settings.set(MODULE_ID, "lastFormState", formState); + updatePresetSelect(root, name); + ui.notifications.info(`Saved preset ${name}.`); +} + +async function applySavedPresetToForm(form, root) { + const select = form.querySelector('[name="savedPreset"]'); + const name = String(select?.value ?? "").trim(); + if (!name) return; + const preset = (game.settings.get(MODULE_ID, "savedPresets") ?? []).find((entry) => entry.name === name); + if (!preset?.state) return; + applyStateToForm(form, preset.state); + updateModeVisibility(form, form.querySelector('[name="mode"]')?.value ?? preset.state.mode ?? "bsp"); + updatePresetSelect(root, name); + await game.settings.set(MODULE_ID, "lastFormState", { ...preset.state, savedPreset: name }); + updatePreviewPane(form, root).catch(() => {}); + ui.notifications.info(`Loaded preset ${name}.`); +} + +async function deleteSavedPresetFromForm(form, root) { + const select = form.querySelector('[name="savedPreset"]'); + const name = String(select?.value ?? "").trim(); + if (!name) return; + const confirmed = await Dialog.confirm({ + title: "Delete Preset", + content: `

Delete saved preset ${escapeXml(name)}?

` + }); + if (!confirmed) return; + const presets = [...(game.settings.get(MODULE_ID, "savedPresets") ?? [])].filter((entry) => entry.name !== name); + await game.settings.set(MODULE_ID, "savedPresets", presets); + updatePresetSelect(root, ""); + ui.notifications.info(`Deleted preset ${name}.`); +} + +function applyStateToForm(form, state = {}) { + for (const [key, value] of Object.entries(state)) { + setInputValue(form, key, value); + } +} + +function updatePresetSelect(root, selectedName = "") { + const select = root.querySelector('[name="savedPreset"]'); + if (!select) return; + syncSelectOptions(select, buildSavedPresetOptions()); + select.value = selectedName; +} + +function readFormState(form) { + const formObject = Object.fromEntries(new FormData(form).entries()); + form.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { + formObject[checkbox.name] = checkbox.checked; + }); + return normalizeFormState(formObject); +} + +async function exportPreviewPng(form, root) { + const image = root.querySelector("[data-preview-image]"); + const svg = image?.dataset?.svg; + if (!svg) return; + const widthMatch = svg.match(/width="(\d+)"/); + const heightMatch = svg.match(/height="(\d+)"/); + const width = Number(widthMatch?.[1] ?? 1200); + const height = Number(heightMatch?.[1] ?? 900); + const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(blob); + try { + const pngUrl = await rasterizeSvgToPng(url, width, height); + const link = document.createElement("a"); + link.href = pngUrl; + link.download = image.dataset.filename || "map-preview.png"; + link.click(); + setTimeout(() => URL.revokeObjectURL(pngUrl), 2000); + } finally { + URL.revokeObjectURL(url); + } +} + +function rasterizeSvgToPng(url, width, height) { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + const img = new Image(); + img.onload = () => { + ctx.clearRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL("image/png")); + }; + img.onerror = reject; + img.src = url; + }); +} + +async function shareFormState(form) { + const state = readFormState(form); + const packed = btoa(unescape(encodeURIComponent(JSON.stringify(state)))); + const share = `${window.location.origin}${window.location.pathname}#sdpg=${packed}`; + await navigator.clipboard.writeText(share).catch(() => {}); + await Dialog.prompt({ + title: "Share Seed URL", + content: ``, + callback: () => share + }).catch(() => null); +} + +async function importSharedFormState(form, root) { + const value = await Dialog.prompt({ + title: "Import Shared Seed", + content: ``, + callback: (html) => html.find('[name="importState"]').val() + }); + const text = String(value ?? "").trim(); + if (!text) return; + const match = text.match(/#sdpg=([^&]+)/); + const packed = match?.[1] ?? text; + try { + const state = JSON.parse(decodeURIComponent(escape(atob(packed)))); + applyStateToForm(form, state); + updateModeVisibility(form, form.querySelector('[name="mode"]')?.value ?? state.mode ?? "bsp"); + updatePreviewPane(form, root).catch(() => {}); + ui.notifications.info("Imported shared generator state."); + } catch (error) { + ui.notifications.error(`Could not import shared state: ${error?.message ?? error}`); + } +} + +function syncSelectOptions(select, options) { + if (!select) return; + const currentValue = select.value; + select.innerHTML = options.map((option) => ``).join(""); + const allowed = new Set(options.map((option) => String(option.value))); + select.value = allowed.has(String(currentValue)) ? String(currentValue) : String(options[0]?.value ?? ""); +} + +async function getRoomTables() { + if (!roomTablesCache) { + roomTablesCache = await loadJson(`modules/${MODULE_ID}/data/room-tables.json`); + } + return roomTablesCache; +} + +async function getShipProfiles() { + if (!shipProfilesCache) { + shipProfilesCache = await loadJson(`modules/${MODULE_ID}/data/ship-profiles.json`); + } + return shipProfilesCache; +} + +async function getFurnitureTables() { + if (!furnitureTablesCache) { + furnitureTablesCache = await loadJson(`modules/${MODULE_ID}/data/furniture-tables.json`); + } + return furnitureTablesCache; +} + +async function loadJson(path) { + const response = await fetch(path); + if (!response.ok) throw new Error(`Failed to load ${path}`); + return response.json(); +} + +function buildDeckPlan(formState, roomTables) { + const seed = hashSeed(formState.seed); + const decks = []; + + for (let deckIndex = 0; deckIndex < formState.decks; deckIndex += 1) { + const rng = mulberry32(seed + deckIndex * 104729); + const deckName = resolveDeckName(deckIndex, formState.mode); + const deck = buildDeckForMode(deckIndex, deckName, formState, roomTables, rng); + decks.push(deck); + } + + if (decks.length > 1) { + applySharedDeckConnectors(decks, formState); + } + + return { + shipName: formState.shipName, + seed: formState.seed, + gridSize: formState.gridSize, + theme: formState.theme, + decks + }; +} + +function buildDeckForMode(deckIndex, deckName, formState, roomTables, rng) { + switch (formState.mode) { + case "corridor": + return buildCorridorDeck(deckIndex, deckName, formState, roomTables, rng); + case "geomorph": + return buildGeomorphDeck(deckIndex, deckName, formState, roomTables, rng); + case "dungeon": + if (formState.theme === "cave") return buildCaveDeck(deckIndex, deckName, formState, roomTables, rng); + return buildDungeonDeck(deckIndex, deckName, formState, roomTables, rng); + case "building": + return formState.corridorStyle === "spine" + ? buildBuildingDeckWithHallway(deckIndex, deckName, formState, roomTables, rng) + : buildBuildingDeck(deckIndex, deckName, formState, roomTables, rng); + case "wilderness": + return buildWildernessDeck(deckIndex, deckName, formState, roomTables, rng); + case "bsp": + default: + return formState.corridorStyle === "spine" + ? buildBspDeckWithSpine(deckIndex, deckName, formState, roomTables, rng) + : buildBspDeck(deckIndex, deckName, formState, roomTables, rng); + } +} + +function buildGeomorphDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, "rectangle", "geomorph"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const tileSize = 6; + const tileCols = Math.max(1, Math.floor((formState.columns - 2) / tileSize)); + const tileRows = Math.max(1, Math.floor((formState.rows - 2) / tileSize)); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + const rooms = []; + const corridors = []; + const connections = []; + const geomorphTiles = []; + + for (let ty = 0; ty < tileRows; ty += 1) { + for (let tx = 0; tx < tileCols; tx += 1) { + const required = { + north: ty > 0 ? (geomorphTiles[(ty - 1) * tileCols + tx]?.connectors.south ?? false) : false, + west: tx > 0 ? (geomorphTiles[ty * tileCols + (tx - 1)]?.connectors.east ?? false) : false + }; + const candidates = GEOMORPH_TILES.filter((tile) => + tile.connectors.north === required.north + && tile.connectors.west === required.west + && (tx < tileCols - 1 || tile.connectors.east === false) + && (ty < tileRows - 1 || tile.connectors.south === false) + ); + const tile = (candidates.length ? candidates : GEOMORPH_TILES)[randomInt(rng, 0, (candidates.length ? candidates : GEOMORPH_TILES).length - 1)]; + geomorphTiles.push(tile); + const baseX = 1 + tx * tileSize; + const baseY = 1 + ty * tileSize; + for (const rect of tile.corridors) { + const corridor = { x: baseX + rect.x, y: baseY + rect.y, width: rect.width, height: rect.height, waypoints: [] }; + corridors.push(corridor); + fillRectCells(occupancy, corridor, true); + fillRectCells(roomType, corridor, "corridor"); + } + for (const rect of tile.rooms) { + const room = { + id: `g${deckIndex}-${rooms.length}`, + x: baseX + rect.x, + y: baseY + rect.y, + width: rect.width, + height: rect.height, + shape: "rect", + markers: [] + }; + rooms.push(room); + fillRectCells(occupancy, room, true); + fillRectCells(roomType, room, "room"); + fillRectCells(roomIdGrid, room, room.id); + } + } + } + + for (let i = 1; i < rooms.length; i += 1) { + connections.push([rooms[i - 1].id, rooms[i].id]); + } + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const wallSegments = buildOccupancyWalls(occupancy, roomType, roomIdGrid); + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors, + wallSegments, + deckConnectors: [], + airlocks: [], + exteriorOpenings: [] + }; +} + +function buildBspDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, formState.mode); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const roomTargets = allocateRoomCounts(hullRegions, formState.roomCount); + const roomRects = hullRegions.flatMap((region, index) => + splitRectExactCount(region, roomTargets[index], formState.splitBias, formState.minRoomSize, rng) + ); + const rooms = roomRects.map((rect, index) => ({ + id: `r${deckIndex}-${index}`, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + shape: "rect", + markers: [] + })); + + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const airlocks = buildHullDoorsFromMask(hullMask, rng); + const airlockDoors = airlocks.map((airlock) => ({ ...airlock, door: true, ds: 2 })); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, airlocks), + ...buildPartitionSegments(rooms, rng), + ...airlockDoors + ]; + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: [], + wallSegments, + deckConnectors: [], + airlocks: airlocks.map((segment, index) => ({ + id: `a${deckIndex}-${index}`, + x: segment.x1, + y: segment.y1, + edge: segment.x1 === segment.x2 ? "V" : "H", + label: `AIRLOCK ${index + 1}` + })) + }; +} + +function buildBspDeckWithSpine(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, formState.mode); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const spineWidth = Math.min(2, Math.max(1, formState.corridorWidth)); + const spineMidY = hull.y + Math.max(2, Math.floor(hull.height / 2) - Math.floor(spineWidth / 2)); + const spineRect = { x: hull.x, y: spineMidY, width: hull.width, height: spineWidth }; + const topBand = { x: hull.x, y: hull.y, width: hull.width, height: Math.max(0, spineRect.y - hull.y) }; + const botBand = { + x: hull.x, + y: spineRect.y + spineRect.height, + width: hull.width, + height: Math.max(0, (hull.y + hull.height) - (spineRect.y + spineRect.height)) + }; + const roomCount = Math.max(4, formState.roomCount); + const topCount = Math.ceil(roomCount / 2); + const botCount = Math.floor(roomCount / 2); + const rooms = []; + const roomRects = []; + if (topBand.height >= formState.minRoomSize) { + roomRects.push(...splitRectExactCount(topBand, topCount, formState.splitBias, formState.minRoomSize, rng)); + } + if (botBand.height >= formState.minRoomSize) { + roomRects.push(...splitRectExactCount(botBand, botCount, formState.splitBias, formState.minRoomSize, rng)); + } + roomRects.forEach((rect, index) => { + rooms.push({ id: `r${deckIndex}-${index}`, ...rect, shape: "rect", markers: [] }); + }); + + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const airlocks = buildHullDoorsFromMask(hullMask, rng); + const airlockDoors = airlocks.map((airlock) => ({ ...airlock, door: true, ds: DOOR_STATE_LOCKED })); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, airlocks), + ...buildPartitionSegments(rooms, rng), + ...buildSpineWalls(spineRect, rooms), + ...airlockDoors + ]; + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: [{ ...spineRect, waypoints: [] }], + wallSegments, + deckConnectors: [], + airlocks: airlocks.map((segment, index) => ({ + id: `a${deckIndex}-${index}`, + x: segment.x1, + y: segment.y1, + edge: segment.x1 === segment.x2 ? "V" : "H", + label: `AIRLOCK ${index + 1}` + })) + }; +} + +function buildCorridorDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, formState.mode); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + const rooms = []; + const attempts = formState.roomCount * 16; + + for (let i = 0; i < attempts && rooms.length < formState.roomCount; i += 1) { + const width = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.width - 2))); + const height = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.height - 2))); + const x = randomInt(rng, hull.x, Math.max(hull.x, hull.x + hull.width - width)); + const y = randomInt(rng, hull.y, Math.max(hull.y, hull.y + hull.height - height)); + const rect = { x, y, width, height }; + if (!rectFitsMask(rect, hullMask)) continue; + if (rooms.some((room) => rectsOverlap(expandRect(room, 1), rect))) continue; + rooms.push({ + id: `r${deckIndex}-${rooms.length}`, + ...rect, + shape: "rect", + markers: [] + }); + fillRectCells(occupancy, rect, true); + fillRectCells(roomType, rect, "room"); + fillRectCells(roomIdGrid, rect, rooms[rooms.length - 1].id); + } + + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + + const corridors = []; + const connections = []; + for (let i = 1; i < rooms.length; i += 1) { + const current = roomCenter(rooms[i]); + const nearestRoom = nearestRoomForIndex(rooms, i); + const nearest = roomCenter(nearestRoom); + const width = corridorWidthForConnection(rooms[i], nearestRoom, formState); + const hall = carveCorridor(nearest, current, occupancy, roomType, width, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([rooms[i].id, nearestRoom.id]); + } + } + + const extraEdges = Math.max(1, Math.floor(rooms.length * (formState.loopFactor / 100) * 0.4)); + for (let i = 0; i < extraEdges; i += 1) { + const a = rooms[randomInt(rng, 0, rooms.length - 1)]; + const b = rooms[randomInt(rng, 0, rooms.length - 1)]; + if (!a || !b || a === b) continue; + const width = corridorWidthForConnection(a, b, formState); + const hall = carveCorridor(roomCenter(a), roomCenter(b), occupancy, roomType, width, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([a.id, b.id]); + } + } + + ensureCorridorConnectivity(rooms, connections, corridors, occupancy, roomType, formState.corridorWidth, hullMask, formState); + + const wallSegments = buildOccupancyWalls(occupancy, roomType, roomIdGrid); + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors, + wallSegments, + deckConnectors: [], + airlocks: [] + }; +} + +function buildCaveDeck(deckIndex, deckName, formState, roomTables, rng) { + const columns = formState.columns; + const rows = formState.rows; + const openMask = makeGrid(columns, rows, false); + + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < columns - 1; x += 1) { + openMask[y][x] = rng() > 0.45; + } + } + + for (let pass = 0; pass < 5; pass += 1) { + const next = makeGrid(columns, rows, false); + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < columns - 1; x += 1) { + const closed = countClosedNeighbours(openMask, x, y, columns, rows); + next[y][x] = closed < 5 ? true : (closed <= 3 ? true : openMask[y][x]); + } + } + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < columns; x += 1) { + openMask[y][x] = next[y][x]; + } + } + } + + const visited = makeGrid(columns, rows, false); + let bestRegion = []; + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < columns - 1; x += 1) { + if (!openMask[y][x] || visited[y][x]) continue; + const region = floodFillOpenRegion(openMask, visited, x, y, columns, rows); + if (region.length > bestRegion.length) bestRegion = region; + } + } + + const finalMask = makeGrid(columns, rows, false); + for (const cell of bestRegion) { + finalMask[cell.y][cell.x] = true; + } + + const midY = Math.floor(rows / 2); + for (let x = 2; x < columns - 2; x += 1) { + finalMask[midY][x] = true; + } + + const chambers = buildCaveChambers(finalMask, formState.roomCount, formState.minRoomSize, rng, deckIndex); + const connections = buildCaveRoomConnections(chambers); + assignDungeonRoomTypes(chambers, roomTables, connections, finalMask, rng); + applyFactionTerritories(chambers, formState); + applyOrganicRoomShapes(chambers, { ...formState, cornerStyle: "square", circularSpecialRooms: true }, rng); + + return { + deckIndex, + deckName, + columns, + rows, + hull: { x: 1, y: 1, width: columns - 2, height: rows - 2 }, + hullRegions: [{ x: 1, y: 1, width: columns - 2, height: rows - 2 }], + hullMask: finalMask, + rooms: chambers, + corridors: [], + wallSegments: extractGridWallSegments(finalMask, columns, rows), + deckConnectors: [], + airlocks: [], + isCave: true + }; +} + +function buildDungeonDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, "dungeon"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + const rooms = []; + const attempts = formState.roomCount * 18; + + for (let i = 0; i < attempts && rooms.length < formState.roomCount; i += 1) { + const width = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.width - 2))); + const height = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.height - 2))); + const x = randomInt(rng, hull.x, Math.max(hull.x, hull.x + hull.width - width)); + const y = randomInt(rng, hull.y, Math.max(hull.y, hull.y + hull.height - height)); + const rect = { x, y, width, height }; + if (!rectFitsMask(rect, hullMask)) continue; + if (rooms.some((room) => rectsOverlap(expandRect(room, 1), rect))) continue; + const room = { + id: `r${deckIndex}-${rooms.length}`, + ...rect, + shape: "rect", + markers: [] + }; + rooms.push(room); + fillRectCells(occupancy, rect, true); + fillRectCells(roomType, rect, "room"); + fillRectCells(roomIdGrid, rect, room.id); + } + + const corridors = []; + const connections = []; + for (let i = 1; i < rooms.length; i += 1) { + const current = roomCenter(rooms[i]); + const nearestRoom = nearestRoomForIndex(rooms, i); + const hall = carveCorridor(roomCenter(nearestRoom), current, occupancy, roomType, formState.corridorWidth, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([rooms[i].id, nearestRoom.id]); + } + } + + const roomDoorTypes = assignDungeonRoomTypes(rooms, roomTables, connections, hullMask, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const extraEdges = Math.max(1, Math.floor(rooms.length * (formState.loopFactor / 100))); + for (let i = 0; i < extraEdges; i += 1) { + const a = rooms[randomInt(rng, 0, rooms.length - 1)]; + const b = rooms[randomInt(rng, 0, rooms.length - 1)]; + if (!a || !b || a === b) continue; + const width = corridorWidthForConnection(a, b, formState); + const hall = carveCorridor(roomCenter(a), roomCenter(b), occupancy, roomType, width, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([a.id, b.id]); + } + } + + ensureCorridorConnectivity(rooms, connections, corridors, occupancy, roomType, formState.corridorWidth, hullMask, formState); + let wallSegments = buildOccupancyWalls(occupancy, roomType, roomIdGrid, { roomDoorTypes }); + wallSegments = injectSecretDoors(wallSegments, rng, formState.secretDoorDensity); + if (["bsp", "corridor"].includes(formState.mode)) { + wallSegments = applyBulkheadUpgrades(wallSegments, rooms); + } + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors, + wallSegments, + deckConnectors: [], + airlocks: [] + }; +} + +function buildBuildingDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, "building"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const roomTargets = allocateRoomCounts(hullRegions, formState.roomCount); + const roomRects = hullRegions.flatMap((region, index) => + splitRectExactCount(region, roomTargets[index], formState.splitBias, formState.minRoomSize, rng) + ); + const rooms = roomRects.map((rect, index) => ({ + id: `r${deckIndex}-${index}`, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + shape: "rect", + markers: [] + })); + + const roomDoorTypes = assignBuildingRoomTypes(rooms, roomTables, formState.buildingSubtype, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const exteriorOpenings = buildBuildingExteriorOpenings(hullMask, rng, formState.buildingSubtype); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, exteriorOpenings), + ...buildPartitionSegments(rooms, rng), + ...exteriorOpenings + ]; + const finishedWalls = injectHorrorDecay(applyDoorTypesToPartitions(wallSegments, rooms, roomDoorTypes), rooms, rng, formState.theme); + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: [], + wallSegments: finishedWalls, + deckConnectors: [], + airlocks: [], + exteriorOpenings + }; +} + +function buildBuildingCirculation(region, formState) { + const corridors = []; + const roomBands = []; + const corridorWidth = 1; + const lobbyDepth = clamp(Math.round(region.height * 0.18), 3, Math.max(3, region.height - formState.minRoomSize - 1)); + const lobbyWidth = clamp(Math.round(region.width * 0.28), 3, Math.max(3, region.width - (formState.minRoomSize * 2))); + + if (region.width >= region.height) { + const mainY = region.y + Math.max(formState.minRoomSize, Math.floor(region.height * 0.42)); + const lobbyX = region.x + Math.floor((region.width - lobbyWidth) / 2); + const lobbyY = region.y + region.height - lobbyDepth; + const mainHall = { x: region.x, y: clamp(mainY, region.y + 2, region.y + region.height - 3), width: region.width, height: corridorWidth, waypoints: [] }; + const lobby = { x: lobbyX, y: lobbyY, width: lobbyWidth, height: lobbyDepth, waypoints: [] }; + const connector = { + x: lobbyX + Math.floor(lobbyWidth / 2), + y: mainHall.y + mainHall.height, + width: corridorWidth, + height: Math.max(0, lobby.y - (mainHall.y + mainHall.height)), + waypoints: [] + }; + corridors.push(mainHall, lobby); + if (connector.height > 0) corridors.push(connector); + + roomBands.push({ x: region.x, y: region.y, width: region.width, height: mainHall.y - region.y }); + roomBands.push({ x: region.x, y: mainHall.y + mainHall.height, width: Math.max(0, lobby.x - region.x), height: region.y + region.height - (mainHall.y + mainHall.height) }); + roomBands.push({ + x: lobby.x + lobby.width, + y: mainHall.y + mainHall.height, + width: Math.max(0, region.x + region.width - (lobby.x + lobby.width)), + height: region.y + region.height - (mainHall.y + mainHall.height) + }); + } else { + const mainX = region.x + Math.floor(region.width / 2); + const crossY = region.y + Math.max(formState.minRoomSize, Math.floor(region.height * 0.35)); + const lobby = { + x: region.x + Math.floor((region.width - lobbyWidth) / 2), + y: region.y + region.height - lobbyDepth, + width: Math.min(lobbyWidth, region.width), + height: lobbyDepth, + waypoints: [] + }; + const mainHall = { x: clamp(mainX, region.x + 2, region.x + region.width - 3), y: region.y, width: corridorWidth, height: region.height, waypoints: [] }; + const crossHall = { x: region.x, y: clamp(crossY, region.y + 2, region.y + region.height - 3), width: region.width, height: corridorWidth, waypoints: [] }; + corridors.push(mainHall, crossHall, lobby); + + roomBands.push({ x: region.x, y: region.y, width: mainHall.x - region.x, height: crossHall.y - region.y }); + roomBands.push({ x: mainHall.x + mainHall.width, y: region.y, width: region.x + region.width - (mainHall.x + mainHall.width), height: crossHall.y - region.y }); + roomBands.push({ x: region.x, y: crossHall.y + crossHall.height, width: lobby.x - region.x, height: region.y + region.height - (crossHall.y + crossHall.height) }); + roomBands.push({ + x: lobby.x + lobby.width, + y: crossHall.y + crossHall.height, + width: region.x + region.width - (lobby.x + lobby.width), + height: region.y + region.height - (crossHall.y + crossHall.height) + }); + } + + return { + corridors: corridors.filter((corridor) => corridor.width > 0 && corridor.height > 0), + roomBands: roomBands.filter((band) => band.width > 0 && band.height > 0) + }; +} + +function buildBuildingDeckWithHallway(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, "building"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const hallways = []; + const roomRects = []; + + for (const region of hullRegions) { + const circulation = buildBuildingCirculation(region, formState); + hallways.push(...circulation.corridors); + const candidateBands = circulation.roomBands.filter((band) => band.width >= formState.minRoomSize && band.height >= formState.minRoomSize); + const regionRoomCount = Math.max(2, Math.round(formState.roomCount * ((region.width * region.height) / Math.max(1, hull.width * hull.height)))); + const counts = allocateRoomCounts(candidateBands, regionRoomCount); + candidateBands.forEach((band, index) => { + roomRects.push(...splitRectExactCount(band, counts[index], formState.splitBias, formState.minRoomSize, rng)); + }); + } + + const rooms = roomRects.map((rect, index) => ({ id: `r${deckIndex}-${index}`, ...rect, shape: "rect", markers: [] })); + const roomDoorTypes = assignBuildingRoomTypes(rooms, roomTables, formState.buildingSubtype, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const exteriorOpenings = buildBuildingExteriorOpenings(hullMask, rng, formState.buildingSubtype); + const hallwayWalls = hallways.flatMap((hallRect) => buildHallwayWalls(hallRect, rooms)); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, exteriorOpenings), + ...buildPartitionSegments(rooms, rng), + ...hallwayWalls, + ...exteriorOpenings + ]; + const finishedWalls = dedupeSegments(injectHorrorDecay(applyDoorTypesToPartitions(wallSegments, rooms, roomDoorTypes), rooms, rng, formState.theme), true); + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: hallways, + wallSegments: finishedWalls, + deckConnectors: [], + airlocks: [], + exteriorOpenings + }; +} + +function buildWildernessDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = [{ x: 1, y: 1, width: formState.columns - 2, height: formState.rows - 2 }]; + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const clearings = []; + const targetAreas = Math.max(5, Math.min(18, formState.roomCount)); + const attempts = targetAreas * 24; + + for (let i = 0; i < attempts && clearings.length < targetAreas; i += 1) { + const width = randomInt(rng, Math.max(3, formState.minRoomSize), Math.max(4, formState.maxRoomSize + 2)); + const height = randomInt(rng, Math.max(3, formState.minRoomSize), Math.max(4, formState.maxRoomSize + 2)); + const x = randomInt(rng, hull.x, Math.max(hull.x, hull.x + hull.width - width)); + const y = randomInt(rng, hull.y, Math.max(hull.y, hull.y + hull.height - height)); + const rect = { x, y, width, height }; + if (clearings.some((room) => rectsOverlap(expandRect(room, 2), rect))) continue; + clearings.push({ + id: `w${deckIndex}-${clearings.length}`, + ...rect, + shape: rng() > 0.55 ? "circle" : "rect", + markers: [] + }); + } + + assignWildernessAreaTypes(clearings, roomTables, formState.theme, rng); + applyFactionTerritories(clearings, formState); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + for (const room of clearings) { + fillRectCells(occupancy, room, true); + fillRectCells(roomType, room, "room"); + fillRectCells(roomIdGrid, room, room.id); + } + + const trails = []; + const connections = []; + for (let i = 1; i < clearings.length; i += 1) { + const from = roomCenter(clearings[i]); + const to = roomCenter(nearestRoomForIndex(clearings, i)); + const trail = carveCorridor(to, from, occupancy, roomType, 1, hullMask); + if (trail.length) { + trails.push(...trail); + connections.push([clearings[i].id, nearestRoomForIndex(clearings, i).id]); + } + } + ensureCorridorConnectivity(clearings, connections, trails, occupancy, roomType, 1, hullMask, formState); + + const wilderness = buildWildernessFeatures(clearings, formState, rng); + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms: clearings, + corridors: trails, + wallSegments: wilderness.wallSegments, + deckConnectors: [], + airlocks: [], + wilderness + }; +} + +function applyOrganicRoomShapes(rooms, formState, rng) { + if (!rooms?.length) return rooms; + + if (formState.circularSpecialRooms) { + applyCircularRoomShapes(rooms); + } + + if (formState.cornerStyle === "square") return rooms; + + for (const room of rooms) { + if (room.shape === "circle" || room.shape === "cave") continue; + if (formState.cornerStyle === "chamfer") { + const points = buildChamferedPoints(room, formState.chamferSize); + if (points) { + room.shape = "polygon"; + room.points = points; + } + continue; + } + if (formState.cornerStyle === "perturb") { + const points = buildPerturbedPoints(room, rng); + if (points) { + room.shape = "polygon"; + room.points = points; + } + } + } + return rooms; +} + +function applyCircularRoomShapes(rooms) { + for (const room of rooms) { + if (!CIRCULAR_ROOM_TYPES.has(room.type)) continue; + if (room.width < 3 || room.height < 3) continue; + const size = Math.min(room.width, room.height); + const offsetX = (room.width - size) / 2; + const offsetY = (room.height - size) / 2; + room.shape = "circle"; + room.circleX = room.x + offsetX; + room.circleY = room.y + offsetY; + room.circleR = size / 2; + } +} + +function buildChamferedPoints(room, chamferSize = 1) { + if (room.width < chamferSize * 2 + 2 || room.height < chamferSize * 2 + 2) return null; + const c = chamferSize; + const { x, y, width: w, height: h } = room; + return [ + { x: x + c, y }, + { x: x + w - c, y }, + { x: x + w, y: y + c }, + { x: x + w, y: y + h - c }, + { x: x + w - c, y: y + h }, + { x: x + c, y: y + h }, + { x, y: y + h - c }, + { x, y: y + c } + ]; +} + +function buildPerturbedPoints(room, rng, amount = 0.4) { + if (room.width < 3 || room.height < 3) return null; + const jitter = () => round((rng() - 0.5) * 2 * amount, 2); + return [ + { x: room.x + jitter(), y: room.y + jitter() }, + { x: room.x + room.width + jitter(), y: room.y + jitter() }, + { x: room.x + room.width + jitter(), y: room.y + room.height + jitter() }, + { x: room.x + jitter(), y: room.y + room.height + jitter() } + ]; +} + +function countClosedNeighbours(openMask, x, y, columns, rows) { + let closed = 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= columns || ny >= rows || !openMask[ny]?.[nx]) { + closed += 1; + } + } + } + return closed; +} + +function floodFillOpenRegion(openMask, visited, startX, startY, columns, rows) { + const cells = []; + const stack = [{ x: startX, y: startY }]; + while (stack.length) { + const { x, y } = stack.pop(); + if (x < 0 || y < 0 || x >= columns || y >= rows) continue; + if (visited[y][x] || !openMask[y][x]) continue; + visited[y][x] = true; + cells.push({ x, y }); + stack.push({ x: x + 1, y }, { x: x - 1, y }, { x, y: y + 1 }, { x, y: y - 1 }); + } + return cells; +} + +function buildCaveChambers(openMask, targetCount, minRoomSize, rng, deckIndex) { + const openCells = []; + for (let y = 1; y < openMask.length - 1; y += 1) { + for (let x = 1; x < (openMask[y]?.length ?? 0) - 1; x += 1) { + if (openMask[y][x]) openCells.push({ x, y }); + } + } + if (!openCells.length) return []; + + const seeds = pickSeparatedOpenCells(openCells, Math.min(targetCount, Math.max(4, Math.floor(openCells.length / 18))), Math.max(3, minRoomSize * 2), rng); + if (!seeds.length) seeds.push(openCells[Math.floor(openCells.length / 2)]); + + const chamberCells = Array.from({ length: seeds.length }, () => []); + const chamberIndexByCell = new Map(); + for (const cell of openCells) { + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < seeds.length; index += 1) { + const seed = seeds[index]; + const distance = Math.abs(cell.x - seed.x) + Math.abs(cell.y - seed.y); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + } + chamberCells[bestIndex].push(cell); + chamberIndexByCell.set(`${cell.x},${cell.y}`, bestIndex); + } + + return chamberCells + .filter((cells) => cells.length >= Math.max(6, minRoomSize * minRoomSize)) + .map((cells, index) => { + const xs = cells.map((cell) => cell.x); + const ys = cells.map((cell) => cell.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + return { + id: `c${deckIndex}-${index}`, + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1, + shape: "cave", + cells, + markers: [], + chamberIndexByCell + }; + }); +} + +function pickSeparatedOpenCells(cells, targetCount, minDistance, rng) { + const pool = [...cells]; + const chosen = []; + while (pool.length && chosen.length < targetCount) { + const pick = pool.splice(randomInt(rng, 0, pool.length - 1), 1)[0]; + if (chosen.every((cell) => Math.abs(cell.x - pick.x) + Math.abs(cell.y - pick.y) >= minDistance)) { + chosen.push(pick); + } + } + return chosen; +} + +function buildCaveRoomConnections(rooms) { + const cellToRoom = new Map(); + for (const room of rooms) { + for (const cell of room.cells ?? []) { + cellToRoom.set(`${cell.x},${cell.y}`, room.id); + } + } + const connections = new Set(); + for (const room of rooms) { + for (const cell of room.cells ?? []) { + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const neighbor = cellToRoom.get(`${cell.x + dx},${cell.y + dy}`); + if (!neighbor || neighbor === room.id) continue; + connections.add([room.id, neighbor].sort().join("|")); + } + } + } + return Array.from(connections, (key) => key.split("|")); +} + +function extractGridWallSegments(openMask, columns, rows) { + const segments = []; + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < columns; x += 1) { + if (!openMask[y]?.[x]) continue; + if (!openMask[y - 1]?.[x]) segments.push({ x1: x, y1: y, x2: x + 1, y2: y }); + if (!openMask[y + 1]?.[x]) segments.push({ x1: x, y1: y + 1, x2: x + 1, y2: y + 1 }); + if (!openMask[y]?.[x - 1]) segments.push({ x1: x, y1: y, x2: x, y2: y + 1 }); + if (!openMask[y]?.[x + 1]) segments.push({ x1: x + 1, y1: y, x2: x + 1, y2: y + 1 }); + } + } + return mergeCollinearSegments(dedupeSegments(segments)); +} + +function mergeCollinearSegments(segments) { + const horiz = segments.filter((segment) => segment.y1 === segment.y2).sort((a, b) => a.y1 - b.y1 || a.x1 - b.x1); + const vert = segments.filter((segment) => segment.x1 === segment.x2).sort((a, b) => a.x1 - b.x1 || a.y1 - b.y1); + return [...mergeRuns(horiz, true), ...mergeRuns(vert, false)]; +} + +function mergeRuns(segments, horizontal) { + const out = []; + for (const segment of segments) { + const last = out[out.length - 1]; + if (last && horizontal && last.y1 === segment.y1 && last.x2 === segment.x1 && !last.door && !segment.door) { + last.x2 = segment.x2; + continue; + } + if (last && !horizontal && last.x1 === segment.x1 && last.y2 === segment.y1 && !last.door && !segment.door) { + last.y2 = segment.y2; + continue; + } + out.push({ ...segment }); + } + return out; +} + +function assignScifiRoomTypes(rooms, roomTables, rng) { + const systemId = game.system?.id ?? ""; + const overrides = ["sla-mothership", "sla-industries-brp"].includes(systemId) ? roomTables.sla_overrides ?? {} : {}; + const usedKeys = new Set(); + const counters = new Map(); + + const bowSorted = [...rooms].sort((a, b) => a.x - b.x || a.y - b.y); + const sternSorted = [...rooms].sort((a, b) => (b.x + b.width) - (a.x + a.width) || a.y - b.y); + const anySorted = [...rooms].sort((a, b) => (b.width * b.height) - (a.width * a.height)); + + reserveRoom("bridge", bowSorted); + reserveRoom("engineering", sternSorted); + reserveRoom("medical", anySorted); + + const weightedPool = roomTables.rooms.filter((entry) => !entry.unique && entry.key !== "void"); + + for (const room of rooms) { + if (room.type) continue; + const choice = weightedChoice(weightedPool, rng); + room.type = choice.key; + counters.set(choice.key, (counters.get(choice.key) ?? 0) + 1); + const labelBase = overrides[choice.key]?.label ?? choice.label; + room.label = choice.numbered ? `${labelBase} ${counters.get(choice.key)}` : labelBase; + } + + function reserveRoom(key, orderedRooms) { + const entry = roomTables.rooms.find((room) => room.key === key); + if (!entry || usedKeys.has(key)) return; + let target = orderedRooms.find((room) => room.width >= entry.minSize[0] && room.height >= entry.minSize[1] && !room.type); + if (!target) { + target = orderedRooms.find((room) => !room.type); + } + if (!target) return; + usedKeys.add(key); + target.type = key; + const label = overrides[key]?.label ?? entry.label; + target.label = label; + } +} + +function assignDungeonRoomTypes(rooms, roomTables, connections, hullMask, rng) { + const entries = roomTables.dungeon?.rooms ?? []; + const counters = new Map(); + const roomDoorTypes = {}; + const usedKeys = new Set(); + if (!rooms.length || !entries.length) return roomDoorTypes; + + const byAreaDesc = [...rooms].sort((a, b) => (b.width * b.height) - (a.width * a.height)); + const byAreaAsc = [...rooms].sort((a, b) => (a.width * a.height) - (b.width * b.height)); + const edgeRooms = [...rooms].sort((a, b) => edgeDistance(a, hullMask) - edgeDistance(b, hullMask)); + const entranceRoom = reserveRoomEntry(entries.find((entry) => entry.key === "entrance"), edgeRooms, "entrance"); + const distances = computeRoomGraphDistances(rooms, connections, entranceRoom?.id); + const byDistanceDesc = [...rooms].sort((a, b) => (distances.get(b.id) ?? -1) - (distances.get(a.id) ?? -1)); + const byDistanceAsc = [...rooms].sort((a, b) => (distances.get(a.id) ?? 999) - (distances.get(b.id) ?? 999)); + const farLargeRooms = [...rooms] + .sort((a, b) => ((distances.get(b.id) ?? -1) * 100 + (b.width * b.height)) - ((distances.get(a.id) ?? -1) * 100 + (a.width * a.height))); + + const bossRoom = reserveRoomEntry(entries.find((entry) => entry.key === "boss-chamber"), farLargeRooms, "boss-chamber"); + const throneRoom = reserveRoomEntry(entries.find((entry) => entry.key === "throne-room"), sortRoomsByProximity(rooms, bossRoom, distances, "support"), "throne-room"); + const vaultRoom = reserveRoomEntry(entries.find((entry) => entry.key === "vault"), sortRoomsByProximity(rooms, bossRoom ?? throneRoom, distances, "far"), "vault"); + const ritualRoom = reserveRoomEntry(entries.find((entry) => entry.key === "ritual-room"), sortRoomsByProximity(rooms, bossRoom ?? throneRoom, distances, "support"), "ritual-room"); + reserveRoomEntry(entries.find((entry) => entry.key === "shrine"), sortRoomsByDistanceBand(rooms, distances, 0.45, 0.75), "shrine"); + reserveRoomEntry(entries.find((entry) => entry.key === "library"), byAreaDesc, "library"); + reserveRoomEntry(entries.find((entry) => entry.key === "laboratory"), byAreaDesc, "laboratory"); + reserveRoomEntry(entries.find((entry) => entry.key === "alchemy"), sortRoomsByDistanceBand(rooms, distances, 0.35, 0.8), "alchemy"); + reserveRoomEntry(entries.find((entry) => entry.key === "forge"), sortRoomsByDistanceBand(rooms, distances, 0.25, 0.7), "forge"); + reserveRoomEntry(entries.find((entry) => entry.key === "trophy-room"), sortRoomsByProximity(rooms, bossRoom ?? throneRoom, distances, "support"), "trophy-room"); + reserveRoomEntry(entries.find((entry) => entry.key === "cistern"), byAreaDesc, "cistern"); + reserveRoomEntry(entries.find((entry) => entry.key === "prison"), byDistanceDesc, "prison"); + reserveRoomEntry(entries.find((entry) => entry.key === "armoury"), byDistanceAsc, "armoury"); + reserveRoomEntry(entries.find((entry) => entry.key === "guardroom"), byDistanceAsc, "guardroom"); + reserveRoomEntry(entries.find((entry) => entry.key === "antechamber"), sortRoomsByProximity(rooms, entranceRoom, distances, "support"), "antechamber"); + reserveRoomEntry(entries.find((entry) => entry.key === "torture"), byDistanceDesc, "torture"); + reserveRoomEntry(entries.find((entry) => entry.key === "collapsed"), byAreaAsc, "collapsed"); + reserveRoomEntry(entries.find((entry) => entry.key === "trap-room"), byAreaAsc, "trap-room"); + + const weightedPool = entries.filter((entry) => !entry.unique && entry.key !== "void"); + for (const room of rooms) { + if (room.type) continue; + const choice = weightedChoice(weightedPool, rng); + setRoomType(room, choice); + } + + return roomDoorTypes; + + function reserveRoomEntry(entry, orderedRooms, labelKey) { + if (!entry || usedKeys.has(labelKey)) return null; + let target = orderedRooms.find((room) => !room.type && roomMeetsEntry(room, entry)); + if (!target) target = orderedRooms.find((room) => !room.type); + if (!target) return null; + setRoomType(target, entry); + usedKeys.add(labelKey); + return target; + } + + function setRoomType(room, entry) { + room.type = entry.key; + counters.set(entry.key, (counters.get(entry.key) ?? 0) + 1); + room.label = entry.numbered ? `${entry.label} ${counters.get(entry.key)}` : entry.label; + roomDoorTypes[room.id] = entry.doorType ?? "normal"; + } +} + +function sortRoomsByProximity(rooms, anchorRoom, distances, mode = "support") { + if (!anchorRoom) return [...rooms]; + const anchorCenter = roomCenter(anchorRoom); + return [...rooms].sort((a, b) => { + const distA = Math.abs(roomCenter(a).x - anchorCenter.x) + Math.abs(roomCenter(a).y - anchorCenter.y); + const distB = Math.abs(roomCenter(b).x - anchorCenter.x) + Math.abs(roomCenter(b).y - anchorCenter.y); + const scoreA = mode === "far" ? ((distances.get(a.id) ?? 0) * 20 - distA) : ((distances.get(a.id) ?? 0) * 5 - distA); + const scoreB = mode === "far" ? ((distances.get(b.id) ?? 0) * 20 - distB) : ((distances.get(b.id) ?? 0) * 5 - distB); + return scoreB - scoreA; + }); +} + +function sortRoomsByDistanceBand(rooms, distances, lowRatio = 0.3, highRatio = 0.8) { + const maxDistance = Math.max(1, ...Array.from(distances.values(), (value) => value ?? 0)); + const low = maxDistance * lowRatio; + const high = maxDistance * highRatio; + return [...rooms].sort((a, b) => { + const da = distances.get(a.id) ?? 0; + const db = distances.get(b.id) ?? 0; + const bandA = da >= low && da <= high ? 1 : 0; + const bandB = db >= low && db <= high ? 1 : 0; + if (bandA !== bandB) return bandB - bandA; + return db - da; + }); +} + +function assignBuildingRoomTypes(rooms, roomTables, subtype, rng) { + const entries = roomTables.building?.subtypes?.[subtype]?.rooms ?? []; + const counters = new Map(); + const roomDoorTypes = {}; + if (!rooms.length || !entries.length) return roomDoorTypes; + + const ordered = [...rooms].sort((a, b) => a.x - b.x || a.y - b.y); + reserve(entries.find((entry) => entry.placement === "entrance"), ordered); + reserve(entries.find((entry) => entry.key === "lobby" || entry.key === "foyer" || entry.key === "loading" || entry.key === "common-room"), ordered); + + const weightedPool = entries.filter((entry) => !entry.unique && entry.key !== "void"); + for (const room of rooms) { + if (room.type) continue; + const choice = weightedChoice(weightedPool, rng); + assign(choice, room); + } + + return roomDoorTypes; + + function reserve(entry, orderedRooms) { + if (!entry) return; + let target = orderedRooms.find((room) => !room.type && roomMeetsEntry(room, entry)); + if (!target) target = orderedRooms.find((room) => !room.type); + if (!target) return; + assign(entry, target); + } + + function assign(entry, room) { + room.type = entry.key; + counters.set(entry.key, (counters.get(entry.key) ?? 0) + 1); + room.label = entry.numbered ? `${entry.label} ${counters.get(entry.key)}` : entry.label; + roomDoorTypes[room.id] = entry.doorType ?? "normal"; + } +} + +function applyFactionTerritories(rooms, formState) { + const preset = FACTION_PRESETS[formState.factionPreset]; + if (!preset?.length) return; + for (const room of rooms) { + const match = preset.find((faction) => faction.roomTypes.includes(room.type)); + const fallback = preset[hashSeed(room.id || room.label || room.type || "room") % preset.length]; + room.faction = match?.name ?? fallback.name; + room.factionColor = match?.color ?? fallback.color; + } +} + +function assignWildernessAreaTypes(clearings, _roomTables, theme, rng) { + const themedPools = { + forest: [ + { key: "camp", label: "CAMP", weight: 2 }, + { key: "ruins", label: "RUINS", weight: 1 }, + { key: "glade", label: "GLADE", weight: 3 }, + { key: "ambush", label: "AMBUSH SITE", weight: 1 }, + { key: "water", label: "WATER EDGE", weight: 1 } + ], + jungle: [ + { key: "nest", label: "NEST", weight: 2 }, + { key: "glade", label: "JUNGLE GLADE", weight: 2 }, + { key: "ruins", label: "OVERGROWN RUINS", weight: 1 }, + { key: "ambush", label: "AMBUSH SITE", weight: 2 }, + { key: "water", label: "MUDDY POOL", weight: 1 } + ], + plains: [ + { key: "camp", label: "CAMP", weight: 2 }, + { key: "ridge", label: "RIDGE", weight: 2 }, + { key: "crossing", label: "TRAIL CROSSING", weight: 2 }, + { key: "ambush", label: "KILL ZONE", weight: 1 } + ], + swamp: [ + { key: "water", label: "MARSH POOL", weight: 3 }, + { key: "camp", label: "DRY CAMP", weight: 1 }, + { key: "ambush", label: "SUNKEN AMBUSH", weight: 2 }, + { key: "ruins", label: "SUNKEN RUINS", weight: 1 } + ], + tundra: [ + { key: "camp", label: "FROST CAMP", weight: 2 }, + { key: "ridge", label: "ICE RIDGE", weight: 2 }, + { key: "crossing", label: "SNOW TRACKS", weight: 2 }, + { key: "ambush", label: "WHITEOUT SITE", weight: 1 } + ], + badlands: [ + { key: "ridge", label: "RIDGE", weight: 3 }, + { key: "ruins", label: "SCRAP RUINS", weight: 2 }, + { key: "camp", label: "RAIDER CAMP", weight: 2 }, + { key: "ambush", label: "AMBUSH SITE", weight: 2 } + ] + }; + const pool = themedPools[theme] ?? themedPools.forest; + const counters = new Map(); + for (const area of clearings) { + const choice = weightedChoice(pool, rng); + area.type = choice.key; + counters.set(choice.key, (counters.get(choice.key) ?? 0) + 1); + area.label = counters.get(choice.key) > 1 ? `${choice.label} ${counters.get(choice.key)}` : choice.label; + } +} + +function buildWildernessFeatures(clearings, formState, rng) { + const treeClusters = []; + const waterFeatures = []; + const encounterZones = []; + const wallSegments = []; + const ridgelines = []; + const ravines = []; + + for (const area of clearings) { + const center = roomCenter(area); + const clusterCount = Math.max(2, Math.round((area.width * area.height) / 8)); + if (!["plains", "tundra"].includes(formState.theme)) { + treeClusters.push({ + x: center.x, + y: center.y, + radius: Math.max(1.2, Math.min(area.width, area.height) * 0.4), + count: clusterCount + }); + } + if (area.type === "water" || (formState.theme === "swamp" && rng() > 0.55)) { + waterFeatures.push({ + x: center.x, + y: center.y, + width: Math.max(2, Math.round(area.width * 0.7)), + height: Math.max(2, Math.round(area.height * 0.45)) + }); + } + if (["ambush", "nest", "ruins", "camp"].includes(area.type) && rng() > 0.35) { + encounterZones.push({ + roomId: area.id, + x: center.x, + y: center.y, + radius: Math.max(1.5, Math.min(area.width, area.height) * 0.35), + label: area.label + }); + } + if (formState.hullShape === "camp" && area.type === "camp") { + wallSegments.push(...rectangleSegments(area.x, area.y, area.width, area.height)); + } + + const elevationChance = ["ridge", "crossing", "ambush"].includes(area.type) ? 0.75 : 0.28; + if (rng() < elevationChance) { + const feature = buildElevationFeature(area, rng, ["badlands", "tundra"].includes(formState.theme) ? "ridge" : (rng() > 0.52 ? "ridge" : "ravine")); + if (feature) { + if (feature.kind === "ridge") ridgelines.push(feature); + else ravines.push(feature); + wallSegments.push(...feature.segments); + } + } + } + + return { treeClusters, waterFeatures, encounterZones, wallSegments, ridgelines, ravines }; +} + +function buildElevationFeature(area, rng, preferredKind = "ridge") { + const horizontal = area.width >= area.height ? rng() > 0.35 : rng() > 0.65; + const marginX = Math.max(1, Math.floor(area.width * 0.15)); + const marginY = Math.max(1, Math.floor(area.height * 0.15)); + const x1 = area.x + marginX; + const x2 = area.x + area.width - marginX; + const y1 = area.y + marginY; + const y2 = area.y + area.height - marginY; + if (horizontal) { + const y = clampInt(area.y + Math.floor(area.height / 2), area.y + 1, area.y + area.height - 1, area.y + 1); + return { + kind: preferredKind, + x1, + y1: y, + x2, + y2: y, + segments: segmentedBarrier(x1, y, x2, y, preferredKind) + }; + } + const x = clampInt(area.x + Math.floor(area.width / 2), area.x + 1, area.x + area.width - 1, area.x + 1); + return { + kind: preferredKind, + x1: x, + y1, + x2: x, + y2, + segments: segmentedBarrier(x, y1, x, y2, preferredKind) + }; +} + +function segmentedBarrier(x1, y1, x2, y2, terrain = "ridge") { + const out = []; + if (y1 === y2) { + const start = Math.min(x1, x2); + const end = Math.max(x1, x2); + for (let x = start; x < end; x += 1) { + out.push({ + x1: x, + y1, + x2: x + 1, + y2, + movementOnly: true, + terrain + }); + } + return out; + } + + const start = Math.min(y1, y2); + const end = Math.max(y1, y2); + for (let y = start; y < end; y += 1) { + out.push({ + x1, + y1: y, + x2, + y2: y + 1, + movementOnly: true, + terrain + }); + } + return out; +} + +function roomMeetsEntry(room, entry) { + const [minW, minH] = entry.minSize ?? [1, 1]; + return room.width >= minW && room.height >= minH; +} + +function edgeDistance(room, hullMask) { + let best = Number.POSITIVE_INFINITY; + for (let y = room.y; y < room.y + room.height; y += 1) { + for (let x = room.x; x < room.x + room.width; x += 1) { + const distance = Number(!hullMask[y - 1]?.[x]) + Number(!hullMask[y + 1]?.[x]) + Number(!hullMask[y]?.[x - 1]) + Number(!hullMask[y]?.[x + 1]); + if (distance > 0) return 0; + best = Math.min(best, x + y); + } + } + return best; +} + +function computeRoomGraphDistances(rooms, connections, sourceId) { + const adjacency = new Map(rooms.map((room) => [room.id, new Set()])); + for (const [a, b] of connections) { + adjacency.get(a)?.add(b); + adjacency.get(b)?.add(a); + } + const distances = new Map(); + if (!sourceId) return distances; + const queue = [[sourceId, 0]]; + distances.set(sourceId, 0); + while (queue.length) { + const [current, distance] = queue.shift(); + for (const next of adjacency.get(current) ?? []) { + if (distances.has(next)) continue; + distances.set(next, distance + 1); + queue.push([next, distance + 1]); + } + } + return distances; +} + +function injectSecretDoors(wallSegments, rng, densityKey = "minimal") { + const chance = SECRET_DOOR_CHANCE[densityKey] ?? 0.01; + if (chance <= 0) return wallSegments; + return wallSegments.map((segment) => { + if (segment.door || segment.isWindow) return segment; + const length = Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + if (length !== 1) return segment; + if (rng() >= chance) return segment; + return { ...segment, door: DOOR_SECRET, ds: DOOR_STATE_LOCKED }; + }); +} + +function injectHorrorDecay(wallSegments, _rooms, rng, theme) { + if (!["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(theme)) return wallSegments; + return wallSegments.map((segment) => { + if (segment.door || segment.isWindow) return segment; + const length = Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + if (length !== 1) return segment; + const roll = rng(); + if (roll < 0.12) return { ...segment, door: DOOR_BASIC, ds: DOOR_STATE_LOCKED }; + if (roll < 0.18) return { ...segment, door: DOOR_BASIC, ds: DOOR_STATE_OPEN }; + return segment; + }); +} + +function applyBulkheadUpgrades(wallSegments, rooms) { + if (!rooms?.length) return wallSegments; + return wallSegments.map((seg) => { + if (!seg.door) return seg; + const touchedRooms = rooms.filter((r) => segmentTouchesRoom(seg, r)); + const types = touchedRooms.map((r) => r.type); + const isBulkhead = BULKHEAD_PAIRS.some(([a, b]) => + (types.includes(a) && types.includes(b)) || + types.includes("reactor") || types.includes("weapon-bay") + ); + if (!isBulkhead) return seg; + return { ...seg, ds: DOOR_STATE_LOCKED, isBulkhead: true }; + }); +} + +function buildBulkheadSvg(wallSegments, gridSize, palette) { + const bulkheads = wallSegments.filter((s) => s.isBulkhead); + if (!bulkheads.length) return ""; + return bulkheads.map((seg) => { + const x1 = seg.x1 * gridSize, y1 = seg.y1 * gridSize; + const x2 = seg.x2 * gridSize, y2 = seg.y2 * gridSize; + const isH = y1 === y2; + const off = 3; + return `` + + `` + + ``; + }).join(""); +} + +function nearestRoomEdgeCell(room, target) { + const tc = roomCenter(target); + const edgeCells = [ + { x: room.x, y: Math.floor(room.y + room.height / 2) }, + { x: room.x + room.width - 1, y: Math.floor(room.y + room.height / 2) }, + { x: Math.floor(room.x + room.width / 2), y: room.y }, + { x: Math.floor(room.x + room.width / 2), y: room.y + room.height - 1 } + ]; + return edgeCells.sort((a, b) => + (Math.abs(a.x - tc.x) + Math.abs(a.y - tc.y)) - (Math.abs(b.x - tc.x) + Math.abs(b.y - tc.y)) + )[0]; +} + +function traceMaintenanceShaft(src, dest) { + const srcEdge = nearestRoomEdgeCell(src, dest); + const dstEdge = nearestRoomEdgeCell(dest, src); + const segments = []; + const hatchPoints = []; + + // L-shaped route: horizontal then vertical + const midX = srcEdge.x; + const x1 = Math.min(srcEdge.x, midX), x2 = Math.max(srcEdge.x, midX); + for (let x = x1; x <= x2; x++) segments.push({ x, y: srcEdge.y, width: 1, height: 1 }); + const y1 = Math.min(srcEdge.y, dstEdge.y), y2 = Math.max(srcEdge.y, dstEdge.y); + for (let y = y1; y <= y2; y++) segments.push({ x: midX, y, width: 1, height: 1 }); + + hatchPoints.push({ x: srcEdge.x, y: srcEdge.y, roomId: src.id, label: "ACCESS HATCH" }); + hatchPoints.push({ x: dstEdge.x, y: dstEdge.y, roomId: dest.id, label: "ACCESS HATCH" }); + + return { id: `shaft-${src.id}-${dest.id}`, segments, hatchPoints, fromRoom: src.id, toRoom: dest.id }; +} + +function buildMaintenanceShafts(deck, formState, rng) { + if (!["bsp", "corridor"].includes(formState.mode)) return []; + if (formState.maintenanceShaftDensity === "none") return []; + + const densityChance = { sparse: 0.25, normal: 0.5, dense: 0.8 }[formState.maintenanceShaftDensity ?? "sparse"] ?? 0.3; + const sourceRooms = deck.rooms.filter((r) => MAINTENANCE_SHAFT_SOURCES.has(r.type)); + const destRooms = deck.rooms.filter((r) => MAINTENANCE_SHAFT_DESTINATIONS.has(r.type)); + if (!sourceRooms.length || !destRooms.length) return []; + + const shafts = []; + const usedPairs = new Set(); + + for (const src of sourceRooms) { + if (rng() > densityChance) continue; + const candidates = destRooms + .filter((d) => d.id !== src.id) + .sort((a, b) => { + const ca = roomCenter(src); + const da = Math.abs(roomCenter(a).x - ca.x) + Math.abs(roomCenter(a).y - ca.y); + const db = Math.abs(roomCenter(b).x - ca.x) + Math.abs(roomCenter(b).y - ca.y); + return da - db; + }); + const dest = candidates[0]; + if (!dest) continue; + + const pairKey = [src.id, dest.id].sort().join("|"); + if (usedPairs.has(pairKey)) continue; + usedPairs.add(pairKey); + + const shaft = traceMaintenanceShaft(src, dest); + if (shaft) shafts.push(shaft); + } + + return shafts; +} + +function buildMaintenanceShaftDrawings(deck, formState) { + const palette = paletteForFormState(formState); + const shaftFill = palette.maintenanceFill ?? "#1a1a2a"; + const drawings = []; + for (const shaft of deck.maintenanceShafts ?? []) { + for (const seg of shaft.segments) { + drawings.push({ + x: seg.x * formState.gridSize, + y: seg.y * formState.gridSize, + z: 5, + hidden: true, + locked: true, + fillType: 1, + fillColor: shaftFill, + fillAlpha: 0.95, + strokeWidth: 1, + strokeColor: palette.line, + strokeAlpha: 0.5, + shape: { type: "r", width: formState.gridSize, height: formState.gridSize }, + flags: { [MODULE_ID]: { generatedDrawing: true, isMaintenanceShaft: true } } + }); + } + } + return drawings; +} + +function buildCellPerimeterWalls(cx, cy) { + // Returns 4 wall segments (in cell coords) forming a 1×1 cell perimeter + return [ + { x1: cx, y1: cy, x2: cx+1, y2: cy }, + { x1: cx, y1: cy+1, x2: cx+1, y2: cy+1 }, + { x1: cx, y1: cy, x2: cx, y2: cy+1 }, + { x1: cx+1, y1: cy, x2: cx+1, y2: cy+1 } + ]; +} + +function buildMaintenanceShaftWalls(deck, formState) { + const gs = formState.gridSize; + const walls = []; + for (const shaft of deck.maintenanceShafts ?? []) { + for (const seg of shaft.segments) { + for (const w of buildCellPerimeterWalls(seg.x, seg.y)) { + walls.push({ + c: [w.x1 * gs, w.y1 * gs, w.x2 * gs, w.y2 * gs], + move: MOVEMENT_NORMAL, + sight: SENSE_NORMAL, + sound: SENSE_NORMAL, + light: SENSE_NORMAL, + dir: CONST.WALL_DIRECTIONS?.BOTH ?? 0, + door: DOOR_NONE, + hidden: true, + flags: { [MODULE_ID]: { generatedWall: true, isMaintenanceShaft: true } } + }); + } + } + // Hatch doors (secret/locked) on room walls at shaft entry points + for (const hatch of shaft.hatchPoints) { + walls.push({ + c: [hatch.x * gs, hatch.y * gs, (hatch.x + 1) * gs, hatch.y * gs], + move: MOVEMENT_NORMAL, + sight: SENSE_NORMAL, + sound: SENSE_NORMAL, + light: SENSE_NORMAL, + dir: CONST.WALL_DIRECTIONS?.BOTH ?? 0, + door: DOOR_SECRET, + ds: DOOR_STATE_LOCKED, + hidden: true, + flags: { [MODULE_ID]: { generatedWall: true, isMaintenanceShaft: true, isHatch: true } } + }); + } + } + return walls; +} + +function buildBuildingExteriorOpenings(hullMask, rng, subtype) { + const hullWalls = buildHullWallSegments(hullMask); + if (!hullWalls.length) return []; + const hull = boundingRectFromRegions([{ x: 1, y: 1, width: hullMask[0]?.length - 2, height: hullMask.length - 2 }]); + const bySide = classifyBuildingHullSides(hullWalls, hull); + const config = getBuildingExteriorConfig(subtype); + const chosen = []; + const used = new Set(); + + addOpenings(pickSideCandidates(bySide[config.front] ?? [], config.frontBias), config.frontDoors, "door", (segment) => ({ + ...collapseSegmentToSingleCell(segment), + door: DOOR_BASIC, + ds: DOOR_STATE_CLOSED + })); + + addOpenings(pickSideCandidates(bySide[config.secondaryDoorSide] ?? [], "center"), config.secondaryDoors, "door", (segment) => ({ + ...collapseSegmentToSingleCell(segment), + door: DOOR_BASIC, + ds: DOOR_STATE_CLOSED + })); + + const windowSides = config.windowSides.flatMap((side) => pickSideCandidates(bySide[side] ?? [], side === config.front ? "spread" : "edge")); + addOpenings(windowSides, config.windows, "window", (segment) => ({ + ...collapseSegmentToSingleCell(segment), + isWindow: true + })); + + return chosen; + + function addOpenings(candidates, count, type, mapFn) { + let added = 0; + for (const segment of candidates) { + if (added >= count) break; + const opening = mapFn(segment); + if (!opening) continue; + const key = segmentKey(opening); + if (used.has(key)) continue; + used.add(key); + chosen.push(opening); + added += 1; + } + } +} + +function classifyBuildingHullSides(hullWalls, hull) { + const out = { north: [], south: [], east: [], west: [] }; + for (const segment of hullWalls) { + if (segment.y1 === segment.y2) { + if (segment.y1 <= hull.y) out.north.push(segment); + else if (segment.y1 >= hull.y + hull.height) out.south.push(segment); + } else if (segment.x1 === segment.x2) { + if (segment.x1 <= hull.x) out.west.push(segment); + else if (segment.x1 >= hull.x + hull.width) out.east.push(segment); + } + } + for (const key of Object.keys(out)) { + out[key].sort((a, b) => (a.x1 + a.x2 + a.y1 + a.y2) - (b.x1 + b.x2 + b.y1 + b.y2)); + } + return out; +} + +function getBuildingExteriorConfig(subtype) { + switch (subtype) { + case "warehouse": + return { front: "south", secondaryDoorSide: "west", frontDoors: 2, secondaryDoors: 1, windows: 2, windowSides: ["north", "east"], frontBias: "center" }; + case "tavern": + return { front: "south", secondaryDoorSide: "east", frontDoors: 2, secondaryDoors: 1, windows: 5, windowSides: ["south", "east", "west"], frontBias: "center" }; + case "mansion": + return { front: "south", secondaryDoorSide: "north", frontDoors: 2, secondaryDoors: 1, windows: 8, windowSides: ["north", "south", "east", "west"], frontBias: "center" }; + case "horror": + return { front: "south", secondaryDoorSide: "west", frontDoors: 1, secondaryDoors: 1, windows: 4, windowSides: ["north", "east", "west"], frontBias: "center" }; + case "residential": + return { front: "south", secondaryDoorSide: "north", frontDoors: 1, secondaryDoors: 1, windows: 6, windowSides: ["south", "east", "west"], frontBias: "center" }; + case "office": + default: + return { front: "south", secondaryDoorSide: "east", frontDoors: 1, secondaryDoors: 1, windows: 6, windowSides: ["north", "south", "east", "west"], frontBias: "center" }; + } +} + +function pickSideCandidates(segments, bias = "center") { + if (!segments.length) return []; + const sorted = [...segments]; + if (bias === "center") { + const centerScore = averageSegmentPosition(sorted); + sorted.sort((a, b) => Math.abs(segmentPosition(a) - centerScore) - Math.abs(segmentPosition(b) - centerScore)); + return sorted; + } + if (bias === "edge") { + return sorted; + } + if (bias === "spread") { + return spreadSegments(sorted); + } + return sorted; +} + +function spreadSegments(segments) { + if (segments.length <= 2) return segments; + const result = []; + const sorted = [...segments]; + let left = 0; + let right = sorted.length - 1; + while (left <= right) { + if (left === right) { + result.push(sorted[left]); + break; + } + result.push(sorted[left], sorted[right]); + left += 1; + right -= 1; + } + return result; +} + +function segmentPosition(segment) { + return segment.x1 === segment.x2 ? (segment.y1 + segment.y2) / 2 : (segment.x1 + segment.x2) / 2; +} + +function averageSegmentPosition(segments) { + return segments.reduce((sum, segment) => sum + segmentPosition(segment), 0) / Math.max(1, segments.length); +} + +function applyDoorTypesToPartitions(segments, rooms, roomDoorTypes) { + if (!segments.length) return segments; + return segments.map((segment) => { + if (!segment.door || segment.isWindow) return segment; + const room = rooms.find((candidate) => segmentTouchesRoom(segment, candidate)); + if (!room) return segment; + const doorType = roomDoorTypes[room.id] ?? "normal"; + if (doorType === "locked") return { ...segment, ds: DOOR_STATE_LOCKED }; + if (doorType === "none") return { ...segment, door: false, ds: undefined }; + return segment; + }); +} + +function segmentTouchesRoom(segment, room) { + if (segment.x1 === segment.x2) { + return segment.x1 >= room.x && segment.x1 <= room.x + room.width + && Math.max(segment.y1, room.y) < Math.min(segment.y2, room.y + room.height); + } + return segment.y1 >= room.y && segment.y1 <= room.y + room.height + && Math.max(segment.x1, room.x) < Math.min(segment.x2, room.x + room.width); +} + +function segmentLength(segment) { + return Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); +} + +function countDoors(segments = []) { + return segments.filter((segment) => Boolean(segment.door)).length; +} + +function collapseSegmentToSingleCell(segment) { + if (segment.x1 === segment.x2) { + const y = Math.min(segment.y1, segment.y2); + return { x1: segment.x1, y1: y, x2: segment.x2, y2: y + 1 }; + } + const x = Math.min(segment.x1, segment.x2); + return { x1: x, y1: segment.y1, x2: x + 1, y2: segment.y2 }; +} + +async function createFoundryScenes(deckPlan, formState) { + const currentScene = game.scenes?.viewed ?? canvas.scene ?? null; + const connectorJournal = await ensureConnectorJournal(deckPlan, formState); + const connectorPages = new Map((connectorJournal?.pages?.contents ?? []).map((page) => [page.name, page.id])); + const rootFolder = await ensureSceneFolder(ROOT_SCENE_FOLDER); + const target = resolveRegenerationTarget(formState, deckPlan, currentScene); + const furnitureTables = await getFurnitureTables().catch(() => null); + const shipFolder = target.folder ?? await Folder.create({ + name: deckPlan.shipName, + type: "Scene", + color: "#2a4060", + folder: rootFolder.id + }).catch(async () => game.folders.find((folder) => folder.type === "Scene" && folder.name === deckPlan.shipName && folder.folder?.id === rootFolder.id)); + + const scenes = []; + for (let index = 0; index < deckPlan.decks.length; index += 1) { + const deck = deckPlan.decks[index]; + await prepareDeckGenerationArtifacts(deck, formState, furnitureTables, deckPlan); + const existingScene = target.scenes[index] ?? null; + let scene = existingScene; + if (existingScene) { + await regenerateSceneInPlace(existingScene, deckPlan, deck, formState, connectorJournal, connectorPages); + } else { + scene = await Scene.create({ + name: `${deckPlan.shipName} — ${deck.deckName}`, + folder: shipFolder?.id ?? rootFolder.id, + width: deck.columns * formState.gridSize, + height: deck.rows * formState.gridSize, + padding: 0, + tokenVision: true, + fogExploration: false, + grid: { + type: resolveSceneGridType(formState.gridMode), + size: formState.gridSize, + distance: 1, + units: "meter" + }, + backgroundColor: paletteForFormState(formState).base, + background: deck.backgroundSrc ? { src: deck.backgroundSrc } : undefined, + thumb: deck.backgroundSrc ?? undefined + }); + + await populateSceneWithDeck(scene, deckPlan, deck, formState, connectorJournal, connectorPages); + } + + scenes.push(scene); + } + + if (formState.generateMissionBrief) { + await JournalEntry.create({ + name: `${deckPlan.shipName} — Mission Brief`, + pages: [{ + name: "Brief", + type: "text", + text: { + content: buildMissionBriefContent(deckPlan, formState), + format: 1 + } + }] + }).catch(() => null); + } + + return scenes; +} + +function resolveRegenerationTarget(formState, deckPlan, currentScene) { + const mode = formState.regenerateTarget === "none" && formState.regenerateInPlace ? "viewed" : formState.regenerateTarget; + if (mode === "viewed" && deckPlan.decks.length === 1 && currentScene) { + return { scenes: [currentScene], folder: currentScene.folder ?? null }; + } + if (mode === "viewed-folder" && currentScene?.folder) { + return { + scenes: getSortedScenesForFolder(currentScene.folder).slice(0, deckPlan.decks.length), + folder: currentScene.folder + }; + } + if (mode === "folder" && formState.targetSceneFolder) { + const folder = game.folders?.get(formState.targetSceneFolder) ?? null; + if (folder?.type === "Scene") { + return { + scenes: getSortedScenesForFolder(folder).slice(0, deckPlan.decks.length), + folder + }; + } + } + return { scenes: [], folder: null }; +} + +function resolveSceneGridType(gridMode) { + if (gridMode === "hex-row") return CONST.GRID_TYPES.HEXODDR; + return CONST.GRID_TYPES.SQUARE; +} + +function getSortedScenesForFolder(folder) { + return (game.scenes?.contents ?? []) + .filter((scene) => scene.folder?.id === folder.id) + .sort((a, b) => (a.sort - b.sort) || a.name.localeCompare(b.name)); +} + +async function prepareDeckGenerationArtifacts(deck, formState, furnitureTables, deckPlan) { + if (furnitureTables) { + const furnitureSeed = hashSeed(`${formState.seed}:${deck.deckIndex}:${formState.mode}:${formState.furnitureDensity}`); + deck.furniture = buildFurnitureForDeck(deck, formState, furnitureTables, mulberry32(furnitureSeed)); + } else { + deck.furniture = []; + } + + // Generate maintenance shafts (hidden crawlway network between key rooms) + const shaftSeed = hashSeed(`${formState.seed}:${deck.deckIndex}:shafts`); + deck.maintenanceShafts = buildMaintenanceShafts(deck, formState, mulberry32(shaftSeed)); + + deck.backgroundSrc = await createDeckBackgroundAsset(deckPlan, deck, formState); + deck.gmOverlaySrc = formState.createGmOverlay ? await createGmOverlayAsset(deckPlan, deck, formState) : null; +} + +async function populateSceneWithDeck(scene, deckPlan, deck, formState, connectorJournal, connectorPages) { + const drawings = sanitizeGeneratedDrawings([ + ...buildRoomFloorDrawings(deck, formState), + ...buildCorridorFloorDrawings(deck, formState), + ...buildFurnitureDrawings(deck, formState), + ...buildHullOutlineDrawings(deck, formState) + ]); + if (drawings.length) { + await scene.createEmbeddedDocuments("Drawing", drawings.map((drawing) => withGeneratedDrawingFlag(drawing))); + } + + const gmOverlayTile = buildGmOverlayTile(deck, formState); + if (gmOverlayTile) { + await scene.createEmbeddedDocuments("Tile", [withGeneratedTileFlag(gmOverlayTile)]); + } + + const walls = deck.wallSegments.map((segment) => wallSegmentToDocument(segment, formState.gridSize)); + if (walls.length) { + await scene.createEmbeddedDocuments("Wall", walls.map((wall) => withGeneratedWallFlag(wall))); + } + + const furnitureWalls = buildFurnitureOcclusionWalls(deck, formState); + if (furnitureWalls.length) { + await scene.createEmbeddedDocuments("Wall", furnitureWalls); + } + + const notes = [ + ...buildDeckConnectorNotes(deck, formState, connectorJournal?.id, connectorPages), + ...buildRoomZoneNotes(deck, formState) + ]; + if (notes.length) { + await scene.createEmbeddedDocuments("Note", notes.map((note) => withGeneratedNoteFlag(note))); + } + + if (formState.placeLights) { + const lights = buildAmbientLights(deck, formState); + if (lights.length) { + await scene.createEmbeddedDocuments("AmbientLight", lights.map((light) => withGeneratedLightFlag(light))); + } + } + + if (formState.seedEncounters) { + const tokens = await buildEncounterTokens(deck, formState); + if (tokens.length) { + await scene.createEmbeddedDocuments("Token", tokens.map((token) => withGeneratedTokenFlag(token))); + } + } + + if (formState.populateRoomsFromCompendium && formState.roomPopulationPack) { + const roomTokens = await buildRoomPopulationTokens(deck, formState); + if (roomTokens.length) { + await scene.createEmbeddedDocuments("Token", roomTokens.map((token) => withGeneratedTokenFlag(token))); + } + } + + if (formState.placeRegions) { + try { + const regions = buildRegionDocuments(deck, formState); + if (regions.length) { + await scene.createEmbeddedDocuments("Region", regions); + } + } catch (_err) { + // Region documents are v13+ — silently skip on older Foundry versions + } + } + + // Maintenance shaft walls (hidden until GM reveals) + const shaftWalls = buildMaintenanceShaftWalls(deck, formState); + if (shaftWalls.length) { + await scene.createEmbeddedDocuments("Wall", shaftWalls.map((w) => withGeneratedWallFlag(w))); + } + + // Maintenance shaft floor drawings (hidden until GM reveals) + const shaftDrawings = buildMaintenanceShaftDrawings(deck, formState); + if (shaftDrawings.length) { + await scene.createEmbeddedDocuments("Drawing", shaftDrawings.map((d) => withGeneratedDrawingFlag(d))); + } + + applyGmOverlayVisibility(scene); +} + +function buildRegionDocuments(deck, formState) { + const gs = formState.gridSize; + const regions = []; + for (const room of deck.rooms) { + const config = REGION_CONFIG[room.type]; + if (!config) continue; + const pts = room.points + ? room.points.flatMap((p) => [p.x * gs, p.y * gs]) + : [ + room.x * gs, room.y * gs, + (room.x + room.width) * gs, room.y * gs, + (room.x + room.width) * gs, (room.y + room.height) * gs, + room.x * gs, (room.y + room.height) * gs + ]; + regions.push({ + name: room.label ? `Region: ${room.label}` : `Region (${room.type})`, + color: config.color, + visibility: config.gmOnly ? 10 : 0, + shapes: [{ type: "polygon", points: pts }], + behaviors: config.behaviors ?? [], + flags: { [MODULE_ID]: { generatedRegion: true, roomType: room.type } } + }); + } + return regions; +} + +function buildGmOverlayTile(deck, formState) { + if (!formState.createGmOverlay || !deck.gmOverlaySrc) return null; + return { + x: 0, + y: 0, + z: 1000, + width: deck.columns * formState.gridSize, + height: deck.rows * formState.gridSize, + rotation: 0, + hidden: true, + locked: true, + texture: { + src: deck.gmOverlaySrc + }, + flags: { + [MODULE_ID]: { + gmOverlay: true, + deckIndex: deck.deckIndex, + overlayType: "gm-room-overlay" + } + } + }; +} + +async function regenerateSceneInPlace(scene, deckPlan, deck, formState, connectorJournal, connectorPages) { + const furnitureTables = await getFurnitureTables().catch(() => null); + await prepareDeckGenerationArtifacts(deck, formState, furnitureTables, deckPlan); + await deleteGeneratedDocumentsInternal(scene); + await scene.update({ + name: `${deckPlan.shipName} — ${deck.deckName}`, + width: deck.columns * formState.gridSize, + height: deck.rows * formState.gridSize, + padding: 0, + tokenVision: true, + fogExploration: false, + grid: { + type: resolveSceneGridType(formState.gridMode), + size: formState.gridSize, + distance: 1, + units: "meter" + }, + backgroundColor: paletteForFormState(formState).base, + background: deck.backgroundSrc ? { src: deck.backgroundSrc } : null, + thumb: deck.backgroundSrc ?? null + }); + await populateSceneWithDeck(scene, deckPlan, deck, formState, connectorJournal, connectorPages); +} + +async function ensureConnectorJournal(deckPlan, formState) { + if (deckPlan.decks.length < 2 || !["dungeon", "building"].includes(formState.mode)) return null; + const pages = []; + for (const deck of deckPlan.decks) { + for (const connector of deck.deckConnectors ?? []) { + pages.push({ + name: connectorNoteKey(deck, connector), + type: "text", + text: { + content: `

${escapeXml(connector.label)}

${escapeXml(connector.access ?? "")}

${escapeXml(buildConnectorNoteCopy(formState.mode, deck, connector))}

` + } + }); + } + } + return JournalEntry.create({ + name: `${deckPlan.shipName} Level Connections`, + pages + }).catch(() => null); +} + +function buildDeckConnectorNotes(deck, formState, entryId, connectorPages) { + if (!entryId || !["dungeon", "building"].includes(formState.mode)) return []; + return (deck.deckConnectors ?? []).map((connector) => ({ + entryId, + pageId: connectorPages.get(connectorNoteKey(deck, connector)), + x: round((connector.x + 0.5) * formState.gridSize), + y: round((connector.y + 0.5) * formState.gridSize), + iconSize: Math.max(36, Math.round(formState.gridSize * 0.55)), + text: connector.label, + hidden: true, + texture: { + src: "icons/svg/book.svg" + }, + flags: { + [MODULE_ID]: { + gmOverlay: true, + connectorId: connector.id, + deckIndex: deck.deckIndex, + regionKind: "connector", + noteText: buildConnectorNoteCopy(formState.mode, deck, connector) + } + } + })).filter((note) => note.pageId); +} + +function buildRoomZoneNotes(deck, formState) { + if (!formState.createRoomNotes) return []; + const notes = deck.rooms.map((room) => { + const center = roomCenter(room); + const bounds = roomBounds(room); + return ({ + x: round(center.x * formState.gridSize), + y: round(center.y * formState.gridSize), + iconSize: Math.max(24, Math.round(formState.gridSize * 0.35)), + text: room.label || humanizeRoomType(room.type) || "Zone", + texture: { + src: "icons/svg/circle.svg" + }, + hidden: true, + flags: { + [MODULE_ID]: { + gmOverlay: true, + roomId: room.id, + roomType: room.type ?? "zone", + roomLabel: room.label ?? "", + deckIndex: deck.deckIndex, + regionKind: "room", + noteText: buildAutomaticRoomNote(deck, room), + bounds: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }, + markers: room.markers ?? [] + } + } + })}); + + if (deck.wilderness?.encounterZones?.length) { + notes.push(...deck.wilderness.encounterZones.map((zone, index) => ({ + x: round(zone.x * formState.gridSize), + y: round(zone.y * formState.gridSize), + iconSize: Math.max(28, Math.round(formState.gridSize * 0.4)), + text: zone.label || `Encounter Zone ${index + 1}`, + texture: { + src: "icons/svg/target.svg" + }, + hidden: true, + flags: { + [MODULE_ID]: { + gmOverlay: true, + deckIndex: deck.deckIndex, + regionKind: "encounter-zone", + roomLabel: zone.label ?? "", + noteText: `Encounter zone on ${deck.deckName}. Radius ${zone.radius} cells.`, + bounds: { + x: zone.x - zone.radius, + y: zone.y - zone.radius, + width: zone.radius * 2, + height: zone.radius * 2 + }, + radius: zone.radius + } + } + }))); + } + + return notes; +} + +function connectorNoteKey(deck, connector) { + return `${deck.deckIndex}:${connector.id}`; +} + +function buildConnectorNoteCopy(mode, deck, connector) { + if (mode === "dungeon") { + return `${connector.label} links ${deck.deckName} to ${connector.access || "the adjoining level"}. Use this point for vertical movement between dungeon levels.`; + } + return `${connector.label} links ${deck.deckName} to ${connector.access || "the adjoining floor"}. Use this point for stairwell movement between building floors.`; +} + +async function exportMothershipViewerJson(deckPlan) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return; + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const json = JSON.stringify(buildMothershipMapViewerJSON(deckPlan), null, 2); + const filename = `${slugify(deckPlan.shipName)}-${Date.now()}-mosh-map.json`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([json], filename, { type: "application/json" }); + await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); +} + +async function exportMapMetadataJson(deckPlan, formState, scenes = []) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return null; + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const filename = `${slugify(deckPlan.shipName)}-${Date.now()}-metadata.json`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const payload = JSON.stringify(buildMapMetadata(deckPlan, formState, scenes), null, 2); + const file = new File([payload], filename, { type: "application/json" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + const path = response?.path ?? `${targetDir}/${filename}`; + for (let index = 0; index < scenes.length; index += 1) { + await scenes[index].update({ + [`flags.${MODULE_ID}.metadataExport`]: { + path, + deckIndex: index, + mode: formState.mode, + theme: formState.theme + } + }).catch(() => {}); + } + return path; +} + +async function createMissionPacket(deckPlan, formState, scenes = [], metadataPath = null) { + const html = buildMissionPacketHtml(deckPlan, formState, scenes, metadataPath); + const exportedPath = await exportMissionPacketHtml(deckPlan, html).catch(() => null); + await JournalEntry.create({ + name: `${deckPlan.shipName} — Mission Packet`, + pages: [{ + name: "Packet", + type: "text", + text: { + content: html, + format: 1 + } + }] + }).catch(() => null); + if (exportedPath) { + ui.notifications.info(`Mission packet exported to ${exportedPath}.`); + } + return exportedPath; +} + +async function exportMissionPacketHtml(deckPlan, html) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return null; + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const filename = `${slugify(deckPlan.shipName)}-${Date.now()}-mission-packet.html`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([html], filename, { type: "text/html" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + return response?.path ?? `${targetDir}/${filename}`; +} + +function buildMissionPacketHtml(deckPlan, formState, scenes = [], metadataPath = null) { + const brief = buildMissionBriefContent(deckPlan, formState); + const previewCards = deckPlan.decks.map((deck, index) => { + const scene = scenes[index]; + const sceneName = scene?.name ?? `${deckPlan.shipName} — ${deck.deckName}`; + const imagePath = scene?.background?.src ?? deck.backgroundSrc ?? ""; + return ` +
+

${escapeXml(sceneName)}

+ ${imagePath ? `${escapeXml(sceneName)} thumbnail` : ""} +

Rooms: ${deck.rooms.length} Corridors: ${deck.corridors.length} Doors: ${countDoors(deck.wallSegments)}

+

${escapeXml(summarizeDeckThreats(deck))}

+
+ `; + }).join(""); + const summaries = deckPlan.decks.map((deck) => ` + + ${escapeXml(deck.deckName)} + ${deck.rooms.length} + ${deck.corridors.length} + ${countDoors(deck.wallSegments)} + ${(deck.wilderness?.encounterZones?.length ?? deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)).length)} + + `).join(""); + return ` + +
+ ${brief} +
+ Metadata export: ${escapeXml(metadataPath ?? "Not exported")}
+ Encounter source: ${escapeXml(formState.encounterPack || resolveEncounterPreset(formState).key)}
+ Generated scenes: ${scenes.length} +
+

Scene List & Preview

+
${previewCards}
+

Metadata Summary

+ + + ${summaries} +
DeckRoomsCorridorsDoorsThreat Nodes
+
+ `.replace(/\n\s+/g, ""); +} + +function buildMapMetadata(deckPlan, formState, scenes = []) { + return { + module: MODULE_ID, + version: game.modules?.get(MODULE_ID)?.version ?? "dev", + name: deckPlan.shipName, + seed: deckPlan.seed, + mode: formState.mode, + theme: formState.theme, + missionTemplate: formState.missionTemplate, + encounterFaction: resolveEncounterPreset(formState).key, + encounterPack: formState.encounterPack || null, + gridSize: formState.gridSize, + decks: deckPlan.decks.map((deck, index) => ({ + deckIndex: deck.deckIndex, + deckName: deck.deckName, + sceneId: scenes[index]?.id ?? null, + sceneName: scenes[index]?.name ?? null, + rooms: deck.rooms.map((room) => ({ + id: room.id, + label: room.label ?? "", + type: room.type ?? "", + faction: room.faction ?? "", + factionColor: room.factionColor ?? "", + x: room.x, + y: room.y, + width: room.width, + height: room.height, + shape: room.shape ?? "rect", + markers: room.markers ?? [] + })), + corridors: (deck.corridors ?? []).map((corridor) => ({ + x: corridor.x, + y: corridor.y, + width: corridor.width, + height: corridor.height, + waypoints: corridor.waypoints ?? [] + })), + doors: (deck.wallSegments ?? []).filter((segment) => segment.door).map((segment) => ({ + x1: segment.x1, + y1: segment.y1, + x2: segment.x2, + y2: segment.y2, + state: segment.ds ?? DOOR_STATE_CLOSED, + secret: segment.door === DOOR_SECRET + })), + walls: (deck.wallSegments ?? []).filter((segment) => !segment.door).map((segment) => ({ + x1: segment.x1, + y1: segment.y1, + x2: segment.x2, + y2: segment.y2, + isWindow: Boolean(segment.isWindow), + movementOnly: Boolean(segment.movementOnly), + terrain: segment.terrain ?? null + })), + wilderness: deck.wilderness ?? null + })) + }; +} + +function buildMothershipMapViewerJSON(deckPlan) { + const gs = deckPlan.gridSize; + return { + name: deckPlan.shipName, + floors: deckPlan.decks.map((deck, index) => ({ + floorNumber: index + 1, + floorName: deck.deckName, + rooms: deck.rooms.map((room) => ({ + id: room.id, + type: room.shape === "circle" ? "circle" : "rect", + x: room.x * gs, + y: room.y * gs, + width: room.width * gs, + height: room.height * gs, + label: room.label ?? "", + markers: (room.markers ?? []).map((marker) => ({ + ...marker, + x: Number.isFinite(marker.x) ? marker.x * gs : marker.x, + y: Number.isFinite(marker.y) ? marker.y * gs : marker.y + })), + labels: room.label + ? [{ + text: room.label, + x: (room.x + room.width / 2) * gs, + y: (room.y + room.height / 2) * gs, + visible: true + }] + : [] + })), + hallways: deck.corridors.map((corridor) => ({ + waypoints: (corridor.waypoints ?? []).map((wp) => ({ + x: wp.x * gs, + y: wp.y * gs + })), + startType: corridor.startDoor ?? "door", + endType: corridor.endDoor ?? "door" + })), + walls: deck.wallSegments.map((segment) => ({ + x1: segment.x1 * gs, + y1: segment.y1 * gs, + x2: segment.x2 * gs, + y2: segment.y2 * gs, + door: Boolean(segment.door) + })), + labels: deck.rooms + .filter((room) => room.label) + .map((room) => ({ + text: room.label, + x: (room.x + room.width / 2) * gs, + y: (room.y + room.height / 2) * gs + })) + })) + }; +} + +function buildRoomFloorDrawings(deck, formState) { + if (formState.mode === "wilderness" || deck.isCave) return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + return deck.rooms.flatMap((room) => { + const bounds = roomBounds(room); + const base = { + x: bounds.x * formState.gridSize, + y: bounds.y * formState.gridSize, + z: 0, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 2, + strokeColor: room.factionColor ?? palette.roomStroke, + strokeAlpha: 1, + fillType: 1, + fillColor: room.factionColor ?? palette.roomFill, + fillAlpha: round(0.8 * ageOpacity, 3), + textAlpha: 0, + bezierFactor: 0, + flags: { + [MODULE_ID]: { + roomId: room.id, + roomType: room.type ?? "", + roomLabel: room.label ?? "", + faction: room.faction ?? "", + factionColor: room.factionColor ?? "" + } + } + }; + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + const size = room.circleR * 2 * formState.gridSize; + return [{ + ...base, + x: room.circleX * formState.gridSize, + y: room.circleY * formState.gridSize, + shape: { + type: "e", + width: size, + height: size + } + }]; + } + if (room.shape === "polygon" && room.points?.length >= 3) { + return [{ + ...base, + shape: { + type: "p", + width: bounds.width * formState.gridSize, + height: bounds.height * formState.gridSize, + points: room.points.flatMap((point) => [ + round((point.x - bounds.x) * formState.gridSize, 2), + round((point.y - bounds.y) * formState.gridSize, 2) + ]) + } + }]; + } + return [{ + ...base, + shape: { + type: "r", + width: bounds.width * formState.gridSize, + height: bounds.height * formState.gridSize + } + }]; + }); +} + +function buildCorridorFloorDrawings(deck, formState) { + if (formState.mode === "wilderness") return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + const drawings = []; + for (const corridor of deck.corridors) { + drawings.push({ + x: corridor.x * formState.gridSize, + y: corridor.y * formState.gridSize, + z: 0, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 0, + strokeColor: palette.corridorFill, + strokeAlpha: 0, + fillType: 1, + fillColor: palette.corridorFill, + fillAlpha: round(0.86 * ageOpacity, 3), + textAlpha: 0, + bezierFactor: 0, + shape: { + type: "r", + width: corridor.width * formState.gridSize, + height: corridor.height * formState.gridSize + } + }); + if (corridor.width >= 2 || corridor.height >= 2) { + const gs = formState.gridSize; + const isHorizontal = corridor.width >= corridor.height; + drawings.push({ + x: isHorizontal ? corridor.x * gs : round((corridor.x + corridor.width / 2 - 0.08) * gs), + y: isHorizontal ? round((corridor.y + corridor.height / 2 - 0.08) * gs) : corridor.y * gs, + z: 1, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 0, + strokeColor: palette.corridorStripe ?? palette.line, + strokeAlpha: 0, + fillType: 1, + fillColor: palette.corridorStripe ?? palette.line, + fillAlpha: round(0.25 * ageOpacity, 3), + textAlpha: 0, + bezierFactor: 0, + shape: { + type: "r", + width: isHorizontal ? corridor.width * gs : Math.max(8, Math.round(gs * 0.16)), + height: isHorizontal ? Math.max(8, Math.round(gs * 0.16)) : corridor.height * gs + } + }); + } + } + return drawings; +} + +function buildAmbientLights(deck, formState) { + const config = THEME_LIGHT_CONFIG[formState.theme] ?? THEME_LIGHT_CONFIG.steel; + const ageOpacity = getAgeOpacity(formState); + const gs = formState.gridSize; + const lights = []; + + if (config.ambient) { + lights.push({ + x: Math.round((deck.columns * gs) / 2), + y: Math.round((deck.rows * gs) / 2), + hidden: false, + config: { + dim: Math.round(deck.columns * gs * 0.75), + bright: 0, + color: config.color, + alpha: round(config.alpha * Math.max(0.45, ageOpacity), 3), + luminosity: config.luminosity ?? 0.5, + walls: true, + vision: false + } + }); + } + + for (const room of deck.rooms) { + if (!config.roomTypes?.includes(room.type)) continue; + const radius = Math.min(room.width, room.height) * gs; + lights.push({ + x: Math.round((room.x + room.width / 2) * gs), + y: Math.round((room.y + room.height / 2) * gs), + hidden: false, + config: { + dim: Math.round(radius * 1.5), + bright: Math.round(radius * 0.5), + color: config.accentColor ?? config.color, + alpha: round(0.58 * Math.max(0.45, ageOpacity), 3), + luminosity: config.roomLuminosity ?? 0.65, + walls: true, + vision: false + } + }); + } + + return dedupeLights(lights); +} + +function buildFurnitureDrawings(deck, formState) { + if (!deck.furniture?.length) return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + return deck.furniture.map((piece) => { + const isSolid = piece.occlusion === "solid"; + return { + x: piece.x * formState.gridSize, + y: piece.y * formState.gridSize, + z: 10, + hidden: false, + locked: false, + rotation: piece.rotation ?? 0, + strokeWidth: isSolid ? 4 : 2, + strokeColor: palette.furnitureStroke, + strokeAlpha: round((isSolid ? 0.98 : 0.94) * ageOpacity, 3), + fillType: 1, + fillColor: palette.furnitureFill, + fillAlpha: round((isSolid ? 0.92 : 0.78) * ageOpacity, 3), + textAlpha: piece.label ? 1 : 0, + text: piece.label ?? "", + fontSize: Math.max(8, Math.round(formState.gridSize * 0.12)), + fontFamily: "Courier New", + bezierFactor: 0, + shape: { + type: piece.shape === "e" ? "e" : "r", + width: piece.w * formState.gridSize, + height: piece.h * formState.gridSize + } + }; + }); +} + +function buildFurnitureOcclusionWalls(deck, formState) { + const walls = []; + const gs = formState.gridSize; + + for (const piece of deck.furniture ?? []) { + const occlusion = piece.occlusion ?? "none"; + if (occlusion === "none") continue; + + const isSight = occlusion === "sight" || occlusion === "solid"; + const isMove = occlusion === "move" || occlusion === "solid"; + + const x = piece.x, y = piece.y, w = piece.w, h = piece.h; + const perimeter = [ + { x1: x, y1: y, x2: x + w, y2: y }, + { x1: x, y1: y + h, x2: x + w, y2: y + h }, + { x1: x, y1: y, x2: x, y2: y + h }, + { x1: x + w, y1: y, x2: x + w, y2: y + h } + ]; + + for (const seg of perimeter) { + walls.push({ + c: [ + Math.round(seg.x1 * gs), Math.round(seg.y1 * gs), + Math.round(seg.x2 * gs), Math.round(seg.y2 * gs) + ], + move: isMove ? MOVEMENT_NORMAL : SENSE_NONE, + sight: isSight ? SENSE_NORMAL : SENSE_NONE, + sound: isSight ? SENSE_NORMAL : SENSE_NONE, + light: isSight ? SENSE_NORMAL : SENSE_NONE, + dir: CONST.WALL_DIRECTIONS?.BOTH ?? 0, + door: DOOR_NONE, + flags: { [MODULE_ID]: { generatedWall: true, furnitureWall: true } } + }); + } + } + + return walls; +} + +function buildHullOutlineDrawings(deck, formState) { + if (formState.mode === "wilderness") return []; + if (!["corridor", "dungeon"].includes(formState.mode) || !deck.hull) return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + return [{ + x: deck.hull.x * formState.gridSize, + y: deck.hull.y * formState.gridSize, + z: -10, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 6, + strokeColor: palette.hull, + strokeAlpha: round(0.35 * ageOpacity, 3), + fillType: 0, + fillColor: palette.hull, + fillAlpha: 0, + textAlpha: 0, + bezierFactor: 0, + shape: { + type: "r", + width: deck.hull.width * formState.gridSize, + height: deck.hull.height * formState.gridSize + } + }]; +} + +function buildDeckSVG(deckPlan, deck, formState) { + const width = deck.columns * formState.gridSize; + const height = deck.rows * formState.gridSize; + const palette = paletteForFormState(formState); + const defs = buildThemePattern(formState.theme, palette, formState); + const labels = formState.coordinates === "none" + ? "" + : buildCoordinateSvg(deck.columns, deck.rows, formState.gridSize, width, height, palette, formState.coordinates); + const roomFloors = formState.mode === "wilderness" ? "" : buildRoomFillSvg(deck.rooms, formState.gridSize, palette, formState); + const corridorFloors = formState.mode === "wilderness" + ? "" + : buildOrganicCorridorSvg(deck.corridors, formState.gridSize, palette, formState); + const softOutlines = formState.softRoomOutlines && formState.mode !== "wilderness" + ? buildSoftRoomOutlinesSvg(deck.rooms, formState.gridSize, palette, formState) + : ""; + const roomLabels = formState.showRoomLabels + ? buildRoomLabelSvg(deck.rooms, formState.gridSize, palette) + : ""; + const connectorLabels = buildDeckConnectorSvg(deck.deckConnectors ?? [], formState.gridSize, palette); + const hullPath = formState.mode === "wilderness" ? "" : buildHullPathSvg(deck.hullMask ?? [], formState.gridSize, palette); + const exteriorOpenings = formState.mode === "wilderness" ? "" : buildExteriorOpeningsSvg(deck, formState.gridSize, palette); + // Secret doors are GM-only — moved to buildGmOverlaySVG + const bulkheads = ["bsp", "corridor"].includes(formState.mode) ? buildBulkheadSvg(deck.wallSegments ?? [], formState.gridSize, palette) : ""; + const roomOverlays = formState.mode === "wilderness" + ? buildWildernessOverlaySvg(deck, formState.gridSize, palette, formState) + : ["bsp", "corridor"].includes(formState.mode) + ? buildScifiRoomOverlaySvg(deck.rooms, formState.gridSize, palette) + : buildDungeonRoomOverlaySvg(deck.rooms, formState.gridSize, palette, formState.theme, formState); + const weathering = buildWeatheringOverlay(width, height, palette, formState); + const ageOpacity = getAgeOpacity(formState); + + return ` + + ${defs} + + + ${corridorFloors} + ${roomFloors} + ${softOutlines} + ${roomOverlays} + ${bulkheads} + ${hullPath} + ${exteriorOpenings} + ${weathering} + ${labels} + ${roomLabels} + ${connectorLabels} + `.replace(/\n+/g, " ").trim(); +} + +function buildGmTrapMarkerSvg(rooms, gridSize) { + return rooms.filter((r) => r.type === "trap-room" || r.isTrap).map((room) => { + const cx = (room.x + room.width / 2) * gridSize; + const cy = (room.y + room.height / 2) * gridSize; + // Large warning circle: dark shadow halo + vivid orange fill + white ⚠ glyph + const r = Math.max(14, Math.round(gridSize * 0.30)); + return `⚠`; + }).join(""); +} + +function buildGmUnexploredTintSvg(rooms, gridSize) { + // Raised opacity: makes unlabelled rooms clearly visible to the GM + return rooms.filter((r) => !r.label && r.type !== "void").map((room) => { + return ``; + }).join(""); +} + +// Coloured border rings around tactically important rooms so the GM can spot them instantly +const GM_SPECIAL_ROOM_COLORS = { + "reactor": { stroke: "#ff6600", fill: "rgba(255,102,0,0.08)", label: "REACTOR" }, + "boss-chamber": { stroke: "#dd0000", fill: "rgba(220,0,0,0.10)", label: "BOSS" }, + "trap-room": { stroke: "#ffcc00", fill: "rgba(255,204,0,0.08)", label: "TRAP" }, + "brig": { stroke: "#00aaff", fill: "rgba(0,170,255,0.08)", label: "BRIG" }, + "armory": { stroke: "#cc6600", fill: "rgba(204,102,0,0.08)", label: "ARMORY" }, + "weapon-bay": { stroke: "#cc2200", fill: "rgba(204,34,0,0.08)", label: "WEAPONS" }, + "airlock": { stroke: "#00ffcc", fill: "rgba(0,255,204,0.07)", label: "AIRLOCK" }, + "bridge": { stroke: "#66aaff", fill: "rgba(102,170,255,0.06)", label: "" }, +}; + +function buildGmSpecialRoomBorderSvg(rooms, gridSize) { + const sw = Math.max(3, Math.round(gridSize * 0.06)); + return rooms.flatMap((room) => { + const cfg = GM_SPECIAL_ROOM_COLORS[room.type]; + if (!cfg) return []; + const x = room.x * gridSize; + const y = room.y * gridSize; + const w = room.width * gridSize; + const h = room.height * gridSize; + const pad = Math.round(sw / 2); + return [``]; + }).join(""); +} + +function buildGmNpcBadgeSvg(rooms, gridSize) { + const badgeMap = { "boss-chamber": "BOSS", "guardroom": "GUARD", "barracks": "SQUAD", "brig": "DETAINEE", "padded-cell": "INMATE", "surgery": "THREAT" }; + return rooms.filter((r) => badgeMap[r.type]).map((room) => { + const cx = (room.x + room.width - 0.5) * gridSize; + const cy = (room.y + 0.5) * gridSize; + const label = badgeMap[room.type]; + // Scale badge with gridSize: wide pill badge, vivid purple, bold white text + const bw = Math.max(56, Math.round(gridSize * 0.78)); + const bh = Math.max(22, Math.round(gridSize * 0.30)); + const fs = Math.max(11, Math.round(gridSize * 0.165)); + return `${label}`; + }).join(""); +} + +function buildHatchGlyphSvg(furniture, gridSize, palette) { + return (furniture ?? []).filter((p) => p.isHatch).map((p) => { + const cx = (p.x + p.w / 2) * gridSize; + const cy = (p.y + p.h / 2) * gridSize; + const r = Math.round(gridSize * 0.28); + return ``; + }).join(""); +} + +function buildMaintenanceShaftSvg(shafts, gridSize, palette) { + const color = palette.maintenanceFill ?? "#cc4400"; + return shafts.flatMap((shaft) => shaft.segments.map((seg) => { + return ``; + })).join(""); +} + +function buildGmOverlaySVG(_deckPlan, deck, formState) { + const width = deck.columns * formState.gridSize; + const height = deck.rows * formState.gridSize; + const palette = paletteForFormState(formState); + const gs = formState.gridSize; + const title = escapeXml(deck.deckName || "GM Overlay"); + const roomBadges = deck.rooms.map((room) => buildGmOverlayRoomBadge(room, formState, palette)).join(""); + const connectorBadges = (deck.deckConnectors ?? []).map((connector) => buildGmOverlayConnectorBadge(connector, formState, palette)).join(""); + const zoneBadges = (deck.wilderness?.encounterZones ?? []).map((zone, index) => buildGmOverlayZoneBadge(zone, index, formState, palette)).join(""); + // GM-only elements — never appear in the player SVG + const secretDoors = formState.mode === "wilderness" ? "" : buildSecretDoorSvg(deck.wallSegments ?? [], gs, palette); + const trapMarkers = buildGmTrapMarkerSvg(deck.rooms, gs); + const unexploredTint = buildGmUnexploredTintSvg(deck.rooms, gs); + const specialBorders = buildGmSpecialRoomBorderSvg(deck.rooms, gs); + const npcBadges = buildGmNpcBadgeSvg(deck.rooms, gs); + const hatchGlyphs = buildHatchGlyphSvg(deck.furniture ?? [], gs, palette); + const maintShafts = buildMaintenanceShaftSvg(deck.maintenanceShafts ?? [], gs, palette); + + return ` + + ${unexploredTint} + ${specialBorders} + ${secretDoors} + ${trapMarkers} + ${hatchGlyphs} + ${maintShafts} + + + ${title} + + ${roomBadges} + ${connectorBadges} + ${zoneBadges} + ${npcBadges} + `.replace(/\n+/g, " ").trim(); +} + +function buildRoomFillSvg(rooms, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return rooms.map((room) => { + const fill = room.factionColor ?? palette.roomFill; + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + const cx = (room.circleX + room.circleR) * gridSize; + const cy = (room.circleY + room.circleR) * gridSize; + const r = room.circleR * gridSize; + return ``; + } + if (room.shape === "polygon" && room.points?.length >= 3) { + const points = room.points.map((point) => `${round(point.x * gridSize, 2)},${round(point.y * gridSize, 2)}`).join(" "); + return ``; + } + if (room.shape === "cave" && room.cells?.length) { + return compressCellsToRects(room.cells).map((rect) => ``).join(""); + } + return ``; + }).join(""); +} + +function buildCorridorFillSvg(corridors, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return corridors.map((corridor) => ``).join(""); +} + +function buildOrganicCorridorSvg(corridors, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return corridors.map((corridor, index) => { + const px = corridor.x * gridSize; + const py = corridor.y * gridSize; + const pw = corridor.width * gridSize; + const ph = corridor.height * gridSize; + const isHorizontal = pw >= ph; + const bow = Math.max(4, Math.round(Math.min(pw, ph) * 0.35)) * (((index % 3) - 1) || 0); + let path; + if (isHorizontal) { + const midX = px + pw / 2; + path = `M ${px} ${py} Q ${midX} ${py + bow} ${px + pw} ${py} L ${px + pw} ${py + ph} Q ${midX} ${py + ph - bow} ${px} ${py + ph} Z`; + } else { + const midY = py + ph / 2; + path = `M ${px} ${py} L ${px + pw} ${py} Q ${px + pw + bow} ${midY} ${px + pw} ${py + ph} L ${px} ${py + ph} Q ${px - bow} ${midY} ${px} ${py} Z`; + } + return ``; + }).join(""); +} + +function buildSoftRoomOutlinesSvg(rooms, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return rooms.map((room) => { + if (room.shape === "cave") return ""; + if (room.shape === "polygon" && room.points?.length >= 3) { + const points = room.points.map((point) => `${round(point.x * gridSize, 2)},${round(point.y * gridSize, 2)}`).join(" "); + return ``; + } + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + const cx = (room.circleX + room.circleR) * gridSize; + const cy = (room.circleY + room.circleR) * gridSize; + const r = room.circleR * gridSize; + return ``; + } + const px = room.x * gridSize; + const py = room.y * gridSize; + const pw = room.width * gridSize; + const ph = room.height * gridSize; + const radius = Math.min(pw, ph) * 0.18; + const path = `M ${px + radius} ${py} L ${px + pw - radius} ${py} Q ${px + pw} ${py} ${px + pw} ${py + radius} L ${px + pw} ${py + ph - radius} Q ${px + pw} ${py + ph} ${px + pw - radius} ${py + ph} L ${px + radius} ${py + ph} Q ${px} ${py + ph} ${px} ${py + ph - radius} L ${px} ${py + radius} Q ${px} ${py} ${px + radius} ${py} Z`; + return ``; + }).join(""); +} + +function buildThemePattern(theme, palette, formState = {}) { + const ageOpacity = getAgeOpacity(formState); + if (theme === "stone") { + return ` + + + + + + + + + + + + + + `; + } + if (theme === "cave") { + return ` + + + + + + + + + + `; + } + if (theme === "wood" || theme === "wood-floor") { + return ` + + + + + `; + } + if (theme === "crypt") { + return ` + + + + + + + + + + `; + } + if (theme === "sewer") { + return ` + + + + + + + + `; + } + if (theme === "brick") { + return ` + + + + + + + + + + `; + } + if (theme === "carpet" || theme === "tile" || theme === "concrete-office") { + return ` + + + + + + `; + } + if (theme === "alien-organic") { + return ` + + + + + `; + } + if (theme === "clean-corporate") { + return ` + + + + + + `; + } + if (theme === "derelict-house") { + return ` + + + + + + + + `; + } + if (theme === "asylum") { + return ` + + + + + + + + + `; + } + if (theme === "haunted-manor") { + return ` + + + + + + `; + } + if (theme === "bloodbath") { + return ` + + + + + + + `; + } + if (theme === "forest" || theme === "jungle") { + return ` + + + + + + + `; + } + if (theme === "plains" || theme === "badlands") { + return ` + + + + + `; + } + if (theme === "swamp") { + return ` + + + + + + `; + } + if (theme === "tundra") { + return ` + + + + + `; + } + if (theme === "alien-terminal") { + // CRT phosphor-screen effect: tight horizontal scanlines + faint grid overlay + // Pattern tile 120×60 for a natural repeating scanline rhythm + return ` + + + + + + + + + + + + + + `; + } + return ` + + + + + + + + `; +} + +function buildCoordinateSvg(columns, rows, gridSize, width, height, palette, mode) { + const out = []; + const fontSize = Math.max(11, Math.round(gridSize * 0.18)); + const coordStroke = textStrokeFor(palette.coordText); + const cellStroke = textStrokeFor(palette.coordCell); + for (let col = 0; col < columns; col += 1) { + const x = col * gridSize + gridSize / 2; + const label = columnLabel(col); + out.push(`${label}`); + } + for (let row = 0; row < rows; row += 1) { + const y = row * gridSize + gridSize / 2 + fontSize * 0.35; + out.push(`${row + 1}`); + } + if (mode === "full") { + const cellFont = Math.max(8, Math.round(gridSize * 0.11)); + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < columns; col += 1) { + out.push(`${columnLabel(col)}${row + 1}`); + } + } + } + return out.join(""); +} + +function buildRoomLabelSvg(rooms, gridSize, palette) { + const labelStroke = textStrokeFor(palette.label); + return rooms.filter((room) => room.label).map((room) => { + const center = roomCenter(room); + const bounds = roomBounds(room); + const x = center.x * gridSize; + const y = center.y * gridSize; + const roomPxW = bounds.width * gridSize; + const fontSize = Math.max(9, Math.min(16, Math.round(Math.min(bounds.width, bounds.height) * gridSize * 0.12))); + const approxCharW = fontSize * 0.6; + const charsPerLine = Math.max(4, Math.floor(roomPxW / Math.max(1, approxCharW)) - 2); + const words = room.label.split(/\s+/).filter(Boolean); + const lines = []; + let current = ""; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length > charsPerLine && current) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current) lines.push(current); + const lineHeight = fontSize * 1.22; + const totalHeight = lines.length * lineHeight; + return lines.map((line, index) => { + const lineY = y - totalHeight / 2 + index * lineHeight + lineHeight / 2; + return `${escapeXml(line)}`; + }).join(""); + }).join(""); +} + +function buildGmOverlayRoomBadge(room, formState, palette) { + const label = room.label || humanizeRoomType(room.type) || "Zone"; + const section = buildRoomSectionLine(room); + const center = roomCenter(room); + const bounds = roomBounds(room); + const x = center.x * formState.gridSize; + const y = center.y * formState.gridSize; + const roomPxW = Math.max(120, Math.min(bounds.width * formState.gridSize - 12, 260)); + const badgeW = Math.max(120, Math.min(roomPxW, Math.max(120, label.length * 10 + 36))); + const badgeH = section ? 44 : 30; + const left = round(x - badgeW / 2); + const top = round(y - badgeH / 2); + const stroke = room.factionColor ?? palette.connectorStroke; + const subText = section ? `${escapeXml(section)}` : ""; + return ` + + + ${escapeXml(label)} + ${subText} + + `.replace(/\n+/g, " "); +} + +function buildGmOverlayConnectorBadge(connector, formState, palette) { + const cx = connector.x * formState.gridSize + formState.gridSize / 2; + const cy = connector.y * formState.gridSize + formState.gridSize / 2; + return ` + + + ${escapeXml((connector.label || "C").charAt(0))} + + `.replace(/\n+/g, " "); +} + +function buildGmOverlayZoneBadge(zone, index, formState, palette) { + const x = zone.x * formState.gridSize; + const y = zone.y * formState.gridSize; + const label = zone.label || `Encounter Zone ${index + 1}`; + return ` + + + ${escapeXml(label)} + + `.replace(/\n+/g, " "); +} + +function buildRoomSectionLine(room) { + const parts = []; + if (room.type) parts.push(humanizeRoomType(room.type)); + if (room.factionName) parts.push(room.factionName); + return parts.join(" • "); +} + +function buildAutomaticRoomNote(deck, room) { + const lines = [ + `Section: ${room.label || humanizeRoomType(room.type) || "Zone"}`, + `Type: ${humanizeRoomType(room.type) || "Unassigned"}`, + `Deck: ${deck.deckName}` + ]; + if (room.factionName) lines.push(`Faction: ${room.factionName}`); + if (Array.isArray(room.markers) && room.markers.length) { + const markers = room.markers.map((marker) => marker.text || marker.label).filter(Boolean); + if (markers.length) lines.push(`Markers: ${markers.join(", ")}`); + } + return lines.join("\n"); +} + +function humanizeRoomType(value) { + return String(value ?? "") + .trim() + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function buildDeckConnectorSvg(connectors, gridSize, palette) { + const connectorTextStroke = textStrokeFor(palette.connectorText); + return connectors.map((connector) => { + const cx = connector.x * gridSize + gridSize / 2; + const cy = connector.y * gridSize + gridSize / 2; + const size = Math.max(12, Math.round(gridSize * 0.5)); + const labelY = cy + size * 0.95; + const accessY = labelY + Math.max(8, Math.round(gridSize * 0.14)); + const glyph = connector.type === "ladder" + ? "L" + : connector.type === "stairs" + ? "S" + : "E"; + return ` + + + ${glyph} + ${escapeXml(connector.label)} + ${escapeXml(connector.access ?? "")} + `; + }).join(""); +} + +function buildHullPathSvg(hullMask, gridSize, palette) { + return buildHullWallSegments(hullMask).map((segment) => ` + + `).join(""); +} + +function buildSecretDoorSvg(wallSegments, gridSize, palette) { + return wallSegments + .filter((segment) => segment.door === DOOR_SECRET) + .map((segment) => { + const cx = ((segment.x1 + segment.x2) / 2) * gridSize; + const cy = ((segment.y1 + segment.y2) / 2) * gridSize; + // Large, high-contrast S-glyph: solid dark halo + bright filled circle + bold white "S" + const r = Math.max(12, Math.round(gridSize * 0.28)); + return `S`; + }) + .join(""); +} + +function buildExteriorOpeningsSvg(deck, gridSize, palette) { + const windows = (deck.exteriorOpenings ?? []).filter((segment) => segment.isWindow).map((segment) => { + const x1 = segment.x1 * gridSize; + const y1 = segment.y1 * gridSize; + const x2 = segment.x2 * gridSize; + const y2 = segment.y2 * gridSize; + return ``; + }); + + const doors = (deck.exteriorOpenings ?? []).filter((segment) => segment.door).map((segment) => buildExteriorDoorGlyph(segment, gridSize, palette)); + const airlocks = (deck.airlocks ?? []).map((airlock) => { + const x = airlock.x * gridSize; + const y = airlock.y * gridSize; + const isVertical = airlock.edge === "V"; + const width = isVertical ? Math.max(8, Math.round(gridSize * 0.14)) : gridSize; + const height = isVertical ? gridSize : Math.max(8, Math.round(gridSize * 0.14)); + const cx = isVertical ? x : x + gridSize / 2; + const cy = isVertical ? y + gridSize / 2 : y; + const box = ``; + const stripe1 = isVertical + ? `` + : ``; + const stripe2 = isVertical + ? `` + : ``; + return `${box}${stripe1}${stripe2}`; + }); + + return [...windows, ...doors, ...airlocks].join(""); +} + +function buildExteriorDoorGlyph(segment, gridSize, palette) { + const x1 = segment.x1 * gridSize; + const y1 = segment.y1 * gridSize; + const x2 = segment.x2 * gridSize; + const y2 = segment.y2 * gridSize; + const doorStroke = Math.max(4, Math.round(gridSize * 0.08)); + return ``; +} + +function buildScifiRoomOverlaySvg(rooms, gridSize, palette) { + return rooms.map((room) => { + const bounds = roomBounds(room); + const center = roomCenter(room); + const px = bounds.x * gridSize; + const py = bounds.y * gridSize; + const pw = bounds.width * gridSize; + const ph = bounds.height * gridSize; + const cx = center.x * gridSize; + const cy = center.y * gridSize; + switch (room.type) { + case "reactor": + return buildReactorOverlay(cx, cy, Math.min(pw, ph) * 0.38, palette); + case "airlock": + case "escape-pods": + case "weapon-bay": + return buildHazardStripeOverlay(px, py, pw, ph, palette); + case "cargo": + return buildCargoHatchOverlay(px, py, pw, ph, palette); + case "medical": + return buildMedicalCrossOverlay(cx, cy, Math.min(pw, ph) * 0.22, palette); + case "bridge": + return buildBridgeDisplayOverlay(cx, cy, pw, ph, palette); + case "brig": + return buildBrigBarOverlay(px, py, pw, ph, palette); + case "observation": + return buildViewportOverlay(cx, cy, Math.min(pw, ph) * 0.3, palette); + default: + return ""; + } + }).join(""); +} + +function buildDungeonRoomOverlaySvg(rooms, gridSize, palette, theme, formState = {}) { + const overlays = rooms.map((room) => { + const bounds = roomBounds(room); + const center = roomCenter(room); + const px = bounds.x * gridSize; + const py = bounds.y * gridSize; + const pw = bounds.width * gridSize; + const ph = bounds.height * gridSize; + const cx = center.x * gridSize; + const cy = center.y * gridSize; + const r = Math.min(pw, ph) * 0.3; + switch (room.type) { + case "shrine": + case "ritual-room": + return buildRitualCircleOverlay(cx, cy, r, palette); + case "boss-chamber": + case "throne-room": + return buildBossFrameOverlay(px, py, pw, ph, palette); + case "cistern": + return buildWaterPoolOverlay(cx, cy, r * 1.1, palette); + case "vault": + return buildVaultFloorOverlay(px, py, pw, ph, palette); + case "crypt": + return buildCryptStoneSlotsOverlay(px, py, pw, ph, gridSize, palette); + case "torture": + return buildTortureStainOverlay(cx, cy, r * 0.6, palette); + case "collapsed": + case "collapse": + return buildRubbleOverlay(px, py, pw, ph, palette); + case "laboratory": + case "alchemy": + return buildLabBenchOverlay(px, py, pw, ph, palette); + case "living-room": + case "entrance-hall": + case "surgery": + case "padded-cell": + return ["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(theme) + ? buildHorrorRoomOverlay(room.type, px, py, pw, ph, cx, cy, palette, formState) + : ""; + default: + return ""; + } + }); + return overlays.join(""); +} + +function buildReactorOverlay(cx, cy, r, palette) { + const glow = palette.detail; + return ``; +} + +function buildHazardStripeOverlay(px, py, pw, ph, palette) { + const sw = 12; + const stripes = []; + for (let x = px - ph; x < px + pw; x += sw * 2) { + stripes.push(``); + } + return `${stripes.join("")}`; +} + +function buildCargoHatchOverlay(px, py, pw, ph, palette) { + const lines = []; + const step = Math.max(12, Math.round(Math.min(pw, ph) * 0.18)); + for (let i = 1; i * step < pw; i += 1) { + lines.push(``); + } + return lines.join(""); +} + +function buildMedicalCrossOverlay(cx, cy, r, palette) { + const t = r * 0.35; + return ``; +} + +function buildBridgeDisplayOverlay(cx, cy, pw, ph, palette) { + const r = Math.min(pw, ph) * 0.28; + return ``; +} + +function buildBrigBarOverlay(px, py, pw, ph, palette) { + const barCount = Math.max(3, Math.round(pw / 18)); + const barSpacing = pw / (barCount + 1); + const bars = []; + for (let i = 1; i <= barCount; i += 1) { + bars.push(``); + } + return bars.join(""); +} + +function buildViewportOverlay(cx, cy, r, palette) { + return ``; +} + +function buildRitualCircleOverlay(cx, cy, r, palette) { + const pts = 12; + const star = Array.from({ length: pts }, (_, index) => { + const angle = (Math.PI / 6) * index - Math.PI / 2; + const radius = index % 2 === 0 ? r : r * 0.45; + return `${Math.round(cx + Math.cos(angle) * radius)},${Math.round(cy + Math.sin(angle) * radius)}`; + }).join(" "); + return ``; +} + +function buildBossFrameOverlay(px, py, pw, ph, palette) { + const m = Math.min(pw, ph) * 0.08; + return ``; +} + +function buildWaterPoolOverlay(cx, cy, r, palette) { + return ``; +} + +function buildVaultFloorOverlay(px, py, pw, ph, palette) { + const step = Math.max(14, Math.round(Math.min(pw, ph) * 0.16)); + const parts = []; + for (let x = px + step; x < px + pw - step / 2; x += step) parts.push(``); + for (let y = py + step; y < py + ph - step / 2; y += step) parts.push(``); + return `${parts.join("")}`; +} + +function buildCryptStoneSlotsOverlay(px, py, pw, ph, gridSize, palette) { + const parts = []; + const slotHeight = Math.max(10, Math.round(gridSize * 0.4)); + for (let y = py + slotHeight; y < py + ph - slotHeight / 2; y += slotHeight * 1.8) { + parts.push(``); + } + return `${parts.join("")}`; +} + +function buildTortureStainOverlay(cx, cy, r, palette) { + return ``; +} + +function buildRubbleOverlay(px, py, pw, ph, palette) { + const rubble = []; + for (let i = 0; i < 8; i += 1) { + const x = px + ((i * 37) % Math.max(40, pw - 12)); + const y = py + ((i * 29) % Math.max(40, ph - 12)); + rubble.push(``); + } + return `${rubble.join("")}`; +} + +function buildLabBenchOverlay(px, py, pw, ph, palette) { + const inset = Math.max(8, Math.round(Math.min(pw, ph) * 0.1)); + return ``; +} + +function buildHorrorRoomOverlay(type, px, py, pw, ph, cx, cy, palette, formState) { + if (type === "surgery") return ``; + if (type === "padded-cell") return ``; + return ``; +} + +function buildWeatheringOverlay(width, height, palette, formState) { + if (!formState.mapAge) return ""; + const opacity = (formState.mapAge / 100) * 0.24; + const cracks = []; + for (let index = 0; index < 8; index += 1) { + const x = ((index * 113) % Math.max(80, width - 30)) + 12; + const y = ((index * 79) % Math.max(80, height - 30)) + 12; + cracks.push(``); + } + const stains = []; + for (let index = 0; index < 5; index += 1) { + const x = ((index * 151) % Math.max(90, width - 60)) + 30; + const y = ((index * 97) % Math.max(90, height - 50)) + 20; + stains.push(``); + } + return `${cracks.join("")}${stains.join("")}`; +} + +function getAgeOpacity(formState = {}) { + return 1 - ((Number(formState.mapAge) || 0) / 100) * 0.35; +} + +function buildWildernessOverlaySvg(deck, gridSize, palette, formState) { + const parts = []; + const ageOpacity = getAgeOpacity(formState); + + for (const corridor of deck.corridors ?? []) { + const x = corridor.x * gridSize; + const y = corridor.y * gridSize; + const w = corridor.width * gridSize; + const h = corridor.height * gridSize; + parts.push(``); + const isHorizontal = corridor.width >= corridor.height; + if (isHorizontal) { + parts.push(``); + } else { + parts.push(``); + } + } + + for (const room of deck.rooms ?? []) { + const px = room.x * gridSize; + const py = room.y * gridSize; + const pw = room.width * gridSize; + const ph = room.height * gridSize; + parts.push(``); + } + + for (const cluster of deck.wilderness?.treeClusters ?? []) { + for (let i = 0; i < cluster.count; i += 1) { + const angle = (Math.PI * 2 * i) / Math.max(1, cluster.count); + const dist = cluster.radius * 0.55 * ((i % 3) + 1) / 3; + const x = (cluster.x + Math.cos(angle) * dist) * gridSize; + const y = (cluster.y + Math.sin(angle) * dist) * gridSize; + const r = Math.max(6, gridSize * 0.22); + parts.push(``); + parts.push(``); + } + } + + for (const water of deck.wilderness?.waterFeatures ?? []) { + parts.push(``); + parts.push(``); + } + + for (const ridge of deck.wilderness?.ridgelines ?? []) { + parts.push(``); + parts.push(``); + } + + for (const ravine of deck.wilderness?.ravines ?? []) { + parts.push(``); + parts.push(``); + } + + for (const zone of deck.wilderness?.encounterZones ?? []) { + parts.push(``); + } + + return parts.join(""); +} + +function buildFurnitureForDeck(deck, formState, furnitureTables, rng) { + if (formState.furnitureDensity === "off") return []; + const densityMultiplier = { sparse: 0.5, normal: 1, dense: 1.6 }[formState.furnitureDensity] ?? 1; + const modeKey = ["bsp", "corridor"].includes(formState.mode) ? "scifi" : formState.mode; + const tableSource = furnitureTables[modeKey] ?? {}; + const doorZonesByRoom = buildRoomDoorAvoidanceZones(deck); + const furniture = []; + + for (const room of deck.rooms) { + if (!room.type || room.type === "void") continue; + const archetypes = tableSource[room.type]; + if (!archetypes?.length) continue; + const placed = []; + for (const archetype of archetypes) { + const [minQty, maxQty] = archetype.qty ?? [0, 0]; + const archDensityMod = archetype.densityMod ?? 1.0; + let qty = Math.round(randomInt(rng, minQty, maxQty) * densityMultiplier * archDensityMod); + if (archetype.placement === "corners") qty = Math.min(qty, 4); + for (let index = 0; index < qty; index += 1) { + const piece = tryPlaceFurniturePiece(archetype, room, placed, doorZonesByRoom.get(room.id) ?? [], rng); + if (!piece) continue; + furniture.push(piece); + placed.push({ x: piece.x, y: piece.y, width: piece.w, height: piece.h }); + } + } + } + + return furniture; +} + +function tryPlaceFurniturePiece(archetype, room, placed, doorZones, rng) { + const margin = 1; + const maxAttempts = archetype.placement === "corners" ? 12 : 8; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const pos = pickFurniturePosition(archetype, room, margin, rng, placed); + if (!pos) continue; + const rect = { x: pos.x, y: pos.y, width: pos.w, height: pos.h }; + if (rect.x < room.x + margin || rect.y < room.y + margin) continue; + if (rect.x + rect.width > room.x + room.width - margin) continue; + if (rect.y + rect.height > room.y + room.height - margin) continue; + if (placed.some((existing) => rectsOverlap(existing, rect))) continue; + if (doorZones.some((zone) => rectsOverlap(zone, expandRect(rect, 0)))) continue; + return { ...archetype, x: rect.x, y: rect.y, w: rect.width, h: rect.height, rotation: pos.rotation ?? 0, roomId: room.id }; + } + return null; +} + +function pickFurniturePosition(archetype, room, margin, rng, placed = []) { + const innerX1 = room.x + margin; + const innerY1 = room.y + margin; + const randomRotation = archetype.w !== archetype.h && rng() > 0.5 ? 90 : 0; + + switch (archetype.placement) { + case "wall": { + const side = Math.floor(rng() * 4); + const dims = side >= 2 && archetype.w !== archetype.h + ? { w: archetype.h, h: archetype.w, rotation: 90 } + : { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + if (side === 0) return { x: randomInt(rng, innerX1, innerX2), y: innerY1, ...dims }; + if (side === 1) return { x: randomInt(rng, innerX1, innerX2), y: room.y + room.height - margin - dims.h, ...dims }; + if (side === 2) return { x: innerX1, y: randomInt(rng, innerY1, innerY2), ...dims }; + return { x: room.x + room.width - margin - dims.w, y: randomInt(rng, innerY1, innerY2), ...dims }; + } + case "far-wall": { + const dims = { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + const farY = room.y + room.height - margin - dims.h; + return { + x: clamp(Math.floor(room.x + (room.width - dims.w) / 2), innerX1, innerX2), + y: clamp(farY, innerY1, innerY2), + ...dims + }; + } + case "center": { + const dims = randomRotation === 90 ? { w: archetype.h, h: archetype.w, rotation: 90 } : { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + const cx = room.x + Math.floor((room.width - dims.w) / 2) + randomInt(rng, -1, 1); + const cy = room.y + Math.floor((room.height - dims.h) / 2) + randomInt(rng, -1, 1); + return { x: clamp(cx, innerX1, innerX2), y: clamp(cy, innerY1, innerY2), ...dims }; + } + case "corners": { + const dims = { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + const corners = [ + { x: innerX1, y: innerY1, ...dims }, + { x: innerX2, y: innerY1, ...dims }, + { x: innerX1, y: innerY2, ...dims }, + { x: innerX2, y: innerY2, ...dims } + ]; + const unused = corners.filter((corner) => !placed.some((existing) => existing.x === corner.x && existing.y === corner.y)); + const pool = unused.length ? unused : corners; + return pool[Math.floor(rng() * pool.length)]; + } + case "scatter": + default: + { + const dims = randomRotation === 90 ? { w: archetype.h, h: archetype.w, rotation: 90 } : { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + return { x: randomInt(rng, innerX1, innerX2), y: randomInt(rng, innerY1, innerY2), ...dims }; + } + } +} + +function buildRoomDoorAvoidanceZones(deck) { + const out = new Map(); + for (const room of deck.rooms) out.set(room.id, []); + for (const segment of deck.wallSegments ?? []) { + if (!segment.door) continue; + for (const room of deck.rooms) { + const zone = buildDoorAvoidanceZone(room, segment); + if (zone) out.get(room.id)?.push(zone); + } + } + return out; +} + +function buildDoorAvoidanceZone(room, segment) { + if (!segmentTouchesRoom(segment, room)) return null; + if (segment.x1 === segment.x2) { + const doorY = Math.min(segment.y1, segment.y2); + if (segment.x1 === room.x) return clampRectToRoom(room, { x: room.x, y: doorY - 1, width: 2, height: 3 }); + if (segment.x1 === room.x + room.width) return clampRectToRoom(room, { x: room.x + room.width - 2, y: doorY - 1, width: 2, height: 3 }); + } else { + const doorX = Math.min(segment.x1, segment.x2); + if (segment.y1 === room.y) return clampRectToRoom(room, { x: doorX - 1, y: room.y, width: 3, height: 2 }); + if (segment.y1 === room.y + room.height) return clampRectToRoom(room, { x: doorX - 1, y: room.y + room.height - 2, width: 3, height: 2 }); + } + return null; +} + +function clampRectToRoom(room, rect) { + const x1 = Math.max(room.x, rect.x); + const y1 = Math.max(room.y, rect.y); + const x2 = Math.min(room.x + room.width, rect.x + rect.width); + const y2 = Math.min(room.y + room.height, rect.y + rect.height); + if (x2 <= x1 || y2 <= y1) return null; + return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; +} + +async function createDeckBackgroundAsset(deckPlan, deck, formState) { + return createSvgAsset(`${slugify(deckPlan.shipName)}-deck-${deck.deckIndex + 1}`, buildDeckSVG(deckPlan, deck, formState)); +} + +async function createGmOverlayAsset(deckPlan, deck, formState) { + return createSvgAsset(`${slugify(deckPlan.shipName)}-deck-${deck.deckIndex + 1}-gm-overlay`, buildGmOverlaySVG(deckPlan, deck, formState)); +} + +async function createSvgAsset(baseName, svg) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return null; + + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const filename = `${slugify(baseName)}-${Date.now()}.svg`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([svg], filename, { type: "image/svg+xml" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + return response?.path ?? `${targetDir}/${filename}`; +} + +function wallSegmentToDocument(segment, gridSize) { + const isWindow = Boolean(segment.isWindow); + const movementOnly = Boolean(segment.movementOnly); + const doorValue = segment.door === DOOR_SECRET + ? DOOR_SECRET + : (segment.door ? DOOR_BASIC : DOOR_NONE); + const wall = { + c: [ + round(segment.x1 * gridSize), + round(segment.y1 * gridSize), + round(segment.x2 * gridSize), + round(segment.y2 * gridSize) + ], + move: segment.move ?? MOVEMENT_NORMAL, + sight: movementOnly || isWindow ? SENSE_NONE : SENSE_NORMAL, + sound: movementOnly ? SENSE_NONE : SENSE_NORMAL, + light: movementOnly || isWindow ? SENSE_NONE : SENSE_NORMAL, + dir: CONST.WALL_DIRECTIONS.BOTH, + door: doorValue, + ds: segment.ds ?? DOOR_STATE_CLOSED + }; + return wall; +} + +function withGeneratedWallFlag(wall) { + return { + ...wall, + flags: { + ...(wall.flags ?? {}), + [MODULE_ID]: { + ...(wall.flags?.[MODULE_ID] ?? {}), + generatedWall: true + } + } + }; +} + +function withGeneratedDrawingFlag(drawing) { + return { + ...drawing, + flags: { + ...(drawing.flags ?? {}), + [MODULE_ID]: { + ...(drawing.flags?.[MODULE_ID] ?? {}), + generatedDrawing: true + } + } + }; +} + +function withGeneratedTileFlag(tile) { + return { + ...tile, + flags: { + ...(tile.flags ?? {}), + [MODULE_ID]: { + ...(tile.flags?.[MODULE_ID] ?? {}), + generatedTile: true + } + } + }; +} + +function sanitizeGeneratedDrawings(drawings = []) { + return drawings.filter((drawing) => { + const polygonPoints = Array.isArray(drawing?.shape?.points) ? drawing.shape.points : []; + const width = Number(drawing?.shape?.width ?? 0); + const height = Number(drawing?.shape?.height ?? 0); + const hasPolygon = drawing?.shape?.type === "p" && polygonPoints.length >= 6; + if (!hasPolygon && (!(width > 0) || !(height > 0))) return false; + + const textVisible = Number(drawing?.textAlpha ?? 0) > 0 && String(drawing?.text ?? "").trim().length > 0; + const fillVisible = Number(drawing?.fillAlpha ?? 0) > 0 && Number(drawing?.fillType ?? 0) !== 0; + const strokeVisible = Number(drawing?.strokeAlpha ?? 0) > 0 && Number(drawing?.strokeWidth ?? 0) > 0; + + return textVisible || fillVisible || strokeVisible; + }); +} + +function withGeneratedLightFlag(light) { + return { + ...light, + flags: { + ...(light.flags ?? {}), + [MODULE_ID]: { + ...(light.flags?.[MODULE_ID] ?? {}), + generatedLight: true + } + } + }; +} + +function withGeneratedTokenFlag(token) { + return { + ...token, + flags: { + ...(token.flags ?? {}), + [MODULE_ID]: { + ...(token.flags?.[MODULE_ID] ?? {}), + generatedToken: true + } + } + }; +} + +function withGeneratedNoteFlag(note) { + return { + ...note, + flags: { + ...(note.flags ?? {}), + [MODULE_ID]: { + ...(note.flags?.[MODULE_ID] ?? {}), + generatedNote: true + } + } + }; +} + +async function buildEncounterTokens(deck, formState) { + const preset = resolveEncounterPreset(formState); + const packActors = await getEncounterPackActors(formState.encounterPack); + const targets = formState.mode === "wilderness" + ? (deck.wilderness?.encounterZones ?? []).map((zone) => ({ + type: "encounter-zone", + label: zone.label, + x: zone.x, + y: zone.y, + width: 2, + height: 2 + })) + : deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)); + const densityBase = { light: 1, standard: 2, heavy: 3 }[formState.encounterDensity] ?? 2; + const tokens = []; + let counter = 0; + for (const room of targets) { + const count = determineEncounterCount(room, densityBase); + const center = roomCenter(room); + for (let index = 0; index < count; index += 1) { + const offset = encounterOffset(index, count); + const actor = packActors.length ? packActors[counter % packActors.length] : null; + tokens.push({ + x: Math.round((center.x - 0.5 + offset.x) * formState.gridSize), + y: Math.round((center.y - 0.5 + offset.y) * formState.gridSize), + width: 1, + height: 1, + hidden: true, + name: actor?.name || buildEncounterName(preset, room, counter), + texture: { + src: actor?.texture || preset.icon + }, + flags: actor + ? { + [MODULE_ID]: { + encounterPack: formState.encounterPack, + sourceActorUuid: actor.uuid, + sourceActorId: actor.id + } + } + : undefined + }); + counter += 1; + } + } + return tokens; +} + +async function getEncounterPackActors(packId) { + const key = String(packId ?? "").trim(); + if (!key) return []; + if (encounterPackCache.has(key)) return encounterPackCache.get(key); + const pack = game.packs?.get(key); + if (!pack || pack.documentName !== "Actor") { + encounterPackCache.set(key, []); + return []; + } + const docs = await pack.getDocuments().catch(() => []); + const actors = docs + .filter((doc) => doc) + .map((doc) => ({ + id: doc.id, + uuid: doc.uuid, + name: doc.name, + texture: doc.prototypeToken?.texture?.src || doc.img || "icons/svg/mystery-man.svg" + })); + encounterPackCache.set(key, actors); + return actors; +} + +async function buildRoomPopulationTokens(deck, formState) { + const actors = await getEncounterPackActors(formState.roomPopulationPack); + if (!actors.length) return []; + const tokens = []; + for (const room of deck.rooms) { + const actor = matchActorToRoom(actors, room); + if (!actor) continue; + const center = roomCenter(room); + tokens.push({ + x: Math.round((center.x - 0.5) * formState.gridSize), + y: Math.round((center.y - 0.5) * formState.gridSize), + width: 1, + height: 1, + hidden: false, + name: actor.name, + texture: { + src: actor.texture + }, + flags: { + [MODULE_ID]: { + roomPopulation: true, + roomId: room.id, + sourceActorUuid: actor.uuid, + sourceActorId: actor.id, + sourceRoomType: room.type ?? "", + sourceRoomLabel: room.label ?? "" + } + } + }); + } + return tokens; +} + +function matchActorToRoom(actors, room) { + const tokens = roomMatchTokens(room); + return actors.find((actor) => { + const name = normalizeMatchText(actor.name); + return tokens.some((token) => name.includes(token)); + }) ?? null; +} + +function roomMatchTokens(room) { + const values = [room.type, room.label, ...(room.markers ?? []).map((marker) => marker.text || marker.label)].filter(Boolean); + const set = new Set(); + for (const value of values) { + const normalized = normalizeMatchText(value); + if (!normalized) continue; + set.add(normalized); + normalized.split(/\s+/).filter((part) => part.length > 2).forEach((part) => set.add(part)); + } + return Array.from(set); +} + +function normalizeMatchText(value) { + return String(value ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function resolveEncounterPreset(formState) { + const auto = (() => { + if (formState.encounterFaction !== "auto") return formState.encounterFaction; + if (["sla-mothership", "sla-industries-brp"].includes(game.system?.id ?? "")) return "sla"; + if (formState.mode === "wilderness") return "wildlife"; + if (formState.mode === "dungeon") return formState.theme === "crypt" ? "undead" : "cult"; + if (["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(formState.theme)) return "cult"; + return "marines"; + })(); + + const presets = { + corporate: { key: "corporate", icon: "icons/svg/shield.svg", names: ["Guard", "Security", "Response Team"] }, + marines: { key: "marines", icon: "icons/svg/sword.svg", names: ["Trooper", "Marine", "Assault Team"] }, + cult: { key: "cult", icon: "icons/svg/eye.svg", names: ["Cultist", "Fanatic", "Acolyte"] }, + undead: { key: "undead", icon: "icons/svg/skull.svg", names: ["Undead", "Wight", "Ghoul"] }, + wildlife: { key: "wildlife", icon: "icons/svg/pawprint.svg", names: ["Beast", "Predator", "Lurker"] }, + sla: { key: "sla", icon: "icons/svg/target.svg", names: ["Operative", "Contractor", "Sector Team"] } + }; + return presets[auto] ?? presets.marines; +} + +function determineEncounterCount(room, densityBase) { + const bonus = ["boss-chamber", "barracks", "camp", "encounter-zone"].includes(room.type) ? 1 : 0; + return Math.max(1, densityBase + bonus - (["vault", "basement"].includes(room.type) ? 1 : 0)); +} + +function encounterOffset(index, count) { + if (count <= 1) return { x: 0, y: 0 }; + const positions = [ + { x: -0.35, y: -0.2 }, + { x: 0.35, y: -0.2 }, + { x: -0.25, y: 0.28 }, + { x: 0.25, y: 0.28 } + ]; + return positions[index % positions.length]; +} + +function buildEncounterName(preset, room, counter) { + const base = preset.names[counter % preset.names.length]; + const suffix = ENCOUNTER_ROOM_LABELS[room.type] ?? room.label ?? "Hostile"; + return `${base} (${suffix})`; +} + +function splitRectExactCount(root, targetCount, splitBias, minRoomSize, rng) { + const rooms = [{ ...root }]; + while (rooms.length < targetCount) { + const candidates = rooms + .map((room, index) => ({ room, index })) + .filter(({ room }) => room.width >= minRoomSize * 2 || room.height >= minRoomSize * 2) + .sort((a, b) => (b.room.width * b.room.height) - (a.room.width * a.room.height)); + const candidate = candidates[0]; + if (!candidate) break; + const room = rooms.splice(candidate.index, 1)[0]; + const splitVertical = chooseSplitOrientation(room, splitBias, rng); + if (splitVertical && room.width >= minRoomSize * 2) { + const split = randomInt(rng, minRoomSize, room.width - minRoomSize); + rooms.push( + { x: room.x, y: room.y, width: split, height: room.height }, + { x: room.x + split, y: room.y, width: room.width - split, height: room.height } + ); + continue; + } + if (room.height >= minRoomSize * 2) { + const split = randomInt(rng, minRoomSize, room.height - minRoomSize); + rooms.push( + { x: room.x, y: room.y, width: room.width, height: split }, + { x: room.x, y: room.y + split, width: room.width, height: room.height - split } + ); + continue; + } + rooms.push(room); + break; + } + return rooms.slice(0, targetCount); +} + +function buildSpineWalls(spineRect, rooms) { + const northEdge = spineRect.y; + const southEdge = spineRect.y + spineRect.height; + const northRooms = rooms.filter((room) => room.y + room.height === northEdge); + const southRooms = rooms.filter((room) => room.y === southEdge); + const segments = []; + + segments.push(...edgeWithGaps( + { x1: spineRect.x, y1: northEdge, x2: spineRect.x + spineRect.width, y2: northEdge }, + northRooms.map((room) => ({ x1: room.x + 1, y1: northEdge, x2: room.x + Math.max(1, room.width - 1), y2: northEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + + segments.push(...edgeWithGaps( + { x1: spineRect.x, y1: southEdge, x2: spineRect.x + spineRect.width, y2: southEdge }, + southRooms.map((room) => ({ x1: room.x + 1, y1: southEdge, x2: room.x + Math.max(1, room.width - 1), y2: southEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + + return segments; +} + +function buildHallwayWalls(hallRect, rooms) { + const segments = []; + const isHorizontal = hallRect.width >= hallRect.height; + if (isHorizontal) { + const northEdge = hallRect.y; + const southEdge = hallRect.y + hallRect.height; + const northRooms = rooms.filter((room) => room.y + room.height === northEdge); + const southRooms = rooms.filter((room) => room.y === southEdge); + segments.push(...edgeWithGaps( + { x1: hallRect.x, y1: northEdge, x2: hallRect.x + hallRect.width, y2: northEdge }, + northRooms.map((room) => ({ x1: room.x + 1, y1: northEdge, x2: room.x + Math.max(1, room.width - 1), y2: northEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + segments.push(...edgeWithGaps( + { x1: hallRect.x, y1: southEdge, x2: hallRect.x + hallRect.width, y2: southEdge }, + southRooms.map((room) => ({ x1: room.x + 1, y1: southEdge, x2: room.x + Math.max(1, room.width - 1), y2: southEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + } else { + const westEdge = hallRect.x; + const eastEdge = hallRect.x + hallRect.width; + const westRooms = rooms.filter((room) => room.x + room.width === westEdge); + const eastRooms = rooms.filter((room) => room.x === eastEdge); + segments.push(...edgeWithGaps( + { x1: westEdge, y1: hallRect.y, x2: westEdge, y2: hallRect.y + hallRect.height }, + westRooms.map((room) => ({ x1: westEdge, y1: room.y + 1, x2: westEdge, y2: room.y + Math.max(1, room.height - 1) })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + segments.push(...edgeWithGaps( + { x1: eastEdge, y1: hallRect.y, x2: eastEdge, y2: hallRect.y + hallRect.height }, + eastRooms.map((room) => ({ x1: eastEdge, y1: room.y + 1, x2: eastEdge, y2: room.y + Math.max(1, room.height - 1) })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + } + return segments; +} + +function edgeWithGaps(edge, gaps) { + if (edge.y1 === edge.y2) { + return splitLinearEdge(edge.x1, edge.x2, edge.y1, gaps.map((gap) => [gap.x1, gap.x2]), "h"); + } + return splitLinearEdge(edge.y1, edge.y2, edge.x1, gaps.map((gap) => [gap.y1, gap.y2]), "v"); +} + +function splitLinearEdge(start, end, fixed, gaps, orientation) { + const normalized = gaps + .map(([a, b]) => [Math.max(start, Math.min(a, b)), Math.min(end, Math.max(a, b))]) + .filter(([a, b]) => b > a) + .sort((a, b) => a[0] - b[0]); + const segments = []; + let cursor = start; + for (const [gapStart, gapEnd] of normalized) { + if (gapStart > cursor) { + segments.push(orientation === "h" + ? { x1: cursor, y1: fixed, x2: gapStart, y2: fixed } + : { x1: fixed, y1: cursor, x2: fixed, y2: gapStart }); + } + segments.push(orientation === "h" + ? { x1: gapStart, y1: fixed, x2: gapEnd, y2: fixed, gap: true } + : { x1: fixed, y1: gapStart, x2: fixed, y2: gapEnd, gap: true }); + cursor = gapEnd; + } + if (cursor < end) { + segments.push(orientation === "h" + ? { x1: cursor, y1: fixed, x2: end, y2: fixed } + : { x1: fixed, y1: cursor, x2: fixed, y2: end }); + } + return segments; +} + +function chooseSplitOrientation(room, splitBias, rng) { + if (splitBias === "horizontal") return false; + if (splitBias === "vertical") return true; + if (room.width === room.height) return rng() > 0.5; + return room.width > room.height; +} + +function buildPartitionSegments(rooms, rng) { + const segments = []; + const seen = new Set(); + for (let i = 0; i < rooms.length; i += 1) { + for (let j = i + 1; j < rooms.length; j += 1) { + const shared = sharedWall(rooms[i], rooms[j]); + if (!shared) continue; + const key = shared.orientation === "v" + ? `v:${shared.x}:${shared.y1}:${shared.y2}` + : `h:${shared.y}:${shared.x1}:${shared.x2}`; + if (seen.has(key)) continue; + seen.add(key); + segments.push(...splitSharedWall(shared, rng)); + } + } + return segments; +} + +function dedupeLights(lights) { + const seen = new Set(); + return lights.filter((light) => { + const key = `${light.x},${light.y},${light.config?.dim ?? 0},${light.config?.bright ?? 0}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function sharedWall(a, b) { + if (a.x + a.width === b.x || b.x + b.width === a.x) { + const x = a.x + a.width === b.x ? b.x : a.x; + const y1 = Math.max(a.y, b.y); + const y2 = Math.min(a.y + a.height, b.y + b.height); + if (y2 - y1 >= 2) return { orientation: "v", x, y1, y2 }; + } + if (a.y + a.height === b.y || b.y + b.height === a.y) { + const y = a.y + a.height === b.y ? b.y : a.y; + const x1 = Math.max(a.x, b.x); + const x2 = Math.min(a.x + a.width, b.x + b.width); + if (x2 - x1 >= 2) return { orientation: "h", y, x1, x2 }; + } + return null; +} + +function splitSharedWall(shared, rng) { + if (shared.orientation === "v") { + const doorY = randomInt(rng, shared.y1, shared.y2 - 1); + const out = []; + if (doorY > shared.y1) out.push({ x1: shared.x, y1: shared.y1, x2: shared.x, y2: doorY }); + out.push({ x1: shared.x, y1: doorY, x2: shared.x, y2: doorY + 1, door: true, ds: 0 }); + if (shared.y2 > doorY + 1) out.push({ x1: shared.x, y1: doorY + 1, x2: shared.x, y2: shared.y2 }); + return out; + } + const doorX = randomInt(rng, shared.x1, shared.x2 - 1); + const out = []; + if (doorX > shared.x1) out.push({ x1: shared.x1, y1: shared.y, x2: doorX, y2: shared.y }); + out.push({ x1: doorX, y1: shared.y, x2: doorX + 1, y2: shared.y, door: true, ds: 0 }); + if (shared.x2 > doorX + 1) out.push({ x1: doorX + 1, y1: shared.y, x2: shared.x2, y2: shared.y }); + return out; +} + +function buildHullDoors(hull, rng) { + const options = [ + { x1: hull.x + Math.floor(hull.width / 2), y1: hull.y, x2: hull.x + Math.floor(hull.width / 2) + 1, y2: hull.y }, + { x1: hull.x + Math.floor(hull.width / 2), y1: hull.y + hull.height, x2: hull.x + Math.floor(hull.width / 2) + 1, y2: hull.y + hull.height }, + { x1: hull.x, y1: hull.y + Math.floor(hull.height / 2), x2: hull.x, y2: hull.y + Math.floor(hull.height / 2) + 1 }, + { x1: hull.x + hull.width, y1: hull.y + Math.floor(hull.height / 2), x2: hull.x + hull.width, y2: hull.y + Math.floor(hull.height / 2) + 1 } + ]; + return [options[randomInt(rng, 0, options.length - 1)]]; +} + +function buildHullDoorsFromMask(hullMask, rng) { + const segments = buildHullWallSegments(hullMask).filter((segment) => { + const length = Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + return length >= 1; + }); + if (!segments.length) return []; + const picks = []; + const desiredCount = segments.length > 40 ? 2 : 1; + const used = new Set(); + while (picks.length < desiredCount && used.size < segments.length) { + const segment = segments[randomInt(rng, 0, segments.length - 1)]; + const key = `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`; + if (used.has(key)) continue; + used.add(key); + if (segment.x1 === segment.x2) { + const y = Math.min(segment.y1, segment.y2); + picks.push({ x1: segment.x1, y1: y, x2: segment.x2, y2: y + 1 }); + } else { + const x = Math.min(segment.x1, segment.x2); + picks.push({ x1: x, y1: segment.y1, x2: x + 1, y2: segment.y2 }); + } + } + return picks.length ? picks : buildHullDoors(boundingRectFromRegions([{ x: 1, y: 1, width: hullMask[0]?.length ?? 10, height: hullMask.length }]), rng); +} + +function rectangleSegments(x, y, width, height, door = false) { + return [ + { x1: x, y1: y, x2: x + width, y2: y, door }, + { x1: x + width, y1: y, x2: x + width, y2: y + height, door }, + { x1: x + width, y1: y + height, x2: x, y2: y + height, door }, + { x1: x, y1: y + height, x2: x, y2: y, door } + ]; +} + +function buildOccupancyWalls(occupancy, roomType, roomIdGrid, options = {}) { + const edges = []; + const thresholds = []; + const roomDoorTypes = options.roomDoorTypes ?? {}; + const height = occupancy.length; + const width = occupancy[0]?.length ?? 0; + const dirs = [ + { dx: 0, dy: -1, key: "n" }, + { dx: 1, dy: 0, key: "e" }, + { dx: 0, dy: 1, key: "s" }, + { dx: -1, dy: 0, key: "w" } + ]; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (!occupancy[y]?.[x]) continue; + for (const dir of dirs) { + const nx = x + dir.dx; + const ny = y + dir.dy; + if (nx < 0 || ny < 0 || nx >= width || ny >= height || !occupancy[ny]?.[nx]) { + edges.push(cellEdge(x, y, dir.key)); + continue; + } + if (roomType[y]?.[x] === "room" && roomType[ny]?.[nx] === "corridor") { + thresholds.push({ + ...cellEdge(x, y, dir.key), + roomId: roomIdGrid[y]?.[x] || "" + }); + } + } + } + } + + const uniqueEdges = dedupeSegments(edges); + const thresholdEdges = dedupeSegments(thresholds, true); + const doorEdges = chooseCorridorDoorEdges(thresholdEdges); + const doorKeys = new Set(doorEdges.map((edge) => segmentKey(edge))); + const thresholdWalls = thresholdEdges + .filter((edge) => !doorKeys.has(segmentKey(edge))) + .map(({ roomId, ...edge }) => edge); + const wallSegments = [...uniqueEdges.map((edge) => ({ ...edge })), ...thresholdWalls]; + for (const edge of doorEdges) { + const { roomId, ...doorEdge } = edge; + const doorType = roomDoorTypes[roomId] ?? "normal"; + if (doorType === "none") { + wallSegments.push(doorEdge); + continue; + } + const ds = doorType === "locked" ? DOOR_STATE_LOCKED : DOOR_STATE_OPEN; + wallSegments.push({ ...doorEdge, door: true, ds }); + } + return dedupeSegments(wallSegments, true); +} + +function carveCorridor(from, to, occupancy, roomType, width, hullMask) { + const path = findHullPath(from, to, hullMask); + if (!path.length) return []; + + const painted = new Set(); + for (const point of path) { + const brush = corridorBrush(point, width, hullMask); + for (const cell of brush) { + painted.add(`${cell.x},${cell.y}`); + if (occupancy[cell.y]?.[cell.x] !== undefined) occupancy[cell.y][cell.x] = true; + if (roomType[cell.y]?.[cell.x] !== undefined && roomType[cell.y][cell.x] !== "room") { + roomType[cell.y][cell.x] = "corridor"; + } + } + } + + const rects = compressCellsToRects(Array.from(painted).map((key) => { + const [x, y] = key.split(",").map(Number); + return { x, y }; + })); + + return rects.map((rect) => ({ + ...rect, + waypoints: path.map((point) => ({ x: point.x, y: point.y })), + startDoor: "door", + endDoor: "door" + })); +} + +function ensureCorridorConnectivity(rooms, connections, corridors, occupancy, roomType, corridorWidth, hullMask, formState = null) { + if (rooms.length <= 1) return; + + const connected = reachableRoomIds(rooms, connections); + const reachable = new Set(connected); + + while (reachable.size < rooms.length) { + const unreachableRooms = rooms.filter((room) => !reachable.has(room.id)); + if (!unreachableRooms.length) break; + + let repaired = false; + for (const room of unreachableRooms) { + const rescueTarget = nearestReachableRoom(room, rooms, reachable); + if (!rescueTarget) continue; + const width = formState ? corridorWidthForConnection(room, rescueTarget, formState) : corridorWidth; + const hall = carveCorridor(roomCenter(room), roomCenter(rescueTarget), occupancy, roomType, width, hullMask); + if (!hall.length) continue; + corridors.push(...hall); + connections.push([room.id, rescueTarget.id]); + const updated = reachableRoomIds(rooms, connections); + reachable.clear(); + for (const id of updated) reachable.add(id); + repaired = true; + break; + } + + if (!repaired) break; + } +} + +function corridorWidthForConnection(roomA, roomB, formState) { + const important = new Set(["bridge", "engineering", "boss-chamber", "throne-room", "entrance", "reactor"]); + if (important.has(roomA?.type) || important.has(roomB?.type)) return Math.max(2, formState.corridorWidth); + return formState.corridorWidth; +} + +function roomCenter(room) { + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + return { + x: room.circleX + room.circleR, + y: room.circleY + room.circleR + }; + } + return { + x: room.x + Math.floor(room.width / 2), + y: room.y + Math.floor(room.height / 2) + }; +} + +function roomBounds(room) { + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + return { + x: room.circleX, + y: room.circleY, + width: room.circleR * 2, + height: room.circleR * 2 + }; + } + if (room.shape === "polygon" && room.points?.length) { + const xs = room.points.map((point) => point.x); + const ys = room.points.map((point) => point.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + return { + x: room.x, + y: room.y, + width: room.width, + height: room.height + }; +} + +function pointInRoom(point, room) { + return point.x >= room.x + && point.x < room.x + room.width + && point.y >= room.y + && point.y < room.y + room.height; +} + +function nearestRoomForIndex(rooms, index) { + const current = roomCenter(rooms[index]); + let nearest = rooms[0]; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < index; i += 1) { + const centre = roomCenter(rooms[i]); + const distance = Math.abs(current.x - centre.x) + Math.abs(current.y - centre.y); + if (distance < bestDistance) { + bestDistance = distance; + nearest = rooms[i]; + } + } + return nearest; +} + +function nearestReachableRoom(room, rooms, reachableIds) { + const current = roomCenter(room); + let nearest = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const candidate of rooms) { + if (!reachableIds.has(candidate.id) || candidate.id === room.id) continue; + const centre = roomCenter(candidate); + const distance = Math.abs(current.x - centre.x) + Math.abs(current.y - centre.y); + if (distance < bestDistance) { + bestDistance = distance; + nearest = candidate; + } + } + return nearest; +} + +function makeGrid(width, height, value) { + return Array.from({ length: height }, () => Array.from({ length: width }, () => value)); +} + +function fillRectCells(grid, rect, value) { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + if (grid[y]?.[x] !== undefined) grid[y][x] = value; + } + } +} + +function rectsOverlap(a, b) { + return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y); +} + +function expandRect(rect, amount) { + return { + x: rect.x - amount, + y: rect.y - amount, + width: rect.width + amount * 2, + height: rect.height + amount * 2 + }; +} + +function cellEdge(x, y, side) { + if (side === "n") return { x1: x, y1: y, x2: x + 1, y2: y }; + if (side === "s") return { x1: x, y1: y + 1, x2: x + 1, y2: y + 1 }; + if (side === "e") return { x1: x + 1, y1: y, x2: x + 1, y2: y + 1 }; + return { x1: x, y1: y, x2: x, y2: y + 1 }; +} + +function dedupeSegments(segments, preserveDoor = false) { + const map = new Map(); + for (const segment of segments) { + const key = `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`; + if (!preserveDoor || !map.has(key) || segment.door) { + map.set(key, segment); + } + } + return Array.from(map.values()); +} + +function chooseCorridorDoorEdges(thresholdEdges) { + const groupedByRoom = new Map(); + for (const edge of thresholdEdges) { + const roomId = edge.roomId || "unknown"; + if (!groupedByRoom.has(roomId)) groupedByRoom.set(roomId, []); + groupedByRoom.get(roomId).push(edge); + } + + const doorEdges = []; + for (const edges of groupedByRoom.values()) { + const runs = groupContiguousDoorRuns(edges); + for (const run of runs) { + doorEdges.push(run[Math.floor(run.length / 2)]); + } + } + return dedupeSegments(doorEdges, true); +} + +function groupContiguousDoorRuns(edges) { + const buckets = new Map(); + for (const edge of edges) { + const orientation = edge.y1 === edge.y2 ? "h" : "v"; + const line = orientation === "h" ? edge.y1 : edge.x1; + const bucketKey = `${orientation}:${line}`; + if (!buckets.has(bucketKey)) buckets.set(bucketKey, []); + buckets.get(bucketKey).push(edge); + } + + const runs = []; + for (const bucket of buckets.values()) { + bucket.sort((a, b) => { + if (a.y1 === a.y2) return a.x1 - b.x1; + return a.y1 - b.y1; + }); + + let currentRun = []; + for (const edge of bucket) { + if (!currentRun.length) { + currentRun.push(edge); + continue; + } + const previous = currentRun[currentRun.length - 1]; + const contiguous = previous.y1 === previous.y2 + ? previous.x2 === edge.x1 + : previous.y2 === edge.y1; + if (contiguous) { + currentRun.push(edge); + } else { + runs.push(currentRun); + currentRun = [edge]; + } + } + if (currentRun.length) runs.push(currentRun); + } + + return runs; +} + +function reachableRoomIds(rooms, connections) { + if (!rooms.length) return new Set(); + const adjacency = new Map(rooms.map((room) => [room.id, new Set()])); + for (const [a, b] of connections) { + adjacency.get(a)?.add(b); + adjacency.get(b)?.add(a); + } + + const reachable = new Set([rooms[0].id]); + const queue = [rooms[0].id]; + while (queue.length) { + const current = queue.shift(); + for (const next of adjacency.get(current) ?? []) { + if (reachable.has(next)) continue; + reachable.add(next); + queue.push(next); + } + } + return reachable; +} + +function weightedChoice(entries, rng) { + const total = entries.reduce((sum, entry) => sum + (entry.weight ?? 1), 0); + let roll = rng() * total; + for (const entry of entries) { + roll -= entry.weight ?? 1; + if (roll <= 0) return entry; + } + return entries[entries.length - 1]; +} + +function paletteForFormState(formState) { + if (!formState?.compactPrintMode) return themePalette(formState?.theme); + return { + base: "#ffffff", + fill: "#ffffff", + line: "#d4d4d4", + detail: "#888888", + hull: "#222222", + roomFill: "#f7f7f7", + roomStroke: "#222222", + corridorFill: "#ececec", + corridorStripe: "#c8c8c8", + label: "#111111", + coordText: "#444444", + coordCell: "#777777", + connectorFill: "#ffffff", + connectorStroke: "#444444", + connectorText: "#111111", + furnitureFill: "#dcdcdc", + furnitureStroke: "#666666", + furnitureLabel: "#111111", + secretDoor: "#555555", + bloodStain: "#999999", + water: "#c9d7e5", + treeColor: "#aeb8a0" + }; +} + +function themePalette(theme) { + switch (theme) { + case "stone": + return { base: "#4a453e", fill: "#5c5650", line: "#2e2b26", detail: "#8c8278", hull: "#1a1714", roomFill: "#c8c0b4", roomStroke: "#1a1714", corridorFill: "#a89e94", corridorStripe: "#c5bbaf", label: "#f5f0e8", coordText: "#ede6da", coordCell: "#ddd4c4", connectorFill: "#57443b", connectorStroke: "#d3b798", connectorText: "#fff5e6", furnitureFill: "#5a5040", furnitureStroke: "#8a7860", furnitureLabel: "#fff5e6", secretDoor: "#884422", water: "#33485b", bloodStain: "#5a2720" }; + case "cave": + return { base: "#2e3530", fill: "#3d4840", line: "#181f1c", detail: "#6e7f72", hull: "#0d110e", roomFill: "#8fa690", roomStroke: "#0d110e", corridorFill: "#6d8070", corridorStripe: "#91a094", label: "#e8f5ec", coordText: "#d8eadd", coordCell: "#c4d8c8", connectorFill: "#30453a", connectorStroke: "#9fd0af", connectorText: "#effff4", furnitureFill: "#3a3028", furnitureStroke: "#7a6a50", furnitureLabel: "#effff4", secretDoor: "#9c5f3a", water: "#1f4960", bloodStain: "#4a1714" }; + case "wood": + case "wood-floor": + return { base: "#4a3828", fill: "#5c4a35", line: "#2b2015", detail: "#9c7c56", hull: "#160f08", roomFill: "#c8aa88", roomStroke: "#1a1008", corridorFill: "#a88c6c", label: "#fff4e2", coordText: "#f5e4c4", coordCell: "#e8d0a8", connectorFill: "#5d3922", connectorStroke: "#dca86a", connectorText: "#fff1dd", furnitureFill: "#6a4a28", furnitureStroke: "#9a7040", furnitureLabel: "#fff1dd", secretDoor: "#a45d2d" }; + case "crypt": + return { base: "#2a2e35", fill: "#373d46", line: "#151820", detail: "#6070a0", hull: "#0a0c12", roomFill: "#a0aac0", roomStroke: "#0a0c12", corridorFill: "#808898", corridorStripe: "#a4adbf", label: "#e8eeff", coordText: "#d4d8f0", coordCell: "#c0c8e0", connectorFill: "#283152", connectorStroke: "#91a7de", connectorText: "#eef3ff", furnitureFill: "#3a3040", furnitureStroke: "#7a6a8a", furnitureLabel: "#eef3ff", secretDoor: "#b36b58", water: "#27395a", bloodStain: "#4c151d" }; + case "sewer": + return { base: "#2e3828", fill: "#3c4835", line: "#1a2018", detail: "#7e9060", hull: "#0e1410", roomFill: "#a0b890", roomStroke: "#0e1410", corridorFill: "#7c9870", corridorStripe: "#9bb38f", label: "#eafff0", coordText: "#d4f0da", coordCell: "#b8e0c0", connectorFill: "#3c4a28", connectorStroke: "#b5d06d", connectorText: "#f6ffe2", furnitureFill: "#2a3a28", furnitureStroke: "#5a7a50", furnitureLabel: "#f6ffe2", secretDoor: "#996644", water: "#2b4731", bloodStain: "#4f241c" }; + case "concrete-office": + return { base: "#5a5f64", fill: "#767b80", line: "#2f3438", detail: "#a5adb7", hull: "#171a1d", roomFill: "#dddfe1", roomStroke: "#23272a", corridorFill: "#cacdd1", corridorStripe: "#9fc7f1", label: "#ffffff", coordText: "#f0f4f8", coordCell: "#d8dfe6", connectorFill: "#42515e", connectorStroke: "#9fc7f1", connectorText: "#eef7ff", furnitureFill: "#c0c8d0", furnitureStroke: "#8090a0", furnitureLabel: "#243442", secretDoor: "#b54b38", bloodStain: "#781d1d" }; + case "brick": + return { base: "#4c3a36", fill: "#604944", line: "#2b1f1b", detail: "#9e7568", hull: "#150f0d", roomFill: "#d7c4bf", roomStroke: "#1c1412", corridorFill: "#c0aaa4", label: "#fff2ef", coordText: "#f1ddd8", coordCell: "#e3c8c0", connectorFill: "#5b3429", connectorStroke: "#d59e88", connectorText: "#fff0eb", furnitureFill: "#7a5040", furnitureStroke: "#a06050", furnitureLabel: "#fff0eb", secretDoor: "#b84d3a" }; + case "carpet": + return { base: "#3d2e44", fill: "#4d3a55", line: "#24192a", detail: "#8f73a1", hull: "#110b15", roomFill: "#d6cbdd", roomStroke: "#1e1623", corridorFill: "#c0b0ca", label: "#faf3ff", coordText: "#eadcf0", coordCell: "#dbc9e3", connectorFill: "#493660", connectorStroke: "#c4a7e6", connectorText: "#fbf3ff", furnitureFill: "#6a4a6a", furnitureStroke: "#9a7090", furnitureLabel: "#fbf3ff", secretDoor: "#d26767" }; + case "tile": + return { base: "#405055", fill: "#57666b", line: "#223036", detail: "#95b4b9", hull: "#11181b", roomFill: "#d9e3e4", roomStroke: "#1a2529", corridorFill: "#c3d1d2", label: "#f5ffff", coordText: "#dcedef", coordCell: "#cae0e2", connectorFill: "#2f5962", connectorStroke: "#8fd3dc", connectorText: "#eeffff", furnitureFill: "#c0c8c0", furnitureStroke: "#808888", furnitureLabel: "#274156", secretDoor: "#c05042" }; + case "industrial": + return { base: "#45413d", fill: "#5c5751", line: "#2f2c29", detail: "#bb8b3d", hull: "#121110", roomFill: "#c8c3ba", roomStroke: "#151515", corridorFill: "#b8b1a6", corridorStripe: "#d5a852", label: "#f7efe2", coordText: "#f6efe4", coordCell: "#eee6da", connectorFill: "#4b3c23", connectorStroke: "#d5a852", connectorText: "#fff1c8", furnitureFill: "#4a3a2a", furnitureStroke: "#7a6040", furnitureLabel: "#fff1c8", secretDoor: "#c65f40", bloodStain: "#7a1e10" }; + case "derelict": + return { base: "#313538", fill: "#44484b", line: "#1f2123", detail: "#6e4a3b", hull: "#0d0e10", roomFill: "#b8b1aa", roomStroke: "#111111", corridorFill: "#8e857d", corridorStripe: "#b38b72", label: "#f4dcc8", coordText: "#e8d7c6", coordCell: "#dcc9b4", connectorFill: "#402a24", connectorStroke: "#c38b72", connectorText: "#fde6db", furnitureFill: "#2a2a2a", furnitureStroke: "#5a5050", furnitureLabel: "#fde6db", secretDoor: "#aa5b4a", bloodStain: "#632420" }; + case "alien-organic": + return { base: "#232b28", fill: "#31413b", line: "#18211d", detail: "#7db39a", hull: "#09100d", roomFill: "#98b7aa", roomStroke: "#101715", corridorFill: "#7c9e92", label: "#e2fff1", coordText: "#d7f8eb", coordCell: "#c7ebdd", connectorFill: "#28483d", connectorStroke: "#96d7ba", connectorText: "#effff7", furnitureFill: "#1a3a1a", furnitureStroke: "#3a7a3a", furnitureLabel: "#effff7", secretDoor: "#c44f44" }; + case "clean-corporate": + return { base: "#d6dde2", fill: "#e5ecef", line: "#bcc8cf", detail: "#8fb7cf", hull: "#58656d", roomFill: "#f8fbfd", roomStroke: "#65717a", corridorFill: "#ebf1f4", corridorStripe: "#8fb7cf", label: "#35424c", coordText: "#5e6b76", coordCell: "#74828d", connectorFill: "#e7f1f8", connectorStroke: "#5f8cad", connectorText: "#274156", furnitureFill: "#d0d8e0", furnitureStroke: "#8090a0", furnitureLabel: "#274156", secretDoor: "#c45446", bloodStain: "#8f3e3e" }; + case "derelict-house": + return { base: "#40352f", fill: "#5a4a42", line: "#2b221e", detail: "#8d776c", hull: "#140f0d", roomFill: "#d1c1b1", roomStroke: "#1d1613", corridorFill: "#b4a293", corridorStripe: "#d9c8b6", label: "#fff1e6", coordText: "#e9d6cb", coordCell: "#d5bfb1", connectorFill: "#5d453a", connectorStroke: "#c7a78b", connectorText: "#fff2e6", furnitureFill: "#6a5248", furnitureStroke: "#8f7564", furnitureLabel: "#fff2e6", secretDoor: "#b4654a", bloodStain: "#5e1818" }; + case "asylum": + return { base: "#566165", fill: "#d6ddd9", line: "#8c9893", detail: "#7a6f67", hull: "#1d2325", roomFill: "#f2f4f1", roomStroke: "#384246", corridorFill: "#dde2de", corridorStripe: "#b5bdb8", label: "#243136", coordText: "#4b585e", coordCell: "#728086", connectorFill: "#77868d", connectorStroke: "#dbe8e2", connectorText: "#122026", furnitureFill: "#b9c1bc", furnitureStroke: "#7f8884", furnitureLabel: "#122026", secretDoor: "#8e3c3c", bloodStain: "#3a1010" }; + case "haunted-manor": + return { base: "#2d2230", fill: "#4b3640", line: "#24161c", detail: "#9d7a64", hull: "#120c10", roomFill: "#cfbcae", roomStroke: "#1d1317", corridorFill: "#b19a8e", corridorStripe: "#d7c0a1", label: "#fff0e2", coordText: "#ead8cb", coordCell: "#d7c2b4", connectorFill: "#523844", connectorStroke: "#d3b59a", connectorText: "#fff3e6", furnitureFill: "#6a5045", furnitureStroke: "#8c6c5c", furnitureLabel: "#fff3e6", secretDoor: "#a34747", bloodStain: "#4f0f17" }; + case "bloodbath": + return { base: "#26191b", fill: "#4a3030", line: "#1a0f11", detail: "#8e6666", hull: "#0e0708", roomFill: "#c9b7b4", roomStroke: "#180f11", corridorFill: "#9f8480", corridorStripe: "#d6aaaa", label: "#fff0ef", coordText: "#f0dada", coordCell: "#d9bcbc", connectorFill: "#5d2c2c", connectorStroke: "#e19c9c", connectorText: "#fff2f2", furnitureFill: "#6a3a3a", furnitureStroke: "#9a6666", furnitureLabel: "#fff2f2", secretDoor: "#d05050", bloodStain: "#650d0d" }; + case "forest": + return { base: "#3f5136", fill: "#67855a", line: "#2b3926", detail: "#91b57e", hull: "#162013", roomFill: "#b5c8a3", roomStroke: "#23301f", corridorFill: "#8f7a54", corridorStripe: "#c8b07a", label: "#f3ffe8", coordText: "#e0f3d7", coordCell: "#cde2c5", connectorFill: "#405935", connectorStroke: "#a8d08d", connectorText: "#f6fff1", furnitureFill: "#6f5a3b", furnitureStroke: "#8d744e", furnitureLabel: "#f6fff1", secretDoor: "#8c4d2c", bloodStain: "#5c221b", water: "#2b5a78", treeColor: "#3d6a35" }; + case "jungle": + return { base: "#2e4a34", fill: "#4e7a4c", line: "#1c2f21", detail: "#7eb86a", hull: "#122017", roomFill: "#98b78d", roomStroke: "#1d2d1f", corridorFill: "#7a6a48", corridorStripe: "#b59d63", label: "#f1ffe9", coordText: "#d7f0d0", coordCell: "#c3dfbc", connectorFill: "#2f5332", connectorStroke: "#95d07a", connectorText: "#f3fff0", furnitureFill: "#5a4a31", furnitureStroke: "#7a6846", furnitureLabel: "#f3fff0", secretDoor: "#7d4927", bloodStain: "#5b2018", water: "#1f5b52", treeColor: "#2f7f33" }; + case "plains": + return { base: "#7d8d5a", fill: "#a8bb70", line: "#647045", detail: "#d9d08a", hull: "#3c4327", roomFill: "#d7d8ac", roomStroke: "#55603b", corridorFill: "#8e7b55", corridorStripe: "#d0bb82", label: "#fffde7", coordText: "#f3f0d6", coordCell: "#e2ddb9", connectorFill: "#7f7448", connectorStroke: "#e0cd8f", connectorText: "#fffde7", furnitureFill: "#8b754c", furnitureStroke: "#a68f60", furnitureLabel: "#fffde7", secretDoor: "#945930", bloodStain: "#6a2b20", water: "#5d8ba3", treeColor: "#6f8b42" }; + case "swamp": + return { base: "#3d4632", fill: "#5e7051", line: "#27301f", detail: "#8ea16d", hull: "#151b11", roomFill: "#aab08a", roomStroke: "#283120", corridorFill: "#6d6243", corridorStripe: "#a19563", label: "#f0f8e2", coordText: "#dbe7cb", coordCell: "#c7d6b8", connectorFill: "#4b5030", connectorStroke: "#b9c58a", connectorText: "#f7ffe8", furnitureFill: "#5f5839", furnitureStroke: "#7c7152", furnitureLabel: "#f7ffe8", secretDoor: "#8d5134", bloodStain: "#58271f", water: "#304e43", treeColor: "#4d6f39" }; + case "tundra": + return { base: "#7d8896", fill: "#b9c4ce", line: "#66707d", detail: "#eef4fa", hull: "#3f4750", roomFill: "#dde5ea", roomStroke: "#55606b", corridorFill: "#a9b6bf", corridorStripe: "#e8edf2", label: "#ffffff", coordText: "#eef4fa", coordCell: "#dbe4ec", connectorFill: "#6b7e8d", connectorStroke: "#eef4fa", connectorText: "#ffffff", furnitureFill: "#9eaab4", furnitureStroke: "#c6d0d8", furnitureLabel: "#2b3440", secretDoor: "#8a4b4b", bloodStain: "#7a2929", water: "#7fb4d3", treeColor: "#b7cbd5" }; + case "badlands": + return { base: "#6c5642", fill: "#9a7a57", line: "#4a3828", detail: "#c49a63", hull: "#2a1e15", roomFill: "#d0bc9a", roomStroke: "#463424", corridorFill: "#8a6946", corridorStripe: "#d1a76d", label: "#fff4e6", coordText: "#f0ddc8", coordCell: "#e2cab1", connectorFill: "#7a5a38", connectorStroke: "#deb17a", connectorText: "#fff4e6", furnitureFill: "#7d6246", furnitureStroke: "#a88864", furnitureLabel: "#fff4e6", secretDoor: "#aa6138", bloodStain: "#6b2318", water: "#5a7485", treeColor: "#7c6d35" }; + case "alien-terminal": + // Phosphor-green CRT aesthetic — Alien / Aliens movie computer terminals + // Very dark green background, bright #00ff41 (classic VT100 phosphor green) for all lines, labels, walls + return { base: "#020c06", fill: "#041208", line: "#001a04", detail: "#00ff41", hull: "#010602", roomFill: "#0a2010", roomStroke: "#001800", corridorFill: "#061510", corridorStripe: "#00cc33", label: "#00ff41", coordText: "#00cc33", coordCell: "#008822", connectorFill: "#0a2a0f", connectorStroke: "#00ff41", connectorText: "#00ff41", furnitureFill: "#0d2a12", furnitureStroke: "#00aa28", furnitureLabel: "#00ff41", secretDoor: "#00ff41", bloodStain: "#003a00", maintenanceFill: "#00ff41" }; + case "steel": + default: + return { base: "#384048", fill: "#505860", line: "#242a30", detail: "#9ba7b0", hull: "#111418", roomFill: "#d7dbe0", roomStroke: "#15191d", corridorFill: "#c5ccd2", corridorStripe: "#8fd4ff", label: "#eff7ff", coordText: "#e9f4fb", coordCell: "#d9e5ef", connectorFill: "#24405c", connectorStroke: "#8fd4ff", connectorText: "#eff8ff", furnitureFill: "#3a4a5a", furnitureStroke: "#5a7090", furnitureLabel: "#eff8ff", secretDoor: "#cc4444", bloodStain: "#7a1e1e" }; + } +} + +const THEME_LIGHT_CONFIG = { + steel: { ambient: true, color: "#a0c0ff", alpha: 0.4, luminosity: 0.5, roomTypes: ["bridge", "engineering", "reactor"] }, + industrial: { ambient: true, color: "#ffb060", alpha: 0.35, luminosity: 0.45, roomTypes: ["engineering", "reactor"] }, + derelict: { ambient: true, color: "#404820", alpha: 0.25, luminosity: 0.3, roomTypes: [] }, + "alien-organic": { ambient: true, color: "#20a040", alpha: 0.3, luminosity: 0.35, roomTypes: ["reactor"] }, + "clean-corporate": { ambient: true, color: "#ffffff", alpha: 0.6, luminosity: 0.7, roomTypes: [] }, + "alien-terminal": { ambient: true, color: "#00ff41", alpha: 0.18, luminosity: 0.22, roomTypes: ["bridge", "reactor", "engineering"] }, + stone: { ambient: false, color: "#ff8020", alpha: 0.5, luminosity: 0.4, roomTypes: ["shrine", "entrance", "guardroom"] }, + cave: { ambient: false, color: "#ff6010", alpha: 0.4, luminosity: 0.35, roomTypes: [] }, + wood: { ambient: false, color: "#ffb040", alpha: 0.55, luminosity: 0.45, roomTypes: ["shrine", "entrance"] }, + "wood-floor": { ambient: true, color: "#ffe8c0", alpha: 0.55, luminosity: 0.6, roomTypes: [] }, + crypt: { ambient: false, color: "#4020a0", alpha: 0.3, luminosity: 0.25, roomTypes: ["ritual-room", "crypt"] }, + sewer: { ambient: false, color: "#204010", alpha: 0.25, luminosity: 0.3, roomTypes: [] }, + "concrete-office": { ambient: true, color: "#ffffff", alpha: 0.7, luminosity: 0.75, roomTypes: [] }, + brick: { ambient: true, color: "#ffe0c0", alpha: 0.5, luminosity: 0.55, roomTypes: ["common-room", "bar"] }, + carpet: { ambient: true, color: "#fff0e0", alpha: 0.6, luminosity: 0.65, roomTypes: [] }, + tile: { ambient: true, color: "#e0f0ff", alpha: 0.65, luminosity: 0.7, roomTypes: [] }, + "derelict-house": { ambient: true, color: "#801010", alpha: 0.18, luminosity: 0.2, roomTypes: ["basement", "boiler-room"] }, + asylum: { ambient: true, color: "#c0c0a0", alpha: 0.22, luminosity: 0.25, roomTypes: ["surgery", "padded-cell"] }, + "haunted-manor": { ambient: false, color: "#4020a0", alpha: 0.15, luminosity: 0.18, roomTypes: ["library", "ballroom"] }, + bloodbath: { ambient: false, color: "#800808", alpha: 0.15, luminosity: 0.15, roomTypes: [] }, + forest: { ambient: true, color: "#b8d88a", alpha: 0.14, luminosity: 0.12, roomTypes: ["camp", "glade"] }, + jungle: { ambient: true, color: "#8cc26c", alpha: 0.12, luminosity: 0.1, roomTypes: ["nest", "ruins"] }, + plains: { ambient: true, color: "#f2df98", alpha: 0.08, luminosity: 0.08, roomTypes: ["camp", "crossing"] }, + swamp: { ambient: true, color: "#8da86a", alpha: 0.12, luminosity: 0.08, roomTypes: ["water", "ambush"] }, + tundra: { ambient: true, color: "#d8ecff", alpha: 0.1, luminosity: 0.1, roomTypes: ["ridge", "camp"] }, + badlands: { ambient: true, color: "#d9a36a", alpha: 0.1, luminosity: 0.08, roomTypes: ["ruins", "ambush"] } +}; + +function applySharedDeckConnectors(decks, formState) { + const connectors = buildSharedDeckConnectorBlueprints(decks[0], formState); + for (let deckIndex = 0; deckIndex < decks.length; deckIndex += 1) { + decks[deckIndex].deckConnectors = connectors.map((connector) => ({ + ...connector, + access: deckAccessLabel(deckIndex, decks.length) + })); + decorateDeckRoomsWithConnectors(decks[deckIndex]); + } +} + +function buildSharedDeckConnectorBlueprints(deck, formState) { + const points = []; + const hullMask = deck.hullMask ?? buildHullMask(deck.columns, deck.rows, deck.hullRegions ?? []); + const primary = findNearestHullCell(hullMask, Math.floor(deck.columns / 2), Math.floor(deck.rows / 2)); + const connectorType = ["dungeon", "building"].includes(formState.mode) ? "stairs" : "elevator"; + const connectorLabel = ["dungeon", "building"].includes(formState.mode) ? "STAIR A" : "LIFT A"; + if (primary) { + points.push({ id: "primary-a", x: primary.x, y: primary.y, label: connectorLabel, type: connectorType }); + } + if (formState.columns >= 24 && formState.rows >= 14) { + const secondary = findNearestHullCell(hullMask, Math.floor(deck.columns * 0.7), Math.floor(deck.rows * 0.35)); + if (secondary && (!primary || secondary.x !== primary.x || secondary.y !== primary.y)) { + const secondaryType = formState.mode === "building" ? "stairs" : "ladder"; + const secondaryLabel = formState.mode === "building" ? "STAIR B" : "LADDER B"; + points.push({ id: "secondary-b", x: secondary.x, y: secondary.y, label: secondaryLabel, type: secondaryType }); + } + } + return points; +} + +function deckAccessLabel(index, deckCount) { + const up = index < deckCount - 1 ? `UP ${index + 2}` : null; + const down = index > 0 ? `DOWN ${index}` : null; + return [up, down].filter(Boolean).join(" / ") || "LINK"; +} + +function decorateDeckRoomsWithConnectors(deck) { + for (const connector of deck.deckConnectors ?? []) { + const room = deck.rooms.find((candidate) => pointInRoom(connector, candidate)); + if (!room) continue; + room.markers ??= []; + room.markers.push({ + type: connector.type === "ladder" ? "Ladder" : connector.type === "stairs" ? "Stairs" : "Elevator", + label: connector.label, + x: connector.x, + y: connector.y, + access: connector.access + }); + } +} + +function buildHullRegions(columns, rows, hullShape, mode) { + const interiorWidth = Math.max(8, columns - 2); + const interiorHeight = Math.max(8, rows - 2); + const x0 = 1; + const y0 = 1; + let shape = mode === "bsp" && hullShape === "ring" ? "cross" : hullShape; + if (shape === "rect") shape = "rectangle"; + + switch (shape) { + case "tapered": { + const frontWidth = Math.max(4, Math.round(interiorWidth * 0.2)); + const midWidth = Math.max(8, Math.round(interiorWidth * 0.55)); + const aftWidth = Math.max(4, interiorWidth - frontWidth - midWidth); + return [ + { x: x0, y: y0 + Math.max(1, Math.floor(interiorHeight * 0.22)), width: frontWidth, height: Math.max(4, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.44))) }, + { x: x0 + frontWidth, y: y0 + Math.max(1, Math.floor(interiorHeight * 0.1)), width: midWidth, height: Math.max(6, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.2))) }, + { x: x0 + frontWidth + midWidth, y: y0 + Math.max(1, Math.floor(interiorHeight * 0.2)), width: aftWidth, height: Math.max(4, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.4))) } + ]; + } + case "hammerhead": { + const headWidth = Math.max(6, Math.round(interiorWidth * 0.28)); + const neckWidth = Math.max(4, Math.round(interiorWidth * 0.16)); + const bodyWidth = Math.max(6, interiorWidth - headWidth - neckWidth); + const headY = y0 + 1; + const headH = Math.max(6, interiorHeight - 2); + const neckH = Math.max(4, Math.round(interiorHeight * 0.42)); + const neckY = y0 + Math.floor((interiorHeight - neckH) / 2); + const bodyY = y0 + Math.max(1, Math.floor(interiorHeight * 0.12)); + const bodyH = Math.max(6, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.24))); + return [ + { x: x0, y: headY, width: headWidth, height: headH }, + { x: x0 + headWidth, y: neckY, width: neckWidth, height: neckH }, + { x: x0 + headWidth + neckWidth, y: bodyY, width: bodyWidth, height: bodyH } + ]; + } + case "cross": { + const stemWidth = Math.max(6, Math.round(interiorWidth * 0.34)); + const barHeight = Math.max(6, Math.round(interiorHeight * 0.34)); + const stemX = x0 + Math.floor((interiorWidth - stemWidth) / 2); + const barY = y0 + Math.floor((interiorHeight - barHeight) / 2); + return [ + { x: stemX, y: y0, width: stemWidth, height: barY - y0 }, + { x: x0, y: barY, width: interiorWidth, height: barHeight }, + { x: stemX, y: barY + barHeight, width: stemWidth, height: y0 + interiorHeight - (barY + barHeight) } + ].filter((region) => region.width >= 3 && region.height >= 3); + } + case "l-shape": { + const leftWidth = Math.max(6, Math.round(interiorWidth * 0.42)); + const bottomHeight = Math.max(6, Math.round(interiorHeight * 0.42)); + return [ + { x: x0, y: y0, width: leftWidth, height: interiorHeight }, + { x: x0 + leftWidth, y: y0 + interiorHeight - bottomHeight, width: interiorWidth - leftWidth, height: bottomHeight } + ]; + } + case "t-shape": { + const stemWidth = Math.max(6, Math.round(interiorWidth * 0.36)); + const barHeight = Math.max(6, Math.round(interiorHeight * 0.34)); + const stemX = x0 + Math.floor((interiorWidth - stemWidth) / 2); + return [ + { x: x0, y: y0, width: interiorWidth, height: barHeight }, + { x: stemX, y: y0 + barHeight, width: stemWidth, height: interiorHeight - barHeight } + ]; + } + case "u-shape": { + const legWidth = Math.max(5, Math.round(interiorWidth * 0.24)); + const baseHeight = Math.max(6, Math.round(interiorHeight * 0.32)); + return [ + { x: x0, y: y0, width: legWidth, height: interiorHeight }, + { x: x0 + interiorWidth - legWidth, y: y0, width: legWidth, height: interiorHeight }, + { x: x0, y: y0 + interiorHeight - baseHeight, width: interiorWidth, height: baseHeight } + ]; + } + case "ring": { + const thickness = Math.max(3, Math.round(Math.min(interiorWidth, interiorHeight) * 0.18)); + return [ + { x: x0, y: y0, width: interiorWidth, height: thickness }, + { x: x0, y: y0 + thickness, width: thickness, height: interiorHeight - thickness * 2 }, + { x: x0 + interiorWidth - thickness, y: y0 + thickness, width: thickness, height: interiorHeight - thickness * 2 }, + { x: x0, y: y0 + interiorHeight - thickness, width: interiorWidth, height: thickness } + ].filter((region) => region.width >= 3 && region.height >= 3); + } + case "rectangle": + default: + return [{ x: x0, y: y0, width: interiorWidth, height: interiorHeight }]; + } +} + +function buildHullMask(columns, rows, regions) { + const mask = makeGrid(columns, rows, false); + for (const region of regions) { + fillRectCells(mask, region, true); + } + return mask; +} + +function boundingRectFromRegions(regions) { + const minX = Math.min(...regions.map((region) => region.x)); + const minY = Math.min(...regions.map((region) => region.y)); + const maxX = Math.max(...regions.map((region) => region.x + region.width)); + const maxY = Math.max(...regions.map((region) => region.y + region.height)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +function allocateRoomCounts(regions, targetCount) { + if (regions.length === 1) return [targetCount]; + const areas = regions.map((region) => region.width * region.height); + const totalArea = areas.reduce((sum, area) => sum + area, 0) || 1; + const counts = regions.map(() => 1); + let remaining = Math.max(0, targetCount - counts.length); + + while (remaining > 0) { + let bestIndex = 0; + let bestScore = Number.NEGATIVE_INFINITY; + for (let index = 0; index < regions.length; index += 1) { + const score = (areas[index] / totalArea) - (counts[index] / Math.max(1, targetCount)); + if (score > bestScore) { + bestScore = score; + bestIndex = index; + } + } + counts[bestIndex] += 1; + remaining -= 1; + } + + return counts; +} + +function buildHullWallSegments(hullMask) { + const segments = []; + for (let y = 0; y < hullMask.length; y += 1) { + for (let x = 0; x < (hullMask[y]?.length ?? 0); x += 1) { + if (!hullMask[y]?.[x]) continue; + const edges = [ + { side: "n", nx: x, ny: y - 1 }, + { side: "e", nx: x + 1, ny: y }, + { side: "s", nx: x, ny: y + 1 }, + { side: "w", nx: x - 1, ny: y } + ]; + for (const edge of edges) { + if (!hullMask[edge.ny]?.[edge.nx]) { + segments.push(cellEdge(x, y, edge.side)); + } + } + } + } + return dedupeSegments(segments); +} + +function buildHullWallsWithDoorGaps(hullMask, doorSegments) { + const blocked = new Set( + doorSegments.map((segment) => segmentKey(segment)) + ); + return buildHullWallSegments(hullMask).filter((segment) => !blocked.has(segmentKey(segment))); +} + +function rectFitsMask(rect, mask) { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + if (!mask[y]?.[x]) return false; + } + } + return true; +} + +function segmentKey(segment) { + return `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`; +} + +function findHullPath(from, to, hullMask) { + const start = `${from.x},${from.y}`; + const goal = `${to.x},${to.y}`; + const queue = [from]; + const seen = new Set([start]); + const parent = new Map(); + const dirs = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 } + ]; + + while (queue.length) { + const current = queue.shift(); + const key = `${current.x},${current.y}`; + if (key === goal) break; + for (const dir of dirs) { + const next = { x: current.x + dir.x, y: current.y + dir.y }; + const nextKey = `${next.x},${next.y}`; + if (seen.has(nextKey) || !hullMask[next.y]?.[next.x]) continue; + seen.add(nextKey); + parent.set(nextKey, key); + queue.push(next); + } + } + + if (!seen.has(goal)) return []; + const path = []; + let currentKey = goal; + while (currentKey) { + const [x, y] = currentKey.split(",").map(Number); + path.push({ x, y }); + currentKey = parent.get(currentKey); + } + return path.reverse(); +} + +function corridorBrush(point, width, hullMask) { + const cells = []; + const radius = width - 1; + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + const cell = { x: point.x + dx, y: point.y + dy }; + if (hullMask[cell.y]?.[cell.x]) cells.push(cell); + } + } + return cells; +} + +function compressCellsToRects(cells) { + const byRow = new Map(); + for (const cell of cells) { + const row = byRow.get(cell.y) ?? []; + row.push(cell.x); + byRow.set(cell.y, row); + } + + const rects = []; + for (const [y, xs] of byRow.entries()) { + xs.sort((a, b) => a - b); + let start = xs[0]; + let prev = xs[0]; + for (let index = 1; index <= xs.length; index += 1) { + const x = xs[index]; + if (x === prev + 1) { + prev = x; + continue; + } + rects.push({ x: start, y, width: prev - start + 1, height: 1 }); + start = x; + prev = x; + } + } + return rects; +} + +function findNearestHullCell(mask, targetX, targetY) { + let best = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (let y = 0; y < mask.length; y += 1) { + for (let x = 0; x < (mask[y]?.length ?? 0); x += 1) { + if (!mask[y]?.[x]) continue; + const distance = Math.abs(targetX - x) + Math.abs(targetY - y); + if (distance < bestDistance) { + bestDistance = distance; + best = { x, y }; + } + } + } + return best; +} + +async function ensureSceneFolder(name) { + let folder = game.folders?.find((entry) => entry.type === "Scene" && entry.name === name && !entry.folder); + if (!folder) { + folder = await Folder.create({ name, type: "Scene", color: "#40637a" }); + } + return folder; +} + +function buildSceneFolderOptions() { + const folders = game.folders?.filter((entry) => entry.type === "Scene") ?? []; + const sorted = [...folders].sort((a, b) => describeFolderPath(a).localeCompare(describeFolderPath(b))); + return [ + { value: "", label: "Choose a scene folder..." }, + ...sorted.map((folder) => ({ + value: folder.id, + label: describeFolderPath(folder) + })) + ]; +} + +function describeFolderPath(folder) { + const parts = []; + let current = folder; + while (current) { + parts.unshift(current.name); + current = current.folder ?? null; + } + return parts.join(" / "); +} + +function buildActorPackOptions() { + const packs = (game.packs?.contents ?? []) + .filter((pack) => pack.documentName === "Actor") + .sort((a, b) => `${a.metadata.packageName}.${a.metadata.label}`.localeCompare(`${b.metadata.packageName}.${b.metadata.label}`)); + return [ + { value: "", label: "Use generated placeholder encounters" }, + ...packs.map((pack) => ({ + value: pack.collection, + label: `${pack.metadata.label} (${pack.collection})` + })) + ]; +} + +function buildSavedPresetOptions() { + const presets = (game.settings.get(MODULE_ID, "savedPresets") ?? []) + .filter((preset) => preset?.name) + .sort((a, b) => a.name.localeCompare(b.name)); + return [ + { value: "", label: "Choose a saved preset..." }, + ...presets.map((preset) => ({ value: preset.name, label: preset.name })) + ]; +} + +function mapOptions(options, selectedValue) { + return options.map((option) => ({ + ...option, + selected: String(option.value) === String(selectedValue) + })); +} + +function setInputValue(form, name, value) { + const element = form.querySelector(`[name="${name}"]`); + if (!element) return; + if (element.type === "checkbox") { + element.checked = Boolean(value); + return; + } + element.value = String(value); +} + +function buildMapName(mode = "bsp", seedInput = Date.now(), buildingSubtype = "office") { + const hash = hashSeed(String(seedInput)); + if (mode === "wilderness") { + const baseName = WILDERNESS_NAME_POOL[hash % WILDERNESS_NAME_POOL.length]; + const suffix = WILDERNESS_SUFFIX_POOL[Math.floor(hash / WILDERNESS_NAME_POOL.length) % WILDERNESS_SUFFIX_POOL.length]; + return `${baseName} ${suffix}`; + } + if (mode === "dungeon") { + const baseName = DUNGEON_NAME_POOL[hash % DUNGEON_NAME_POOL.length]; + const suffix = DUNGEON_SUFFIX_POOL[Math.floor(hash / DUNGEON_NAME_POOL.length) % DUNGEON_SUFFIX_POOL.length]; + return `${baseName} ${suffix}`; + } + if (mode === "building") { + const baseName = BUILDING_NAME_POOL[hash % BUILDING_NAME_POOL.length]; + const suffixPool = { + office: ["Corporate HQ", "Office Block", "Business Centre", "Bureau"], + warehouse: ["Warehouse", "Distribution Centre", "Storage Facility", "Depot"], + residential: ["Apartment", "Residence", "Estate", "Housing Block"], + tavern: ["The Rusty Flagon", "The Black Horse", "The Wanderer's Rest", "The Crooked Crown"], + mansion: ["Manor", "Estate", "Hall", "House"], + horror: ["Derelict House", "Asylum Wing", "Haunted Hall", "Murder Site"] + }[buildingSubtype] ?? BUILDING_SUFFIX_POOL; + const suffix = suffixPool[Math.floor(hash / BUILDING_NAME_POOL.length) % suffixPool.length]; + return `${baseName} ${suffix}`; + } + const baseName = SHIP_NAME_POOL[hash % SHIP_NAME_POOL.length]; + const suffix = SHIP_SUFFIX_POOL[Math.floor(hash / SHIP_NAME_POOL.length) % SHIP_SUFFIX_POOL.length]; + return `${baseName} ${suffix}`; +} + +function resolveDeckName(index, mode = "bsp") { + if (mode === "wilderness") return "Surface Map"; + if (mode === "dungeon") return DUNGEON_LEVEL_NAMES[index] ?? `Level ${index + 1}`; + if (mode === "building") return BUILDING_LEVEL_NAMES[index] ?? `Floor ${index + 1}`; + return DECK_NAME_POOL[index] ?? `Deck ${index + 1}`; +} + +function buildMissionBriefContent(deckPlan, formState) { + const chosenTemplate = resolveMissionTemplate(formState); + const introByTemplate = { + scifi: "The following operational map has been assembled from technical schematics, survey sweeps, and local traffic records. Expect compartment deviations and obstructed routes.", + dungeon: { + stone: "The following dungeon complex has been mapped from available intelligence and survivor accounts. Treat all information as provisional.", + crypt: "The catacombs below are ancient. What was once a place of rest for the honoured dead has become something else entirely.", + cave: "A naturally formed cave complex, partially occupied. The passages are unstable in places.", + sewer: "The lower channels are flooded, foul, and likely inhabited. Maintain sealed gear and expect poor visibility." + }, + horror: { + "derelict-house": "The structure shows clear signs of decay and breach. Expect unreliable doors, unstable interiors, and poor sightlines.", + asylum: "Institutional records indicate sealed wards and restricted treatment rooms. Interior movement may be obstructed and threats concealed.", + "haunted-manor": "The manor layout is intact but poorly maintained. Expect dead ends, servant routes, and compromised access points.", + bloodbath: "The site is an active or recent kill zone. Visibility may be poor, surfaces contaminated, and rooms psychologically destabilising." + }, + sla: "The following contract-zone map has been compiled for operative deployment. Media exposure, collateral risk, and sector politics may alter mission value without warning.", + wilderness: { + forest: "The following area has been surveyed from aerial observation. Ground-truth data should be established before committing forces.", + swamp: "The terrain is treacherous and visibility is limited. Standard movement rates should be halved.", + jungle: "Vegetation density is extreme and lines of sight are unreliable. Maintain visual contact between elements at all times." + } + }; + const introSource = introByTemplate[chosenTemplate] ?? introByTemplate.scifi; + const intro = typeof introSource === "string" + ? introSource + : introSource?.[formState.theme] ?? "The following location has been mapped from the best data currently available. Treat all positions as approximate until confirmed on the ground."; + const deckLines = deckPlan.decks.map((deck) => `
  • ${escapeXml(deck.deckName)}: ${deck.rooms.length} rooms, ${deck.corridors.length} corridors, ${escapeXml(summarizeDeckThreats(deck))}
  • `).join(""); + return ` +

    ${escapeXml(deckPlan.shipName)} Mission Brief

    +

    ${escapeXml(intro)}

    +

    Seed: ${escapeXml(deckPlan.seed)}
    Theme: ${escapeXml(formState.theme)}
    Mode: ${escapeXml(formState.mode)}

    +

    Operational Notes

    +
      +
    • Generated with ${deckPlan.decks.length} level${deckPlan.decks.length === 1 ? "" : "s"} at ${formState.gridSize}px grid resolution.
    • +
    • Room labels and openings are intended as tactical guides; final conditions should be confirmed on scene entry.
    • +
    • ${escapeXml(briefSpecialInstruction(formState, chosenTemplate))}
    • +
    +

    Deck Summary

    +
      ${deckLines}
    + `.replace(/\n\s+/g, ""); +} + +function resolveMissionTemplate(formState) { + if (formState.missionTemplate !== "auto") return formState.missionTemplate; + if (["sla-mothership", "sla-industries-brp"].includes(game.system?.id ?? "")) return "sla"; + if (formState.mode === "wilderness") return "wilderness"; + if (formState.mode === "dungeon") return "dungeon"; + if (["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(formState.theme)) return "horror"; + return "scifi"; +} + +function summarizeDeckThreats(deck) { + const dangerous = deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)); + if (!dangerous.length && !(deck.wilderness?.encounterZones?.length)) return "no explicit threat nodes marked"; + if (!dangerous.length && deck.wilderness?.encounterZones?.length) { + return `${deck.wilderness.encounterZones.length} wilderness encounter zone${deck.wilderness.encounterZones.length === 1 ? "" : "s"} flagged`; + } + const labels = dangerous.slice(0, 3).map((room) => room.label || room.type.toUpperCase()); + return `${dangerous.length} high-risk room${dangerous.length === 1 ? "" : "s"} flagged (${labels.join(", ")}${dangerous.length > 3 ? ", ..." : ""})`; +} + +function briefSpecialInstruction(formState, chosenTemplate = resolveMissionTemplate(formState)) { + if (chosenTemplate === "sla") return "Contract conditions remain fluid. Expect media scrutiny, unstable civilians, and rapid shifts in mission value if the situation escalates."; + if (chosenTemplate === "horror") return "Assume compromised lighting, psychological contamination, and hostile movement from behind unsecured doors or soft barriers."; + if (chosenTemplate === "dungeon") return "Advance with scouts, secure retreats, and expect locked chambers to signal either treasure, ritual focus, or concentrated resistance."; + if (chosenTemplate === "wilderness") return "Ridgelines, ravines, and water obstacles are treated as movement-affecting terrain and should shape approach routes."; + if (formState.seedEncounters) return "Encounter seed markers were placed in hostile or high-risk rooms for GM staging."; + if (formState.mapAge >= 60) return "The map has been weathered heavily; assume locked, broken, or compromised access points are more common."; + if (["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(formState.theme)) return "Horror decay modifiers are active: doors may be barricaded, breached, or partially collapsed."; + return "Maintain standard caution and update the plan with local observations during play."; +} + +function slugify(value) { + return String(value ?? MODULE_ID) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || MODULE_ID; +} + +function hashSeed(value) { + const text = String(value ?? ""); + let hash = 1779033703; + for (let i = 0; i < text.length; i += 1) { + hash = Math.imul(hash ^ text.charCodeAt(i), 3432918353); + hash = (hash << 13) | (hash >>> 19); + } + return hash >>> 0; +} + +function mulberry32(seed) { + let current = seed >>> 0; + return function rng() { + current |= 0; + current = (current + 0x6D2B79F5) | 0; + let t = Math.imul(current ^ (current >>> 15), 1 | current); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randomInt(rng, min, max) { + if (max <= min) return min; + return Math.floor(rng() * (max - min + 1)) + min; +} + +function clampInt(value, min, max, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.max(min, Math.min(max, Math.round(number))); +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, Number(value))); +} + +function normalizeChoice(value, fallback, choices) { + return choices.includes(value) ? value : fallback; +} + +function columnLabel(index) { + let value = Number(index ?? 0); + let out = ""; + do { + out = String.fromCharCode(65 + (value % 26)) + out; + value = Math.floor(value / 26) - 1; + } while (value >= 0); + return out; +} + +function round(value) { + return Math.round(Number(value) || 0); +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function textStrokeFor(fillColor) { + const rgb = hexToRgb(fillColor); + if (!rgb) return "rgba(10, 14, 18, 0.96)"; + const luminance = ((0.2126 * rgb.r) + (0.7152 * rgb.g) + (0.0722 * rgb.b)) / 255; + return luminance > 0.62 ? "rgba(12, 16, 20, 0.96)" : "rgba(248, 251, 255, 0.96)"; +} + +function hexToRgb(color) { + const value = String(color ?? "").trim(); + const short = value.match(/^#([0-9a-f]{3})$/i); + if (short) { + const [r, g, b] = short[1].split("").map((char) => parseInt(char + char, 16)); + return { r, g, b }; + } + const full = value.match(/^#([0-9a-f]{6})$/i); + if (full) { + return { + r: parseInt(full[1].slice(0, 2), 16), + g: parseInt(full[1].slice(2, 4), 16), + b: parseInt(full[1].slice(4, 6), 16) + }; + } + return null; +} diff --git a/module/scifi-deckplan-generator.mjs.bak-20260324-134812 b/module/scifi-deckplan-generator.mjs.bak-20260324-134812 new file mode 100644 index 0000000..dd8cab2 --- /dev/null +++ b/module/scifi-deckplan-generator.mjs.bak-20260324-134812 @@ -0,0 +1,6114 @@ +const MODULE_ID = "scifi-deckplan-generator"; +const ROOT_SCENE_FOLDER = "Generated Maps"; +const ASSET_DIR = MODULE_ID; +const GENERATED_WALL_FLAG = `${MODULE_ID}.generatedWall`; +const GENERATED_DRAWING_FLAG = `${MODULE_ID}.generatedDrawing`; +const GENERATED_LIGHT_FLAG = `${MODULE_ID}.generatedLight`; +const GENERATED_TOKEN_FLAG = `${MODULE_ID}.generatedToken`; +const GENERATED_NOTE_FLAG = `${MODULE_ID}.generatedNote`; +const GENERATED_REGION_FLAG = `${MODULE_ID}.generatedRegion`; +const MOVEMENT_NORMAL = CONST.WALL_MOVEMENT_TYPES?.NORMAL ?? CONST.WALL_SENSE_TYPES?.NORMAL ?? 20; +const SENSE_NORMAL = CONST.WALL_SENSE_TYPES?.NORMAL ?? 20; +const SENSE_NONE = CONST.WALL_SENSE_TYPES?.NONE ?? 0; +const DOOR_NONE = CONST.WALL_DOOR_TYPES?.NONE ?? 0; +const DOOR_BASIC = CONST.WALL_DOOR_TYPES?.DOOR ?? 1; +const DOOR_SECRET = CONST.WALL_DOOR_TYPES?.SECRET ?? 2; +const DOOR_STATE_OPEN = CONST.WALL_DOOR_STATES?.OPEN ?? 0; +const DOOR_STATE_CLOSED = CONST.WALL_DOOR_STATES?.CLOSED ?? 1; +const DOOR_STATE_LOCKED = CONST.WALL_DOOR_STATES?.LOCKED ?? 2; + +const CIRCULAR_ROOM_TYPES = new Set([ + "reactor", + "cistern", + "shrine", + "observation", + "ritual-room", + "forge", + "boiler-room", + "pool" +]); + +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +const GENERATION_MODES = [ + { value: "bsp", label: "BSP Room Layout (Ship/Station)" }, + { value: "corridor", label: "Corridor Graph (Ship/Station)" }, + { value: "geomorph", label: "Geomorph Tile Stitching" }, + { value: "dungeon", label: "Random Dungeon" }, + { value: "building", label: "Random Building" }, + { value: "wilderness", label: "Outdoor Wilderness" } +]; + +const GRID_SIZES = [ + { value: 50, label: "50 px" }, + { value: 75, label: "75 px" }, + { value: 100, label: "100 px" } +]; + +const CHAMFER_SIZES = [ + { value: 1, label: "1 Cell" }, + { value: 2, label: "2 Cells" } +]; + +const GRID_MODE_OPTIONS = [ + { value: "square", label: "Square Grid" }, + { value: "hex-row", label: "Hex Grid (Odd Rows)" } +]; + +const CORNER_STYLE_OPTIONS = [ + { value: "square", label: "Square" }, + { value: "chamfer", label: "Chamfered" }, + { value: "perturb", label: "Organic" } +]; + +const MODE_THEME_OPTIONS = { + bsp: [ + { value: "steel", label: "Steel" }, + { value: "industrial", label: "Industrial" }, + { value: "derelict", label: "Derelict" }, + { value: "alien-organic", label: "Alien Organic" }, + { value: "clean-corporate", label: "Clean Corporate" } + ], + corridor: [ + { value: "steel", label: "Steel" }, + { value: "industrial", label: "Industrial" }, + { value: "derelict", label: "Derelict" }, + { value: "alien-organic", label: "Alien Organic" }, + { value: "clean-corporate", label: "Clean Corporate" } + ], + geomorph: [ + { value: "steel", label: "Steel" }, + { value: "industrial", label: "Industrial" }, + { value: "derelict", label: "Derelict" }, + { value: "clean-corporate", label: "Clean Corporate" } + ], + dungeon: [ + { value: "stone", label: "Stone Dungeon" }, + { value: "cave", label: "Cave System" }, + { value: "wood", label: "Wooden Stronghold" }, + { value: "crypt", label: "Undead Crypt" }, + { value: "sewer", label: "Sewer / Undercroft" } + ], + building: [ + { value: "concrete-office", label: "Concrete Office" }, + { value: "wood-floor", label: "Wood Floor" }, + { value: "brick", label: "Brick" }, + { value: "carpet", label: "Carpet" }, + { value: "tile", label: "Tile" }, + { value: "derelict-house", label: "Derelict House" }, + { value: "asylum", label: "Asylum / Hospital" }, + { value: "haunted-manor", label: "Haunted Manor" }, + { value: "bloodbath", label: "Bloodbath Scene" } + ], + wilderness: [ + { value: "forest", label: "Temperate Forest" }, + { value: "jungle", label: "Dense Jungle" }, + { value: "plains", label: "Open Plains" }, + { value: "swamp", label: "Swamp / Marsh" }, + { value: "tundra", label: "Frozen Tundra" }, + { value: "badlands", label: "Badlands / Scrub" } + ] +}; + +const MODE_HULL_OPTIONS = { + bsp: [ + { value: "rectangle", label: "Rectangle" }, + { value: "tapered", label: "Tapered Ship" }, + { value: "hammerhead", label: "Hammerhead" }, + { value: "cross", label: "Cross Station" }, + { value: "l-shape", label: "L-Shape" }, + { value: "ring", label: "Ring" } + ], + corridor: [ + { value: "rectangle", label: "Rectangle" }, + { value: "tapered", label: "Tapered Ship" }, + { value: "hammerhead", label: "Hammerhead" }, + { value: "cross", label: "Cross Station" }, + { value: "l-shape", label: "L-Shape" }, + { value: "ring", label: "Ring" } + ], + geomorph: [ + { value: "rectangle", label: "Tile Grid" } + ], + dungeon: [ + { value: "rectangle", label: "Rectangle" }, + { value: "cross", label: "Crossed Halls" }, + { value: "l-shape", label: "L-Shape" }, + { value: "ring", label: "Ring / Crypt Loop" } + ], + building: [ + { value: "rect", label: "Rectangle" }, + { value: "l-shape", label: "L-Shape" }, + { value: "t-shape", label: "T-Shape" }, + { value: "u-shape", label: "U-Shape" } + ], + wilderness: [ + { value: "open", label: "Open Ground" }, + { value: "clearing", label: "Forest Clearing" }, + { value: "ford", label: "River Ford" }, + { value: "camp", label: "Camp Perimeter" } + ] +}; + +const BUILDING_SUBTYPES = [ + { value: "office", label: "Office" }, + { value: "warehouse", label: "Warehouse" }, + { value: "residential", label: "Residential" }, + { value: "tavern", label: "Tavern" }, + { value: "mansion", label: "Mansion" }, + { value: "horror", label: "Horror / Derelict" } +]; + +const COORDINATE_MODES = [ + { value: "none", label: "Off" }, + { value: "border", label: "Border" }, + { value: "full", label: "Full Cell" } +]; + +const SPLIT_BIASES = [ + { value: "balanced", label: "Balanced" }, + { value: "horizontal", label: "Horizontal Bias" }, + { value: "vertical", label: "Vertical Bias" } +]; + +const CORRIDOR_WIDTHS = [ + { value: 1, label: "1 Cell" }, + { value: 2, label: "2 Cells" } +]; + +const CORRIDOR_STYLES = [ + { value: "spine", label: "Spine Corridor" }, + { value: "partitioned", label: "Partitioned (No Corridor)" } +]; + +const FURNITURE_DENSITIES = [ + { value: "off", label: "None" }, + { value: "sparse", label: "Sparse" }, + { value: "normal", label: "Normal" }, + { value: "dense", label: "Dense" } +]; + +const MISSION_TEMPLATE_OPTIONS = [ + { value: "auto", label: "Auto" }, + { value: "scifi", label: "Sci-Fi Ops" }, + { value: "dungeon", label: "Dungeon Delve" }, + { value: "horror", label: "Horror Incident" }, + { value: "wilderness", label: "Wilderness Recon" }, + { value: "sla", label: "SLA Briefing" } +]; + +const ENCOUNTER_FACTION_OPTIONS = [ + { value: "auto", label: "Auto" }, + { value: "corporate", label: "Corporate Security" }, + { value: "marines", label: "Marines / Troopers" }, + { value: "cult", label: "Cult / Fanatics" }, + { value: "undead", label: "Undead" }, + { value: "wildlife", label: "Wildlife / Beasts" }, + { value: "sla", label: "SLA Operatives" } +]; + +const ENCOUNTER_DENSITY_OPTIONS = [ + { value: "light", label: "Light" }, + { value: "standard", label: "Standard" }, + { value: "heavy", label: "Heavy" } +]; + +const FACTION_PRESET_OPTIONS = [ + { value: "none", label: "None" }, + { value: "ship", label: "Crew / Pirates / Automated / Infected" }, + { value: "dungeon", label: "Delvers / Cult / Undead / Guardians" }, + { value: "building", label: "Staff / Security / Residents / Intruders" }, + { value: "sla", label: "Operatives / Civilians / Hostiles / Media" } +]; + +const REGENERATE_TARGET_OPTIONS = [ + { value: "none", label: "Create fresh scenes" }, + { value: "viewed", label: "Viewed scene (single deck)" }, + { value: "viewed-folder", label: "Viewed scene folder" }, + { value: "folder", label: "Selected scene folder" } +]; + +const ENCOUNTER_ROOM_TYPES = new Set([ + "boss-chamber", + "guardroom", + "barracks", + "torture", + "surgery", + "padded-cell", + "basement", + "boiler-room", + "prison", + "security", + "armory", + "armoury", + "brig" +]); + +const ENCOUNTER_ROOM_LABELS = { + "boss-chamber": "BOSS", + guardroom: "GUARD", + barracks: "SOLDIER", + torture: "TORTURER", + surgery: "THREAT", + "padded-cell": "INMATE", + basement: "LURKER", + "boiler-room": "THREAT", + prison: "PRISONER", + security: "SECURITY", + armory: "ARMED", + armoury: "ARMED", + brig: "DETAINEE" +}; + +const DECK_NAME_POOL = [ + "Command Deck", + "Habitation Deck", + "Cargo Deck", + "Engineering Deck", + "Maintenance Deck", + "Operations Deck", + "Docking Deck", + "Research Deck" +]; + +const DUNGEON_LEVEL_NAMES = [ + "Upper Level", + "Lower Level", + "Catacombs", + "Deep Vaults", + "Flooded Passages", + "The Abyss", + "Undercroft", + "Burial Halls" +]; + +const BUILDING_LEVEL_NAMES = [ + "Ground Floor", + "Upper Floor", + "Service Level", + "Executive Floor", + "Basement", + "Upper Offices", + "Storage Level", + "Residential Floor" +]; + +const SHIP_NAME_POOL = [ + "Axiom", + "Erebus", + "Helix", + "Kestrel", + "Meridian", + "Oblique", + "Perseverance", + "Tethys", + "Valiance", + "Vortex", + "Waypoint", + "Peregrine" +]; + +const SHIP_SUFFIX_POOL = [ + "MK-I", + "MK-II", + "MK-III", + "MK-IV", + "IX", + "VII", + "Class C", + "Block 12", + "Run 7", + "Gamma" +]; + +const DUNGEON_NAME_POOL = [ + "Blackstone", + "Grimhollow", + "Ebondeep", + "Cindervault", + "Mirehold", + "Wyrmrest", + "Ashcrypt", + "Hollowmere" +]; + +const DUNGEON_SUFFIX_POOL = [ + "Catacombs", + "Vault", + "Crypt", + "Halls", + "Depths", + "Labyrinth", + "Stronghold", + "Sewers" +]; + +const BUILDING_NAME_POOL = [ + "Meridian", + "Halcyon", + "Northbank", + "Greywall", + "Kingsport", + "Crown", + "Glasswater", + "Market" +]; + +const BUILDING_SUFFIX_POOL = [ + "Offices", + "Warehouse", + "House", + "Hall", + "Tower", + "Tavern", + "Mansion", + "Works" +]; + +const WILDERNESS_NAME_POOL = [ + "Ashwood", + "Blackfen", + "Dreadmere", + "Frostline", + "Ironbrush", + "Mirewatch", + "Rookwood", + "Thornfield" +]; + +const WILDERNESS_SUFFIX_POOL = [ + "Approach", + "Hollow", + "Crossing", + "Wilds", + "March", + "Basin", + "Trail", + "Expanse" +]; + +const GEOMORPH_TILES = [ + { + key: "straight-ns", + connectors: { north: true, east: false, south: true, west: false }, + corridors: [{ x: 2, y: 0, width: 2, height: 6 }], + rooms: [{ x: 1, y: 1, width: 4, height: 2 }] + }, + { + key: "straight-ew", + connectors: { north: false, east: true, south: false, west: true }, + corridors: [{ x: 0, y: 2, width: 6, height: 2 }], + rooms: [{ x: 1, y: 1, width: 2, height: 4 }] + }, + { + key: "corner-ne", + connectors: { north: true, east: true, south: false, west: false }, + corridors: [{ x: 2, y: 0, width: 2, height: 3 }, { x: 2, y: 2, width: 4, height: 2 }], + rooms: [{ x: 1, y: 3, width: 3, height: 2 }] + }, + { + key: "corner-sw", + connectors: { north: false, east: false, south: true, west: true }, + corridors: [{ x: 0, y: 2, width: 4, height: 2 }, { x: 2, y: 2, width: 2, height: 4 }], + rooms: [{ x: 2, y: 0, width: 3, height: 2 }] + }, + { + key: "tee-nes", + connectors: { north: true, east: true, south: true, west: false }, + corridors: [{ x: 2, y: 0, width: 2, height: 6 }, { x: 2, y: 2, width: 4, height: 2 }], + rooms: [{ x: 0, y: 1, width: 2, height: 4 }] + }, + { + key: "hub", + connectors: { north: true, east: true, south: true, west: true }, + corridors: [{ x: 2, y: 0, width: 2, height: 6 }, { x: 0, y: 2, width: 6, height: 2 }], + rooms: [{ x: 1, y: 1, width: 4, height: 4 }] + }, + { + key: "sealed-room", + connectors: { north: false, east: false, south: false, west: false }, + corridors: [], + rooms: [{ x: 1, y: 1, width: 4, height: 4 }] + } +]; + +const FACTION_PRESETS = { + ship: [ + { name: "Crew", color: "#4b8ecf", roomTypes: ["bridge", "quarters", "medical", "galley", "observation"] }, + { name: "Pirates", color: "#b45b42", roomTypes: ["cargo", "armory", "brig", "security"] }, + { name: "Automated", color: "#7b93a8", roomTypes: ["engineering", "reactor", "maintenance", "laboratory"] }, + { name: "Infected", color: "#7a3e59", roomTypes: ["airlock", "escape-pods", "hydroponics", "storage"] } + ], + dungeon: [ + { name: "Delvers", color: "#5678a8", roomTypes: ["entrance", "library", "cistern"] }, + { name: "Cult", color: "#8a4d6f", roomTypes: ["ritual-room", "shrine", "alchemy"] }, + { name: "Undead", color: "#8b8f9e", roomTypes: ["crypt", "vault", "throne-room"] }, + { name: "Guardians", color: "#b38742", roomTypes: ["guardroom", "armoury", "boss-chamber", "prison"] } + ], + building: [ + { name: "Staff", color: "#4d8f7a", roomTypes: ["office", "lobby", "foyer", "common-room", "bar"] }, + { name: "Security", color: "#4f6b94", roomTypes: ["security", "loading", "storage", "armory"] }, + { name: "Residents", color: "#a88153", roomTypes: ["bedroom", "living-room", "kitchen", "bathroom"] }, + { name: "Intruders", color: "#9a4a4a", roomTypes: ["basement", "boiler-room", "attic", "surgery"] } + ], + sla: [ + { name: "Operatives", color: "#5b86c5", roomTypes: ["bridge", "security", "armory", "command"] }, + { name: "Civilians", color: "#7aa073", roomTypes: ["quarters", "medical", "galley", "common-room"] }, + { name: "Hostiles", color: "#b85a4f", roomTypes: ["cargo", "brig", "maintenance", "boiler-room"] }, + { name: "Media", color: "#b28b3c", roomTypes: ["observation", "lobby", "office", "lab"] } + ] +}; + +let roomTablesCache = null; +let shipProfilesCache = null; +let furnitureTablesCache = null; +const encounterPackCache = new Map(); + +Hooks.once("init", () => { + game.scifiDeckPlanGenerator = { + open: () => ScifiDeckPlanGeneratorApp.open() + }; + + game.settings.register(MODULE_ID, "lastFormState", { + scope: "client", + config: false, + default: {} + }); + + game.settings.register(MODULE_ID, "savedPresets", { + scope: "world", + config: false, + type: Array, + default: [] + }); + + game.settings.register(MODULE_ID, "gmOverlayVisible", { + scope: "client", + config: false, + type: Boolean, + default: true + }); + + game.settings.registerMenu(MODULE_ID, "openGenerator", { + name: game.i18n.localize("SDPG.MenuName"), + hint: game.i18n.localize("SDPG.MenuHint"), + label: game.i18n.localize("SDPG.SceneButton"), + icon: "fas fa-rocket", + type: ScifiDeckPlanGeneratorApp, + restricted: true + }); +}); + +Hooks.on("renderSceneDirectory", (_app, html) => installSceneDirectoryButton(html)); +Hooks.on("renderSidebarTab", (app, html) => { + const isScenesTab = app?.tabName === "scenes" || app?.options?.id === "scenes"; + if (isScenesTab) installSceneDirectoryButton(html); +}); +Hooks.on("canvasReady", () => applyGmOverlayVisibility(canvas.scene ?? null)); +Hooks.on("drawTile", (tile) => applyGmOverlayPlaceableVisibility(tile)); +Hooks.on("drawNote", (note) => applyGmOverlayPlaceableVisibility(note)); + +Hooks.on("getSceneControlButtons", (controls) => { + if (!game.user?.isGM) return; + + const buttonTool = { + name: "openScifiDeckPlanGenerator", + title: game.i18n.localize("SDPG.ToolbarOpen"), + icon: "fas fa-rocket", + button: true, + onClick: () => ScifiDeckPlanGeneratorApp.open() + }; + const gmOverlayTool = { + name: "toggleScifiDeckPlanGmOverlay", + title: "Toggle GM Overlay", + icon: "fas fa-user-secret", + toggle: true, + active: game.settings.get(MODULE_ID, "gmOverlayVisible") !== false, + onClick: async (active) => { + await setGmOverlayVisible(active); + } + }; + + if (Array.isArray(controls)) { + const wallsControls = controls.find((control) => control.name === "walls"); + if (wallsControls) { + wallsControls.tools ??= []; + if (!wallsControls.tools.some((tool) => tool.name === buttonTool.name)) { + wallsControls.tools.push(buttonTool); + } + if (!wallsControls.tools.some((tool) => tool.name === gmOverlayTool.name)) { + wallsControls.tools.push(gmOverlayTool); + } + return; + } + + controls.push({ + name: MODULE_ID, + title: game.i18n.localize("SDPG.ToolbarGroup"), + icon: "fas fa-rocket", + layer: "walls", + tools: [buttonTool, gmOverlayTool] + }); + return; + } + + if (!controls || typeof controls !== "object") return; + const wallsControls = controls.walls; + if (!wallsControls) return; + wallsControls.tools ??= {}; + wallsControls.tools[buttonTool.name] ??= buttonTool; + wallsControls.tools[gmOverlayTool.name] ??= gmOverlayTool; +}); + +class ScifiDeckPlanGeneratorApp extends HandlebarsApplicationMixin(ApplicationV2) { + #isGenerating = false; + + static DEFAULT_OPTIONS = { + id: MODULE_ID, + tag: "form", + classes: ["scifi-deckplan-generator"], + window: { + title: "SDPG.AppTitle", + resizable: true, + width: 980, + height: 900 + }, + form: { + handler: ScifiDeckPlanGeneratorApp.formHandler, + submitOnChange: false, + closeOnSubmit: true + } + }; + + static PARTS = { + form: { + template: `modules/${MODULE_ID}/templates/scifi-deckplan-generator.html` + } + }; + + static open() { + return new ScifiDeckPlanGeneratorApp().render({ force: true }); + } + + static async formHandler(event, _form, formData) { + return this.generateFromForm(formData.object, event?.target ?? event?.currentTarget ?? null); + } + + async _prepareContext() { + const saved = game.settings.get(MODULE_ID, "lastFormState") ?? {}; + const profilesData = await getShipProfiles(); + const defaults = { + shipName: buildMapName("bsp"), + mode: "bsp", + profile: "hauler", + columns: 32, + rows: 20, + gridSize: 75, + gridMode: "square", + decks: 1, + seed: "", + theme: "steel", + hullShape: "rectangle", + coordinates: "border", + roomCount: 12, + minRoomSize: 3, + maxRoomSize: 8, + cornerStyle: "chamfer", + chamferSize: 1, + circularSpecialRooms: true, + softRoomOutlines: true, + splitBias: "balanced", + corridorStyle: "spine", + loopFactor: 30, + corridorWidth: 1, + buildingSubtype: "office", + secretDoorDensity: 8, + furnitureDensity: "normal", + missionTemplate: "auto", + encounterFaction: "auto", + encounterDensity: "standard", + encounterPack: "", + roomPopulationPack: "", + populateRoomsFromCompendium: false, + compactPrintMode: false, + factionPreset: "none", + savedPreset: "", + activateScenes: true, + regenerateInPlace: false, + regenerateTarget: "none", + targetSceneFolder: "", + showRoomLabels: true, + placeLights: true, + generateMissionBrief: true, + generateMissionPacket: false, + seedEncounters: false, + createRoomNotes: true, + createGmOverlay: true, + exportMetadataJson: true, + mapAge: 0, + exportMothershipJson: false + }; + const merged = { ...defaults, ...saved }; + const themeOptions = getThemeOptionsForMode(merged.mode); + const hullOptions = getHullOptionsForMode(merged.mode); + const sceneFolders = buildSceneFolderOptions(); + const actorPacks = buildActorPackOptions(); + const savedPresets = buildSavedPresetOptions(); + + return { + defaults: merged, + modes: mapOptions(GENERATION_MODES, merged.mode), + profiles: mapOptions( + profilesData.profiles.map((profile) => ({ value: profile.key, label: profile.label })), + merged.profile + ), + themes: mapOptions(themeOptions, merged.theme), + hullShapes: mapOptions(hullOptions, merged.hullShape), + gridSizes: mapOptions(GRID_SIZES, Number(merged.gridSize)), + gridModes: mapOptions(GRID_MODE_OPTIONS, merged.gridMode), + chamferSizes: mapOptions(CHAMFER_SIZES, Number(merged.chamferSize)), + coordinates: mapOptions(COORDINATE_MODES, merged.coordinates), + cornerStyles: mapOptions(CORNER_STYLE_OPTIONS, merged.cornerStyle), + splitBiases: mapOptions(SPLIT_BIASES, merged.splitBias), + corridorStyles: mapOptions(CORRIDOR_STYLES, merged.corridorStyle), + corridorWidths: mapOptions(CORRIDOR_WIDTHS, Number(merged.corridorWidth)), + furnitureDensities: mapOptions(FURNITURE_DENSITIES, merged.furnitureDensity), + missionTemplates: mapOptions(MISSION_TEMPLATE_OPTIONS, merged.missionTemplate), + encounterFactions: mapOptions(ENCOUNTER_FACTION_OPTIONS, merged.encounterFaction), + encounterDensities: mapOptions(ENCOUNTER_DENSITY_OPTIONS, merged.encounterDensity), + regenerateTargets: mapOptions(REGENERATE_TARGET_OPTIONS, merged.regenerateTarget ?? "none"), + sceneFolders: mapOptions(sceneFolders, merged.targetSceneFolder ?? ""), + actorPacks: mapOptions(actorPacks, merged.encounterPack ?? ""), + roomPopulationPacks: mapOptions(actorPacks, merged.roomPopulationPack ?? ""), + factionPresets: mapOptions(FACTION_PRESET_OPTIONS, merged.factionPreset ?? "none"), + savedPresets: mapOptions(savedPresets, merged.savedPreset ?? ""), + buildingSubtypes: mapOptions(BUILDING_SUBTYPES, merged.buildingSubtype), + profileCards: profilesData.profiles.map((profile) => ({ + label: profile.label, + summary: `${profile.columns}x${profile.rows}, ${profile.decks} level${profile.decks === 1 ? "" : "s"}, ${profile.mode.toUpperCase()}, ${profile.theme}`, + mode: profile.mode + })) + }; + } + + _onRender(context, options) { + super._onRender(context, options); + const root = this.element; + const form = root.matches?.("form") ? root : root.querySelector("form"); + if (!form) return; + + root.querySelector('[data-action="apply-profile"]')?.addEventListener("click", async (event) => { + event.preventDefault(); + const profilesData = await getShipProfiles(); + const selectedKey = form.querySelector('[name="profile"]')?.value; + const profile = profilesData.profiles.find((entry) => entry.key === selectedKey); + if (!profile) return; + setInputValue(form, "mode", profile.mode); + updateModeVisibility(form, profile.mode); + setInputValue(form, "columns", profile.columns); + setInputValue(form, "rows", profile.rows); + setInputValue(form, "decks", profile.decks); + setInputValue(form, "theme", profile.theme); + setInputValue(form, "hullShape", profile.hullShape ?? "rectangle"); + setInputValue(form, "splitBias", profile.splitBias ?? "balanced"); + setInputValue(form, "corridorStyle", profile.corridorStyle ?? "spine"); + setInputValue(form, "roomCount", profile.roomCount ?? 12); + setInputValue(form, "minRoomSize", profile.minRoomSize ?? 3); + setInputValue(form, "maxRoomSize", profile.maxRoomSize ?? 8); + setInputValue(form, "loopFactor", profile.loopFactor ?? 30); + setInputValue(form, "corridorWidth", profile.corridorWidth ?? 1); + setInputValue(form, "buildingSubtype", profile.buildingSubtype ?? "office"); + setInputValue(form, "secretDoorDensity", profile.secretDoorDensity ?? 8); + setInputValue(form, "furnitureDensity", profile.furnitureDensity ?? "normal"); + setInputValue(form, "shipName", profile.label); + updatePreviewPane(form, root).catch(() => {}); + }); + + root.querySelector('[data-action="load-saved-preset"]')?.addEventListener("click", async () => { + await applySavedPresetToForm(form, root); + }); + root.querySelector('[data-action="save-current-preset"]')?.addEventListener("click", async () => { + await saveFormAsPreset(form, root); + }); + root.querySelector('[data-action="delete-saved-preset"]')?.addEventListener("click", async () => { + await deleteSavedPresetFromForm(form, root); + }); + root.querySelector('[data-action="export-png"]')?.addEventListener("click", async () => { + await exportPreviewPng(form, root); + }); + root.querySelector('[data-action="share-seed"]')?.addEventListener("click", async () => { + await shareFormState(form); + }); + root.querySelector('[data-action="import-seed"]')?.addEventListener("click", async () => { + await importSharedFormState(form, root); + }); + + setInputValue(form, "buildingSubtype", context.defaults.buildingSubtype ?? "office"); + updateModeVisibility(form, form.querySelector('[name="mode"]')?.value ?? context.defaults.mode ?? "bsp"); + + form.querySelector('[name="mode"]')?.addEventListener("change", (event) => { + updateModeVisibility(form, event.currentTarget.value); + updatePreviewPane(form, root).catch(() => {}); + }); + form.querySelector(".sdpg-reroll-seed")?.addEventListener("click", () => { + const seedInput = form.querySelector('[name="seed"]'); + if (seedInput) seedInput.value = String(Math.floor(Math.random() * 1_000_000)); + updatePreviewPane(form, root).catch(() => {}); + }); + root.querySelector('[data-action="preview-map"]')?.addEventListener("click", () => { + updatePreviewPane(form, root).catch(() => {}); + }); + form.querySelectorAll("input, select").forEach((element) => { + element.addEventListener("change", () => updatePreviewPane(form, root).catch(() => {})); + }); + updatePreviewPane(form, root).catch(() => {}); + } + + async generateFromForm(formObject) { + if (this.#isGenerating) return []; + this.#isGenerating = true; + const formState = normalizeFormState(formObject); + try { + await game.settings.set(MODULE_ID, "lastFormState", formState); + ui.notifications.info(`Generating ${formState.shipName}...`); + const roomTables = await getRoomTables(); + const deckPlan = buildDeckPlan(formState, roomTables); + const scenes = await createFoundryScenes(deckPlan, formState); + let metadataPath = null; + + if (formState.exportMothershipJson) { + await exportMothershipViewerJson(deckPlan); + } + if (formState.exportMetadataJson) { + metadataPath = await exportMapMetadataJson(deckPlan, formState, scenes); + } + if (formState.generateMissionPacket) { + await createMissionPacket(deckPlan, formState, scenes, metadataPath); + } + + if (formState.activateScenes && scenes.length) { + await scenes[scenes.length - 1].activate(); + await scenes[scenes.length - 1].view(); + } + + ui.notifications.info(`Generated ${scenes.length} map scene${scenes.length === 1 ? "" : "s"} for ${deckPlan.shipName}.`); + return scenes; + } catch (error) { + console.error(`${MODULE_ID} generation failed`, error); + ui.notifications.error(`Map generation failed: ${error?.message ?? error}`); + throw error; + } finally { + this.#isGenerating = false; + this.close({ force: true }).catch(() => {}); + } + } +} + +function installSceneDirectoryButton(html) { + if (!game.user?.isGM) return; + const root = $(html); + if (root.find(".sdpg-launch").length) return; + + const button = $(` + + `); + button.on("click", () => ScifiDeckPlanGeneratorApp.open()); + const deleteButton = $(` + + `); + deleteButton.on("click", () => deleteGeneratedDocuments(game.scenes?.viewed ?? canvas.scene ?? null)); + const annotateButton = $(` + + `); + annotateButton.on("click", () => openSceneAnnotationDialog(game.scenes?.viewed ?? canvas.scene ?? null)); + const gmOverlayButton = $(` + + `); + gmOverlayButton.on("click", async () => { + await setGmOverlayVisible(!(game.settings.get(MODULE_ID, "gmOverlayVisible") !== false)); + }); + + const createSceneButton = root.find("button[data-action='createScene'], a[data-action='createScene'], button.create-document").first(); + if (createSceneButton.length) { + const row = $('
    '); + row.append(button); + row.append(gmOverlayButton); + row.append(annotateButton); + row.append(deleteButton); + createSceneButton.closest(".header-actions, .action-buttons, .directory-header, header, .tab").first().after(row); + return; + } + + const header = root.find(".directory-header, header").first(); + if (header.length) { + const row = $('
    '); + row.append(button); + row.append(gmOverlayButton); + row.append(annotateButton); + row.append(deleteButton); + header.prepend(row); + } +} + +async function setGmOverlayVisible(visible) { + await game.settings.set(MODULE_ID, "gmOverlayVisible", Boolean(visible)); + applyGmOverlayVisibility(canvas.scene ?? null); + ui.controls?.render?.(true); + ui.notifications.info(`GM overlay ${visible ? "shown" : "hidden"} for your client.`); +} + +function applyGmOverlayVisibility(scene) { + if (!game.user?.isGM || !scene) return; + const visible = game.settings.get(MODULE_ID, "gmOverlayVisible") !== false; + const sceneIsActive = (canvas.scene?.id ?? null) === scene.id; + if (!sceneIsActive) return; + + for (const tile of canvas.tiles?.placeables ?? []) { + applyGmOverlayPlaceableVisibility(tile, visible); + } + + for (const note of canvas.notes?.placeables ?? []) { + applyGmOverlayPlaceableVisibility(note, visible); + } +} + +function applyGmOverlayPlaceableVisibility(placeable, overrideVisible = null) { + if (!game.user?.isGM || !placeable?.document?.flags?.[MODULE_ID]?.gmOverlay) return; + const visible = overrideVisible ?? (game.settings.get(MODULE_ID, "gmOverlayVisible") !== false); + placeable.visible = visible; + placeable.alpha = visible ? 0.95 : 0; + placeable.renderable = visible; +} + +async function deleteGeneratedDocuments(scene) { + if (!scene) { + ui.notifications.warn("No scene active."); + return; + } + + const { wallIds, drawingIds, tileIds, lightIds, tokenIds, noteIds } = collectGeneratedSceneIds(scene); + + if (!wallIds.length && !drawingIds.length && !tileIds.length && !lightIds.length && !tokenIds.length && !noteIds.length) { + ui.notifications.info("No generated elements found in this scene."); + return; + } + + await Dialog.confirm({ + title: "Delete Generated Elements", + content: `

    Remove ${wallIds.length} walls, ${drawingIds.length} drawings, ${tileIds.length} tiles, ${lightIds.length} lights, ${tokenIds.length} encounter tokens, and ${noteIds.length} generated notes from ${escapeXml(scene.name)}?

    `, + yes: async () => { + await deleteGeneratedDocumentsInternal(scene, { wallIds, drawingIds, tileIds, lightIds, tokenIds, noteIds }); + ui.notifications.info(`Deleted generated elements from ${scene.name}.`); + } + }); +} + +async function openSceneAnnotationDialog(scene) { + if (!scene) { + ui.notifications.warn("No scene active."); + return; + } + const roomDrawings = scene.drawings.contents + .filter((drawing) => drawing.flags?.[MODULE_ID]?.roomId) + .sort((a, b) => (a.flags[MODULE_ID].roomLabel || a.flags[MODULE_ID].roomType || "").localeCompare(b.flags[MODULE_ID].roomLabel || b.flags[MODULE_ID].roomType || "")); + if (!roomDrawings.length) { + ui.notifications.warn("No generated room drawings found on this scene."); + return; + } + const value = await Dialog.prompt({ + title: "Annotate Generated Room", + content: ` +
    + + +
    +
    + + +
    + `, + callback: (html) => ({ + roomId: html.find('[name="roomId"]').val(), + note: html.find('[name="annotation"]').val() + }) + }).catch(() => null); + if (!value?.roomId || !String(value.note ?? "").trim()) return; + const drawing = roomDrawings.find((entry) => entry.id === value.roomId); + if (!drawing) return; + const room = drawing.flags[MODULE_ID]; + await scene.createEmbeddedDocuments("Note", [withGeneratedNoteFlag({ + x: round((drawing.x ?? 0) + (drawing.shape?.width ?? 0) / 2), + y: round((drawing.y ?? 0) + (drawing.shape?.height ?? 0) / 2), + iconSize: 36, + text: room.roomLabel || room.roomType || "Room Note", + texture: { src: "icons/svg/book.svg" }, + hidden: true, + flags: { + [MODULE_ID]: { + annotation: true, + gmOverlay: true, + roomId: room.roomId, + roomType: room.roomType, + roomLabel: room.roomLabel, + noteText: String(value.note).trim() + } + } + })]); + ui.notifications.info("Room annotation added."); +} + +function collectGeneratedSceneIds(scene) { + return { + wallIds: scene.walls.contents.filter((wall) => wall.flags?.[MODULE_ID]?.generatedWall).map((wall) => wall.id), + drawingIds: scene.drawings.contents.filter((drawing) => drawing.flags?.[MODULE_ID]?.generatedDrawing).map((drawing) => drawing.id), + tileIds: scene.tiles.contents.filter((tile) => tile.flags?.[MODULE_ID]?.generatedTile).map((tile) => tile.id), + lightIds: scene.lights.contents.filter((light) => light.flags?.[MODULE_ID]?.generatedLight).map((light) => light.id), + tokenIds: scene.tokens.contents.filter((token) => token.flags?.[MODULE_ID]?.generatedToken).map((token) => token.id), + noteIds: scene.notes.contents.filter((note) => note.flags?.[MODULE_ID]?.generatedNote).map((note) => note.id) + }; +} + +async function deleteGeneratedDocumentsInternal(scene, ids = collectGeneratedSceneIds(scene)) { + if (ids.wallIds?.length) await scene.deleteEmbeddedDocuments("Wall", ids.wallIds); + if (ids.drawingIds?.length) await scene.deleteEmbeddedDocuments("Drawing", ids.drawingIds); + if (ids.tileIds?.length) await scene.deleteEmbeddedDocuments("Tile", ids.tileIds); + if (ids.lightIds?.length) await scene.deleteEmbeddedDocuments("AmbientLight", ids.lightIds); + if (ids.tokenIds?.length) await scene.deleteEmbeddedDocuments("Token", ids.tokenIds); + if (ids.noteIds?.length) await scene.deleteEmbeddedDocuments("Note", ids.noteIds); +} + +function normalizeFormState(raw = {}) { + const mode = normalizeChoice(raw.mode, "bsp", GENERATION_MODES.map((entry) => entry.value)); + const seedValue = String(raw.seed ?? "").trim(); + const minRoomSize = clampInt(raw.minRoomSize, 2, 8, 3); + const maxRoomSize = Math.max(minRoomSize, clampInt(raw.maxRoomSize, 4, 12, 8)); + const buildingSubtype = normalizeChoice(raw.buildingSubtype, "office", BUILDING_SUBTYPES.map((entry) => entry.value)); + const generatedName = buildMapName(mode, seedValue || Date.now(), buildingSubtype); + const themeChoices = getThemeOptionsForMode(mode).map((entry) => entry.value); + const hullChoices = getHullOptionsForMode(mode).map((entry) => entry.value); + const compactPrintMode = Boolean(raw.compactPrintMode); + const columns = compactPrintMode ? clampInt(raw.columns, 8, 12, 12) : clampInt(raw.columns, 10, 80, 32); + const rows = compactPrintMode ? clampInt(raw.rows, 6, 8, 8) : clampInt(raw.rows, 6, 50, 20); + const decks = compactPrintMode ? 1 : clampInt(raw.decks, 1, 8, 1); + return { + shipName: String(raw.shipName || generatedName).trim() || generatedName, + mode, + profile: String(raw.profile || "custom"), + savedPreset: String(raw.savedPreset || ""), + columns, + rows, + gridSize: clampInt(raw.gridSize, 50, 100, 75), + gridMode: normalizeChoice(raw.gridMode, "square", GRID_MODE_OPTIONS.map((entry) => entry.value)), + decks, + seed: seedValue || String(Math.floor(Math.random() * 1_000_000)), + theme: normalizeChoice(raw.theme, themeChoices[0], themeChoices), + hullShape: normalizeChoice(raw.hullShape, hullChoices[0], hullChoices), + coordinates: compactPrintMode ? "none" : normalizeChoice(raw.coordinates, "border", COORDINATE_MODES.map((entry) => entry.value)), + roomCount: clampInt(raw.roomCount, 6, 40, 12), + minRoomSize, + maxRoomSize, + cornerStyle: normalizeChoice(raw.cornerStyle, "chamfer", CORNER_STYLE_OPTIONS.map((entry) => entry.value)), + chamferSize: clampInt(raw.chamferSize, 1, 2, 1), + circularSpecialRooms: raw.circularSpecialRooms !== false && raw.circularSpecialRooms !== "false", + softRoomOutlines: raw.softRoomOutlines !== false && raw.softRoomOutlines !== "false", + splitBias: normalizeChoice(raw.splitBias, "balanced", SPLIT_BIASES.map((entry) => entry.value)), + corridorStyle: normalizeChoice(raw.corridorStyle, "spine", CORRIDOR_STYLES.map((entry) => entry.value)), + loopFactor: clampInt(raw.loopFactor, 0, 100, 30), + corridorWidth: clampInt(raw.corridorWidth, 1, 2, 1), + buildingSubtype, + secretDoorDensity: clampInt(raw.secretDoorDensity, 0, 20, 8), + furnitureDensity: normalizeChoice(raw.furnitureDensity, "normal", FURNITURE_DENSITIES.map((entry) => entry.value)), + missionTemplate: normalizeChoice(raw.missionTemplate, "auto", MISSION_TEMPLATE_OPTIONS.map((entry) => entry.value)), + encounterFaction: normalizeChoice(raw.encounterFaction, "auto", ENCOUNTER_FACTION_OPTIONS.map((entry) => entry.value)), + encounterDensity: normalizeChoice(raw.encounterDensity, "standard", ENCOUNTER_DENSITY_OPTIONS.map((entry) => entry.value)), + encounterPack: String(raw.encounterPack ?? "").trim(), + roomPopulationPack: String(raw.roomPopulationPack ?? "").trim(), + populateRoomsFromCompendium: Boolean(raw.populateRoomsFromCompendium), + compactPrintMode, + factionPreset: normalizeChoice(raw.factionPreset, "none", FACTION_PRESET_OPTIONS.map((entry) => entry.value)), + activateScenes: Boolean(raw.activateScenes), + regenerateInPlace: Boolean(raw.regenerateInPlace), + regenerateTarget: normalizeChoice(raw.regenerateTarget, "none", REGENERATE_TARGET_OPTIONS.map((entry) => entry.value)), + targetSceneFolder: String(raw.targetSceneFolder ?? "").trim(), + showRoomLabels: Boolean(raw.showRoomLabels), + placeLights: raw.placeLights !== false && raw.placeLights !== "false", + generateMissionBrief: raw.generateMissionBrief !== false && raw.generateMissionBrief !== "false", + generateMissionPacket: Boolean(raw.generateMissionPacket), + seedEncounters: Boolean(raw.seedEncounters), + createRoomNotes: Boolean(raw.createRoomNotes), + createGmOverlay: raw.createGmOverlay !== false && raw.createGmOverlay !== "false", + exportMetadataJson: raw.exportMetadataJson !== false && raw.exportMetadataJson !== "false", + mapAge: clampInt(raw.mapAge, 0, 100, 0), + exportMothershipJson: ["bsp", "corridor"].includes(mode) && Boolean(raw.exportMothershipJson) + }; +} + +function getThemeOptionsForMode(mode) { + return MODE_THEME_OPTIONS[mode] ?? MODE_THEME_OPTIONS.bsp; +} + +function getHullOptionsForMode(mode) { + return MODE_HULL_OPTIONS[mode] ?? MODE_HULL_OPTIONS.bsp; +} + +function updateModeVisibility(form, mode) { + const themeSelect = form.querySelector('[name="theme"]'); + const hullSelect = form.querySelector('[name="hullShape"]'); + syncSelectOptions(themeSelect, getThemeOptionsForMode(mode)); + syncSelectOptions(hullSelect, getHullOptionsForMode(mode)); + + form.querySelectorAll(".sdpg-mode-field").forEach((field) => { + const allowed = (field.dataset.modes ?? "").split(/\s+/).filter(Boolean); + const active = !allowed.length || allowed.includes(mode); + field.classList.toggle("is-hidden", !active); + field.querySelectorAll("input, select").forEach((element) => { + element.disabled = !active; + }); + }); + + const exportToggle = form.querySelector('[name="exportMothershipJson"]'); + if (exportToggle) exportToggle.checked = ["bsp", "corridor"].includes(mode) && exportToggle.checked; +} + +async function updatePreviewPane(form, root) { + const image = root.querySelector("[data-preview-image]"); + const summary = root.querySelector("[data-preview-summary]"); + if (!image || !summary) return; + try { + summary.textContent = "Rendering preview..."; + const formObject = Object.fromEntries(new FormData(form).entries()); + form.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { + formObject[checkbox.name] = checkbox.checked; + }); + const formState = normalizeFormState(formObject); + const roomTables = await getRoomTables(); + const deckPlan = buildDeckPlan(formState, roomTables); + const previewDeck = deckPlan.decks[0]; + const svg = buildDeckSVG(deckPlan, previewDeck, formState); + image.dataset.svg = svg; + image.dataset.filename = `${slugify(deckPlan.shipName)}-preview.png`; + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + summary.textContent = buildPreviewSummary(deckPlan, previewDeck, formState); + } catch (error) { + console.error(`${MODULE_ID} preview failed`, error); + summary.textContent = `Preview failed: ${error?.message ?? error}`; + } +} + +function buildPreviewSummary(deckPlan, deck, formState) { + const hazards = deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)).length; + const wildernessFeatures = formState.mode === "wilderness" + ? `${deck.wilderness?.treeClusters?.length ?? 0} tree clusters, ${deck.wilderness?.waterFeatures?.length ?? 0} water features, ${deck.wilderness?.encounterZones?.length ?? 0} encounter zones` + : `${deck.rooms.length} rooms, ${deck.corridors.length} corridors, ${countDoors(deck.wallSegments)} doors`; + const targetLabel = { + none: "new scenes", + viewed: "viewed scene", + "viewed-folder": "viewed folder", + folder: formState.targetSceneFolder ? "selected folder" : "new scenes" + }[formState.regenerateTarget === "none" && formState.regenerateInPlace ? "viewed" : formState.regenerateTarget] ?? "new scenes"; + return `${deckPlan.shipName} | ${deck.deckName} | ${deckPlan.decks.length} deck${deckPlan.decks.length === 1 ? "" : "s"} | ${formState.theme} | ${formState.gridMode} | ${wildernessFeatures}${hazards ? ` | ${hazards} danger rooms` : ""} | target: ${targetLabel}`; +} + +async function saveFormAsPreset(form, root) { + const name = (await Dialog.prompt({ + title: "Save Preset", + content: `
    `, + callback: (html) => html.find('[name="presetName"]').val() + }))?.trim(); + if (!name) return; + const formState = readFormState(form); + formState.savedPreset = name; + const presets = [...(game.settings.get(MODULE_ID, "savedPresets") ?? [])].filter((preset) => preset.name !== name); + presets.push({ name, state: formState }); + presets.sort((a, b) => a.name.localeCompare(b.name)); + await game.settings.set(MODULE_ID, "savedPresets", presets); + await game.settings.set(MODULE_ID, "lastFormState", formState); + updatePresetSelect(root, name); + ui.notifications.info(`Saved preset ${name}.`); +} + +async function applySavedPresetToForm(form, root) { + const select = form.querySelector('[name="savedPreset"]'); + const name = String(select?.value ?? "").trim(); + if (!name) return; + const preset = (game.settings.get(MODULE_ID, "savedPresets") ?? []).find((entry) => entry.name === name); + if (!preset?.state) return; + applyStateToForm(form, preset.state); + updateModeVisibility(form, form.querySelector('[name="mode"]')?.value ?? preset.state.mode ?? "bsp"); + updatePresetSelect(root, name); + await game.settings.set(MODULE_ID, "lastFormState", { ...preset.state, savedPreset: name }); + updatePreviewPane(form, root).catch(() => {}); + ui.notifications.info(`Loaded preset ${name}.`); +} + +async function deleteSavedPresetFromForm(form, root) { + const select = form.querySelector('[name="savedPreset"]'); + const name = String(select?.value ?? "").trim(); + if (!name) return; + const confirmed = await Dialog.confirm({ + title: "Delete Preset", + content: `

    Delete saved preset ${escapeXml(name)}?

    ` + }); + if (!confirmed) return; + const presets = [...(game.settings.get(MODULE_ID, "savedPresets") ?? [])].filter((entry) => entry.name !== name); + await game.settings.set(MODULE_ID, "savedPresets", presets); + updatePresetSelect(root, ""); + ui.notifications.info(`Deleted preset ${name}.`); +} + +function applyStateToForm(form, state = {}) { + for (const [key, value] of Object.entries(state)) { + setInputValue(form, key, value); + } +} + +function updatePresetSelect(root, selectedName = "") { + const select = root.querySelector('[name="savedPreset"]'); + if (!select) return; + syncSelectOptions(select, buildSavedPresetOptions()); + select.value = selectedName; +} + +function readFormState(form) { + const formObject = Object.fromEntries(new FormData(form).entries()); + form.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { + formObject[checkbox.name] = checkbox.checked; + }); + return normalizeFormState(formObject); +} + +async function exportPreviewPng(form, root) { + const image = root.querySelector("[data-preview-image]"); + const svg = image?.dataset?.svg; + if (!svg) return; + const widthMatch = svg.match(/width="(\d+)"/); + const heightMatch = svg.match(/height="(\d+)"/); + const width = Number(widthMatch?.[1] ?? 1200); + const height = Number(heightMatch?.[1] ?? 900); + const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(blob); + try { + const pngUrl = await rasterizeSvgToPng(url, width, height); + const link = document.createElement("a"); + link.href = pngUrl; + link.download = image.dataset.filename || "map-preview.png"; + link.click(); + setTimeout(() => URL.revokeObjectURL(pngUrl), 2000); + } finally { + URL.revokeObjectURL(url); + } +} + +function rasterizeSvgToPng(url, width, height) { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + const img = new Image(); + img.onload = () => { + ctx.clearRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL("image/png")); + }; + img.onerror = reject; + img.src = url; + }); +} + +async function shareFormState(form) { + const state = readFormState(form); + const packed = btoa(unescape(encodeURIComponent(JSON.stringify(state)))); + const share = `${window.location.origin}${window.location.pathname}#sdpg=${packed}`; + await navigator.clipboard.writeText(share).catch(() => {}); + await Dialog.prompt({ + title: "Share Seed URL", + content: ``, + callback: () => share + }).catch(() => null); +} + +async function importSharedFormState(form, root) { + const value = await Dialog.prompt({ + title: "Import Shared Seed", + content: ``, + callback: (html) => html.find('[name="importState"]').val() + }); + const text = String(value ?? "").trim(); + if (!text) return; + const match = text.match(/#sdpg=([^&]+)/); + const packed = match?.[1] ?? text; + try { + const state = JSON.parse(decodeURIComponent(escape(atob(packed)))); + applyStateToForm(form, state); + updateModeVisibility(form, form.querySelector('[name="mode"]')?.value ?? state.mode ?? "bsp"); + updatePreviewPane(form, root).catch(() => {}); + ui.notifications.info("Imported shared generator state."); + } catch (error) { + ui.notifications.error(`Could not import shared state: ${error?.message ?? error}`); + } +} + +function syncSelectOptions(select, options) { + if (!select) return; + const currentValue = select.value; + select.innerHTML = options.map((option) => ``).join(""); + const allowed = new Set(options.map((option) => String(option.value))); + select.value = allowed.has(String(currentValue)) ? String(currentValue) : String(options[0]?.value ?? ""); +} + +async function getRoomTables() { + if (!roomTablesCache) { + roomTablesCache = await loadJson(`modules/${MODULE_ID}/data/room-tables.json`); + } + return roomTablesCache; +} + +async function getShipProfiles() { + if (!shipProfilesCache) { + shipProfilesCache = await loadJson(`modules/${MODULE_ID}/data/ship-profiles.json`); + } + return shipProfilesCache; +} + +async function getFurnitureTables() { + if (!furnitureTablesCache) { + furnitureTablesCache = await loadJson(`modules/${MODULE_ID}/data/furniture-tables.json`); + } + return furnitureTablesCache; +} + +async function loadJson(path) { + const response = await fetch(path); + if (!response.ok) throw new Error(`Failed to load ${path}`); + return response.json(); +} + +function buildDeckPlan(formState, roomTables) { + const seed = hashSeed(formState.seed); + const decks = []; + + for (let deckIndex = 0; deckIndex < formState.decks; deckIndex += 1) { + const rng = mulberry32(seed + deckIndex * 104729); + const deckName = resolveDeckName(deckIndex, formState.mode); + const deck = buildDeckForMode(deckIndex, deckName, formState, roomTables, rng); + decks.push(deck); + } + + if (decks.length > 1) { + applySharedDeckConnectors(decks, formState); + } + + return { + shipName: formState.shipName, + seed: formState.seed, + gridSize: formState.gridSize, + theme: formState.theme, + decks + }; +} + +function buildDeckForMode(deckIndex, deckName, formState, roomTables, rng) { + switch (formState.mode) { + case "corridor": + return buildCorridorDeck(deckIndex, deckName, formState, roomTables, rng); + case "geomorph": + return buildGeomorphDeck(deckIndex, deckName, formState, roomTables, rng); + case "dungeon": + if (formState.theme === "cave") return buildCaveDeck(deckIndex, deckName, formState, roomTables, rng); + return buildDungeonDeck(deckIndex, deckName, formState, roomTables, rng); + case "building": + return formState.corridorStyle === "spine" + ? buildBuildingDeckWithHallway(deckIndex, deckName, formState, roomTables, rng) + : buildBuildingDeck(deckIndex, deckName, formState, roomTables, rng); + case "wilderness": + return buildWildernessDeck(deckIndex, deckName, formState, roomTables, rng); + case "bsp": + default: + return formState.corridorStyle === "spine" + ? buildBspDeckWithSpine(deckIndex, deckName, formState, roomTables, rng) + : buildBspDeck(deckIndex, deckName, formState, roomTables, rng); + } +} + +function buildGeomorphDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, "rectangle", "geomorph"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const tileSize = 6; + const tileCols = Math.max(1, Math.floor((formState.columns - 2) / tileSize)); + const tileRows = Math.max(1, Math.floor((formState.rows - 2) / tileSize)); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + const rooms = []; + const corridors = []; + const connections = []; + const geomorphTiles = []; + + for (let ty = 0; ty < tileRows; ty += 1) { + for (let tx = 0; tx < tileCols; tx += 1) { + const required = { + north: ty > 0 ? (geomorphTiles[(ty - 1) * tileCols + tx]?.connectors.south ?? false) : false, + west: tx > 0 ? (geomorphTiles[ty * tileCols + (tx - 1)]?.connectors.east ?? false) : false + }; + const candidates = GEOMORPH_TILES.filter((tile) => + tile.connectors.north === required.north + && tile.connectors.west === required.west + && (tx < tileCols - 1 || tile.connectors.east === false) + && (ty < tileRows - 1 || tile.connectors.south === false) + ); + const tile = (candidates.length ? candidates : GEOMORPH_TILES)[randomInt(rng, 0, (candidates.length ? candidates : GEOMORPH_TILES).length - 1)]; + geomorphTiles.push(tile); + const baseX = 1 + tx * tileSize; + const baseY = 1 + ty * tileSize; + for (const rect of tile.corridors) { + const corridor = { x: baseX + rect.x, y: baseY + rect.y, width: rect.width, height: rect.height, waypoints: [] }; + corridors.push(corridor); + fillRectCells(occupancy, corridor, true); + fillRectCells(roomType, corridor, "corridor"); + } + for (const rect of tile.rooms) { + const room = { + id: `g${deckIndex}-${rooms.length}`, + x: baseX + rect.x, + y: baseY + rect.y, + width: rect.width, + height: rect.height, + shape: "rect", + markers: [] + }; + rooms.push(room); + fillRectCells(occupancy, room, true); + fillRectCells(roomType, room, "room"); + fillRectCells(roomIdGrid, room, room.id); + } + } + } + + for (let i = 1; i < rooms.length; i += 1) { + connections.push([rooms[i - 1].id, rooms[i].id]); + } + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const wallSegments = buildOccupancyWalls(occupancy, roomType, roomIdGrid); + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors, + wallSegments, + deckConnectors: [], + airlocks: [], + exteriorOpenings: [] + }; +} + +function buildBspDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, formState.mode); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const roomTargets = allocateRoomCounts(hullRegions, formState.roomCount); + const roomRects = hullRegions.flatMap((region, index) => + splitRectExactCount(region, roomTargets[index], formState.splitBias, formState.minRoomSize, rng) + ); + const rooms = roomRects.map((rect, index) => ({ + id: `r${deckIndex}-${index}`, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + shape: "rect", + markers: [] + })); + + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const airlocks = buildHullDoorsFromMask(hullMask, rng); + const airlockDoors = airlocks.map((airlock) => ({ ...airlock, door: true, ds: 2 })); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, airlocks), + ...buildPartitionSegments(rooms, rng), + ...airlockDoors + ]; + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: [], + wallSegments, + deckConnectors: [], + airlocks: airlocks.map((segment, index) => ({ + id: `a${deckIndex}-${index}`, + x: segment.x1, + y: segment.y1, + edge: segment.x1 === segment.x2 ? "V" : "H", + label: `AIRLOCK ${index + 1}` + })) + }; +} + +function buildBspDeckWithSpine(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, formState.mode); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const spineWidth = Math.min(2, Math.max(1, formState.corridorWidth)); + const spineMidY = hull.y + Math.max(2, Math.floor(hull.height / 2) - Math.floor(spineWidth / 2)); + const spineRect = { x: hull.x, y: spineMidY, width: hull.width, height: spineWidth }; + const topBand = { x: hull.x, y: hull.y, width: hull.width, height: Math.max(0, spineRect.y - hull.y) }; + const botBand = { + x: hull.x, + y: spineRect.y + spineRect.height, + width: hull.width, + height: Math.max(0, (hull.y + hull.height) - (spineRect.y + spineRect.height)) + }; + const roomCount = Math.max(4, formState.roomCount); + const topCount = Math.ceil(roomCount / 2); + const botCount = Math.floor(roomCount / 2); + const rooms = []; + const roomRects = []; + if (topBand.height >= formState.minRoomSize) { + roomRects.push(...splitRectExactCount(topBand, topCount, formState.splitBias, formState.minRoomSize, rng)); + } + if (botBand.height >= formState.minRoomSize) { + roomRects.push(...splitRectExactCount(botBand, botCount, formState.splitBias, formState.minRoomSize, rng)); + } + roomRects.forEach((rect, index) => { + rooms.push({ id: `r${deckIndex}-${index}`, ...rect, shape: "rect", markers: [] }); + }); + + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const airlocks = buildHullDoorsFromMask(hullMask, rng); + const airlockDoors = airlocks.map((airlock) => ({ ...airlock, door: true, ds: DOOR_STATE_LOCKED })); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, airlocks), + ...buildPartitionSegments(rooms, rng), + ...buildSpineWalls(spineRect, rooms), + ...airlockDoors + ]; + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: [{ ...spineRect, waypoints: [] }], + wallSegments, + deckConnectors: [], + airlocks: airlocks.map((segment, index) => ({ + id: `a${deckIndex}-${index}`, + x: segment.x1, + y: segment.y1, + edge: segment.x1 === segment.x2 ? "V" : "H", + label: `AIRLOCK ${index + 1}` + })) + }; +} + +function buildCorridorDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, formState.mode); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + const rooms = []; + const attempts = formState.roomCount * 16; + + for (let i = 0; i < attempts && rooms.length < formState.roomCount; i += 1) { + const width = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.width - 2))); + const height = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.height - 2))); + const x = randomInt(rng, hull.x, Math.max(hull.x, hull.x + hull.width - width)); + const y = randomInt(rng, hull.y, Math.max(hull.y, hull.y + hull.height - height)); + const rect = { x, y, width, height }; + if (!rectFitsMask(rect, hullMask)) continue; + if (rooms.some((room) => rectsOverlap(expandRect(room, 1), rect))) continue; + rooms.push({ + id: `r${deckIndex}-${rooms.length}`, + ...rect, + shape: "rect", + markers: [] + }); + fillRectCells(occupancy, rect, true); + fillRectCells(roomType, rect, "room"); + fillRectCells(roomIdGrid, rect, rooms[rooms.length - 1].id); + } + + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + + const corridors = []; + const connections = []; + for (let i = 1; i < rooms.length; i += 1) { + const current = roomCenter(rooms[i]); + const nearestRoom = nearestRoomForIndex(rooms, i); + const nearest = roomCenter(nearestRoom); + const width = corridorWidthForConnection(rooms[i], nearestRoom, formState); + const hall = carveCorridor(nearest, current, occupancy, roomType, width, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([rooms[i].id, nearestRoom.id]); + } + } + + const extraEdges = Math.max(1, Math.floor(rooms.length * (formState.loopFactor / 100) * 0.4)); + for (let i = 0; i < extraEdges; i += 1) { + const a = rooms[randomInt(rng, 0, rooms.length - 1)]; + const b = rooms[randomInt(rng, 0, rooms.length - 1)]; + if (!a || !b || a === b) continue; + const width = corridorWidthForConnection(a, b, formState); + const hall = carveCorridor(roomCenter(a), roomCenter(b), occupancy, roomType, width, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([a.id, b.id]); + } + } + + ensureCorridorConnectivity(rooms, connections, corridors, occupancy, roomType, formState.corridorWidth, hullMask, formState); + + const wallSegments = buildOccupancyWalls(occupancy, roomType, roomIdGrid); + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors, + wallSegments, + deckConnectors: [], + airlocks: [] + }; +} + +function buildCaveDeck(deckIndex, deckName, formState, roomTables, rng) { + const columns = formState.columns; + const rows = formState.rows; + const openMask = makeGrid(columns, rows, false); + + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < columns - 1; x += 1) { + openMask[y][x] = rng() > 0.45; + } + } + + for (let pass = 0; pass < 5; pass += 1) { + const next = makeGrid(columns, rows, false); + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < columns - 1; x += 1) { + const closed = countClosedNeighbours(openMask, x, y, columns, rows); + next[y][x] = closed < 5 ? true : (closed <= 3 ? true : openMask[y][x]); + } + } + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < columns; x += 1) { + openMask[y][x] = next[y][x]; + } + } + } + + const visited = makeGrid(columns, rows, false); + let bestRegion = []; + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < columns - 1; x += 1) { + if (!openMask[y][x] || visited[y][x]) continue; + const region = floodFillOpenRegion(openMask, visited, x, y, columns, rows); + if (region.length > bestRegion.length) bestRegion = region; + } + } + + const finalMask = makeGrid(columns, rows, false); + for (const cell of bestRegion) { + finalMask[cell.y][cell.x] = true; + } + + const midY = Math.floor(rows / 2); + for (let x = 2; x < columns - 2; x += 1) { + finalMask[midY][x] = true; + } + + const chambers = buildCaveChambers(finalMask, formState.roomCount, formState.minRoomSize, rng, deckIndex); + const connections = buildCaveRoomConnections(chambers); + assignDungeonRoomTypes(chambers, roomTables, connections, finalMask, rng); + applyFactionTerritories(chambers, formState); + applyOrganicRoomShapes(chambers, { ...formState, cornerStyle: "square", circularSpecialRooms: true }, rng); + + return { + deckIndex, + deckName, + columns, + rows, + hull: { x: 1, y: 1, width: columns - 2, height: rows - 2 }, + hullRegions: [{ x: 1, y: 1, width: columns - 2, height: rows - 2 }], + hullMask: finalMask, + rooms: chambers, + corridors: [], + wallSegments: extractGridWallSegments(finalMask, columns, rows), + deckConnectors: [], + airlocks: [], + isCave: true + }; +} + +function buildDungeonDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, "dungeon"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + const rooms = []; + const attempts = formState.roomCount * 18; + + for (let i = 0; i < attempts && rooms.length < formState.roomCount; i += 1) { + const width = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.width - 2))); + const height = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.height - 2))); + const x = randomInt(rng, hull.x, Math.max(hull.x, hull.x + hull.width - width)); + const y = randomInt(rng, hull.y, Math.max(hull.y, hull.y + hull.height - height)); + const rect = { x, y, width, height }; + if (!rectFitsMask(rect, hullMask)) continue; + if (rooms.some((room) => rectsOverlap(expandRect(room, 1), rect))) continue; + const room = { + id: `r${deckIndex}-${rooms.length}`, + ...rect, + shape: "rect", + markers: [] + }; + rooms.push(room); + fillRectCells(occupancy, rect, true); + fillRectCells(roomType, rect, "room"); + fillRectCells(roomIdGrid, rect, room.id); + } + + const corridors = []; + const connections = []; + for (let i = 1; i < rooms.length; i += 1) { + const current = roomCenter(rooms[i]); + const nearestRoom = nearestRoomForIndex(rooms, i); + const hall = carveCorridor(roomCenter(nearestRoom), current, occupancy, roomType, formState.corridorWidth, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([rooms[i].id, nearestRoom.id]); + } + } + + const roomDoorTypes = assignDungeonRoomTypes(rooms, roomTables, connections, hullMask, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const extraEdges = Math.max(1, Math.floor(rooms.length * (formState.loopFactor / 100))); + for (let i = 0; i < extraEdges; i += 1) { + const a = rooms[randomInt(rng, 0, rooms.length - 1)]; + const b = rooms[randomInt(rng, 0, rooms.length - 1)]; + if (!a || !b || a === b) continue; + const width = corridorWidthForConnection(a, b, formState); + const hall = carveCorridor(roomCenter(a), roomCenter(b), occupancy, roomType, width, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([a.id, b.id]); + } + } + + ensureCorridorConnectivity(rooms, connections, corridors, occupancy, roomType, formState.corridorWidth, hullMask, formState); + let wallSegments = buildOccupancyWalls(occupancy, roomType, roomIdGrid, { roomDoorTypes }); + wallSegments = injectSecretDoors(wallSegments, rng, formState.secretDoorDensity / 100); + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors, + wallSegments, + deckConnectors: [], + airlocks: [] + }; +} + +function buildBuildingDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, "building"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const roomTargets = allocateRoomCounts(hullRegions, formState.roomCount); + const roomRects = hullRegions.flatMap((region, index) => + splitRectExactCount(region, roomTargets[index], formState.splitBias, formState.minRoomSize, rng) + ); + const rooms = roomRects.map((rect, index) => ({ + id: `r${deckIndex}-${index}`, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + shape: "rect", + markers: [] + })); + + const roomDoorTypes = assignBuildingRoomTypes(rooms, roomTables, formState.buildingSubtype, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const exteriorOpenings = buildBuildingExteriorOpenings(hullMask, rng, formState.buildingSubtype); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, exteriorOpenings), + ...buildPartitionSegments(rooms, rng), + ...exteriorOpenings + ]; + const finishedWalls = injectHorrorDecay(applyDoorTypesToPartitions(wallSegments, rooms, roomDoorTypes), rooms, rng, formState.theme); + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: [], + wallSegments: finishedWalls, + deckConnectors: [], + airlocks: [], + exteriorOpenings + }; +} + +function buildBuildingCirculation(region, formState) { + const corridors = []; + const roomBands = []; + const corridorWidth = 1; + const lobbyDepth = clamp(Math.round(region.height * 0.18), 3, Math.max(3, region.height - formState.minRoomSize - 1)); + const lobbyWidth = clamp(Math.round(region.width * 0.28), 3, Math.max(3, region.width - (formState.minRoomSize * 2))); + + if (region.width >= region.height) { + const mainY = region.y + Math.max(formState.minRoomSize, Math.floor(region.height * 0.42)); + const lobbyX = region.x + Math.floor((region.width - lobbyWidth) / 2); + const lobbyY = region.y + region.height - lobbyDepth; + const mainHall = { x: region.x, y: clamp(mainY, region.y + 2, region.y + region.height - 3), width: region.width, height: corridorWidth, waypoints: [] }; + const lobby = { x: lobbyX, y: lobbyY, width: lobbyWidth, height: lobbyDepth, waypoints: [] }; + const connector = { + x: lobbyX + Math.floor(lobbyWidth / 2), + y: mainHall.y + mainHall.height, + width: corridorWidth, + height: Math.max(0, lobby.y - (mainHall.y + mainHall.height)), + waypoints: [] + }; + corridors.push(mainHall, lobby); + if (connector.height > 0) corridors.push(connector); + + roomBands.push({ x: region.x, y: region.y, width: region.width, height: mainHall.y - region.y }); + roomBands.push({ x: region.x, y: mainHall.y + mainHall.height, width: Math.max(0, lobby.x - region.x), height: region.y + region.height - (mainHall.y + mainHall.height) }); + roomBands.push({ + x: lobby.x + lobby.width, + y: mainHall.y + mainHall.height, + width: Math.max(0, region.x + region.width - (lobby.x + lobby.width)), + height: region.y + region.height - (mainHall.y + mainHall.height) + }); + } else { + const mainX = region.x + Math.floor(region.width / 2); + const crossY = region.y + Math.max(formState.minRoomSize, Math.floor(region.height * 0.35)); + const lobby = { + x: region.x + Math.floor((region.width - lobbyWidth) / 2), + y: region.y + region.height - lobbyDepth, + width: Math.min(lobbyWidth, region.width), + height: lobbyDepth, + waypoints: [] + }; + const mainHall = { x: clamp(mainX, region.x + 2, region.x + region.width - 3), y: region.y, width: corridorWidth, height: region.height, waypoints: [] }; + const crossHall = { x: region.x, y: clamp(crossY, region.y + 2, region.y + region.height - 3), width: region.width, height: corridorWidth, waypoints: [] }; + corridors.push(mainHall, crossHall, lobby); + + roomBands.push({ x: region.x, y: region.y, width: mainHall.x - region.x, height: crossHall.y - region.y }); + roomBands.push({ x: mainHall.x + mainHall.width, y: region.y, width: region.x + region.width - (mainHall.x + mainHall.width), height: crossHall.y - region.y }); + roomBands.push({ x: region.x, y: crossHall.y + crossHall.height, width: lobby.x - region.x, height: region.y + region.height - (crossHall.y + crossHall.height) }); + roomBands.push({ + x: lobby.x + lobby.width, + y: crossHall.y + crossHall.height, + width: region.x + region.width - (lobby.x + lobby.width), + height: region.y + region.height - (crossHall.y + crossHall.height) + }); + } + + return { + corridors: corridors.filter((corridor) => corridor.width > 0 && corridor.height > 0), + roomBands: roomBands.filter((band) => band.width > 0 && band.height > 0) + }; +} + +function buildBuildingDeckWithHallway(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, "building"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const hallways = []; + const roomRects = []; + + for (const region of hullRegions) { + const circulation = buildBuildingCirculation(region, formState); + hallways.push(...circulation.corridors); + const candidateBands = circulation.roomBands.filter((band) => band.width >= formState.minRoomSize && band.height >= formState.minRoomSize); + const regionRoomCount = Math.max(2, Math.round(formState.roomCount * ((region.width * region.height) / Math.max(1, hull.width * hull.height)))); + const counts = allocateRoomCounts(candidateBands, regionRoomCount); + candidateBands.forEach((band, index) => { + roomRects.push(...splitRectExactCount(band, counts[index], formState.splitBias, formState.minRoomSize, rng)); + }); + } + + const rooms = roomRects.map((rect, index) => ({ id: `r${deckIndex}-${index}`, ...rect, shape: "rect", markers: [] })); + const roomDoorTypes = assignBuildingRoomTypes(rooms, roomTables, formState.buildingSubtype, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const exteriorOpenings = buildBuildingExteriorOpenings(hullMask, rng, formState.buildingSubtype); + const hallwayWalls = hallways.flatMap((hallRect) => buildHallwayWalls(hallRect, rooms)); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, exteriorOpenings), + ...buildPartitionSegments(rooms, rng), + ...hallwayWalls, + ...exteriorOpenings + ]; + const finishedWalls = dedupeSegments(injectHorrorDecay(applyDoorTypesToPartitions(wallSegments, rooms, roomDoorTypes), rooms, rng, formState.theme), true); + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: hallways, + wallSegments: finishedWalls, + deckConnectors: [], + airlocks: [], + exteriorOpenings + }; +} + +function buildWildernessDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = [{ x: 1, y: 1, width: formState.columns - 2, height: formState.rows - 2 }]; + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const clearings = []; + const targetAreas = Math.max(5, Math.min(18, formState.roomCount)); + const attempts = targetAreas * 24; + + for (let i = 0; i < attempts && clearings.length < targetAreas; i += 1) { + const width = randomInt(rng, Math.max(3, formState.minRoomSize), Math.max(4, formState.maxRoomSize + 2)); + const height = randomInt(rng, Math.max(3, formState.minRoomSize), Math.max(4, formState.maxRoomSize + 2)); + const x = randomInt(rng, hull.x, Math.max(hull.x, hull.x + hull.width - width)); + const y = randomInt(rng, hull.y, Math.max(hull.y, hull.y + hull.height - height)); + const rect = { x, y, width, height }; + if (clearings.some((room) => rectsOverlap(expandRect(room, 2), rect))) continue; + clearings.push({ + id: `w${deckIndex}-${clearings.length}`, + ...rect, + shape: rng() > 0.55 ? "circle" : "rect", + markers: [] + }); + } + + assignWildernessAreaTypes(clearings, roomTables, formState.theme, rng); + applyFactionTerritories(clearings, formState); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + for (const room of clearings) { + fillRectCells(occupancy, room, true); + fillRectCells(roomType, room, "room"); + fillRectCells(roomIdGrid, room, room.id); + } + + const trails = []; + const connections = []; + for (let i = 1; i < clearings.length; i += 1) { + const from = roomCenter(clearings[i]); + const to = roomCenter(nearestRoomForIndex(clearings, i)); + const trail = carveCorridor(to, from, occupancy, roomType, 1, hullMask); + if (trail.length) { + trails.push(...trail); + connections.push([clearings[i].id, nearestRoomForIndex(clearings, i).id]); + } + } + ensureCorridorConnectivity(clearings, connections, trails, occupancy, roomType, 1, hullMask, formState); + + const wilderness = buildWildernessFeatures(clearings, formState, rng); + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms: clearings, + corridors: trails, + wallSegments: wilderness.wallSegments, + deckConnectors: [], + airlocks: [], + wilderness + }; +} + +function applyOrganicRoomShapes(rooms, formState, rng) { + if (!rooms?.length) return rooms; + + if (formState.circularSpecialRooms) { + applyCircularRoomShapes(rooms); + } + + if (formState.cornerStyle === "square") return rooms; + + for (const room of rooms) { + if (room.shape === "circle" || room.shape === "cave") continue; + if (formState.cornerStyle === "chamfer") { + const points = buildChamferedPoints(room, formState.chamferSize); + if (points) { + room.shape = "polygon"; + room.points = points; + } + continue; + } + if (formState.cornerStyle === "perturb") { + const points = buildPerturbedPoints(room, rng); + if (points) { + room.shape = "polygon"; + room.points = points; + } + } + } + return rooms; +} + +function applyCircularRoomShapes(rooms) { + for (const room of rooms) { + if (!CIRCULAR_ROOM_TYPES.has(room.type)) continue; + if (room.width < 3 || room.height < 3) continue; + const size = Math.min(room.width, room.height); + const offsetX = (room.width - size) / 2; + const offsetY = (room.height - size) / 2; + room.shape = "circle"; + room.circleX = room.x + offsetX; + room.circleY = room.y + offsetY; + room.circleR = size / 2; + } +} + +function buildChamferedPoints(room, chamferSize = 1) { + if (room.width < chamferSize * 2 + 2 || room.height < chamferSize * 2 + 2) return null; + const c = chamferSize; + const { x, y, width: w, height: h } = room; + return [ + { x: x + c, y }, + { x: x + w - c, y }, + { x: x + w, y: y + c }, + { x: x + w, y: y + h - c }, + { x: x + w - c, y: y + h }, + { x: x + c, y: y + h }, + { x, y: y + h - c }, + { x, y: y + c } + ]; +} + +function buildPerturbedPoints(room, rng, amount = 0.4) { + if (room.width < 3 || room.height < 3) return null; + const jitter = () => round((rng() - 0.5) * 2 * amount, 2); + return [ + { x: room.x + jitter(), y: room.y + jitter() }, + { x: room.x + room.width + jitter(), y: room.y + jitter() }, + { x: room.x + room.width + jitter(), y: room.y + room.height + jitter() }, + { x: room.x + jitter(), y: room.y + room.height + jitter() } + ]; +} + +function countClosedNeighbours(openMask, x, y, columns, rows) { + let closed = 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= columns || ny >= rows || !openMask[ny]?.[nx]) { + closed += 1; + } + } + } + return closed; +} + +function floodFillOpenRegion(openMask, visited, startX, startY, columns, rows) { + const cells = []; + const stack = [{ x: startX, y: startY }]; + while (stack.length) { + const { x, y } = stack.pop(); + if (x < 0 || y < 0 || x >= columns || y >= rows) continue; + if (visited[y][x] || !openMask[y][x]) continue; + visited[y][x] = true; + cells.push({ x, y }); + stack.push({ x: x + 1, y }, { x: x - 1, y }, { x, y: y + 1 }, { x, y: y - 1 }); + } + return cells; +} + +function buildCaveChambers(openMask, targetCount, minRoomSize, rng, deckIndex) { + const openCells = []; + for (let y = 1; y < openMask.length - 1; y += 1) { + for (let x = 1; x < (openMask[y]?.length ?? 0) - 1; x += 1) { + if (openMask[y][x]) openCells.push({ x, y }); + } + } + if (!openCells.length) return []; + + const seeds = pickSeparatedOpenCells(openCells, Math.min(targetCount, Math.max(4, Math.floor(openCells.length / 18))), Math.max(3, minRoomSize * 2), rng); + if (!seeds.length) seeds.push(openCells[Math.floor(openCells.length / 2)]); + + const chamberCells = Array.from({ length: seeds.length }, () => []); + const chamberIndexByCell = new Map(); + for (const cell of openCells) { + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < seeds.length; index += 1) { + const seed = seeds[index]; + const distance = Math.abs(cell.x - seed.x) + Math.abs(cell.y - seed.y); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + } + chamberCells[bestIndex].push(cell); + chamberIndexByCell.set(`${cell.x},${cell.y}`, bestIndex); + } + + return chamberCells + .filter((cells) => cells.length >= Math.max(6, minRoomSize * minRoomSize)) + .map((cells, index) => { + const xs = cells.map((cell) => cell.x); + const ys = cells.map((cell) => cell.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + return { + id: `c${deckIndex}-${index}`, + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1, + shape: "cave", + cells, + markers: [], + chamberIndexByCell + }; + }); +} + +function pickSeparatedOpenCells(cells, targetCount, minDistance, rng) { + const pool = [...cells]; + const chosen = []; + while (pool.length && chosen.length < targetCount) { + const pick = pool.splice(randomInt(rng, 0, pool.length - 1), 1)[0]; + if (chosen.every((cell) => Math.abs(cell.x - pick.x) + Math.abs(cell.y - pick.y) >= minDistance)) { + chosen.push(pick); + } + } + return chosen; +} + +function buildCaveRoomConnections(rooms) { + const cellToRoom = new Map(); + for (const room of rooms) { + for (const cell of room.cells ?? []) { + cellToRoom.set(`${cell.x},${cell.y}`, room.id); + } + } + const connections = new Set(); + for (const room of rooms) { + for (const cell of room.cells ?? []) { + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const neighbor = cellToRoom.get(`${cell.x + dx},${cell.y + dy}`); + if (!neighbor || neighbor === room.id) continue; + connections.add([room.id, neighbor].sort().join("|")); + } + } + } + return Array.from(connections, (key) => key.split("|")); +} + +function extractGridWallSegments(openMask, columns, rows) { + const segments = []; + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < columns; x += 1) { + if (!openMask[y]?.[x]) continue; + if (!openMask[y - 1]?.[x]) segments.push({ x1: x, y1: y, x2: x + 1, y2: y }); + if (!openMask[y + 1]?.[x]) segments.push({ x1: x, y1: y + 1, x2: x + 1, y2: y + 1 }); + if (!openMask[y]?.[x - 1]) segments.push({ x1: x, y1: y, x2: x, y2: y + 1 }); + if (!openMask[y]?.[x + 1]) segments.push({ x1: x + 1, y1: y, x2: x + 1, y2: y + 1 }); + } + } + return mergeCollinearSegments(dedupeSegments(segments)); +} + +function mergeCollinearSegments(segments) { + const horiz = segments.filter((segment) => segment.y1 === segment.y2).sort((a, b) => a.y1 - b.y1 || a.x1 - b.x1); + const vert = segments.filter((segment) => segment.x1 === segment.x2).sort((a, b) => a.x1 - b.x1 || a.y1 - b.y1); + return [...mergeRuns(horiz, true), ...mergeRuns(vert, false)]; +} + +function mergeRuns(segments, horizontal) { + const out = []; + for (const segment of segments) { + const last = out[out.length - 1]; + if (last && horizontal && last.y1 === segment.y1 && last.x2 === segment.x1 && !last.door && !segment.door) { + last.x2 = segment.x2; + continue; + } + if (last && !horizontal && last.x1 === segment.x1 && last.y2 === segment.y1 && !last.door && !segment.door) { + last.y2 = segment.y2; + continue; + } + out.push({ ...segment }); + } + return out; +} + +function assignScifiRoomTypes(rooms, roomTables, rng) { + const systemId = game.system?.id ?? ""; + const overrides = ["sla-mothership", "sla-industries-brp"].includes(systemId) ? roomTables.sla_overrides ?? {} : {}; + const usedKeys = new Set(); + const counters = new Map(); + + const bowSorted = [...rooms].sort((a, b) => a.x - b.x || a.y - b.y); + const sternSorted = [...rooms].sort((a, b) => (b.x + b.width) - (a.x + a.width) || a.y - b.y); + const anySorted = [...rooms].sort((a, b) => (b.width * b.height) - (a.width * a.height)); + + reserveRoom("bridge", bowSorted); + reserveRoom("engineering", sternSorted); + reserveRoom("medical", anySorted); + + const weightedPool = roomTables.rooms.filter((entry) => !entry.unique && entry.key !== "void"); + + for (const room of rooms) { + if (room.type) continue; + const choice = weightedChoice(weightedPool, rng); + room.type = choice.key; + counters.set(choice.key, (counters.get(choice.key) ?? 0) + 1); + const labelBase = overrides[choice.key]?.label ?? choice.label; + room.label = choice.numbered ? `${labelBase} ${counters.get(choice.key)}` : labelBase; + } + + function reserveRoom(key, orderedRooms) { + const entry = roomTables.rooms.find((room) => room.key === key); + if (!entry || usedKeys.has(key)) return; + let target = orderedRooms.find((room) => room.width >= entry.minSize[0] && room.height >= entry.minSize[1] && !room.type); + if (!target) { + target = orderedRooms.find((room) => !room.type); + } + if (!target) return; + usedKeys.add(key); + target.type = key; + const label = overrides[key]?.label ?? entry.label; + target.label = label; + } +} + +function assignDungeonRoomTypes(rooms, roomTables, connections, hullMask, rng) { + const entries = roomTables.dungeon?.rooms ?? []; + const counters = new Map(); + const roomDoorTypes = {}; + const usedKeys = new Set(); + if (!rooms.length || !entries.length) return roomDoorTypes; + + const byAreaDesc = [...rooms].sort((a, b) => (b.width * b.height) - (a.width * a.height)); + const byAreaAsc = [...rooms].sort((a, b) => (a.width * a.height) - (b.width * b.height)); + const edgeRooms = [...rooms].sort((a, b) => edgeDistance(a, hullMask) - edgeDistance(b, hullMask)); + const entranceRoom = reserveRoomEntry(entries.find((entry) => entry.key === "entrance"), edgeRooms, "entrance"); + const distances = computeRoomGraphDistances(rooms, connections, entranceRoom?.id); + const byDistanceDesc = [...rooms].sort((a, b) => (distances.get(b.id) ?? -1) - (distances.get(a.id) ?? -1)); + const byDistanceAsc = [...rooms].sort((a, b) => (distances.get(a.id) ?? 999) - (distances.get(b.id) ?? 999)); + const farLargeRooms = [...rooms] + .sort((a, b) => ((distances.get(b.id) ?? -1) * 100 + (b.width * b.height)) - ((distances.get(a.id) ?? -1) * 100 + (a.width * a.height))); + + const bossRoom = reserveRoomEntry(entries.find((entry) => entry.key === "boss-chamber"), farLargeRooms, "boss-chamber"); + const throneRoom = reserveRoomEntry(entries.find((entry) => entry.key === "throne-room"), sortRoomsByProximity(rooms, bossRoom, distances, "support"), "throne-room"); + const vaultRoom = reserveRoomEntry(entries.find((entry) => entry.key === "vault"), sortRoomsByProximity(rooms, bossRoom ?? throneRoom, distances, "far"), "vault"); + const ritualRoom = reserveRoomEntry(entries.find((entry) => entry.key === "ritual-room"), sortRoomsByProximity(rooms, bossRoom ?? throneRoom, distances, "support"), "ritual-room"); + reserveRoomEntry(entries.find((entry) => entry.key === "shrine"), sortRoomsByDistanceBand(rooms, distances, 0.45, 0.75), "shrine"); + reserveRoomEntry(entries.find((entry) => entry.key === "library"), byAreaDesc, "library"); + reserveRoomEntry(entries.find((entry) => entry.key === "laboratory"), byAreaDesc, "laboratory"); + reserveRoomEntry(entries.find((entry) => entry.key === "alchemy"), sortRoomsByDistanceBand(rooms, distances, 0.35, 0.8), "alchemy"); + reserveRoomEntry(entries.find((entry) => entry.key === "forge"), sortRoomsByDistanceBand(rooms, distances, 0.25, 0.7), "forge"); + reserveRoomEntry(entries.find((entry) => entry.key === "trophy-room"), sortRoomsByProximity(rooms, bossRoom ?? throneRoom, distances, "support"), "trophy-room"); + reserveRoomEntry(entries.find((entry) => entry.key === "cistern"), byAreaDesc, "cistern"); + reserveRoomEntry(entries.find((entry) => entry.key === "prison"), byDistanceDesc, "prison"); + reserveRoomEntry(entries.find((entry) => entry.key === "armoury"), byDistanceAsc, "armoury"); + reserveRoomEntry(entries.find((entry) => entry.key === "guardroom"), byDistanceAsc, "guardroom"); + reserveRoomEntry(entries.find((entry) => entry.key === "antechamber"), sortRoomsByProximity(rooms, entranceRoom, distances, "support"), "antechamber"); + reserveRoomEntry(entries.find((entry) => entry.key === "torture"), byDistanceDesc, "torture"); + reserveRoomEntry(entries.find((entry) => entry.key === "collapsed"), byAreaAsc, "collapsed"); + reserveRoomEntry(entries.find((entry) => entry.key === "trap-room"), byAreaAsc, "trap-room"); + + const weightedPool = entries.filter((entry) => !entry.unique && entry.key !== "void"); + for (const room of rooms) { + if (room.type) continue; + const choice = weightedChoice(weightedPool, rng); + setRoomType(room, choice); + } + + return roomDoorTypes; + + function reserveRoomEntry(entry, orderedRooms, labelKey) { + if (!entry || usedKeys.has(labelKey)) return null; + let target = orderedRooms.find((room) => !room.type && roomMeetsEntry(room, entry)); + if (!target) target = orderedRooms.find((room) => !room.type); + if (!target) return null; + setRoomType(target, entry); + usedKeys.add(labelKey); + return target; + } + + function setRoomType(room, entry) { + room.type = entry.key; + counters.set(entry.key, (counters.get(entry.key) ?? 0) + 1); + room.label = entry.numbered ? `${entry.label} ${counters.get(entry.key)}` : entry.label; + roomDoorTypes[room.id] = entry.doorType ?? "normal"; + } +} + +function sortRoomsByProximity(rooms, anchorRoom, distances, mode = "support") { + if (!anchorRoom) return [...rooms]; + const anchorCenter = roomCenter(anchorRoom); + return [...rooms].sort((a, b) => { + const distA = Math.abs(roomCenter(a).x - anchorCenter.x) + Math.abs(roomCenter(a).y - anchorCenter.y); + const distB = Math.abs(roomCenter(b).x - anchorCenter.x) + Math.abs(roomCenter(b).y - anchorCenter.y); + const scoreA = mode === "far" ? ((distances.get(a.id) ?? 0) * 20 - distA) : ((distances.get(a.id) ?? 0) * 5 - distA); + const scoreB = mode === "far" ? ((distances.get(b.id) ?? 0) * 20 - distB) : ((distances.get(b.id) ?? 0) * 5 - distB); + return scoreB - scoreA; + }); +} + +function sortRoomsByDistanceBand(rooms, distances, lowRatio = 0.3, highRatio = 0.8) { + const maxDistance = Math.max(1, ...Array.from(distances.values(), (value) => value ?? 0)); + const low = maxDistance * lowRatio; + const high = maxDistance * highRatio; + return [...rooms].sort((a, b) => { + const da = distances.get(a.id) ?? 0; + const db = distances.get(b.id) ?? 0; + const bandA = da >= low && da <= high ? 1 : 0; + const bandB = db >= low && db <= high ? 1 : 0; + if (bandA !== bandB) return bandB - bandA; + return db - da; + }); +} + +function assignBuildingRoomTypes(rooms, roomTables, subtype, rng) { + const entries = roomTables.building?.subtypes?.[subtype]?.rooms ?? []; + const counters = new Map(); + const roomDoorTypes = {}; + if (!rooms.length || !entries.length) return roomDoorTypes; + + const ordered = [...rooms].sort((a, b) => a.x - b.x || a.y - b.y); + reserve(entries.find((entry) => entry.placement === "entrance"), ordered); + reserve(entries.find((entry) => entry.key === "lobby" || entry.key === "foyer" || entry.key === "loading" || entry.key === "common-room"), ordered); + + const weightedPool = entries.filter((entry) => !entry.unique && entry.key !== "void"); + for (const room of rooms) { + if (room.type) continue; + const choice = weightedChoice(weightedPool, rng); + assign(choice, room); + } + + return roomDoorTypes; + + function reserve(entry, orderedRooms) { + if (!entry) return; + let target = orderedRooms.find((room) => !room.type && roomMeetsEntry(room, entry)); + if (!target) target = orderedRooms.find((room) => !room.type); + if (!target) return; + assign(entry, target); + } + + function assign(entry, room) { + room.type = entry.key; + counters.set(entry.key, (counters.get(entry.key) ?? 0) + 1); + room.label = entry.numbered ? `${entry.label} ${counters.get(entry.key)}` : entry.label; + roomDoorTypes[room.id] = entry.doorType ?? "normal"; + } +} + +function applyFactionTerritories(rooms, formState) { + const preset = FACTION_PRESETS[formState.factionPreset]; + if (!preset?.length) return; + for (const room of rooms) { + const match = preset.find((faction) => faction.roomTypes.includes(room.type)); + const fallback = preset[hashSeed(room.id || room.label || room.type || "room") % preset.length]; + room.faction = match?.name ?? fallback.name; + room.factionColor = match?.color ?? fallback.color; + } +} + +function assignWildernessAreaTypes(clearings, _roomTables, theme, rng) { + const themedPools = { + forest: [ + { key: "camp", label: "CAMP", weight: 2 }, + { key: "ruins", label: "RUINS", weight: 1 }, + { key: "glade", label: "GLADE", weight: 3 }, + { key: "ambush", label: "AMBUSH SITE", weight: 1 }, + { key: "water", label: "WATER EDGE", weight: 1 } + ], + jungle: [ + { key: "nest", label: "NEST", weight: 2 }, + { key: "glade", label: "JUNGLE GLADE", weight: 2 }, + { key: "ruins", label: "OVERGROWN RUINS", weight: 1 }, + { key: "ambush", label: "AMBUSH SITE", weight: 2 }, + { key: "water", label: "MUDDY POOL", weight: 1 } + ], + plains: [ + { key: "camp", label: "CAMP", weight: 2 }, + { key: "ridge", label: "RIDGE", weight: 2 }, + { key: "crossing", label: "TRAIL CROSSING", weight: 2 }, + { key: "ambush", label: "KILL ZONE", weight: 1 } + ], + swamp: [ + { key: "water", label: "MARSH POOL", weight: 3 }, + { key: "camp", label: "DRY CAMP", weight: 1 }, + { key: "ambush", label: "SUNKEN AMBUSH", weight: 2 }, + { key: "ruins", label: "SUNKEN RUINS", weight: 1 } + ], + tundra: [ + { key: "camp", label: "FROST CAMP", weight: 2 }, + { key: "ridge", label: "ICE RIDGE", weight: 2 }, + { key: "crossing", label: "SNOW TRACKS", weight: 2 }, + { key: "ambush", label: "WHITEOUT SITE", weight: 1 } + ], + badlands: [ + { key: "ridge", label: "RIDGE", weight: 3 }, + { key: "ruins", label: "SCRAP RUINS", weight: 2 }, + { key: "camp", label: "RAIDER CAMP", weight: 2 }, + { key: "ambush", label: "AMBUSH SITE", weight: 2 } + ] + }; + const pool = themedPools[theme] ?? themedPools.forest; + const counters = new Map(); + for (const area of clearings) { + const choice = weightedChoice(pool, rng); + area.type = choice.key; + counters.set(choice.key, (counters.get(choice.key) ?? 0) + 1); + area.label = counters.get(choice.key) > 1 ? `${choice.label} ${counters.get(choice.key)}` : choice.label; + } +} + +function buildWildernessFeatures(clearings, formState, rng) { + const treeClusters = []; + const waterFeatures = []; + const encounterZones = []; + const wallSegments = []; + const ridgelines = []; + const ravines = []; + + for (const area of clearings) { + const center = roomCenter(area); + const clusterCount = Math.max(2, Math.round((area.width * area.height) / 8)); + if (!["plains", "tundra"].includes(formState.theme)) { + treeClusters.push({ + x: center.x, + y: center.y, + radius: Math.max(1.2, Math.min(area.width, area.height) * 0.4), + count: clusterCount + }); + } + if (area.type === "water" || (formState.theme === "swamp" && rng() > 0.55)) { + waterFeatures.push({ + x: center.x, + y: center.y, + width: Math.max(2, Math.round(area.width * 0.7)), + height: Math.max(2, Math.round(area.height * 0.45)) + }); + } + if (["ambush", "nest", "ruins", "camp"].includes(area.type) && rng() > 0.35) { + encounterZones.push({ + roomId: area.id, + x: center.x, + y: center.y, + radius: Math.max(1.5, Math.min(area.width, area.height) * 0.35), + label: area.label + }); + } + if (formState.hullShape === "camp" && area.type === "camp") { + wallSegments.push(...rectangleSegments(area.x, area.y, area.width, area.height)); + } + + const elevationChance = ["ridge", "crossing", "ambush"].includes(area.type) ? 0.75 : 0.28; + if (rng() < elevationChance) { + const feature = buildElevationFeature(area, rng, ["badlands", "tundra"].includes(formState.theme) ? "ridge" : (rng() > 0.52 ? "ridge" : "ravine")); + if (feature) { + if (feature.kind === "ridge") ridgelines.push(feature); + else ravines.push(feature); + wallSegments.push(...feature.segments); + } + } + } + + return { treeClusters, waterFeatures, encounterZones, wallSegments, ridgelines, ravines }; +} + +function buildElevationFeature(area, rng, preferredKind = "ridge") { + const horizontal = area.width >= area.height ? rng() > 0.35 : rng() > 0.65; + const marginX = Math.max(1, Math.floor(area.width * 0.15)); + const marginY = Math.max(1, Math.floor(area.height * 0.15)); + const x1 = area.x + marginX; + const x2 = area.x + area.width - marginX; + const y1 = area.y + marginY; + const y2 = area.y + area.height - marginY; + if (horizontal) { + const y = clampInt(area.y + Math.floor(area.height / 2), area.y + 1, area.y + area.height - 1, area.y + 1); + return { + kind: preferredKind, + x1, + y1: y, + x2, + y2: y, + segments: segmentedBarrier(x1, y, x2, y, preferredKind) + }; + } + const x = clampInt(area.x + Math.floor(area.width / 2), area.x + 1, area.x + area.width - 1, area.x + 1); + return { + kind: preferredKind, + x1: x, + y1, + x2: x, + y2, + segments: segmentedBarrier(x, y1, x, y2, preferredKind) + }; +} + +function segmentedBarrier(x1, y1, x2, y2, terrain = "ridge") { + const out = []; + if (y1 === y2) { + const start = Math.min(x1, x2); + const end = Math.max(x1, x2); + for (let x = start; x < end; x += 1) { + out.push({ + x1: x, + y1, + x2: x + 1, + y2, + movementOnly: true, + terrain + }); + } + return out; + } + + const start = Math.min(y1, y2); + const end = Math.max(y1, y2); + for (let y = start; y < end; y += 1) { + out.push({ + x1, + y1: y, + x2, + y2: y + 1, + movementOnly: true, + terrain + }); + } + return out; +} + +function roomMeetsEntry(room, entry) { + const [minW, minH] = entry.minSize ?? [1, 1]; + return room.width >= minW && room.height >= minH; +} + +function edgeDistance(room, hullMask) { + let best = Number.POSITIVE_INFINITY; + for (let y = room.y; y < room.y + room.height; y += 1) { + for (let x = room.x; x < room.x + room.width; x += 1) { + const distance = Number(!hullMask[y - 1]?.[x]) + Number(!hullMask[y + 1]?.[x]) + Number(!hullMask[y]?.[x - 1]) + Number(!hullMask[y]?.[x + 1]); + if (distance > 0) return 0; + best = Math.min(best, x + y); + } + } + return best; +} + +function computeRoomGraphDistances(rooms, connections, sourceId) { + const adjacency = new Map(rooms.map((room) => [room.id, new Set()])); + for (const [a, b] of connections) { + adjacency.get(a)?.add(b); + adjacency.get(b)?.add(a); + } + const distances = new Map(); + if (!sourceId) return distances; + const queue = [[sourceId, 0]]; + distances.set(sourceId, 0); + while (queue.length) { + const [current, distance] = queue.shift(); + for (const next of adjacency.get(current) ?? []) { + if (distances.has(next)) continue; + distances.set(next, distance + 1); + queue.push([next, distance + 1]); + } + } + return distances; +} + +function injectSecretDoors(wallSegments, rng, density = 0.12) { + if (density <= 0) return wallSegments; + return wallSegments.map((segment) => { + if (segment.door || segment.isWindow) return segment; + const length = Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + if (length !== 1) return segment; + if (rng() >= density) return segment; + return { ...segment, door: DOOR_SECRET, ds: DOOR_STATE_LOCKED }; + }); +} + +function injectHorrorDecay(wallSegments, _rooms, rng, theme) { + if (!["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(theme)) return wallSegments; + return wallSegments.map((segment) => { + if (segment.door || segment.isWindow) return segment; + const length = Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + if (length !== 1) return segment; + const roll = rng(); + if (roll < 0.12) return { ...segment, door: DOOR_BASIC, ds: DOOR_STATE_LOCKED }; + if (roll < 0.18) return { ...segment, door: DOOR_BASIC, ds: DOOR_STATE_OPEN }; + return segment; + }); +} + +function buildBuildingExteriorOpenings(hullMask, rng, subtype) { + const hullWalls = buildHullWallSegments(hullMask); + if (!hullWalls.length) return []; + const hull = boundingRectFromRegions([{ x: 1, y: 1, width: hullMask[0]?.length - 2, height: hullMask.length - 2 }]); + const bySide = classifyBuildingHullSides(hullWalls, hull); + const config = getBuildingExteriorConfig(subtype); + const chosen = []; + const used = new Set(); + + addOpenings(pickSideCandidates(bySide[config.front] ?? [], config.frontBias), config.frontDoors, "door", (segment) => ({ + ...collapseSegmentToSingleCell(segment), + door: DOOR_BASIC, + ds: DOOR_STATE_CLOSED + })); + + addOpenings(pickSideCandidates(bySide[config.secondaryDoorSide] ?? [], "center"), config.secondaryDoors, "door", (segment) => ({ + ...collapseSegmentToSingleCell(segment), + door: DOOR_BASIC, + ds: DOOR_STATE_CLOSED + })); + + const windowSides = config.windowSides.flatMap((side) => pickSideCandidates(bySide[side] ?? [], side === config.front ? "spread" : "edge")); + addOpenings(windowSides, config.windows, "window", (segment) => ({ + ...collapseSegmentToSingleCell(segment), + isWindow: true + })); + + return chosen; + + function addOpenings(candidates, count, type, mapFn) { + let added = 0; + for (const segment of candidates) { + if (added >= count) break; + const opening = mapFn(segment); + if (!opening) continue; + const key = segmentKey(opening); + if (used.has(key)) continue; + used.add(key); + chosen.push(opening); + added += 1; + } + } +} + +function classifyBuildingHullSides(hullWalls, hull) { + const out = { north: [], south: [], east: [], west: [] }; + for (const segment of hullWalls) { + if (segment.y1 === segment.y2) { + if (segment.y1 <= hull.y) out.north.push(segment); + else if (segment.y1 >= hull.y + hull.height) out.south.push(segment); + } else if (segment.x1 === segment.x2) { + if (segment.x1 <= hull.x) out.west.push(segment); + else if (segment.x1 >= hull.x + hull.width) out.east.push(segment); + } + } + for (const key of Object.keys(out)) { + out[key].sort((a, b) => (a.x1 + a.x2 + a.y1 + a.y2) - (b.x1 + b.x2 + b.y1 + b.y2)); + } + return out; +} + +function getBuildingExteriorConfig(subtype) { + switch (subtype) { + case "warehouse": + return { front: "south", secondaryDoorSide: "west", frontDoors: 2, secondaryDoors: 1, windows: 2, windowSides: ["north", "east"], frontBias: "center" }; + case "tavern": + return { front: "south", secondaryDoorSide: "east", frontDoors: 2, secondaryDoors: 1, windows: 5, windowSides: ["south", "east", "west"], frontBias: "center" }; + case "mansion": + return { front: "south", secondaryDoorSide: "north", frontDoors: 2, secondaryDoors: 1, windows: 8, windowSides: ["north", "south", "east", "west"], frontBias: "center" }; + case "horror": + return { front: "south", secondaryDoorSide: "west", frontDoors: 1, secondaryDoors: 1, windows: 4, windowSides: ["north", "east", "west"], frontBias: "center" }; + case "residential": + return { front: "south", secondaryDoorSide: "north", frontDoors: 1, secondaryDoors: 1, windows: 6, windowSides: ["south", "east", "west"], frontBias: "center" }; + case "office": + default: + return { front: "south", secondaryDoorSide: "east", frontDoors: 1, secondaryDoors: 1, windows: 6, windowSides: ["north", "south", "east", "west"], frontBias: "center" }; + } +} + +function pickSideCandidates(segments, bias = "center") { + if (!segments.length) return []; + const sorted = [...segments]; + if (bias === "center") { + const centerScore = averageSegmentPosition(sorted); + sorted.sort((a, b) => Math.abs(segmentPosition(a) - centerScore) - Math.abs(segmentPosition(b) - centerScore)); + return sorted; + } + if (bias === "edge") { + return sorted; + } + if (bias === "spread") { + return spreadSegments(sorted); + } + return sorted; +} + +function spreadSegments(segments) { + if (segments.length <= 2) return segments; + const result = []; + const sorted = [...segments]; + let left = 0; + let right = sorted.length - 1; + while (left <= right) { + if (left === right) { + result.push(sorted[left]); + break; + } + result.push(sorted[left], sorted[right]); + left += 1; + right -= 1; + } + return result; +} + +function segmentPosition(segment) { + return segment.x1 === segment.x2 ? (segment.y1 + segment.y2) / 2 : (segment.x1 + segment.x2) / 2; +} + +function averageSegmentPosition(segments) { + return segments.reduce((sum, segment) => sum + segmentPosition(segment), 0) / Math.max(1, segments.length); +} + +function applyDoorTypesToPartitions(segments, rooms, roomDoorTypes) { + if (!segments.length) return segments; + return segments.map((segment) => { + if (!segment.door || segment.isWindow) return segment; + const room = rooms.find((candidate) => segmentTouchesRoom(segment, candidate)); + if (!room) return segment; + const doorType = roomDoorTypes[room.id] ?? "normal"; + if (doorType === "locked") return { ...segment, ds: DOOR_STATE_LOCKED }; + if (doorType === "none") return { ...segment, door: false, ds: undefined }; + return segment; + }); +} + +function segmentTouchesRoom(segment, room) { + if (segment.x1 === segment.x2) { + return segment.x1 >= room.x && segment.x1 <= room.x + room.width + && Math.max(segment.y1, room.y) < Math.min(segment.y2, room.y + room.height); + } + return segment.y1 >= room.y && segment.y1 <= room.y + room.height + && Math.max(segment.x1, room.x) < Math.min(segment.x2, room.x + room.width); +} + +function segmentLength(segment) { + return Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); +} + +function countDoors(segments = []) { + return segments.filter((segment) => Boolean(segment.door)).length; +} + +function collapseSegmentToSingleCell(segment) { + if (segment.x1 === segment.x2) { + const y = Math.min(segment.y1, segment.y2); + return { x1: segment.x1, y1: y, x2: segment.x2, y2: y + 1 }; + } + const x = Math.min(segment.x1, segment.x2); + return { x1: x, y1: segment.y1, x2: x + 1, y2: segment.y2 }; +} + +async function createFoundryScenes(deckPlan, formState) { + const currentScene = game.scenes?.viewed ?? canvas.scene ?? null; + const connectorJournal = await ensureConnectorJournal(deckPlan, formState); + const connectorPages = new Map((connectorJournal?.pages?.contents ?? []).map((page) => [page.name, page.id])); + const rootFolder = await ensureSceneFolder(ROOT_SCENE_FOLDER); + const target = resolveRegenerationTarget(formState, deckPlan, currentScene); + const furnitureTables = await getFurnitureTables().catch(() => null); + const shipFolder = target.folder ?? await Folder.create({ + name: deckPlan.shipName, + type: "Scene", + color: "#2a4060", + folder: rootFolder.id + }).catch(async () => game.folders.find((folder) => folder.type === "Scene" && folder.name === deckPlan.shipName && folder.folder?.id === rootFolder.id)); + + const scenes = []; + for (let index = 0; index < deckPlan.decks.length; index += 1) { + const deck = deckPlan.decks[index]; + await prepareDeckGenerationArtifacts(deck, formState, furnitureTables, deckPlan); + const existingScene = target.scenes[index] ?? null; + let scene = existingScene; + if (existingScene) { + await regenerateSceneInPlace(existingScene, deckPlan, deck, formState, connectorJournal, connectorPages); + } else { + scene = await Scene.create({ + name: `${deckPlan.shipName} — ${deck.deckName}`, + folder: shipFolder?.id ?? rootFolder.id, + width: deck.columns * formState.gridSize, + height: deck.rows * formState.gridSize, + padding: 0, + tokenVision: true, + fogExploration: false, + grid: { + type: resolveSceneGridType(formState.gridMode), + size: formState.gridSize, + distance: 1, + units: "meter" + }, + backgroundColor: paletteForFormState(formState).base, + background: deck.backgroundSrc ? { src: deck.backgroundSrc } : undefined, + thumb: deck.backgroundSrc ?? undefined + }); + + await populateSceneWithDeck(scene, deckPlan, deck, formState, connectorJournal, connectorPages); + } + + scenes.push(scene); + } + + if (formState.generateMissionBrief) { + await JournalEntry.create({ + name: `${deckPlan.shipName} — Mission Brief`, + pages: [{ + name: "Brief", + type: "text", + text: { + content: buildMissionBriefContent(deckPlan, formState), + format: 1 + } + }] + }).catch(() => null); + } + + return scenes; +} + +function resolveRegenerationTarget(formState, deckPlan, currentScene) { + const mode = formState.regenerateTarget === "none" && formState.regenerateInPlace ? "viewed" : formState.regenerateTarget; + if (mode === "viewed" && deckPlan.decks.length === 1 && currentScene) { + return { scenes: [currentScene], folder: currentScene.folder ?? null }; + } + if (mode === "viewed-folder" && currentScene?.folder) { + return { + scenes: getSortedScenesForFolder(currentScene.folder).slice(0, deckPlan.decks.length), + folder: currentScene.folder + }; + } + if (mode === "folder" && formState.targetSceneFolder) { + const folder = game.folders?.get(formState.targetSceneFolder) ?? null; + if (folder?.type === "Scene") { + return { + scenes: getSortedScenesForFolder(folder).slice(0, deckPlan.decks.length), + folder + }; + } + } + return { scenes: [], folder: null }; +} + +function resolveSceneGridType(gridMode) { + if (gridMode === "hex-row") return CONST.GRID_TYPES.HEXODDR; + return CONST.GRID_TYPES.SQUARE; +} + +function getSortedScenesForFolder(folder) { + return (game.scenes?.contents ?? []) + .filter((scene) => scene.folder?.id === folder.id) + .sort((a, b) => (a.sort - b.sort) || a.name.localeCompare(b.name)); +} + +async function prepareDeckGenerationArtifacts(deck, formState, furnitureTables, deckPlan) { + if (furnitureTables) { + const furnitureSeed = hashSeed(`${formState.seed}:${deck.deckIndex}:${formState.mode}:${formState.furnitureDensity}`); + deck.furniture = buildFurnitureForDeck(deck, formState, furnitureTables, mulberry32(furnitureSeed)); + } else { + deck.furniture = []; + } + deck.backgroundSrc = await createDeckBackgroundAsset(deckPlan, deck, formState); + deck.gmOverlaySrc = formState.createGmOverlay ? await createGmOverlayAsset(deckPlan, deck, formState) : null; +} + +async function populateSceneWithDeck(scene, deckPlan, deck, formState, connectorJournal, connectorPages) { + const drawings = sanitizeGeneratedDrawings([ + ...buildRoomFloorDrawings(deck, formState), + ...buildCorridorFloorDrawings(deck, formState), + ...buildFurnitureDrawings(deck, formState), + ...buildHullOutlineDrawings(deck, formState) + ]); + if (drawings.length) { + await scene.createEmbeddedDocuments("Drawing", drawings.map((drawing) => withGeneratedDrawingFlag(drawing))); + } + + const gmOverlayTile = buildGmOverlayTile(deck, formState); + if (gmOverlayTile) { + await scene.createEmbeddedDocuments("Tile", [withGeneratedTileFlag(gmOverlayTile)]); + } + + const walls = deck.wallSegments.map((segment) => wallSegmentToDocument(segment, formState.gridSize)); + if (walls.length) { + await scene.createEmbeddedDocuments("Wall", walls.map((wall) => withGeneratedWallFlag(wall))); + } + + const notes = [ + ...buildDeckConnectorNotes(deck, formState, connectorJournal?.id, connectorPages), + ...buildRoomZoneNotes(deck, formState) + ]; + if (notes.length) { + await scene.createEmbeddedDocuments("Note", notes.map((note) => withGeneratedNoteFlag(note))); + } + + if (formState.placeLights) { + const lights = buildAmbientLights(deck, formState); + if (lights.length) { + await scene.createEmbeddedDocuments("AmbientLight", lights.map((light) => withGeneratedLightFlag(light))); + } + } + + if (formState.seedEncounters) { + const tokens = await buildEncounterTokens(deck, formState); + if (tokens.length) { + await scene.createEmbeddedDocuments("Token", tokens.map((token) => withGeneratedTokenFlag(token))); + } + } + + if (formState.populateRoomsFromCompendium && formState.roomPopulationPack) { + const roomTokens = await buildRoomPopulationTokens(deck, formState); + if (roomTokens.length) { + await scene.createEmbeddedDocuments("Token", roomTokens.map((token) => withGeneratedTokenFlag(token))); + } + } + + applyGmOverlayVisibility(scene); +} + +function buildGmOverlayTile(deck, formState) { + if (!formState.createGmOverlay || !deck.gmOverlaySrc) return null; + return { + x: 0, + y: 0, + z: 1000, + width: deck.columns * formState.gridSize, + height: deck.rows * formState.gridSize, + rotation: 0, + hidden: true, + locked: true, + texture: { + src: deck.gmOverlaySrc + }, + flags: { + [MODULE_ID]: { + gmOverlay: true, + deckIndex: deck.deckIndex, + overlayType: "gm-room-overlay" + } + } + }; +} + +async function regenerateSceneInPlace(scene, deckPlan, deck, formState, connectorJournal, connectorPages) { + const furnitureTables = await getFurnitureTables().catch(() => null); + await prepareDeckGenerationArtifacts(deck, formState, furnitureTables, deckPlan); + await deleteGeneratedDocumentsInternal(scene); + await scene.update({ + name: `${deckPlan.shipName} — ${deck.deckName}`, + width: deck.columns * formState.gridSize, + height: deck.rows * formState.gridSize, + padding: 0, + tokenVision: true, + fogExploration: false, + grid: { + type: resolveSceneGridType(formState.gridMode), + size: formState.gridSize, + distance: 1, + units: "meter" + }, + backgroundColor: paletteForFormState(formState).base, + background: deck.backgroundSrc ? { src: deck.backgroundSrc } : null, + thumb: deck.backgroundSrc ?? null + }); + await populateSceneWithDeck(scene, deckPlan, deck, formState, connectorJournal, connectorPages); +} + +async function ensureConnectorJournal(deckPlan, formState) { + if (deckPlan.decks.length < 2 || !["dungeon", "building"].includes(formState.mode)) return null; + const pages = []; + for (const deck of deckPlan.decks) { + for (const connector of deck.deckConnectors ?? []) { + pages.push({ + name: connectorNoteKey(deck, connector), + type: "text", + text: { + content: `

    ${escapeXml(connector.label)}

    ${escapeXml(connector.access ?? "")}

    ${escapeXml(buildConnectorNoteCopy(formState.mode, deck, connector))}

    ` + } + }); + } + } + return JournalEntry.create({ + name: `${deckPlan.shipName} Level Connections`, + pages + }).catch(() => null); +} + +function buildDeckConnectorNotes(deck, formState, entryId, connectorPages) { + if (!entryId || !["dungeon", "building"].includes(formState.mode)) return []; + return (deck.deckConnectors ?? []).map((connector) => ({ + entryId, + pageId: connectorPages.get(connectorNoteKey(deck, connector)), + x: round((connector.x + 0.5) * formState.gridSize), + y: round((connector.y + 0.5) * formState.gridSize), + iconSize: Math.max(36, Math.round(formState.gridSize * 0.55)), + text: connector.label, + hidden: true, + texture: { + src: "icons/svg/book.svg" + }, + flags: { + [MODULE_ID]: { + gmOverlay: true, + connectorId: connector.id, + deckIndex: deck.deckIndex, + regionKind: "connector", + noteText: buildConnectorNoteCopy(formState.mode, deck, connector) + } + } + })).filter((note) => note.pageId); +} + +function buildRoomZoneNotes(deck, formState) { + if (!formState.createRoomNotes) return []; + const notes = deck.rooms.map((room) => { + const center = roomCenter(room); + const bounds = roomBounds(room); + return ({ + x: round(center.x * formState.gridSize), + y: round(center.y * formState.gridSize), + iconSize: Math.max(24, Math.round(formState.gridSize * 0.35)), + text: room.label || humanizeRoomType(room.type) || "Zone", + texture: { + src: "icons/svg/circle.svg" + }, + hidden: true, + flags: { + [MODULE_ID]: { + gmOverlay: true, + roomId: room.id, + roomType: room.type ?? "zone", + roomLabel: room.label ?? "", + deckIndex: deck.deckIndex, + regionKind: "room", + noteText: buildAutomaticRoomNote(deck, room), + bounds: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }, + markers: room.markers ?? [] + } + } + })}); + + if (deck.wilderness?.encounterZones?.length) { + notes.push(...deck.wilderness.encounterZones.map((zone, index) => ({ + x: round(zone.x * formState.gridSize), + y: round(zone.y * formState.gridSize), + iconSize: Math.max(28, Math.round(formState.gridSize * 0.4)), + text: zone.label || `Encounter Zone ${index + 1}`, + texture: { + src: "icons/svg/target.svg" + }, + hidden: true, + flags: { + [MODULE_ID]: { + gmOverlay: true, + deckIndex: deck.deckIndex, + regionKind: "encounter-zone", + roomLabel: zone.label ?? "", + noteText: `Encounter zone on ${deck.deckName}. Radius ${zone.radius} cells.`, + bounds: { + x: zone.x - zone.radius, + y: zone.y - zone.radius, + width: zone.radius * 2, + height: zone.radius * 2 + }, + radius: zone.radius + } + } + }))); + } + + return notes; +} + +function connectorNoteKey(deck, connector) { + return `${deck.deckIndex}:${connector.id}`; +} + +function buildConnectorNoteCopy(mode, deck, connector) { + if (mode === "dungeon") { + return `${connector.label} links ${deck.deckName} to ${connector.access || "the adjoining level"}. Use this point for vertical movement between dungeon levels.`; + } + return `${connector.label} links ${deck.deckName} to ${connector.access || "the adjoining floor"}. Use this point for stairwell movement between building floors.`; +} + +async function exportMothershipViewerJson(deckPlan) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return; + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const json = JSON.stringify(buildMothershipMapViewerJSON(deckPlan), null, 2); + const filename = `${slugify(deckPlan.shipName)}-${Date.now()}-mosh-map.json`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([json], filename, { type: "application/json" }); + await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); +} + +async function exportMapMetadataJson(deckPlan, formState, scenes = []) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return null; + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const filename = `${slugify(deckPlan.shipName)}-${Date.now()}-metadata.json`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const payload = JSON.stringify(buildMapMetadata(deckPlan, formState, scenes), null, 2); + const file = new File([payload], filename, { type: "application/json" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + const path = response?.path ?? `${targetDir}/${filename}`; + for (let index = 0; index < scenes.length; index += 1) { + await scenes[index].update({ + [`flags.${MODULE_ID}.metadataExport`]: { + path, + deckIndex: index, + mode: formState.mode, + theme: formState.theme + } + }).catch(() => {}); + } + return path; +} + +async function createMissionPacket(deckPlan, formState, scenes = [], metadataPath = null) { + const html = buildMissionPacketHtml(deckPlan, formState, scenes, metadataPath); + const exportedPath = await exportMissionPacketHtml(deckPlan, html).catch(() => null); + await JournalEntry.create({ + name: `${deckPlan.shipName} — Mission Packet`, + pages: [{ + name: "Packet", + type: "text", + text: { + content: html, + format: 1 + } + }] + }).catch(() => null); + if (exportedPath) { + ui.notifications.info(`Mission packet exported to ${exportedPath}.`); + } + return exportedPath; +} + +async function exportMissionPacketHtml(deckPlan, html) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return null; + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const filename = `${slugify(deckPlan.shipName)}-${Date.now()}-mission-packet.html`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([html], filename, { type: "text/html" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + return response?.path ?? `${targetDir}/${filename}`; +} + +function buildMissionPacketHtml(deckPlan, formState, scenes = [], metadataPath = null) { + const brief = buildMissionBriefContent(deckPlan, formState); + const previewCards = deckPlan.decks.map((deck, index) => { + const scene = scenes[index]; + const sceneName = scene?.name ?? `${deckPlan.shipName} — ${deck.deckName}`; + const imagePath = scene?.background?.src ?? deck.backgroundSrc ?? ""; + return ` +
    +

    ${escapeXml(sceneName)}

    + ${imagePath ? `${escapeXml(sceneName)} thumbnail` : ""} +

    Rooms: ${deck.rooms.length} Corridors: ${deck.corridors.length} Doors: ${countDoors(deck.wallSegments)}

    +

    ${escapeXml(summarizeDeckThreats(deck))}

    +
    + `; + }).join(""); + const summaries = deckPlan.decks.map((deck) => ` + + ${escapeXml(deck.deckName)} + ${deck.rooms.length} + ${deck.corridors.length} + ${countDoors(deck.wallSegments)} + ${(deck.wilderness?.encounterZones?.length ?? deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)).length)} + + `).join(""); + return ` + +
    + ${brief} +
    + Metadata export: ${escapeXml(metadataPath ?? "Not exported")}
    + Encounter source: ${escapeXml(formState.encounterPack || resolveEncounterPreset(formState).key)}
    + Generated scenes: ${scenes.length} +
    +

    Scene List & Preview

    +
    ${previewCards}
    +

    Metadata Summary

    + + + ${summaries} +
    DeckRoomsCorridorsDoorsThreat Nodes
    +
    + `.replace(/\n\s+/g, ""); +} + +function buildMapMetadata(deckPlan, formState, scenes = []) { + return { + module: MODULE_ID, + version: game.modules?.get(MODULE_ID)?.version ?? "dev", + name: deckPlan.shipName, + seed: deckPlan.seed, + mode: formState.mode, + theme: formState.theme, + missionTemplate: formState.missionTemplate, + encounterFaction: resolveEncounterPreset(formState).key, + encounterPack: formState.encounterPack || null, + gridSize: formState.gridSize, + decks: deckPlan.decks.map((deck, index) => ({ + deckIndex: deck.deckIndex, + deckName: deck.deckName, + sceneId: scenes[index]?.id ?? null, + sceneName: scenes[index]?.name ?? null, + rooms: deck.rooms.map((room) => ({ + id: room.id, + label: room.label ?? "", + type: room.type ?? "", + faction: room.faction ?? "", + factionColor: room.factionColor ?? "", + x: room.x, + y: room.y, + width: room.width, + height: room.height, + shape: room.shape ?? "rect", + markers: room.markers ?? [] + })), + corridors: (deck.corridors ?? []).map((corridor) => ({ + x: corridor.x, + y: corridor.y, + width: corridor.width, + height: corridor.height, + waypoints: corridor.waypoints ?? [] + })), + doors: (deck.wallSegments ?? []).filter((segment) => segment.door).map((segment) => ({ + x1: segment.x1, + y1: segment.y1, + x2: segment.x2, + y2: segment.y2, + state: segment.ds ?? DOOR_STATE_CLOSED, + secret: segment.door === DOOR_SECRET + })), + walls: (deck.wallSegments ?? []).filter((segment) => !segment.door).map((segment) => ({ + x1: segment.x1, + y1: segment.y1, + x2: segment.x2, + y2: segment.y2, + isWindow: Boolean(segment.isWindow), + movementOnly: Boolean(segment.movementOnly), + terrain: segment.terrain ?? null + })), + wilderness: deck.wilderness ?? null + })) + }; +} + +function buildMothershipMapViewerJSON(deckPlan) { + const gs = deckPlan.gridSize; + return { + name: deckPlan.shipName, + floors: deckPlan.decks.map((deck, index) => ({ + floorNumber: index + 1, + floorName: deck.deckName, + rooms: deck.rooms.map((room) => ({ + id: room.id, + type: room.shape === "circle" ? "circle" : "rect", + x: room.x * gs, + y: room.y * gs, + width: room.width * gs, + height: room.height * gs, + label: room.label ?? "", + markers: (room.markers ?? []).map((marker) => ({ + ...marker, + x: Number.isFinite(marker.x) ? marker.x * gs : marker.x, + y: Number.isFinite(marker.y) ? marker.y * gs : marker.y + })), + labels: room.label + ? [{ + text: room.label, + x: (room.x + room.width / 2) * gs, + y: (room.y + room.height / 2) * gs, + visible: true + }] + : [] + })), + hallways: deck.corridors.map((corridor) => ({ + waypoints: (corridor.waypoints ?? []).map((wp) => ({ + x: wp.x * gs, + y: wp.y * gs + })), + startType: corridor.startDoor ?? "door", + endType: corridor.endDoor ?? "door" + })), + walls: deck.wallSegments.map((segment) => ({ + x1: segment.x1 * gs, + y1: segment.y1 * gs, + x2: segment.x2 * gs, + y2: segment.y2 * gs, + door: Boolean(segment.door) + })), + labels: deck.rooms + .filter((room) => room.label) + .map((room) => ({ + text: room.label, + x: (room.x + room.width / 2) * gs, + y: (room.y + room.height / 2) * gs + })) + })) + }; +} + +function buildRoomFloorDrawings(deck, formState) { + if (formState.mode === "wilderness" || deck.isCave) return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + return deck.rooms.flatMap((room) => { + const bounds = roomBounds(room); + const base = { + x: bounds.x * formState.gridSize, + y: bounds.y * formState.gridSize, + z: 0, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 2, + strokeColor: room.factionColor ?? palette.roomStroke, + strokeAlpha: 1, + fillType: 1, + fillColor: room.factionColor ?? palette.roomFill, + fillAlpha: round(0.8 * ageOpacity, 3), + textAlpha: 0, + bezierFactor: 0, + flags: { + [MODULE_ID]: { + roomId: room.id, + roomType: room.type ?? "", + roomLabel: room.label ?? "", + faction: room.faction ?? "", + factionColor: room.factionColor ?? "" + } + } + }; + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + const size = room.circleR * 2 * formState.gridSize; + return [{ + ...base, + x: room.circleX * formState.gridSize, + y: room.circleY * formState.gridSize, + shape: { + type: "e", + width: size, + height: size + } + }]; + } + if (room.shape === "polygon" && room.points?.length >= 3) { + return [{ + ...base, + shape: { + type: "p", + width: bounds.width * formState.gridSize, + height: bounds.height * formState.gridSize, + points: room.points.flatMap((point) => [ + round((point.x - bounds.x) * formState.gridSize, 2), + round((point.y - bounds.y) * formState.gridSize, 2) + ]) + } + }]; + } + return [{ + ...base, + shape: { + type: "r", + width: bounds.width * formState.gridSize, + height: bounds.height * formState.gridSize + } + }]; + }); +} + +function buildCorridorFloorDrawings(deck, formState) { + if (formState.mode === "wilderness") return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + const drawings = []; + for (const corridor of deck.corridors) { + drawings.push({ + x: corridor.x * formState.gridSize, + y: corridor.y * formState.gridSize, + z: 0, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 0, + strokeColor: palette.corridorFill, + strokeAlpha: 0, + fillType: 1, + fillColor: palette.corridorFill, + fillAlpha: round(0.86 * ageOpacity, 3), + textAlpha: 0, + bezierFactor: 0, + shape: { + type: "r", + width: corridor.width * formState.gridSize, + height: corridor.height * formState.gridSize + } + }); + if (corridor.width >= 2 || corridor.height >= 2) { + const gs = formState.gridSize; + const isHorizontal = corridor.width >= corridor.height; + drawings.push({ + x: isHorizontal ? corridor.x * gs : round((corridor.x + corridor.width / 2 - 0.08) * gs), + y: isHorizontal ? round((corridor.y + corridor.height / 2 - 0.08) * gs) : corridor.y * gs, + z: 1, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 0, + strokeColor: palette.corridorStripe ?? palette.line, + strokeAlpha: 0, + fillType: 1, + fillColor: palette.corridorStripe ?? palette.line, + fillAlpha: round(0.25 * ageOpacity, 3), + textAlpha: 0, + bezierFactor: 0, + shape: { + type: "r", + width: isHorizontal ? corridor.width * gs : Math.max(8, Math.round(gs * 0.16)), + height: isHorizontal ? Math.max(8, Math.round(gs * 0.16)) : corridor.height * gs + } + }); + } + } + return drawings; +} + +function buildAmbientLights(deck, formState) { + const config = THEME_LIGHT_CONFIG[formState.theme] ?? THEME_LIGHT_CONFIG.steel; + const ageOpacity = getAgeOpacity(formState); + const gs = formState.gridSize; + const lights = []; + + if (config.ambient) { + lights.push({ + x: Math.round((deck.columns * gs) / 2), + y: Math.round((deck.rows * gs) / 2), + hidden: false, + config: { + dim: Math.round(deck.columns * gs * 0.75), + bright: 0, + color: config.color, + alpha: round(config.alpha * Math.max(0.45, ageOpacity), 3), + luminosity: config.luminosity ?? 0.5, + walls: true, + vision: false + } + }); + } + + for (const room of deck.rooms) { + if (!config.roomTypes?.includes(room.type)) continue; + const radius = Math.min(room.width, room.height) * gs; + lights.push({ + x: Math.round((room.x + room.width / 2) * gs), + y: Math.round((room.y + room.height / 2) * gs), + hidden: false, + config: { + dim: Math.round(radius * 1.5), + bright: Math.round(radius * 0.5), + color: config.accentColor ?? config.color, + alpha: round(0.58 * Math.max(0.45, ageOpacity), 3), + luminosity: config.roomLuminosity ?? 0.65, + walls: true, + vision: false + } + }); + } + + return dedupeLights(lights); +} + +function buildFurnitureDrawings(deck, formState) { + if (!deck.furniture?.length) return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + return deck.furniture.map((piece) => ({ + x: piece.x * formState.gridSize, + y: piece.y * formState.gridSize, + z: 10, + hidden: false, + locked: false, + rotation: piece.rotation ?? 0, + strokeWidth: 2, + strokeColor: palette.furnitureStroke, + strokeAlpha: round(0.94 * ageOpacity, 3), + fillType: 1, + fillColor: palette.furnitureFill, + fillAlpha: round(0.78 * ageOpacity, 3), + textAlpha: piece.label ? 1 : 0, + text: piece.label ?? "", + fontSize: Math.max(8, Math.round(formState.gridSize * 0.12)), + fontFamily: "Courier New", + bezierFactor: 0, + shape: { + type: piece.shape === "e" ? "e" : "r", + width: piece.w * formState.gridSize, + height: piece.h * formState.gridSize + } + })); +} + +function buildHullOutlineDrawings(deck, formState) { + if (formState.mode === "wilderness") return []; + if (!["corridor", "dungeon"].includes(formState.mode) || !deck.hull) return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + return [{ + x: deck.hull.x * formState.gridSize, + y: deck.hull.y * formState.gridSize, + z: -10, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 6, + strokeColor: palette.hull, + strokeAlpha: round(0.35 * ageOpacity, 3), + fillType: 0, + fillColor: palette.hull, + fillAlpha: 0, + textAlpha: 0, + bezierFactor: 0, + shape: { + type: "r", + width: deck.hull.width * formState.gridSize, + height: deck.hull.height * formState.gridSize + } + }]; +} + +function buildDeckSVG(deckPlan, deck, formState) { + const width = deck.columns * formState.gridSize; + const height = deck.rows * formState.gridSize; + const palette = paletteForFormState(formState); + const defs = buildThemePattern(formState.theme, palette, formState); + const labels = formState.coordinates === "none" + ? "" + : buildCoordinateSvg(deck.columns, deck.rows, formState.gridSize, width, height, palette, formState.coordinates); + const roomFloors = formState.mode === "wilderness" ? "" : buildRoomFillSvg(deck.rooms, formState.gridSize, palette, formState); + const corridorFloors = formState.mode === "wilderness" + ? "" + : buildOrganicCorridorSvg(deck.corridors, formState.gridSize, palette, formState); + const softOutlines = formState.softRoomOutlines && formState.mode !== "wilderness" + ? buildSoftRoomOutlinesSvg(deck.rooms, formState.gridSize, palette, formState) + : ""; + const roomLabels = formState.showRoomLabels + ? buildRoomLabelSvg(deck.rooms, formState.gridSize, palette) + : ""; + const connectorLabels = buildDeckConnectorSvg(deck.deckConnectors ?? [], formState.gridSize, palette); + const hullPath = formState.mode === "wilderness" ? "" : buildHullPathSvg(deck.hullMask ?? [], formState.gridSize, palette); + const exteriorOpenings = formState.mode === "wilderness" ? "" : buildExteriorOpeningsSvg(deck, formState.gridSize, palette); + const secretDoors = formState.mode === "wilderness" ? "" : buildSecretDoorSvg(deck.wallSegments ?? [], formState.gridSize, palette); + const roomOverlays = formState.mode === "wilderness" + ? buildWildernessOverlaySvg(deck, formState.gridSize, palette, formState) + : ["bsp", "corridor"].includes(formState.mode) + ? buildScifiRoomOverlaySvg(deck.rooms, formState.gridSize, palette) + : buildDungeonRoomOverlaySvg(deck.rooms, formState.gridSize, palette, formState.theme, formState); + const weathering = buildWeatheringOverlay(width, height, palette, formState); + const ageOpacity = getAgeOpacity(formState); + + return ` + + ${defs} + + + ${hullPath} + ${exteriorOpenings} + ${corridorFloors} + ${roomFloors} + ${softOutlines} + ${roomOverlays} + ${weathering} + ${labels} + ${roomLabels} + ${connectorLabels} + ${secretDoors} + `.replace(/\n+/g, " ").trim(); +} + +function buildGmOverlaySVG(_deckPlan, deck, formState) { + const width = deck.columns * formState.gridSize; + const height = deck.rows * formState.gridSize; + const palette = paletteForFormState(formState); + const title = escapeXml(deck.deckName || "GM Overlay"); + const roomBadges = deck.rooms.map((room) => buildGmOverlayRoomBadge(room, formState, palette)).join(""); + const connectorBadges = (deck.deckConnectors ?? []).map((connector) => buildGmOverlayConnectorBadge(connector, formState, palette)).join(""); + const zoneBadges = (deck.wilderness?.encounterZones ?? []).map((zone, index) => buildGmOverlayZoneBadge(zone, index, formState, palette)).join(""); + + return ` + + + + ${title} + + ${roomBadges} + ${connectorBadges} + ${zoneBadges} + `.replace(/\n+/g, " ").trim(); +} + +function buildRoomFillSvg(rooms, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return rooms.map((room) => { + const fill = room.factionColor ?? palette.roomFill; + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + const cx = (room.circleX + room.circleR) * gridSize; + const cy = (room.circleY + room.circleR) * gridSize; + const r = room.circleR * gridSize; + return ``; + } + if (room.shape === "polygon" && room.points?.length >= 3) { + const points = room.points.map((point) => `${round(point.x * gridSize, 2)},${round(point.y * gridSize, 2)}`).join(" "); + return ``; + } + if (room.shape === "cave" && room.cells?.length) { + return compressCellsToRects(room.cells).map((rect) => ``).join(""); + } + return ``; + }).join(""); +} + +function buildCorridorFillSvg(corridors, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return corridors.map((corridor) => ``).join(""); +} + +function buildOrganicCorridorSvg(corridors, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return corridors.map((corridor, index) => { + const px = corridor.x * gridSize; + const py = corridor.y * gridSize; + const pw = corridor.width * gridSize; + const ph = corridor.height * gridSize; + const isHorizontal = pw >= ph; + const bow = Math.max(4, Math.round(Math.min(pw, ph) * 0.35)) * (((index % 3) - 1) || 0); + let path; + if (isHorizontal) { + const midX = px + pw / 2; + path = `M ${px} ${py} Q ${midX} ${py + bow} ${px + pw} ${py} L ${px + pw} ${py + ph} Q ${midX} ${py + ph - bow} ${px} ${py + ph} Z`; + } else { + const midY = py + ph / 2; + path = `M ${px} ${py} L ${px + pw} ${py} Q ${px + pw + bow} ${midY} ${px + pw} ${py + ph} L ${px} ${py + ph} Q ${px - bow} ${midY} ${px} ${py} Z`; + } + return ``; + }).join(""); +} + +function buildSoftRoomOutlinesSvg(rooms, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return rooms.map((room) => { + if (room.shape === "cave") return ""; + if (room.shape === "polygon" && room.points?.length >= 3) { + const points = room.points.map((point) => `${round(point.x * gridSize, 2)},${round(point.y * gridSize, 2)}`).join(" "); + return ``; + } + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + const cx = (room.circleX + room.circleR) * gridSize; + const cy = (room.circleY + room.circleR) * gridSize; + const r = room.circleR * gridSize; + return ``; + } + const px = room.x * gridSize; + const py = room.y * gridSize; + const pw = room.width * gridSize; + const ph = room.height * gridSize; + const radius = Math.min(pw, ph) * 0.18; + const path = `M ${px + radius} ${py} L ${px + pw - radius} ${py} Q ${px + pw} ${py} ${px + pw} ${py + radius} L ${px + pw} ${py + ph - radius} Q ${px + pw} ${py + ph} ${px + pw - radius} ${py + ph} L ${px + radius} ${py + ph} Q ${px} ${py + ph} ${px} ${py + ph - radius} L ${px} ${py + radius} Q ${px} ${py} ${px + radius} ${py} Z`; + return ``; + }).join(""); +} + +function buildThemePattern(theme, palette, formState = {}) { + const ageOpacity = getAgeOpacity(formState); + if (theme === "stone") { + return ` + + + + + + + + + + + + + + `; + } + if (theme === "cave") { + return ` + + + + + + + + + + `; + } + if (theme === "wood" || theme === "wood-floor") { + return ` + + + + + `; + } + if (theme === "crypt") { + return ` + + + + + + + + + + `; + } + if (theme === "sewer") { + return ` + + + + + + + + `; + } + if (theme === "brick") { + return ` + + + + + + + + + + `; + } + if (theme === "carpet" || theme === "tile" || theme === "concrete-office") { + return ` + + + + + + `; + } + if (theme === "alien-organic") { + return ` + + + + + `; + } + if (theme === "clean-corporate") { + return ` + + + + + + `; + } + if (theme === "derelict-house") { + return ` + + + + + + + + `; + } + if (theme === "asylum") { + return ` + + + + + + + + + `; + } + if (theme === "haunted-manor") { + return ` + + + + + + `; + } + if (theme === "bloodbath") { + return ` + + + + + + + `; + } + if (theme === "forest" || theme === "jungle") { + return ` + + + + + + + `; + } + if (theme === "plains" || theme === "badlands") { + return ` + + + + + `; + } + if (theme === "swamp") { + return ` + + + + + + `; + } + if (theme === "tundra") { + return ` + + + + + `; + } + return ` + + + + + + + + `; +} + +function buildCoordinateSvg(columns, rows, gridSize, width, height, palette, mode) { + const out = []; + const fontSize = Math.max(11, Math.round(gridSize * 0.18)); + const coordStroke = textStrokeFor(palette.coordText); + const cellStroke = textStrokeFor(palette.coordCell); + for (let col = 0; col < columns; col += 1) { + const x = col * gridSize + gridSize / 2; + const label = columnLabel(col); + out.push(`${label}`); + } + for (let row = 0; row < rows; row += 1) { + const y = row * gridSize + gridSize / 2 + fontSize * 0.35; + out.push(`${row + 1}`); + } + if (mode === "full") { + const cellFont = Math.max(8, Math.round(gridSize * 0.11)); + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < columns; col += 1) { + out.push(`${columnLabel(col)}${row + 1}`); + } + } + } + return out.join(""); +} + +function buildRoomLabelSvg(rooms, gridSize, palette) { + const labelStroke = textStrokeFor(palette.label); + return rooms.filter((room) => room.label).map((room) => { + const center = roomCenter(room); + const bounds = roomBounds(room); + const x = center.x * gridSize; + const y = center.y * gridSize; + const roomPxW = bounds.width * gridSize; + const fontSize = Math.max(9, Math.min(16, Math.round(Math.min(bounds.width, bounds.height) * gridSize * 0.12))); + const approxCharW = fontSize * 0.6; + const charsPerLine = Math.max(4, Math.floor(roomPxW / Math.max(1, approxCharW)) - 2); + const words = room.label.split(/\s+/).filter(Boolean); + const lines = []; + let current = ""; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length > charsPerLine && current) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current) lines.push(current); + const lineHeight = fontSize * 1.22; + const totalHeight = lines.length * lineHeight; + return lines.map((line, index) => { + const lineY = y - totalHeight / 2 + index * lineHeight + lineHeight / 2; + return `${escapeXml(line)}`; + }).join(""); + }).join(""); +} + +function buildGmOverlayRoomBadge(room, formState, palette) { + const label = room.label || humanizeRoomType(room.type) || "Zone"; + const section = buildRoomSectionLine(room); + const center = roomCenter(room); + const bounds = roomBounds(room); + const x = center.x * formState.gridSize; + const y = center.y * formState.gridSize; + const roomPxW = Math.max(120, Math.min(bounds.width * formState.gridSize - 12, 260)); + const badgeW = Math.max(120, Math.min(roomPxW, Math.max(120, label.length * 10 + 36))); + const badgeH = section ? 44 : 30; + const left = round(x - badgeW / 2); + const top = round(y - badgeH / 2); + const stroke = room.factionColor ?? palette.connectorStroke; + const subText = section ? `${escapeXml(section)}` : ""; + return ` + + + ${escapeXml(label)} + ${subText} + + `.replace(/\n+/g, " "); +} + +function buildGmOverlayConnectorBadge(connector, formState, palette) { + const cx = connector.x * formState.gridSize + formState.gridSize / 2; + const cy = connector.y * formState.gridSize + formState.gridSize / 2; + return ` + + + ${escapeXml((connector.label || "C").charAt(0))} + + `.replace(/\n+/g, " "); +} + +function buildGmOverlayZoneBadge(zone, index, formState, palette) { + const x = zone.x * formState.gridSize; + const y = zone.y * formState.gridSize; + const label = zone.label || `Encounter Zone ${index + 1}`; + return ` + + + ${escapeXml(label)} + + `.replace(/\n+/g, " "); +} + +function buildRoomSectionLine(room) { + const parts = []; + if (room.type) parts.push(humanizeRoomType(room.type)); + if (room.factionName) parts.push(room.factionName); + return parts.join(" • "); +} + +function buildAutomaticRoomNote(deck, room) { + const lines = [ + `Section: ${room.label || humanizeRoomType(room.type) || "Zone"}`, + `Type: ${humanizeRoomType(room.type) || "Unassigned"}`, + `Deck: ${deck.deckName}` + ]; + if (room.factionName) lines.push(`Faction: ${room.factionName}`); + if (Array.isArray(room.markers) && room.markers.length) { + const markers = room.markers.map((marker) => marker.text || marker.label).filter(Boolean); + if (markers.length) lines.push(`Markers: ${markers.join(", ")}`); + } + return lines.join("\n"); +} + +function humanizeRoomType(value) { + return String(value ?? "") + .trim() + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function buildDeckConnectorSvg(connectors, gridSize, palette) { + const connectorTextStroke = textStrokeFor(palette.connectorText); + return connectors.map((connector) => { + const cx = connector.x * gridSize + gridSize / 2; + const cy = connector.y * gridSize + gridSize / 2; + const size = Math.max(12, Math.round(gridSize * 0.5)); + const labelY = cy + size * 0.95; + const accessY = labelY + Math.max(8, Math.round(gridSize * 0.14)); + const glyph = connector.type === "ladder" + ? "L" + : connector.type === "stairs" + ? "S" + : "E"; + return ` + + + ${glyph} + ${escapeXml(connector.label)} + ${escapeXml(connector.access ?? "")} + `; + }).join(""); +} + +function buildHullPathSvg(hullMask, gridSize, palette) { + return buildHullWallSegments(hullMask).map((segment) => ` + + `).join(""); +} + +function buildSecretDoorSvg(wallSegments, gridSize, palette) { + return wallSegments + .filter((segment) => segment.door === DOOR_SECRET) + .map((segment) => { + const cx = ((segment.x1 + segment.x2) / 2) * gridSize; + const cy = ((segment.y1 + segment.y2) / 2) * gridSize; + const r = Math.max(5, Math.round(gridSize * 0.1)); + return `S`; + }) + .join(""); +} + +function buildExteriorOpeningsSvg(deck, gridSize, palette) { + const windows = (deck.exteriorOpenings ?? []).filter((segment) => segment.isWindow).map((segment) => { + const x1 = segment.x1 * gridSize; + const y1 = segment.y1 * gridSize; + const x2 = segment.x2 * gridSize; + const y2 = segment.y2 * gridSize; + return ``; + }); + + const doors = (deck.exteriorOpenings ?? []).filter((segment) => segment.door).map((segment) => buildExteriorDoorGlyph(segment, gridSize, palette)); + const airlocks = (deck.airlocks ?? []).map((airlock) => { + const x = airlock.x * gridSize; + const y = airlock.y * gridSize; + const isVertical = airlock.edge === "V"; + const width = isVertical ? Math.max(8, Math.round(gridSize * 0.14)) : gridSize; + const height = isVertical ? gridSize : Math.max(8, Math.round(gridSize * 0.14)); + const cx = isVertical ? x : x + gridSize / 2; + const cy = isVertical ? y + gridSize / 2 : y; + const box = ``; + const stripe1 = isVertical + ? `` + : ``; + const stripe2 = isVertical + ? `` + : ``; + return `${box}${stripe1}${stripe2}`; + }); + + return [...windows, ...doors, ...airlocks].join(""); +} + +function buildExteriorDoorGlyph(segment, gridSize, palette) { + const x1 = segment.x1 * gridSize; + const y1 = segment.y1 * gridSize; + const x2 = segment.x2 * gridSize; + const y2 = segment.y2 * gridSize; + const doorStroke = Math.max(4, Math.round(gridSize * 0.08)); + return ``; +} + +function buildScifiRoomOverlaySvg(rooms, gridSize, palette) { + return rooms.map((room) => { + const bounds = roomBounds(room); + const center = roomCenter(room); + const px = bounds.x * gridSize; + const py = bounds.y * gridSize; + const pw = bounds.width * gridSize; + const ph = bounds.height * gridSize; + const cx = center.x * gridSize; + const cy = center.y * gridSize; + switch (room.type) { + case "reactor": + return buildReactorOverlay(cx, cy, Math.min(pw, ph) * 0.38, palette); + case "airlock": + case "escape-pods": + case "weapon-bay": + return buildHazardStripeOverlay(px, py, pw, ph, palette); + case "cargo": + return buildCargoHatchOverlay(px, py, pw, ph, palette); + case "medical": + return buildMedicalCrossOverlay(cx, cy, Math.min(pw, ph) * 0.22, palette); + case "bridge": + return buildBridgeDisplayOverlay(cx, cy, pw, ph, palette); + case "brig": + return buildBrigBarOverlay(px, py, pw, ph, palette); + case "observation": + return buildViewportOverlay(cx, cy, Math.min(pw, ph) * 0.3, palette); + default: + return ""; + } + }).join(""); +} + +function buildDungeonRoomOverlaySvg(rooms, gridSize, palette, theme, formState = {}) { + const overlays = rooms.map((room) => { + const bounds = roomBounds(room); + const center = roomCenter(room); + const px = bounds.x * gridSize; + const py = bounds.y * gridSize; + const pw = bounds.width * gridSize; + const ph = bounds.height * gridSize; + const cx = center.x * gridSize; + const cy = center.y * gridSize; + const r = Math.min(pw, ph) * 0.3; + switch (room.type) { + case "shrine": + case "ritual-room": + return buildRitualCircleOverlay(cx, cy, r, palette); + case "boss-chamber": + case "throne-room": + return buildBossFrameOverlay(px, py, pw, ph, palette); + case "cistern": + return buildWaterPoolOverlay(cx, cy, r * 1.1, palette); + case "vault": + return buildVaultFloorOverlay(px, py, pw, ph, palette); + case "crypt": + return buildCryptStoneSlotsOverlay(px, py, pw, ph, gridSize, palette); + case "torture": + return buildTortureStainOverlay(cx, cy, r * 0.6, palette); + case "collapsed": + case "collapse": + return buildRubbleOverlay(px, py, pw, ph, palette); + case "laboratory": + case "alchemy": + return buildLabBenchOverlay(px, py, pw, ph, palette); + case "living-room": + case "entrance-hall": + case "surgery": + case "padded-cell": + return ["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(theme) + ? buildHorrorRoomOverlay(room.type, px, py, pw, ph, cx, cy, palette, formState) + : ""; + default: + return ""; + } + }); + return overlays.join(""); +} + +function buildReactorOverlay(cx, cy, r, palette) { + const glow = palette.detail; + return ``; +} + +function buildHazardStripeOverlay(px, py, pw, ph, palette) { + const sw = 12; + const stripes = []; + for (let x = px - ph; x < px + pw; x += sw * 2) { + stripes.push(``); + } + return `${stripes.join("")}`; +} + +function buildCargoHatchOverlay(px, py, pw, ph, palette) { + const lines = []; + const step = Math.max(12, Math.round(Math.min(pw, ph) * 0.18)); + for (let i = 1; i * step < pw; i += 1) { + lines.push(``); + } + return lines.join(""); +} + +function buildMedicalCrossOverlay(cx, cy, r, palette) { + const t = r * 0.35; + return ``; +} + +function buildBridgeDisplayOverlay(cx, cy, pw, ph, palette) { + const r = Math.min(pw, ph) * 0.28; + return ``; +} + +function buildBrigBarOverlay(px, py, pw, ph, palette) { + const barCount = Math.max(3, Math.round(pw / 18)); + const barSpacing = pw / (barCount + 1); + const bars = []; + for (let i = 1; i <= barCount; i += 1) { + bars.push(``); + } + return bars.join(""); +} + +function buildViewportOverlay(cx, cy, r, palette) { + return ``; +} + +function buildRitualCircleOverlay(cx, cy, r, palette) { + const pts = 12; + const star = Array.from({ length: pts }, (_, index) => { + const angle = (Math.PI / 6) * index - Math.PI / 2; + const radius = index % 2 === 0 ? r : r * 0.45; + return `${Math.round(cx + Math.cos(angle) * radius)},${Math.round(cy + Math.sin(angle) * radius)}`; + }).join(" "); + return ``; +} + +function buildBossFrameOverlay(px, py, pw, ph, palette) { + const m = Math.min(pw, ph) * 0.08; + return ``; +} + +function buildWaterPoolOverlay(cx, cy, r, palette) { + return ``; +} + +function buildVaultFloorOverlay(px, py, pw, ph, palette) { + const step = Math.max(14, Math.round(Math.min(pw, ph) * 0.16)); + const parts = []; + for (let x = px + step; x < px + pw - step / 2; x += step) parts.push(``); + for (let y = py + step; y < py + ph - step / 2; y += step) parts.push(``); + return `${parts.join("")}`; +} + +function buildCryptStoneSlotsOverlay(px, py, pw, ph, gridSize, palette) { + const parts = []; + const slotHeight = Math.max(10, Math.round(gridSize * 0.4)); + for (let y = py + slotHeight; y < py + ph - slotHeight / 2; y += slotHeight * 1.8) { + parts.push(``); + } + return `${parts.join("")}`; +} + +function buildTortureStainOverlay(cx, cy, r, palette) { + return ``; +} + +function buildRubbleOverlay(px, py, pw, ph, palette) { + const rubble = []; + for (let i = 0; i < 8; i += 1) { + const x = px + ((i * 37) % Math.max(40, pw - 12)); + const y = py + ((i * 29) % Math.max(40, ph - 12)); + rubble.push(``); + } + return `${rubble.join("")}`; +} + +function buildLabBenchOverlay(px, py, pw, ph, palette) { + const inset = Math.max(8, Math.round(Math.min(pw, ph) * 0.1)); + return ``; +} + +function buildHorrorRoomOverlay(type, px, py, pw, ph, cx, cy, palette, formState) { + if (type === "surgery") return ``; + if (type === "padded-cell") return ``; + return ``; +} + +function buildWeatheringOverlay(width, height, palette, formState) { + if (!formState.mapAge) return ""; + const opacity = (formState.mapAge / 100) * 0.24; + const cracks = []; + for (let index = 0; index < 8; index += 1) { + const x = ((index * 113) % Math.max(80, width - 30)) + 12; + const y = ((index * 79) % Math.max(80, height - 30)) + 12; + cracks.push(``); + } + const stains = []; + for (let index = 0; index < 5; index += 1) { + const x = ((index * 151) % Math.max(90, width - 60)) + 30; + const y = ((index * 97) % Math.max(90, height - 50)) + 20; + stains.push(``); + } + return `${cracks.join("")}${stains.join("")}`; +} + +function getAgeOpacity(formState = {}) { + return 1 - ((Number(formState.mapAge) || 0) / 100) * 0.35; +} + +function buildWildernessOverlaySvg(deck, gridSize, palette, formState) { + const parts = []; + const ageOpacity = getAgeOpacity(formState); + + for (const corridor of deck.corridors ?? []) { + const x = corridor.x * gridSize; + const y = corridor.y * gridSize; + const w = corridor.width * gridSize; + const h = corridor.height * gridSize; + parts.push(``); + const isHorizontal = corridor.width >= corridor.height; + if (isHorizontal) { + parts.push(``); + } else { + parts.push(``); + } + } + + for (const room of deck.rooms ?? []) { + const px = room.x * gridSize; + const py = room.y * gridSize; + const pw = room.width * gridSize; + const ph = room.height * gridSize; + parts.push(``); + } + + for (const cluster of deck.wilderness?.treeClusters ?? []) { + for (let i = 0; i < cluster.count; i += 1) { + const angle = (Math.PI * 2 * i) / Math.max(1, cluster.count); + const dist = cluster.radius * 0.55 * ((i % 3) + 1) / 3; + const x = (cluster.x + Math.cos(angle) * dist) * gridSize; + const y = (cluster.y + Math.sin(angle) * dist) * gridSize; + const r = Math.max(6, gridSize * 0.22); + parts.push(``); + parts.push(``); + } + } + + for (const water of deck.wilderness?.waterFeatures ?? []) { + parts.push(``); + parts.push(``); + } + + for (const ridge of deck.wilderness?.ridgelines ?? []) { + parts.push(``); + parts.push(``); + } + + for (const ravine of deck.wilderness?.ravines ?? []) { + parts.push(``); + parts.push(``); + } + + for (const zone of deck.wilderness?.encounterZones ?? []) { + parts.push(``); + } + + return parts.join(""); +} + +function buildFurnitureForDeck(deck, formState, furnitureTables, rng) { + if (formState.furnitureDensity === "off") return []; + const densityMultiplier = { sparse: 0.5, normal: 1, dense: 1.6 }[formState.furnitureDensity] ?? 1; + const modeKey = ["bsp", "corridor"].includes(formState.mode) ? "scifi" : formState.mode; + const tableSource = furnitureTables[modeKey] ?? {}; + const doorZonesByRoom = buildRoomDoorAvoidanceZones(deck); + const furniture = []; + + for (const room of deck.rooms) { + if (!room.type || room.type === "void") continue; + const archetypes = tableSource[room.type]; + if (!archetypes?.length) continue; + const placed = []; + for (const archetype of archetypes) { + const [minQty, maxQty] = archetype.qty ?? [0, 0]; + let qty = Math.round(randomInt(rng, minQty, maxQty) * densityMultiplier); + if (archetype.placement === "corners") qty = Math.min(qty, 4); + for (let index = 0; index < qty; index += 1) { + const piece = tryPlaceFurniturePiece(archetype, room, placed, doorZonesByRoom.get(room.id) ?? [], rng); + if (!piece) continue; + furniture.push(piece); + placed.push({ x: piece.x, y: piece.y, width: piece.w, height: piece.h }); + } + } + } + + return furniture; +} + +function tryPlaceFurniturePiece(archetype, room, placed, doorZones, rng) { + const margin = 1; + const maxAttempts = archetype.placement === "corners" ? 12 : 8; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const pos = pickFurniturePosition(archetype, room, margin, rng, placed); + if (!pos) continue; + const rect = { x: pos.x, y: pos.y, width: pos.w, height: pos.h }; + if (rect.x < room.x + margin || rect.y < room.y + margin) continue; + if (rect.x + rect.width > room.x + room.width - margin) continue; + if (rect.y + rect.height > room.y + room.height - margin) continue; + if (placed.some((existing) => rectsOverlap(existing, rect))) continue; + if (doorZones.some((zone) => rectsOverlap(zone, expandRect(rect, 0)))) continue; + return { ...archetype, x: rect.x, y: rect.y, w: rect.width, h: rect.height, rotation: pos.rotation ?? 0, roomId: room.id }; + } + return null; +} + +function pickFurniturePosition(archetype, room, margin, rng, placed = []) { + const innerX1 = room.x + margin; + const innerY1 = room.y + margin; + const randomRotation = archetype.w !== archetype.h && rng() > 0.5 ? 90 : 0; + + switch (archetype.placement) { + case "wall": { + const side = Math.floor(rng() * 4); + const dims = side >= 2 && archetype.w !== archetype.h + ? { w: archetype.h, h: archetype.w, rotation: 90 } + : { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + if (side === 0) return { x: randomInt(rng, innerX1, innerX2), y: innerY1, ...dims }; + if (side === 1) return { x: randomInt(rng, innerX1, innerX2), y: room.y + room.height - margin - dims.h, ...dims }; + if (side === 2) return { x: innerX1, y: randomInt(rng, innerY1, innerY2), ...dims }; + return { x: room.x + room.width - margin - dims.w, y: randomInt(rng, innerY1, innerY2), ...dims }; + } + case "far-wall": { + const dims = { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + const farY = room.y + room.height - margin - dims.h; + return { + x: clamp(Math.floor(room.x + (room.width - dims.w) / 2), innerX1, innerX2), + y: clamp(farY, innerY1, innerY2), + ...dims + }; + } + case "center": { + const dims = randomRotation === 90 ? { w: archetype.h, h: archetype.w, rotation: 90 } : { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + const cx = room.x + Math.floor((room.width - dims.w) / 2) + randomInt(rng, -1, 1); + const cy = room.y + Math.floor((room.height - dims.h) / 2) + randomInt(rng, -1, 1); + return { x: clamp(cx, innerX1, innerX2), y: clamp(cy, innerY1, innerY2), ...dims }; + } + case "corners": { + const dims = { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + const corners = [ + { x: innerX1, y: innerY1, ...dims }, + { x: innerX2, y: innerY1, ...dims }, + { x: innerX1, y: innerY2, ...dims }, + { x: innerX2, y: innerY2, ...dims } + ]; + const unused = corners.filter((corner) => !placed.some((existing) => existing.x === corner.x && existing.y === corner.y)); + const pool = unused.length ? unused : corners; + return pool[Math.floor(rng() * pool.length)]; + } + case "scatter": + default: + { + const dims = randomRotation === 90 ? { w: archetype.h, h: archetype.w, rotation: 90 } : { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + return { x: randomInt(rng, innerX1, innerX2), y: randomInt(rng, innerY1, innerY2), ...dims }; + } + } +} + +function buildRoomDoorAvoidanceZones(deck) { + const out = new Map(); + for (const room of deck.rooms) out.set(room.id, []); + for (const segment of deck.wallSegments ?? []) { + if (!segment.door) continue; + for (const room of deck.rooms) { + const zone = buildDoorAvoidanceZone(room, segment); + if (zone) out.get(room.id)?.push(zone); + } + } + return out; +} + +function buildDoorAvoidanceZone(room, segment) { + if (!segmentTouchesRoom(segment, room)) return null; + if (segment.x1 === segment.x2) { + const doorY = Math.min(segment.y1, segment.y2); + if (segment.x1 === room.x) return clampRectToRoom(room, { x: room.x, y: doorY - 1, width: 2, height: 3 }); + if (segment.x1 === room.x + room.width) return clampRectToRoom(room, { x: room.x + room.width - 2, y: doorY - 1, width: 2, height: 3 }); + } else { + const doorX = Math.min(segment.x1, segment.x2); + if (segment.y1 === room.y) return clampRectToRoom(room, { x: doorX - 1, y: room.y, width: 3, height: 2 }); + if (segment.y1 === room.y + room.height) return clampRectToRoom(room, { x: doorX - 1, y: room.y + room.height - 2, width: 3, height: 2 }); + } + return null; +} + +function clampRectToRoom(room, rect) { + const x1 = Math.max(room.x, rect.x); + const y1 = Math.max(room.y, rect.y); + const x2 = Math.min(room.x + room.width, rect.x + rect.width); + const y2 = Math.min(room.y + room.height, rect.y + rect.height); + if (x2 <= x1 || y2 <= y1) return null; + return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; +} + +async function createDeckBackgroundAsset(deckPlan, deck, formState) { + return createSvgAsset(`${slugify(deckPlan.shipName)}-deck-${deck.deckIndex + 1}`, buildDeckSVG(deckPlan, deck, formState)); +} + +async function createGmOverlayAsset(deckPlan, deck, formState) { + return createSvgAsset(`${slugify(deckPlan.shipName)}-deck-${deck.deckIndex + 1}-gm-overlay`, buildGmOverlaySVG(deckPlan, deck, formState)); +} + +async function createSvgAsset(baseName, svg) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return null; + + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const filename = `${slugify(baseName)}-${Date.now()}.svg`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([svg], filename, { type: "image/svg+xml" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + return response?.path ?? `${targetDir}/${filename}`; +} + +function wallSegmentToDocument(segment, gridSize) { + const isWindow = Boolean(segment.isWindow); + const movementOnly = Boolean(segment.movementOnly); + const doorValue = segment.door === DOOR_SECRET + ? DOOR_SECRET + : (segment.door ? DOOR_BASIC : DOOR_NONE); + const wall = { + c: [ + round(segment.x1 * gridSize), + round(segment.y1 * gridSize), + round(segment.x2 * gridSize), + round(segment.y2 * gridSize) + ], + move: segment.move ?? MOVEMENT_NORMAL, + sight: movementOnly || isWindow ? SENSE_NONE : SENSE_NORMAL, + sound: movementOnly ? SENSE_NONE : SENSE_NORMAL, + light: movementOnly || isWindow ? SENSE_NONE : SENSE_NORMAL, + dir: CONST.WALL_DIRECTIONS.BOTH, + door: doorValue, + ds: segment.ds ?? DOOR_STATE_CLOSED + }; + return wall; +} + +function withGeneratedWallFlag(wall) { + return { + ...wall, + flags: { + ...(wall.flags ?? {}), + [MODULE_ID]: { + ...(wall.flags?.[MODULE_ID] ?? {}), + generatedWall: true + } + } + }; +} + +function withGeneratedDrawingFlag(drawing) { + return { + ...drawing, + flags: { + ...(drawing.flags ?? {}), + [MODULE_ID]: { + ...(drawing.flags?.[MODULE_ID] ?? {}), + generatedDrawing: true + } + } + }; +} + +function withGeneratedTileFlag(tile) { + return { + ...tile, + flags: { + ...(tile.flags ?? {}), + [MODULE_ID]: { + ...(tile.flags?.[MODULE_ID] ?? {}), + generatedTile: true + } + } + }; +} + +function sanitizeGeneratedDrawings(drawings = []) { + return drawings.filter((drawing) => { + const polygonPoints = Array.isArray(drawing?.shape?.points) ? drawing.shape.points : []; + const width = Number(drawing?.shape?.width ?? 0); + const height = Number(drawing?.shape?.height ?? 0); + const hasPolygon = drawing?.shape?.type === "p" && polygonPoints.length >= 6; + if (!hasPolygon && (!(width > 0) || !(height > 0))) return false; + + const textVisible = Number(drawing?.textAlpha ?? 0) > 0 && String(drawing?.text ?? "").trim().length > 0; + const fillVisible = Number(drawing?.fillAlpha ?? 0) > 0 && Number(drawing?.fillType ?? 0) !== 0; + const strokeVisible = Number(drawing?.strokeAlpha ?? 0) > 0 && Number(drawing?.strokeWidth ?? 0) > 0; + + return textVisible || fillVisible || strokeVisible; + }); +} + +function withGeneratedLightFlag(light) { + return { + ...light, + flags: { + ...(light.flags ?? {}), + [MODULE_ID]: { + ...(light.flags?.[MODULE_ID] ?? {}), + generatedLight: true + } + } + }; +} + +function withGeneratedTokenFlag(token) { + return { + ...token, + flags: { + ...(token.flags ?? {}), + [MODULE_ID]: { + ...(token.flags?.[MODULE_ID] ?? {}), + generatedToken: true + } + } + }; +} + +function withGeneratedNoteFlag(note) { + return { + ...note, + flags: { + ...(note.flags ?? {}), + [MODULE_ID]: { + ...(note.flags?.[MODULE_ID] ?? {}), + generatedNote: true + } + } + }; +} + +async function buildEncounterTokens(deck, formState) { + const preset = resolveEncounterPreset(formState); + const packActors = await getEncounterPackActors(formState.encounterPack); + const targets = formState.mode === "wilderness" + ? (deck.wilderness?.encounterZones ?? []).map((zone) => ({ + type: "encounter-zone", + label: zone.label, + x: zone.x, + y: zone.y, + width: 2, + height: 2 + })) + : deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)); + const densityBase = { light: 1, standard: 2, heavy: 3 }[formState.encounterDensity] ?? 2; + const tokens = []; + let counter = 0; + for (const room of targets) { + const count = determineEncounterCount(room, densityBase); + const center = roomCenter(room); + for (let index = 0; index < count; index += 1) { + const offset = encounterOffset(index, count); + const actor = packActors.length ? packActors[counter % packActors.length] : null; + tokens.push({ + x: Math.round((center.x - 0.5 + offset.x) * formState.gridSize), + y: Math.round((center.y - 0.5 + offset.y) * formState.gridSize), + width: 1, + height: 1, + hidden: true, + name: actor?.name || buildEncounterName(preset, room, counter), + texture: { + src: actor?.texture || preset.icon + }, + flags: actor + ? { + [MODULE_ID]: { + encounterPack: formState.encounterPack, + sourceActorUuid: actor.uuid, + sourceActorId: actor.id + } + } + : undefined + }); + counter += 1; + } + } + return tokens; +} + +async function getEncounterPackActors(packId) { + const key = String(packId ?? "").trim(); + if (!key) return []; + if (encounterPackCache.has(key)) return encounterPackCache.get(key); + const pack = game.packs?.get(key); + if (!pack || pack.documentName !== "Actor") { + encounterPackCache.set(key, []); + return []; + } + const docs = await pack.getDocuments().catch(() => []); + const actors = docs + .filter((doc) => doc) + .map((doc) => ({ + id: doc.id, + uuid: doc.uuid, + name: doc.name, + texture: doc.prototypeToken?.texture?.src || doc.img || "icons/svg/mystery-man.svg" + })); + encounterPackCache.set(key, actors); + return actors; +} + +async function buildRoomPopulationTokens(deck, formState) { + const actors = await getEncounterPackActors(formState.roomPopulationPack); + if (!actors.length) return []; + const tokens = []; + for (const room of deck.rooms) { + const actor = matchActorToRoom(actors, room); + if (!actor) continue; + const center = roomCenter(room); + tokens.push({ + x: Math.round((center.x - 0.5) * formState.gridSize), + y: Math.round((center.y - 0.5) * formState.gridSize), + width: 1, + height: 1, + hidden: false, + name: actor.name, + texture: { + src: actor.texture + }, + flags: { + [MODULE_ID]: { + roomPopulation: true, + roomId: room.id, + sourceActorUuid: actor.uuid, + sourceActorId: actor.id, + sourceRoomType: room.type ?? "", + sourceRoomLabel: room.label ?? "" + } + } + }); + } + return tokens; +} + +function matchActorToRoom(actors, room) { + const tokens = roomMatchTokens(room); + return actors.find((actor) => { + const name = normalizeMatchText(actor.name); + return tokens.some((token) => name.includes(token)); + }) ?? null; +} + +function roomMatchTokens(room) { + const values = [room.type, room.label, ...(room.markers ?? []).map((marker) => marker.text || marker.label)].filter(Boolean); + const set = new Set(); + for (const value of values) { + const normalized = normalizeMatchText(value); + if (!normalized) continue; + set.add(normalized); + normalized.split(/\s+/).filter((part) => part.length > 2).forEach((part) => set.add(part)); + } + return Array.from(set); +} + +function normalizeMatchText(value) { + return String(value ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function resolveEncounterPreset(formState) { + const auto = (() => { + if (formState.encounterFaction !== "auto") return formState.encounterFaction; + if (["sla-mothership", "sla-industries-brp"].includes(game.system?.id ?? "")) return "sla"; + if (formState.mode === "wilderness") return "wildlife"; + if (formState.mode === "dungeon") return formState.theme === "crypt" ? "undead" : "cult"; + if (["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(formState.theme)) return "cult"; + return "marines"; + })(); + + const presets = { + corporate: { key: "corporate", icon: "icons/svg/shield.svg", names: ["Guard", "Security", "Response Team"] }, + marines: { key: "marines", icon: "icons/svg/sword.svg", names: ["Trooper", "Marine", "Assault Team"] }, + cult: { key: "cult", icon: "icons/svg/eye.svg", names: ["Cultist", "Fanatic", "Acolyte"] }, + undead: { key: "undead", icon: "icons/svg/skull.svg", names: ["Undead", "Wight", "Ghoul"] }, + wildlife: { key: "wildlife", icon: "icons/svg/pawprint.svg", names: ["Beast", "Predator", "Lurker"] }, + sla: { key: "sla", icon: "icons/svg/target.svg", names: ["Operative", "Contractor", "Sector Team"] } + }; + return presets[auto] ?? presets.marines; +} + +function determineEncounterCount(room, densityBase) { + const bonus = ["boss-chamber", "barracks", "camp", "encounter-zone"].includes(room.type) ? 1 : 0; + return Math.max(1, densityBase + bonus - (["vault", "basement"].includes(room.type) ? 1 : 0)); +} + +function encounterOffset(index, count) { + if (count <= 1) return { x: 0, y: 0 }; + const positions = [ + { x: -0.35, y: -0.2 }, + { x: 0.35, y: -0.2 }, + { x: -0.25, y: 0.28 }, + { x: 0.25, y: 0.28 } + ]; + return positions[index % positions.length]; +} + +function buildEncounterName(preset, room, counter) { + const base = preset.names[counter % preset.names.length]; + const suffix = ENCOUNTER_ROOM_LABELS[room.type] ?? room.label ?? "Hostile"; + return `${base} (${suffix})`; +} + +function splitRectExactCount(root, targetCount, splitBias, minRoomSize, rng) { + const rooms = [{ ...root }]; + while (rooms.length < targetCount) { + const candidates = rooms + .map((room, index) => ({ room, index })) + .filter(({ room }) => room.width >= minRoomSize * 2 || room.height >= minRoomSize * 2) + .sort((a, b) => (b.room.width * b.room.height) - (a.room.width * a.room.height)); + const candidate = candidates[0]; + if (!candidate) break; + const room = rooms.splice(candidate.index, 1)[0]; + const splitVertical = chooseSplitOrientation(room, splitBias, rng); + if (splitVertical && room.width >= minRoomSize * 2) { + const split = randomInt(rng, minRoomSize, room.width - minRoomSize); + rooms.push( + { x: room.x, y: room.y, width: split, height: room.height }, + { x: room.x + split, y: room.y, width: room.width - split, height: room.height } + ); + continue; + } + if (room.height >= minRoomSize * 2) { + const split = randomInt(rng, minRoomSize, room.height - minRoomSize); + rooms.push( + { x: room.x, y: room.y, width: room.width, height: split }, + { x: room.x, y: room.y + split, width: room.width, height: room.height - split } + ); + continue; + } + rooms.push(room); + break; + } + return rooms.slice(0, targetCount); +} + +function buildSpineWalls(spineRect, rooms) { + const northEdge = spineRect.y; + const southEdge = spineRect.y + spineRect.height; + const northRooms = rooms.filter((room) => room.y + room.height === northEdge); + const southRooms = rooms.filter((room) => room.y === southEdge); + const segments = []; + + segments.push(...edgeWithGaps( + { x1: spineRect.x, y1: northEdge, x2: spineRect.x + spineRect.width, y2: northEdge }, + northRooms.map((room) => ({ x1: room.x + 1, y1: northEdge, x2: room.x + Math.max(1, room.width - 1), y2: northEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + + segments.push(...edgeWithGaps( + { x1: spineRect.x, y1: southEdge, x2: spineRect.x + spineRect.width, y2: southEdge }, + southRooms.map((room) => ({ x1: room.x + 1, y1: southEdge, x2: room.x + Math.max(1, room.width - 1), y2: southEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + + return segments; +} + +function buildHallwayWalls(hallRect, rooms) { + const segments = []; + const isHorizontal = hallRect.width >= hallRect.height; + if (isHorizontal) { + const northEdge = hallRect.y; + const southEdge = hallRect.y + hallRect.height; + const northRooms = rooms.filter((room) => room.y + room.height === northEdge); + const southRooms = rooms.filter((room) => room.y === southEdge); + segments.push(...edgeWithGaps( + { x1: hallRect.x, y1: northEdge, x2: hallRect.x + hallRect.width, y2: northEdge }, + northRooms.map((room) => ({ x1: room.x + 1, y1: northEdge, x2: room.x + Math.max(1, room.width - 1), y2: northEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + segments.push(...edgeWithGaps( + { x1: hallRect.x, y1: southEdge, x2: hallRect.x + hallRect.width, y2: southEdge }, + southRooms.map((room) => ({ x1: room.x + 1, y1: southEdge, x2: room.x + Math.max(1, room.width - 1), y2: southEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + } else { + const westEdge = hallRect.x; + const eastEdge = hallRect.x + hallRect.width; + const westRooms = rooms.filter((room) => room.x + room.width === westEdge); + const eastRooms = rooms.filter((room) => room.x === eastEdge); + segments.push(...edgeWithGaps( + { x1: westEdge, y1: hallRect.y, x2: westEdge, y2: hallRect.y + hallRect.height }, + westRooms.map((room) => ({ x1: westEdge, y1: room.y + 1, x2: westEdge, y2: room.y + Math.max(1, room.height - 1) })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + segments.push(...edgeWithGaps( + { x1: eastEdge, y1: hallRect.y, x2: eastEdge, y2: hallRect.y + hallRect.height }, + eastRooms.map((room) => ({ x1: eastEdge, y1: room.y + 1, x2: eastEdge, y2: room.y + Math.max(1, room.height - 1) })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + } + return segments; +} + +function edgeWithGaps(edge, gaps) { + if (edge.y1 === edge.y2) { + return splitLinearEdge(edge.x1, edge.x2, edge.y1, gaps.map((gap) => [gap.x1, gap.x2]), "h"); + } + return splitLinearEdge(edge.y1, edge.y2, edge.x1, gaps.map((gap) => [gap.y1, gap.y2]), "v"); +} + +function splitLinearEdge(start, end, fixed, gaps, orientation) { + const normalized = gaps + .map(([a, b]) => [Math.max(start, Math.min(a, b)), Math.min(end, Math.max(a, b))]) + .filter(([a, b]) => b > a) + .sort((a, b) => a[0] - b[0]); + const segments = []; + let cursor = start; + for (const [gapStart, gapEnd] of normalized) { + if (gapStart > cursor) { + segments.push(orientation === "h" + ? { x1: cursor, y1: fixed, x2: gapStart, y2: fixed } + : { x1: fixed, y1: cursor, x2: fixed, y2: gapStart }); + } + segments.push(orientation === "h" + ? { x1: gapStart, y1: fixed, x2: gapEnd, y2: fixed, gap: true } + : { x1: fixed, y1: gapStart, x2: fixed, y2: gapEnd, gap: true }); + cursor = gapEnd; + } + if (cursor < end) { + segments.push(orientation === "h" + ? { x1: cursor, y1: fixed, x2: end, y2: fixed } + : { x1: fixed, y1: cursor, x2: fixed, y2: end }); + } + return segments; +} + +function chooseSplitOrientation(room, splitBias, rng) { + if (splitBias === "horizontal") return false; + if (splitBias === "vertical") return true; + if (room.width === room.height) return rng() > 0.5; + return room.width > room.height; +} + +function buildPartitionSegments(rooms, rng) { + const segments = []; + const seen = new Set(); + for (let i = 0; i < rooms.length; i += 1) { + for (let j = i + 1; j < rooms.length; j += 1) { + const shared = sharedWall(rooms[i], rooms[j]); + if (!shared) continue; + const key = shared.orientation === "v" + ? `v:${shared.x}:${shared.y1}:${shared.y2}` + : `h:${shared.y}:${shared.x1}:${shared.x2}`; + if (seen.has(key)) continue; + seen.add(key); + segments.push(...splitSharedWall(shared, rng)); + } + } + return segments; +} + +function dedupeLights(lights) { + const seen = new Set(); + return lights.filter((light) => { + const key = `${light.x},${light.y},${light.config?.dim ?? 0},${light.config?.bright ?? 0}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function sharedWall(a, b) { + if (a.x + a.width === b.x || b.x + b.width === a.x) { + const x = a.x + a.width === b.x ? b.x : a.x; + const y1 = Math.max(a.y, b.y); + const y2 = Math.min(a.y + a.height, b.y + b.height); + if (y2 - y1 >= 2) return { orientation: "v", x, y1, y2 }; + } + if (a.y + a.height === b.y || b.y + b.height === a.y) { + const y = a.y + a.height === b.y ? b.y : a.y; + const x1 = Math.max(a.x, b.x); + const x2 = Math.min(a.x + a.width, b.x + b.width); + if (x2 - x1 >= 2) return { orientation: "h", y, x1, x2 }; + } + return null; +} + +function splitSharedWall(shared, rng) { + if (shared.orientation === "v") { + const doorY = randomInt(rng, shared.y1, shared.y2 - 1); + const out = []; + if (doorY > shared.y1) out.push({ x1: shared.x, y1: shared.y1, x2: shared.x, y2: doorY }); + out.push({ x1: shared.x, y1: doorY, x2: shared.x, y2: doorY + 1, door: true, ds: 0 }); + if (shared.y2 > doorY + 1) out.push({ x1: shared.x, y1: doorY + 1, x2: shared.x, y2: shared.y2 }); + return out; + } + const doorX = randomInt(rng, shared.x1, shared.x2 - 1); + const out = []; + if (doorX > shared.x1) out.push({ x1: shared.x1, y1: shared.y, x2: doorX, y2: shared.y }); + out.push({ x1: doorX, y1: shared.y, x2: doorX + 1, y2: shared.y, door: true, ds: 0 }); + if (shared.x2 > doorX + 1) out.push({ x1: doorX + 1, y1: shared.y, x2: shared.x2, y2: shared.y }); + return out; +} + +function buildHullDoors(hull, rng) { + const options = [ + { x1: hull.x + Math.floor(hull.width / 2), y1: hull.y, x2: hull.x + Math.floor(hull.width / 2) + 1, y2: hull.y }, + { x1: hull.x + Math.floor(hull.width / 2), y1: hull.y + hull.height, x2: hull.x + Math.floor(hull.width / 2) + 1, y2: hull.y + hull.height }, + { x1: hull.x, y1: hull.y + Math.floor(hull.height / 2), x2: hull.x, y2: hull.y + Math.floor(hull.height / 2) + 1 }, + { x1: hull.x + hull.width, y1: hull.y + Math.floor(hull.height / 2), x2: hull.x + hull.width, y2: hull.y + Math.floor(hull.height / 2) + 1 } + ]; + return [options[randomInt(rng, 0, options.length - 1)]]; +} + +function buildHullDoorsFromMask(hullMask, rng) { + const segments = buildHullWallSegments(hullMask).filter((segment) => { + const length = Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + return length >= 1; + }); + if (!segments.length) return []; + const picks = []; + const desiredCount = segments.length > 40 ? 2 : 1; + const used = new Set(); + while (picks.length < desiredCount && used.size < segments.length) { + const segment = segments[randomInt(rng, 0, segments.length - 1)]; + const key = `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`; + if (used.has(key)) continue; + used.add(key); + if (segment.x1 === segment.x2) { + const y = Math.min(segment.y1, segment.y2); + picks.push({ x1: segment.x1, y1: y, x2: segment.x2, y2: y + 1 }); + } else { + const x = Math.min(segment.x1, segment.x2); + picks.push({ x1: x, y1: segment.y1, x2: x + 1, y2: segment.y2 }); + } + } + return picks.length ? picks : buildHullDoors(boundingRectFromRegions([{ x: 1, y: 1, width: hullMask[0]?.length ?? 10, height: hullMask.length }]), rng); +} + +function rectangleSegments(x, y, width, height, door = false) { + return [ + { x1: x, y1: y, x2: x + width, y2: y, door }, + { x1: x + width, y1: y, x2: x + width, y2: y + height, door }, + { x1: x + width, y1: y + height, x2: x, y2: y + height, door }, + { x1: x, y1: y + height, x2: x, y2: y, door } + ]; +} + +function buildOccupancyWalls(occupancy, roomType, roomIdGrid, options = {}) { + const edges = []; + const thresholds = []; + const roomDoorTypes = options.roomDoorTypes ?? {}; + const height = occupancy.length; + const width = occupancy[0]?.length ?? 0; + const dirs = [ + { dx: 0, dy: -1, key: "n" }, + { dx: 1, dy: 0, key: "e" }, + { dx: 0, dy: 1, key: "s" }, + { dx: -1, dy: 0, key: "w" } + ]; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (!occupancy[y]?.[x]) continue; + for (const dir of dirs) { + const nx = x + dir.dx; + const ny = y + dir.dy; + if (nx < 0 || ny < 0 || nx >= width || ny >= height || !occupancy[ny]?.[nx]) { + edges.push(cellEdge(x, y, dir.key)); + continue; + } + if (roomType[y]?.[x] === "room" && roomType[ny]?.[nx] === "corridor") { + thresholds.push({ + ...cellEdge(x, y, dir.key), + roomId: roomIdGrid[y]?.[x] || "" + }); + } + } + } + } + + const uniqueEdges = dedupeSegments(edges); + const thresholdEdges = dedupeSegments(thresholds, true); + const doorEdges = chooseCorridorDoorEdges(thresholdEdges); + const doorKeys = new Set(doorEdges.map((edge) => segmentKey(edge))); + const thresholdWalls = thresholdEdges + .filter((edge) => !doorKeys.has(segmentKey(edge))) + .map(({ roomId, ...edge }) => edge); + const wallSegments = [...uniqueEdges.map((edge) => ({ ...edge })), ...thresholdWalls]; + for (const edge of doorEdges) { + const { roomId, ...doorEdge } = edge; + const doorType = roomDoorTypes[roomId] ?? "normal"; + if (doorType === "none") { + wallSegments.push(doorEdge); + continue; + } + const ds = doorType === "locked" ? DOOR_STATE_LOCKED : DOOR_STATE_OPEN; + wallSegments.push({ ...doorEdge, door: true, ds }); + } + return dedupeSegments(wallSegments, true); +} + +function carveCorridor(from, to, occupancy, roomType, width, hullMask) { + const path = findHullPath(from, to, hullMask); + if (!path.length) return []; + + const painted = new Set(); + for (const point of path) { + const brush = corridorBrush(point, width, hullMask); + for (const cell of brush) { + painted.add(`${cell.x},${cell.y}`); + if (occupancy[cell.y]?.[cell.x] !== undefined) occupancy[cell.y][cell.x] = true; + if (roomType[cell.y]?.[cell.x] !== undefined && roomType[cell.y][cell.x] !== "room") { + roomType[cell.y][cell.x] = "corridor"; + } + } + } + + const rects = compressCellsToRects(Array.from(painted).map((key) => { + const [x, y] = key.split(",").map(Number); + return { x, y }; + })); + + return rects.map((rect) => ({ + ...rect, + waypoints: path.map((point) => ({ x: point.x, y: point.y })), + startDoor: "door", + endDoor: "door" + })); +} + +function ensureCorridorConnectivity(rooms, connections, corridors, occupancy, roomType, corridorWidth, hullMask, formState = null) { + if (rooms.length <= 1) return; + + const connected = reachableRoomIds(rooms, connections); + const reachable = new Set(connected); + + while (reachable.size < rooms.length) { + const unreachableRooms = rooms.filter((room) => !reachable.has(room.id)); + if (!unreachableRooms.length) break; + + let repaired = false; + for (const room of unreachableRooms) { + const rescueTarget = nearestReachableRoom(room, rooms, reachable); + if (!rescueTarget) continue; + const width = formState ? corridorWidthForConnection(room, rescueTarget, formState) : corridorWidth; + const hall = carveCorridor(roomCenter(room), roomCenter(rescueTarget), occupancy, roomType, width, hullMask); + if (!hall.length) continue; + corridors.push(...hall); + connections.push([room.id, rescueTarget.id]); + const updated = reachableRoomIds(rooms, connections); + reachable.clear(); + for (const id of updated) reachable.add(id); + repaired = true; + break; + } + + if (!repaired) break; + } +} + +function corridorWidthForConnection(roomA, roomB, formState) { + const important = new Set(["bridge", "engineering", "boss-chamber", "throne-room", "entrance", "reactor"]); + if (important.has(roomA?.type) || important.has(roomB?.type)) return Math.max(2, formState.corridorWidth); + return formState.corridorWidth; +} + +function roomCenter(room) { + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + return { + x: room.circleX + room.circleR, + y: room.circleY + room.circleR + }; + } + return { + x: room.x + Math.floor(room.width / 2), + y: room.y + Math.floor(room.height / 2) + }; +} + +function roomBounds(room) { + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + return { + x: room.circleX, + y: room.circleY, + width: room.circleR * 2, + height: room.circleR * 2 + }; + } + if (room.shape === "polygon" && room.points?.length) { + const xs = room.points.map((point) => point.x); + const ys = room.points.map((point) => point.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + return { + x: room.x, + y: room.y, + width: room.width, + height: room.height + }; +} + +function pointInRoom(point, room) { + return point.x >= room.x + && point.x < room.x + room.width + && point.y >= room.y + && point.y < room.y + room.height; +} + +function nearestRoomForIndex(rooms, index) { + const current = roomCenter(rooms[index]); + let nearest = rooms[0]; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < index; i += 1) { + const centre = roomCenter(rooms[i]); + const distance = Math.abs(current.x - centre.x) + Math.abs(current.y - centre.y); + if (distance < bestDistance) { + bestDistance = distance; + nearest = rooms[i]; + } + } + return nearest; +} + +function nearestReachableRoom(room, rooms, reachableIds) { + const current = roomCenter(room); + let nearest = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const candidate of rooms) { + if (!reachableIds.has(candidate.id) || candidate.id === room.id) continue; + const centre = roomCenter(candidate); + const distance = Math.abs(current.x - centre.x) + Math.abs(current.y - centre.y); + if (distance < bestDistance) { + bestDistance = distance; + nearest = candidate; + } + } + return nearest; +} + +function makeGrid(width, height, value) { + return Array.from({ length: height }, () => Array.from({ length: width }, () => value)); +} + +function fillRectCells(grid, rect, value) { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + if (grid[y]?.[x] !== undefined) grid[y][x] = value; + } + } +} + +function rectsOverlap(a, b) { + return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y); +} + +function expandRect(rect, amount) { + return { + x: rect.x - amount, + y: rect.y - amount, + width: rect.width + amount * 2, + height: rect.height + amount * 2 + }; +} + +function cellEdge(x, y, side) { + if (side === "n") return { x1: x, y1: y, x2: x + 1, y2: y }; + if (side === "s") return { x1: x, y1: y + 1, x2: x + 1, y2: y + 1 }; + if (side === "e") return { x1: x + 1, y1: y, x2: x + 1, y2: y + 1 }; + return { x1: x, y1: y, x2: x, y2: y + 1 }; +} + +function dedupeSegments(segments, preserveDoor = false) { + const map = new Map(); + for (const segment of segments) { + const key = `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`; + if (!preserveDoor || !map.has(key) || segment.door) { + map.set(key, segment); + } + } + return Array.from(map.values()); +} + +function chooseCorridorDoorEdges(thresholdEdges) { + const groupedByRoom = new Map(); + for (const edge of thresholdEdges) { + const roomId = edge.roomId || "unknown"; + if (!groupedByRoom.has(roomId)) groupedByRoom.set(roomId, []); + groupedByRoom.get(roomId).push(edge); + } + + const doorEdges = []; + for (const edges of groupedByRoom.values()) { + const runs = groupContiguousDoorRuns(edges); + for (const run of runs) { + doorEdges.push(run[Math.floor(run.length / 2)]); + } + } + return dedupeSegments(doorEdges, true); +} + +function groupContiguousDoorRuns(edges) { + const buckets = new Map(); + for (const edge of edges) { + const orientation = edge.y1 === edge.y2 ? "h" : "v"; + const line = orientation === "h" ? edge.y1 : edge.x1; + const bucketKey = `${orientation}:${line}`; + if (!buckets.has(bucketKey)) buckets.set(bucketKey, []); + buckets.get(bucketKey).push(edge); + } + + const runs = []; + for (const bucket of buckets.values()) { + bucket.sort((a, b) => { + if (a.y1 === a.y2) return a.x1 - b.x1; + return a.y1 - b.y1; + }); + + let currentRun = []; + for (const edge of bucket) { + if (!currentRun.length) { + currentRun.push(edge); + continue; + } + const previous = currentRun[currentRun.length - 1]; + const contiguous = previous.y1 === previous.y2 + ? previous.x2 === edge.x1 + : previous.y2 === edge.y1; + if (contiguous) { + currentRun.push(edge); + } else { + runs.push(currentRun); + currentRun = [edge]; + } + } + if (currentRun.length) runs.push(currentRun); + } + + return runs; +} + +function reachableRoomIds(rooms, connections) { + if (!rooms.length) return new Set(); + const adjacency = new Map(rooms.map((room) => [room.id, new Set()])); + for (const [a, b] of connections) { + adjacency.get(a)?.add(b); + adjacency.get(b)?.add(a); + } + + const reachable = new Set([rooms[0].id]); + const queue = [rooms[0].id]; + while (queue.length) { + const current = queue.shift(); + for (const next of adjacency.get(current) ?? []) { + if (reachable.has(next)) continue; + reachable.add(next); + queue.push(next); + } + } + return reachable; +} + +function weightedChoice(entries, rng) { + const total = entries.reduce((sum, entry) => sum + (entry.weight ?? 1), 0); + let roll = rng() * total; + for (const entry of entries) { + roll -= entry.weight ?? 1; + if (roll <= 0) return entry; + } + return entries[entries.length - 1]; +} + +function paletteForFormState(formState) { + if (!formState?.compactPrintMode) return themePalette(formState?.theme); + return { + base: "#ffffff", + fill: "#ffffff", + line: "#d4d4d4", + detail: "#888888", + hull: "#222222", + roomFill: "#f7f7f7", + roomStroke: "#222222", + corridorFill: "#ececec", + corridorStripe: "#c8c8c8", + label: "#111111", + coordText: "#444444", + coordCell: "#777777", + connectorFill: "#ffffff", + connectorStroke: "#444444", + connectorText: "#111111", + furnitureFill: "#dcdcdc", + furnitureStroke: "#666666", + furnitureLabel: "#111111", + secretDoor: "#555555", + bloodStain: "#999999", + water: "#c9d7e5", + treeColor: "#aeb8a0" + }; +} + +function themePalette(theme) { + switch (theme) { + case "stone": + return { base: "#4a453e", fill: "#5c5650", line: "#2e2b26", detail: "#8c8278", hull: "#1a1714", roomFill: "#c8c0b4", roomStroke: "#1a1714", corridorFill: "#a89e94", corridorStripe: "#c5bbaf", label: "#f5f0e8", coordText: "#ede6da", coordCell: "#ddd4c4", connectorFill: "#57443b", connectorStroke: "#d3b798", connectorText: "#fff5e6", furnitureFill: "#5a5040", furnitureStroke: "#8a7860", furnitureLabel: "#fff5e6", secretDoor: "#884422", water: "#33485b", bloodStain: "#5a2720" }; + case "cave": + return { base: "#2e3530", fill: "#3d4840", line: "#181f1c", detail: "#6e7f72", hull: "#0d110e", roomFill: "#8fa690", roomStroke: "#0d110e", corridorFill: "#6d8070", corridorStripe: "#91a094", label: "#e8f5ec", coordText: "#d8eadd", coordCell: "#c4d8c8", connectorFill: "#30453a", connectorStroke: "#9fd0af", connectorText: "#effff4", furnitureFill: "#3a3028", furnitureStroke: "#7a6a50", furnitureLabel: "#effff4", secretDoor: "#9c5f3a", water: "#1f4960", bloodStain: "#4a1714" }; + case "wood": + case "wood-floor": + return { base: "#4a3828", fill: "#5c4a35", line: "#2b2015", detail: "#9c7c56", hull: "#160f08", roomFill: "#c8aa88", roomStroke: "#1a1008", corridorFill: "#a88c6c", label: "#fff4e2", coordText: "#f5e4c4", coordCell: "#e8d0a8", connectorFill: "#5d3922", connectorStroke: "#dca86a", connectorText: "#fff1dd", furnitureFill: "#6a4a28", furnitureStroke: "#9a7040", furnitureLabel: "#fff1dd", secretDoor: "#a45d2d" }; + case "crypt": + return { base: "#2a2e35", fill: "#373d46", line: "#151820", detail: "#6070a0", hull: "#0a0c12", roomFill: "#a0aac0", roomStroke: "#0a0c12", corridorFill: "#808898", corridorStripe: "#a4adbf", label: "#e8eeff", coordText: "#d4d8f0", coordCell: "#c0c8e0", connectorFill: "#283152", connectorStroke: "#91a7de", connectorText: "#eef3ff", furnitureFill: "#3a3040", furnitureStroke: "#7a6a8a", furnitureLabel: "#eef3ff", secretDoor: "#b36b58", water: "#27395a", bloodStain: "#4c151d" }; + case "sewer": + return { base: "#2e3828", fill: "#3c4835", line: "#1a2018", detail: "#7e9060", hull: "#0e1410", roomFill: "#a0b890", roomStroke: "#0e1410", corridorFill: "#7c9870", corridorStripe: "#9bb38f", label: "#eafff0", coordText: "#d4f0da", coordCell: "#b8e0c0", connectorFill: "#3c4a28", connectorStroke: "#b5d06d", connectorText: "#f6ffe2", furnitureFill: "#2a3a28", furnitureStroke: "#5a7a50", furnitureLabel: "#f6ffe2", secretDoor: "#996644", water: "#2b4731", bloodStain: "#4f241c" }; + case "concrete-office": + return { base: "#5a5f64", fill: "#767b80", line: "#2f3438", detail: "#a5adb7", hull: "#171a1d", roomFill: "#dddfe1", roomStroke: "#23272a", corridorFill: "#cacdd1", corridorStripe: "#9fc7f1", label: "#ffffff", coordText: "#f0f4f8", coordCell: "#d8dfe6", connectorFill: "#42515e", connectorStroke: "#9fc7f1", connectorText: "#eef7ff", furnitureFill: "#c0c8d0", furnitureStroke: "#8090a0", furnitureLabel: "#243442", secretDoor: "#b54b38", bloodStain: "#781d1d" }; + case "brick": + return { base: "#4c3a36", fill: "#604944", line: "#2b1f1b", detail: "#9e7568", hull: "#150f0d", roomFill: "#d7c4bf", roomStroke: "#1c1412", corridorFill: "#c0aaa4", label: "#fff2ef", coordText: "#f1ddd8", coordCell: "#e3c8c0", connectorFill: "#5b3429", connectorStroke: "#d59e88", connectorText: "#fff0eb", furnitureFill: "#7a5040", furnitureStroke: "#a06050", furnitureLabel: "#fff0eb", secretDoor: "#b84d3a" }; + case "carpet": + return { base: "#3d2e44", fill: "#4d3a55", line: "#24192a", detail: "#8f73a1", hull: "#110b15", roomFill: "#d6cbdd", roomStroke: "#1e1623", corridorFill: "#c0b0ca", label: "#faf3ff", coordText: "#eadcf0", coordCell: "#dbc9e3", connectorFill: "#493660", connectorStroke: "#c4a7e6", connectorText: "#fbf3ff", furnitureFill: "#6a4a6a", furnitureStroke: "#9a7090", furnitureLabel: "#fbf3ff", secretDoor: "#d26767" }; + case "tile": + return { base: "#405055", fill: "#57666b", line: "#223036", detail: "#95b4b9", hull: "#11181b", roomFill: "#d9e3e4", roomStroke: "#1a2529", corridorFill: "#c3d1d2", label: "#f5ffff", coordText: "#dcedef", coordCell: "#cae0e2", connectorFill: "#2f5962", connectorStroke: "#8fd3dc", connectorText: "#eeffff", furnitureFill: "#c0c8c0", furnitureStroke: "#808888", furnitureLabel: "#274156", secretDoor: "#c05042" }; + case "industrial": + return { base: "#45413d", fill: "#5c5751", line: "#2f2c29", detail: "#bb8b3d", hull: "#121110", roomFill: "#c8c3ba", roomStroke: "#151515", corridorFill: "#b8b1a6", corridorStripe: "#d5a852", label: "#f7efe2", coordText: "#f6efe4", coordCell: "#eee6da", connectorFill: "#4b3c23", connectorStroke: "#d5a852", connectorText: "#fff1c8", furnitureFill: "#4a3a2a", furnitureStroke: "#7a6040", furnitureLabel: "#fff1c8", secretDoor: "#c65f40", bloodStain: "#7a1e10" }; + case "derelict": + return { base: "#313538", fill: "#44484b", line: "#1f2123", detail: "#6e4a3b", hull: "#0d0e10", roomFill: "#b8b1aa", roomStroke: "#111111", corridorFill: "#8e857d", corridorStripe: "#b38b72", label: "#f4dcc8", coordText: "#e8d7c6", coordCell: "#dcc9b4", connectorFill: "#402a24", connectorStroke: "#c38b72", connectorText: "#fde6db", furnitureFill: "#2a2a2a", furnitureStroke: "#5a5050", furnitureLabel: "#fde6db", secretDoor: "#aa5b4a", bloodStain: "#632420" }; + case "alien-organic": + return { base: "#232b28", fill: "#31413b", line: "#18211d", detail: "#7db39a", hull: "#09100d", roomFill: "#98b7aa", roomStroke: "#101715", corridorFill: "#7c9e92", label: "#e2fff1", coordText: "#d7f8eb", coordCell: "#c7ebdd", connectorFill: "#28483d", connectorStroke: "#96d7ba", connectorText: "#effff7", furnitureFill: "#1a3a1a", furnitureStroke: "#3a7a3a", furnitureLabel: "#effff7", secretDoor: "#c44f44" }; + case "clean-corporate": + return { base: "#d6dde2", fill: "#e5ecef", line: "#bcc8cf", detail: "#8fb7cf", hull: "#58656d", roomFill: "#f8fbfd", roomStroke: "#65717a", corridorFill: "#ebf1f4", corridorStripe: "#8fb7cf", label: "#35424c", coordText: "#5e6b76", coordCell: "#74828d", connectorFill: "#e7f1f8", connectorStroke: "#5f8cad", connectorText: "#274156", furnitureFill: "#d0d8e0", furnitureStroke: "#8090a0", furnitureLabel: "#274156", secretDoor: "#c45446", bloodStain: "#8f3e3e" }; + case "derelict-house": + return { base: "#40352f", fill: "#5a4a42", line: "#2b221e", detail: "#8d776c", hull: "#140f0d", roomFill: "#d1c1b1", roomStroke: "#1d1613", corridorFill: "#b4a293", corridorStripe: "#d9c8b6", label: "#fff1e6", coordText: "#e9d6cb", coordCell: "#d5bfb1", connectorFill: "#5d453a", connectorStroke: "#c7a78b", connectorText: "#fff2e6", furnitureFill: "#6a5248", furnitureStroke: "#8f7564", furnitureLabel: "#fff2e6", secretDoor: "#b4654a", bloodStain: "#5e1818" }; + case "asylum": + return { base: "#566165", fill: "#d6ddd9", line: "#8c9893", detail: "#7a6f67", hull: "#1d2325", roomFill: "#f2f4f1", roomStroke: "#384246", corridorFill: "#dde2de", corridorStripe: "#b5bdb8", label: "#243136", coordText: "#4b585e", coordCell: "#728086", connectorFill: "#77868d", connectorStroke: "#dbe8e2", connectorText: "#122026", furnitureFill: "#b9c1bc", furnitureStroke: "#7f8884", furnitureLabel: "#122026", secretDoor: "#8e3c3c", bloodStain: "#3a1010" }; + case "haunted-manor": + return { base: "#2d2230", fill: "#4b3640", line: "#24161c", detail: "#9d7a64", hull: "#120c10", roomFill: "#cfbcae", roomStroke: "#1d1317", corridorFill: "#b19a8e", corridorStripe: "#d7c0a1", label: "#fff0e2", coordText: "#ead8cb", coordCell: "#d7c2b4", connectorFill: "#523844", connectorStroke: "#d3b59a", connectorText: "#fff3e6", furnitureFill: "#6a5045", furnitureStroke: "#8c6c5c", furnitureLabel: "#fff3e6", secretDoor: "#a34747", bloodStain: "#4f0f17" }; + case "bloodbath": + return { base: "#26191b", fill: "#4a3030", line: "#1a0f11", detail: "#8e6666", hull: "#0e0708", roomFill: "#c9b7b4", roomStroke: "#180f11", corridorFill: "#9f8480", corridorStripe: "#d6aaaa", label: "#fff0ef", coordText: "#f0dada", coordCell: "#d9bcbc", connectorFill: "#5d2c2c", connectorStroke: "#e19c9c", connectorText: "#fff2f2", furnitureFill: "#6a3a3a", furnitureStroke: "#9a6666", furnitureLabel: "#fff2f2", secretDoor: "#d05050", bloodStain: "#650d0d" }; + case "forest": + return { base: "#3f5136", fill: "#67855a", line: "#2b3926", detail: "#91b57e", hull: "#162013", roomFill: "#b5c8a3", roomStroke: "#23301f", corridorFill: "#8f7a54", corridorStripe: "#c8b07a", label: "#f3ffe8", coordText: "#e0f3d7", coordCell: "#cde2c5", connectorFill: "#405935", connectorStroke: "#a8d08d", connectorText: "#f6fff1", furnitureFill: "#6f5a3b", furnitureStroke: "#8d744e", furnitureLabel: "#f6fff1", secretDoor: "#8c4d2c", bloodStain: "#5c221b", water: "#2b5a78", treeColor: "#3d6a35" }; + case "jungle": + return { base: "#2e4a34", fill: "#4e7a4c", line: "#1c2f21", detail: "#7eb86a", hull: "#122017", roomFill: "#98b78d", roomStroke: "#1d2d1f", corridorFill: "#7a6a48", corridorStripe: "#b59d63", label: "#f1ffe9", coordText: "#d7f0d0", coordCell: "#c3dfbc", connectorFill: "#2f5332", connectorStroke: "#95d07a", connectorText: "#f3fff0", furnitureFill: "#5a4a31", furnitureStroke: "#7a6846", furnitureLabel: "#f3fff0", secretDoor: "#7d4927", bloodStain: "#5b2018", water: "#1f5b52", treeColor: "#2f7f33" }; + case "plains": + return { base: "#7d8d5a", fill: "#a8bb70", line: "#647045", detail: "#d9d08a", hull: "#3c4327", roomFill: "#d7d8ac", roomStroke: "#55603b", corridorFill: "#8e7b55", corridorStripe: "#d0bb82", label: "#fffde7", coordText: "#f3f0d6", coordCell: "#e2ddb9", connectorFill: "#7f7448", connectorStroke: "#e0cd8f", connectorText: "#fffde7", furnitureFill: "#8b754c", furnitureStroke: "#a68f60", furnitureLabel: "#fffde7", secretDoor: "#945930", bloodStain: "#6a2b20", water: "#5d8ba3", treeColor: "#6f8b42" }; + case "swamp": + return { base: "#3d4632", fill: "#5e7051", line: "#27301f", detail: "#8ea16d", hull: "#151b11", roomFill: "#aab08a", roomStroke: "#283120", corridorFill: "#6d6243", corridorStripe: "#a19563", label: "#f0f8e2", coordText: "#dbe7cb", coordCell: "#c7d6b8", connectorFill: "#4b5030", connectorStroke: "#b9c58a", connectorText: "#f7ffe8", furnitureFill: "#5f5839", furnitureStroke: "#7c7152", furnitureLabel: "#f7ffe8", secretDoor: "#8d5134", bloodStain: "#58271f", water: "#304e43", treeColor: "#4d6f39" }; + case "tundra": + return { base: "#7d8896", fill: "#b9c4ce", line: "#66707d", detail: "#eef4fa", hull: "#3f4750", roomFill: "#dde5ea", roomStroke: "#55606b", corridorFill: "#a9b6bf", corridorStripe: "#e8edf2", label: "#ffffff", coordText: "#eef4fa", coordCell: "#dbe4ec", connectorFill: "#6b7e8d", connectorStroke: "#eef4fa", connectorText: "#ffffff", furnitureFill: "#9eaab4", furnitureStroke: "#c6d0d8", furnitureLabel: "#2b3440", secretDoor: "#8a4b4b", bloodStain: "#7a2929", water: "#7fb4d3", treeColor: "#b7cbd5" }; + case "badlands": + return { base: "#6c5642", fill: "#9a7a57", line: "#4a3828", detail: "#c49a63", hull: "#2a1e15", roomFill: "#d0bc9a", roomStroke: "#463424", corridorFill: "#8a6946", corridorStripe: "#d1a76d", label: "#fff4e6", coordText: "#f0ddc8", coordCell: "#e2cab1", connectorFill: "#7a5a38", connectorStroke: "#deb17a", connectorText: "#fff4e6", furnitureFill: "#7d6246", furnitureStroke: "#a88864", furnitureLabel: "#fff4e6", secretDoor: "#aa6138", bloodStain: "#6b2318", water: "#5a7485", treeColor: "#7c6d35" }; + case "steel": + default: + return { base: "#384048", fill: "#505860", line: "#242a30", detail: "#9ba7b0", hull: "#111418", roomFill: "#d7dbe0", roomStroke: "#15191d", corridorFill: "#c5ccd2", corridorStripe: "#8fd4ff", label: "#eff7ff", coordText: "#e9f4fb", coordCell: "#d9e5ef", connectorFill: "#24405c", connectorStroke: "#8fd4ff", connectorText: "#eff8ff", furnitureFill: "#3a4a5a", furnitureStroke: "#5a7090", furnitureLabel: "#eff8ff", secretDoor: "#cc4444", bloodStain: "#7a1e1e" }; + } +} + +const THEME_LIGHT_CONFIG = { + steel: { ambient: true, color: "#a0c0ff", alpha: 0.4, luminosity: 0.5, roomTypes: ["bridge", "engineering", "reactor"] }, + industrial: { ambient: true, color: "#ffb060", alpha: 0.35, luminosity: 0.45, roomTypes: ["engineering", "reactor"] }, + derelict: { ambient: true, color: "#404820", alpha: 0.25, luminosity: 0.3, roomTypes: [] }, + "alien-organic": { ambient: true, color: "#20a040", alpha: 0.3, luminosity: 0.35, roomTypes: ["reactor"] }, + "clean-corporate": { ambient: true, color: "#ffffff", alpha: 0.6, luminosity: 0.7, roomTypes: [] }, + stone: { ambient: false, color: "#ff8020", alpha: 0.5, luminosity: 0.4, roomTypes: ["shrine", "entrance", "guardroom"] }, + cave: { ambient: false, color: "#ff6010", alpha: 0.4, luminosity: 0.35, roomTypes: [] }, + wood: { ambient: false, color: "#ffb040", alpha: 0.55, luminosity: 0.45, roomTypes: ["shrine", "entrance"] }, + "wood-floor": { ambient: true, color: "#ffe8c0", alpha: 0.55, luminosity: 0.6, roomTypes: [] }, + crypt: { ambient: false, color: "#4020a0", alpha: 0.3, luminosity: 0.25, roomTypes: ["ritual-room", "crypt"] }, + sewer: { ambient: false, color: "#204010", alpha: 0.25, luminosity: 0.3, roomTypes: [] }, + "concrete-office": { ambient: true, color: "#ffffff", alpha: 0.7, luminosity: 0.75, roomTypes: [] }, + brick: { ambient: true, color: "#ffe0c0", alpha: 0.5, luminosity: 0.55, roomTypes: ["common-room", "bar"] }, + carpet: { ambient: true, color: "#fff0e0", alpha: 0.6, luminosity: 0.65, roomTypes: [] }, + tile: { ambient: true, color: "#e0f0ff", alpha: 0.65, luminosity: 0.7, roomTypes: [] }, + "derelict-house": { ambient: true, color: "#801010", alpha: 0.18, luminosity: 0.2, roomTypes: ["basement", "boiler-room"] }, + asylum: { ambient: true, color: "#c0c0a0", alpha: 0.22, luminosity: 0.25, roomTypes: ["surgery", "padded-cell"] }, + "haunted-manor": { ambient: false, color: "#4020a0", alpha: 0.15, luminosity: 0.18, roomTypes: ["library", "ballroom"] }, + bloodbath: { ambient: false, color: "#800808", alpha: 0.15, luminosity: 0.15, roomTypes: [] }, + forest: { ambient: true, color: "#b8d88a", alpha: 0.14, luminosity: 0.12, roomTypes: ["camp", "glade"] }, + jungle: { ambient: true, color: "#8cc26c", alpha: 0.12, luminosity: 0.1, roomTypes: ["nest", "ruins"] }, + plains: { ambient: true, color: "#f2df98", alpha: 0.08, luminosity: 0.08, roomTypes: ["camp", "crossing"] }, + swamp: { ambient: true, color: "#8da86a", alpha: 0.12, luminosity: 0.08, roomTypes: ["water", "ambush"] }, + tundra: { ambient: true, color: "#d8ecff", alpha: 0.1, luminosity: 0.1, roomTypes: ["ridge", "camp"] }, + badlands: { ambient: true, color: "#d9a36a", alpha: 0.1, luminosity: 0.08, roomTypes: ["ruins", "ambush"] } +}; + +function applySharedDeckConnectors(decks, formState) { + const connectors = buildSharedDeckConnectorBlueprints(decks[0], formState); + for (let deckIndex = 0; deckIndex < decks.length; deckIndex += 1) { + decks[deckIndex].deckConnectors = connectors.map((connector) => ({ + ...connector, + access: deckAccessLabel(deckIndex, decks.length) + })); + decorateDeckRoomsWithConnectors(decks[deckIndex]); + } +} + +function buildSharedDeckConnectorBlueprints(deck, formState) { + const points = []; + const hullMask = deck.hullMask ?? buildHullMask(deck.columns, deck.rows, deck.hullRegions ?? []); + const primary = findNearestHullCell(hullMask, Math.floor(deck.columns / 2), Math.floor(deck.rows / 2)); + const connectorType = ["dungeon", "building"].includes(formState.mode) ? "stairs" : "elevator"; + const connectorLabel = ["dungeon", "building"].includes(formState.mode) ? "STAIR A" : "LIFT A"; + if (primary) { + points.push({ id: "primary-a", x: primary.x, y: primary.y, label: connectorLabel, type: connectorType }); + } + if (formState.columns >= 24 && formState.rows >= 14) { + const secondary = findNearestHullCell(hullMask, Math.floor(deck.columns * 0.7), Math.floor(deck.rows * 0.35)); + if (secondary && (!primary || secondary.x !== primary.x || secondary.y !== primary.y)) { + const secondaryType = formState.mode === "building" ? "stairs" : "ladder"; + const secondaryLabel = formState.mode === "building" ? "STAIR B" : "LADDER B"; + points.push({ id: "secondary-b", x: secondary.x, y: secondary.y, label: secondaryLabel, type: secondaryType }); + } + } + return points; +} + +function deckAccessLabel(index, deckCount) { + const up = index < deckCount - 1 ? `UP ${index + 2}` : null; + const down = index > 0 ? `DOWN ${index}` : null; + return [up, down].filter(Boolean).join(" / ") || "LINK"; +} + +function decorateDeckRoomsWithConnectors(deck) { + for (const connector of deck.deckConnectors ?? []) { + const room = deck.rooms.find((candidate) => pointInRoom(connector, candidate)); + if (!room) continue; + room.markers ??= []; + room.markers.push({ + type: connector.type === "ladder" ? "Ladder" : connector.type === "stairs" ? "Stairs" : "Elevator", + label: connector.label, + x: connector.x, + y: connector.y, + access: connector.access + }); + } +} + +function buildHullRegions(columns, rows, hullShape, mode) { + const interiorWidth = Math.max(8, columns - 2); + const interiorHeight = Math.max(8, rows - 2); + const x0 = 1; + const y0 = 1; + let shape = mode === "bsp" && hullShape === "ring" ? "cross" : hullShape; + if (shape === "rect") shape = "rectangle"; + + switch (shape) { + case "tapered": { + const frontWidth = Math.max(4, Math.round(interiorWidth * 0.2)); + const midWidth = Math.max(8, Math.round(interiorWidth * 0.55)); + const aftWidth = Math.max(4, interiorWidth - frontWidth - midWidth); + return [ + { x: x0, y: y0 + Math.max(1, Math.floor(interiorHeight * 0.22)), width: frontWidth, height: Math.max(4, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.44))) }, + { x: x0 + frontWidth, y: y0 + Math.max(1, Math.floor(interiorHeight * 0.1)), width: midWidth, height: Math.max(6, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.2))) }, + { x: x0 + frontWidth + midWidth, y: y0 + Math.max(1, Math.floor(interiorHeight * 0.2)), width: aftWidth, height: Math.max(4, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.4))) } + ]; + } + case "hammerhead": { + const headWidth = Math.max(6, Math.round(interiorWidth * 0.28)); + const neckWidth = Math.max(4, Math.round(interiorWidth * 0.16)); + const bodyWidth = Math.max(6, interiorWidth - headWidth - neckWidth); + const headY = y0 + 1; + const headH = Math.max(6, interiorHeight - 2); + const neckH = Math.max(4, Math.round(interiorHeight * 0.42)); + const neckY = y0 + Math.floor((interiorHeight - neckH) / 2); + const bodyY = y0 + Math.max(1, Math.floor(interiorHeight * 0.12)); + const bodyH = Math.max(6, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.24))); + return [ + { x: x0, y: headY, width: headWidth, height: headH }, + { x: x0 + headWidth, y: neckY, width: neckWidth, height: neckH }, + { x: x0 + headWidth + neckWidth, y: bodyY, width: bodyWidth, height: bodyH } + ]; + } + case "cross": { + const stemWidth = Math.max(6, Math.round(interiorWidth * 0.34)); + const barHeight = Math.max(6, Math.round(interiorHeight * 0.34)); + const stemX = x0 + Math.floor((interiorWidth - stemWidth) / 2); + const barY = y0 + Math.floor((interiorHeight - barHeight) / 2); + return [ + { x: stemX, y: y0, width: stemWidth, height: barY - y0 }, + { x: x0, y: barY, width: interiorWidth, height: barHeight }, + { x: stemX, y: barY + barHeight, width: stemWidth, height: y0 + interiorHeight - (barY + barHeight) } + ].filter((region) => region.width >= 3 && region.height >= 3); + } + case "l-shape": { + const leftWidth = Math.max(6, Math.round(interiorWidth * 0.42)); + const bottomHeight = Math.max(6, Math.round(interiorHeight * 0.42)); + return [ + { x: x0, y: y0, width: leftWidth, height: interiorHeight }, + { x: x0 + leftWidth, y: y0 + interiorHeight - bottomHeight, width: interiorWidth - leftWidth, height: bottomHeight } + ]; + } + case "t-shape": { + const stemWidth = Math.max(6, Math.round(interiorWidth * 0.36)); + const barHeight = Math.max(6, Math.round(interiorHeight * 0.34)); + const stemX = x0 + Math.floor((interiorWidth - stemWidth) / 2); + return [ + { x: x0, y: y0, width: interiorWidth, height: barHeight }, + { x: stemX, y: y0 + barHeight, width: stemWidth, height: interiorHeight - barHeight } + ]; + } + case "u-shape": { + const legWidth = Math.max(5, Math.round(interiorWidth * 0.24)); + const baseHeight = Math.max(6, Math.round(interiorHeight * 0.32)); + return [ + { x: x0, y: y0, width: legWidth, height: interiorHeight }, + { x: x0 + interiorWidth - legWidth, y: y0, width: legWidth, height: interiorHeight }, + { x: x0, y: y0 + interiorHeight - baseHeight, width: interiorWidth, height: baseHeight } + ]; + } + case "ring": { + const thickness = Math.max(3, Math.round(Math.min(interiorWidth, interiorHeight) * 0.18)); + return [ + { x: x0, y: y0, width: interiorWidth, height: thickness }, + { x: x0, y: y0 + thickness, width: thickness, height: interiorHeight - thickness * 2 }, + { x: x0 + interiorWidth - thickness, y: y0 + thickness, width: thickness, height: interiorHeight - thickness * 2 }, + { x: x0, y: y0 + interiorHeight - thickness, width: interiorWidth, height: thickness } + ].filter((region) => region.width >= 3 && region.height >= 3); + } + case "rectangle": + default: + return [{ x: x0, y: y0, width: interiorWidth, height: interiorHeight }]; + } +} + +function buildHullMask(columns, rows, regions) { + const mask = makeGrid(columns, rows, false); + for (const region of regions) { + fillRectCells(mask, region, true); + } + return mask; +} + +function boundingRectFromRegions(regions) { + const minX = Math.min(...regions.map((region) => region.x)); + const minY = Math.min(...regions.map((region) => region.y)); + const maxX = Math.max(...regions.map((region) => region.x + region.width)); + const maxY = Math.max(...regions.map((region) => region.y + region.height)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +function allocateRoomCounts(regions, targetCount) { + if (regions.length === 1) return [targetCount]; + const areas = regions.map((region) => region.width * region.height); + const totalArea = areas.reduce((sum, area) => sum + area, 0) || 1; + const counts = regions.map(() => 1); + let remaining = Math.max(0, targetCount - counts.length); + + while (remaining > 0) { + let bestIndex = 0; + let bestScore = Number.NEGATIVE_INFINITY; + for (let index = 0; index < regions.length; index += 1) { + const score = (areas[index] / totalArea) - (counts[index] / Math.max(1, targetCount)); + if (score > bestScore) { + bestScore = score; + bestIndex = index; + } + } + counts[bestIndex] += 1; + remaining -= 1; + } + + return counts; +} + +function buildHullWallSegments(hullMask) { + const segments = []; + for (let y = 0; y < hullMask.length; y += 1) { + for (let x = 0; x < (hullMask[y]?.length ?? 0); x += 1) { + if (!hullMask[y]?.[x]) continue; + const edges = [ + { side: "n", nx: x, ny: y - 1 }, + { side: "e", nx: x + 1, ny: y }, + { side: "s", nx: x, ny: y + 1 }, + { side: "w", nx: x - 1, ny: y } + ]; + for (const edge of edges) { + if (!hullMask[edge.ny]?.[edge.nx]) { + segments.push(cellEdge(x, y, edge.side)); + } + } + } + } + return dedupeSegments(segments); +} + +function buildHullWallsWithDoorGaps(hullMask, doorSegments) { + const blocked = new Set( + doorSegments.map((segment) => segmentKey(segment)) + ); + return buildHullWallSegments(hullMask).filter((segment) => !blocked.has(segmentKey(segment))); +} + +function rectFitsMask(rect, mask) { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + if (!mask[y]?.[x]) return false; + } + } + return true; +} + +function segmentKey(segment) { + return `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`; +} + +function findHullPath(from, to, hullMask) { + const start = `${from.x},${from.y}`; + const goal = `${to.x},${to.y}`; + const queue = [from]; + const seen = new Set([start]); + const parent = new Map(); + const dirs = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 } + ]; + + while (queue.length) { + const current = queue.shift(); + const key = `${current.x},${current.y}`; + if (key === goal) break; + for (const dir of dirs) { + const next = { x: current.x + dir.x, y: current.y + dir.y }; + const nextKey = `${next.x},${next.y}`; + if (seen.has(nextKey) || !hullMask[next.y]?.[next.x]) continue; + seen.add(nextKey); + parent.set(nextKey, key); + queue.push(next); + } + } + + if (!seen.has(goal)) return []; + const path = []; + let currentKey = goal; + while (currentKey) { + const [x, y] = currentKey.split(",").map(Number); + path.push({ x, y }); + currentKey = parent.get(currentKey); + } + return path.reverse(); +} + +function corridorBrush(point, width, hullMask) { + const cells = []; + const radius = width - 1; + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + const cell = { x: point.x + dx, y: point.y + dy }; + if (hullMask[cell.y]?.[cell.x]) cells.push(cell); + } + } + return cells; +} + +function compressCellsToRects(cells) { + const byRow = new Map(); + for (const cell of cells) { + const row = byRow.get(cell.y) ?? []; + row.push(cell.x); + byRow.set(cell.y, row); + } + + const rects = []; + for (const [y, xs] of byRow.entries()) { + xs.sort((a, b) => a - b); + let start = xs[0]; + let prev = xs[0]; + for (let index = 1; index <= xs.length; index += 1) { + const x = xs[index]; + if (x === prev + 1) { + prev = x; + continue; + } + rects.push({ x: start, y, width: prev - start + 1, height: 1 }); + start = x; + prev = x; + } + } + return rects; +} + +function findNearestHullCell(mask, targetX, targetY) { + let best = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (let y = 0; y < mask.length; y += 1) { + for (let x = 0; x < (mask[y]?.length ?? 0); x += 1) { + if (!mask[y]?.[x]) continue; + const distance = Math.abs(targetX - x) + Math.abs(targetY - y); + if (distance < bestDistance) { + bestDistance = distance; + best = { x, y }; + } + } + } + return best; +} + +async function ensureSceneFolder(name) { + let folder = game.folders?.find((entry) => entry.type === "Scene" && entry.name === name && !entry.folder); + if (!folder) { + folder = await Folder.create({ name, type: "Scene", color: "#40637a" }); + } + return folder; +} + +function buildSceneFolderOptions() { + const folders = game.folders?.filter((entry) => entry.type === "Scene") ?? []; + const sorted = [...folders].sort((a, b) => describeFolderPath(a).localeCompare(describeFolderPath(b))); + return [ + { value: "", label: "Choose a scene folder..." }, + ...sorted.map((folder) => ({ + value: folder.id, + label: describeFolderPath(folder) + })) + ]; +} + +function describeFolderPath(folder) { + const parts = []; + let current = folder; + while (current) { + parts.unshift(current.name); + current = current.folder ?? null; + } + return parts.join(" / "); +} + +function buildActorPackOptions() { + const packs = (game.packs?.contents ?? []) + .filter((pack) => pack.documentName === "Actor") + .sort((a, b) => `${a.metadata.packageName}.${a.metadata.label}`.localeCompare(`${b.metadata.packageName}.${b.metadata.label}`)); + return [ + { value: "", label: "Use generated placeholder encounters" }, + ...packs.map((pack) => ({ + value: pack.collection, + label: `${pack.metadata.label} (${pack.collection})` + })) + ]; +} + +function buildSavedPresetOptions() { + const presets = (game.settings.get(MODULE_ID, "savedPresets") ?? []) + .filter((preset) => preset?.name) + .sort((a, b) => a.name.localeCompare(b.name)); + return [ + { value: "", label: "Choose a saved preset..." }, + ...presets.map((preset) => ({ value: preset.name, label: preset.name })) + ]; +} + +function mapOptions(options, selectedValue) { + return options.map((option) => ({ + ...option, + selected: String(option.value) === String(selectedValue) + })); +} + +function setInputValue(form, name, value) { + const element = form.querySelector(`[name="${name}"]`); + if (!element) return; + if (element.type === "checkbox") { + element.checked = Boolean(value); + return; + } + element.value = String(value); +} + +function buildMapName(mode = "bsp", seedInput = Date.now(), buildingSubtype = "office") { + const hash = hashSeed(String(seedInput)); + if (mode === "wilderness") { + const baseName = WILDERNESS_NAME_POOL[hash % WILDERNESS_NAME_POOL.length]; + const suffix = WILDERNESS_SUFFIX_POOL[Math.floor(hash / WILDERNESS_NAME_POOL.length) % WILDERNESS_SUFFIX_POOL.length]; + return `${baseName} ${suffix}`; + } + if (mode === "dungeon") { + const baseName = DUNGEON_NAME_POOL[hash % DUNGEON_NAME_POOL.length]; + const suffix = DUNGEON_SUFFIX_POOL[Math.floor(hash / DUNGEON_NAME_POOL.length) % DUNGEON_SUFFIX_POOL.length]; + return `${baseName} ${suffix}`; + } + if (mode === "building") { + const baseName = BUILDING_NAME_POOL[hash % BUILDING_NAME_POOL.length]; + const suffixPool = { + office: ["Corporate HQ", "Office Block", "Business Centre", "Bureau"], + warehouse: ["Warehouse", "Distribution Centre", "Storage Facility", "Depot"], + residential: ["Apartment", "Residence", "Estate", "Housing Block"], + tavern: ["The Rusty Flagon", "The Black Horse", "The Wanderer's Rest", "The Crooked Crown"], + mansion: ["Manor", "Estate", "Hall", "House"], + horror: ["Derelict House", "Asylum Wing", "Haunted Hall", "Murder Site"] + }[buildingSubtype] ?? BUILDING_SUFFIX_POOL; + const suffix = suffixPool[Math.floor(hash / BUILDING_NAME_POOL.length) % suffixPool.length]; + return `${baseName} ${suffix}`; + } + const baseName = SHIP_NAME_POOL[hash % SHIP_NAME_POOL.length]; + const suffix = SHIP_SUFFIX_POOL[Math.floor(hash / SHIP_NAME_POOL.length) % SHIP_SUFFIX_POOL.length]; + return `${baseName} ${suffix}`; +} + +function resolveDeckName(index, mode = "bsp") { + if (mode === "wilderness") return "Surface Map"; + if (mode === "dungeon") return DUNGEON_LEVEL_NAMES[index] ?? `Level ${index + 1}`; + if (mode === "building") return BUILDING_LEVEL_NAMES[index] ?? `Floor ${index + 1}`; + return DECK_NAME_POOL[index] ?? `Deck ${index + 1}`; +} + +function buildMissionBriefContent(deckPlan, formState) { + const chosenTemplate = resolveMissionTemplate(formState); + const introByTemplate = { + scifi: "The following operational map has been assembled from technical schematics, survey sweeps, and local traffic records. Expect compartment deviations and obstructed routes.", + dungeon: { + stone: "The following dungeon complex has been mapped from available intelligence and survivor accounts. Treat all information as provisional.", + crypt: "The catacombs below are ancient. What was once a place of rest for the honoured dead has become something else entirely.", + cave: "A naturally formed cave complex, partially occupied. The passages are unstable in places.", + sewer: "The lower channels are flooded, foul, and likely inhabited. Maintain sealed gear and expect poor visibility." + }, + horror: { + "derelict-house": "The structure shows clear signs of decay and breach. Expect unreliable doors, unstable interiors, and poor sightlines.", + asylum: "Institutional records indicate sealed wards and restricted treatment rooms. Interior movement may be obstructed and threats concealed.", + "haunted-manor": "The manor layout is intact but poorly maintained. Expect dead ends, servant routes, and compromised access points.", + bloodbath: "The site is an active or recent kill zone. Visibility may be poor, surfaces contaminated, and rooms psychologically destabilising." + }, + sla: "The following contract-zone map has been compiled for operative deployment. Media exposure, collateral risk, and sector politics may alter mission value without warning.", + wilderness: { + forest: "The following area has been surveyed from aerial observation. Ground-truth data should be established before committing forces.", + swamp: "The terrain is treacherous and visibility is limited. Standard movement rates should be halved.", + jungle: "Vegetation density is extreme and lines of sight are unreliable. Maintain visual contact between elements at all times." + } + }; + const introSource = introByTemplate[chosenTemplate] ?? introByTemplate.scifi; + const intro = typeof introSource === "string" + ? introSource + : introSource?.[formState.theme] ?? "The following location has been mapped from the best data currently available. Treat all positions as approximate until confirmed on the ground."; + const deckLines = deckPlan.decks.map((deck) => `
  • ${escapeXml(deck.deckName)}: ${deck.rooms.length} rooms, ${deck.corridors.length} corridors, ${escapeXml(summarizeDeckThreats(deck))}
  • `).join(""); + return ` +

    ${escapeXml(deckPlan.shipName)} Mission Brief

    +

    ${escapeXml(intro)}

    +

    Seed: ${escapeXml(deckPlan.seed)}
    Theme: ${escapeXml(formState.theme)}
    Mode: ${escapeXml(formState.mode)}

    +

    Operational Notes

    +
      +
    • Generated with ${deckPlan.decks.length} level${deckPlan.decks.length === 1 ? "" : "s"} at ${formState.gridSize}px grid resolution.
    • +
    • Room labels and openings are intended as tactical guides; final conditions should be confirmed on scene entry.
    • +
    • ${escapeXml(briefSpecialInstruction(formState, chosenTemplate))}
    • +
    +

    Deck Summary

    +
      ${deckLines}
    + `.replace(/\n\s+/g, ""); +} + +function resolveMissionTemplate(formState) { + if (formState.missionTemplate !== "auto") return formState.missionTemplate; + if (["sla-mothership", "sla-industries-brp"].includes(game.system?.id ?? "")) return "sla"; + if (formState.mode === "wilderness") return "wilderness"; + if (formState.mode === "dungeon") return "dungeon"; + if (["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(formState.theme)) return "horror"; + return "scifi"; +} + +function summarizeDeckThreats(deck) { + const dangerous = deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)); + if (!dangerous.length && !(deck.wilderness?.encounterZones?.length)) return "no explicit threat nodes marked"; + if (!dangerous.length && deck.wilderness?.encounterZones?.length) { + return `${deck.wilderness.encounterZones.length} wilderness encounter zone${deck.wilderness.encounterZones.length === 1 ? "" : "s"} flagged`; + } + const labels = dangerous.slice(0, 3).map((room) => room.label || room.type.toUpperCase()); + return `${dangerous.length} high-risk room${dangerous.length === 1 ? "" : "s"} flagged (${labels.join(", ")}${dangerous.length > 3 ? ", ..." : ""})`; +} + +function briefSpecialInstruction(formState, chosenTemplate = resolveMissionTemplate(formState)) { + if (chosenTemplate === "sla") return "Contract conditions remain fluid. Expect media scrutiny, unstable civilians, and rapid shifts in mission value if the situation escalates."; + if (chosenTemplate === "horror") return "Assume compromised lighting, psychological contamination, and hostile movement from behind unsecured doors or soft barriers."; + if (chosenTemplate === "dungeon") return "Advance with scouts, secure retreats, and expect locked chambers to signal either treasure, ritual focus, or concentrated resistance."; + if (chosenTemplate === "wilderness") return "Ridgelines, ravines, and water obstacles are treated as movement-affecting terrain and should shape approach routes."; + if (formState.seedEncounters) return "Encounter seed markers were placed in hostile or high-risk rooms for GM staging."; + if (formState.mapAge >= 60) return "The map has been weathered heavily; assume locked, broken, or compromised access points are more common."; + if (["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(formState.theme)) return "Horror decay modifiers are active: doors may be barricaded, breached, or partially collapsed."; + return "Maintain standard caution and update the plan with local observations during play."; +} + +function slugify(value) { + return String(value ?? MODULE_ID) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || MODULE_ID; +} + +function hashSeed(value) { + const text = String(value ?? ""); + let hash = 1779033703; + for (let i = 0; i < text.length; i += 1) { + hash = Math.imul(hash ^ text.charCodeAt(i), 3432918353); + hash = (hash << 13) | (hash >>> 19); + } + return hash >>> 0; +} + +function mulberry32(seed) { + let current = seed >>> 0; + return function rng() { + current |= 0; + current = (current + 0x6D2B79F5) | 0; + let t = Math.imul(current ^ (current >>> 15), 1 | current); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randomInt(rng, min, max) { + if (max <= min) return min; + return Math.floor(rng() * (max - min + 1)) + min; +} + +function clampInt(value, min, max, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.max(min, Math.min(max, Math.round(number))); +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, Number(value))); +} + +function normalizeChoice(value, fallback, choices) { + return choices.includes(value) ? value : fallback; +} + +function columnLabel(index) { + let value = Number(index ?? 0); + let out = ""; + do { + out = String.fromCharCode(65 + (value % 26)) + out; + value = Math.floor(value / 26) - 1; + } while (value >= 0); + return out; +} + +function round(value) { + return Math.round(Number(value) || 0); +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function textStrokeFor(fillColor) { + const rgb = hexToRgb(fillColor); + if (!rgb) return "rgba(10, 14, 18, 0.96)"; + const luminance = ((0.2126 * rgb.r) + (0.7152 * rgb.g) + (0.0722 * rgb.b)) / 255; + return luminance > 0.62 ? "rgba(12, 16, 20, 0.96)" : "rgba(248, 251, 255, 0.96)"; +} + +function hexToRgb(color) { + const value = String(color ?? "").trim(); + const short = value.match(/^#([0-9a-f]{3})$/i); + if (short) { + const [r, g, b] = short[1].split("").map((char) => parseInt(char + char, 16)); + return { r, g, b }; + } + const full = value.match(/^#([0-9a-f]{6})$/i); + if (full) { + return { + r: parseInt(full[1].slice(0, 2), 16), + g: parseInt(full[1].slice(2, 4), 16), + b: parseInt(full[1].slice(4, 6), 16) + }; + } + return null; +} diff --git a/module/scifi-deckplan-generator.mjs.bak-20260324-143610 b/module/scifi-deckplan-generator.mjs.bak-20260324-143610 new file mode 100644 index 0000000..4d4bea5 --- /dev/null +++ b/module/scifi-deckplan-generator.mjs.bak-20260324-143610 @@ -0,0 +1,6566 @@ +const MODULE_ID = "scifi-deckplan-generator"; +const ROOT_SCENE_FOLDER = "Generated Maps"; +const ASSET_DIR = MODULE_ID; +const GENERATED_WALL_FLAG = `${MODULE_ID}.generatedWall`; +const GENERATED_DRAWING_FLAG = `${MODULE_ID}.generatedDrawing`; +const GENERATED_LIGHT_FLAG = `${MODULE_ID}.generatedLight`; +const GENERATED_TOKEN_FLAG = `${MODULE_ID}.generatedToken`; +const GENERATED_NOTE_FLAG = `${MODULE_ID}.generatedNote`; +const GENERATED_REGION_FLAG = `${MODULE_ID}.generatedRegion`; +const MOVEMENT_NORMAL = CONST.WALL_MOVEMENT_TYPES?.NORMAL ?? CONST.WALL_SENSE_TYPES?.NORMAL ?? 20; +const SENSE_NORMAL = CONST.WALL_SENSE_TYPES?.NORMAL ?? 20; +const SENSE_NONE = CONST.WALL_SENSE_TYPES?.NONE ?? 0; +const DOOR_NONE = CONST.WALL_DOOR_TYPES?.NONE ?? 0; +const DOOR_BASIC = CONST.WALL_DOOR_TYPES?.DOOR ?? 1; +const DOOR_SECRET = CONST.WALL_DOOR_TYPES?.SECRET ?? 2; +const DOOR_STATE_OPEN = CONST.WALL_DOOR_STATES?.OPEN ?? 0; +const DOOR_STATE_CLOSED = CONST.WALL_DOOR_STATES?.CLOSED ?? 1; +const DOOR_STATE_LOCKED = CONST.WALL_DOOR_STATES?.LOCKED ?? 2; + +const CIRCULAR_ROOM_TYPES = new Set([ + "reactor", + "cistern", + "shrine", + "observation", + "ritual-room", + "forge", + "boiler-room", + "pool" +]); + +const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; + +const GENERATION_MODES = [ + { value: "bsp", label: "BSP Room Layout (Ship/Station)" }, + { value: "corridor", label: "Corridor Graph (Ship/Station)" }, + { value: "geomorph", label: "Geomorph Tile Stitching" }, + { value: "dungeon", label: "Random Dungeon" }, + { value: "building", label: "Random Building" }, + { value: "wilderness", label: "Outdoor Wilderness" } +]; + +const GRID_SIZES = [ + { value: 50, label: "50 px" }, + { value: 75, label: "75 px" }, + { value: 100, label: "100 px" } +]; + +const CHAMFER_SIZES = [ + { value: 1, label: "1 Cell" }, + { value: 2, label: "2 Cells" } +]; + +const GRID_MODE_OPTIONS = [ + { value: "square", label: "Square Grid" }, + { value: "hex-row", label: "Hex Grid (Odd Rows)" } +]; + +const CORNER_STYLE_OPTIONS = [ + { value: "square", label: "Square" }, + { value: "chamfer", label: "Chamfered" }, + { value: "perturb", label: "Organic" } +]; + +const MODE_THEME_OPTIONS = { + bsp: [ + { value: "steel", label: "Steel" }, + { value: "industrial", label: "Industrial" }, + { value: "derelict", label: "Derelict" }, + { value: "alien-organic", label: "Alien Organic" }, + { value: "clean-corporate", label: "Clean Corporate" } + ], + corridor: [ + { value: "steel", label: "Steel" }, + { value: "industrial", label: "Industrial" }, + { value: "derelict", label: "Derelict" }, + { value: "alien-organic", label: "Alien Organic" }, + { value: "clean-corporate", label: "Clean Corporate" } + ], + geomorph: [ + { value: "steel", label: "Steel" }, + { value: "industrial", label: "Industrial" }, + { value: "derelict", label: "Derelict" }, + { value: "clean-corporate", label: "Clean Corporate" } + ], + dungeon: [ + { value: "stone", label: "Stone Dungeon" }, + { value: "cave", label: "Cave System" }, + { value: "wood", label: "Wooden Stronghold" }, + { value: "crypt", label: "Undead Crypt" }, + { value: "sewer", label: "Sewer / Undercroft" } + ], + building: [ + { value: "concrete-office", label: "Concrete Office" }, + { value: "wood-floor", label: "Wood Floor" }, + { value: "brick", label: "Brick" }, + { value: "carpet", label: "Carpet" }, + { value: "tile", label: "Tile" }, + { value: "derelict-house", label: "Derelict House" }, + { value: "asylum", label: "Asylum / Hospital" }, + { value: "haunted-manor", label: "Haunted Manor" }, + { value: "bloodbath", label: "Bloodbath Scene" } + ], + wilderness: [ + { value: "forest", label: "Temperate Forest" }, + { value: "jungle", label: "Dense Jungle" }, + { value: "plains", label: "Open Plains" }, + { value: "swamp", label: "Swamp / Marsh" }, + { value: "tundra", label: "Frozen Tundra" }, + { value: "badlands", label: "Badlands / Scrub" } + ] +}; + +const MODE_HULL_OPTIONS = { + bsp: [ + { value: "rectangle", label: "Rectangle" }, + { value: "tapered", label: "Tapered Ship" }, + { value: "hammerhead", label: "Hammerhead" }, + { value: "cross", label: "Cross Station" }, + { value: "l-shape", label: "L-Shape" }, + { value: "ring", label: "Ring" } + ], + corridor: [ + { value: "rectangle", label: "Rectangle" }, + { value: "tapered", label: "Tapered Ship" }, + { value: "hammerhead", label: "Hammerhead" }, + { value: "cross", label: "Cross Station" }, + { value: "l-shape", label: "L-Shape" }, + { value: "ring", label: "Ring" } + ], + geomorph: [ + { value: "rectangle", label: "Tile Grid" } + ], + dungeon: [ + { value: "rectangle", label: "Rectangle" }, + { value: "cross", label: "Crossed Halls" }, + { value: "l-shape", label: "L-Shape" }, + { value: "ring", label: "Ring / Crypt Loop" } + ], + building: [ + { value: "rect", label: "Rectangle" }, + { value: "l-shape", label: "L-Shape" }, + { value: "t-shape", label: "T-Shape" }, + { value: "u-shape", label: "U-Shape" } + ], + wilderness: [ + { value: "open", label: "Open Ground" }, + { value: "clearing", label: "Forest Clearing" }, + { value: "ford", label: "River Ford" }, + { value: "camp", label: "Camp Perimeter" } + ] +}; + +const BUILDING_SUBTYPES = [ + { value: "office", label: "Office" }, + { value: "warehouse", label: "Warehouse" }, + { value: "residential", label: "Residential" }, + { value: "tavern", label: "Tavern" }, + { value: "mansion", label: "Mansion" }, + { value: "horror", label: "Horror / Derelict" } +]; + +const COORDINATE_MODES = [ + { value: "none", label: "Off" }, + { value: "border", label: "Border" }, + { value: "full", label: "Full Cell" } +]; + +const SPLIT_BIASES = [ + { value: "balanced", label: "Balanced" }, + { value: "horizontal", label: "Horizontal Bias" }, + { value: "vertical", label: "Vertical Bias" } +]; + +const CORRIDOR_WIDTHS = [ + { value: 1, label: "1 Cell" }, + { value: 2, label: "2 Cells" } +]; + +const CORRIDOR_STYLES = [ + { value: "spine", label: "Spine Corridor" }, + { value: "partitioned", label: "Partitioned (No Corridor)" } +]; + +const FURNITURE_DENSITIES = [ + { value: "off", label: "None" }, + { value: "sparse", label: "Sparse" }, + { value: "normal", label: "Normal" }, + { value: "dense", label: "Dense" } +]; + +const MISSION_TEMPLATE_OPTIONS = [ + { value: "auto", label: "Auto" }, + { value: "scifi", label: "Sci-Fi Ops" }, + { value: "dungeon", label: "Dungeon Delve" }, + { value: "horror", label: "Horror Incident" }, + { value: "wilderness", label: "Wilderness Recon" }, + { value: "sla", label: "SLA Briefing" } +]; + +const ENCOUNTER_FACTION_OPTIONS = [ + { value: "auto", label: "Auto" }, + { value: "corporate", label: "Corporate Security" }, + { value: "marines", label: "Marines / Troopers" }, + { value: "cult", label: "Cult / Fanatics" }, + { value: "undead", label: "Undead" }, + { value: "wildlife", label: "Wildlife / Beasts" }, + { value: "sla", label: "SLA Operatives" } +]; + +const ENCOUNTER_DENSITY_OPTIONS = [ + { value: "light", label: "Light" }, + { value: "standard", label: "Standard" }, + { value: "heavy", label: "Heavy" } +]; + +const FACTION_PRESET_OPTIONS = [ + { value: "none", label: "None" }, + { value: "ship", label: "Crew / Pirates / Automated / Infected" }, + { value: "dungeon", label: "Delvers / Cult / Undead / Guardians" }, + { value: "building", label: "Staff / Security / Residents / Intruders" }, + { value: "sla", label: "Operatives / Civilians / Hostiles / Media" } +]; + +const REGENERATE_TARGET_OPTIONS = [ + { value: "none", label: "Create fresh scenes" }, + { value: "viewed", label: "Viewed scene (single deck)" }, + { value: "viewed-folder", label: "Viewed scene folder" }, + { value: "folder", label: "Selected scene folder" } +]; + +const SECRET_DOOR_DENSITY_OPTIONS = [ + { value: "none", label: "None" }, + { value: "minimal", label: "Minimal (1–2 per map)" }, + { value: "sparse", label: "Sparse (2–4 per map)" }, + { value: "normal", label: "Normal (4–8 per map)" }, + { value: "dense", label: "Dense (hidden network)" } +]; + +const SECRET_DOOR_CHANCE = { + none: 0, + minimal: 0.01, + sparse: 0.04, + normal: 0.10, + dense: 0.20 +}; + +const MAINTENANCE_SHAFT_DENSITY_OPTIONS = [ + { value: "none", label: "None" }, + { value: "sparse", label: "Sparse" }, + { value: "normal", label: "Normal" }, + { value: "dense", label: "Dense" } +]; + +const MAINTENANCE_SHAFT_SOURCES = new Set([ + "engineering", "reactor", "life-support", "maintenance", + "cargo", "storage", "fuel-bay", "docking-bay" +]); + +const MAINTENANCE_SHAFT_DESTINATIONS = new Set([ + "engineering", "reactor", "life-support", "maintenance", "bridge", + "ops", "armory", "weapon-bay", "brig" +]); + +const BULKHEAD_PAIRS = [ + ["reactor", "engineering"], + ["reactor", "crew-quarters"], + ["reactor", "cargo"], + ["brig", "corridor"], + ["airlock", "bridge"], + ["airlock", "crew-quarters"], + ["weapon-bay", "cargo"], + ["docking-bay", "engineering"] +]; + +const REGION_CONFIG = { + "reactor": { + color: "#ff8800", + gmOnly: false, + behaviors: [{ type: "adjustDarknessLevel", darkness: { min: 0, max: 0.3 } }] + }, + "boss-chamber": { + color: "#aa0000", + gmOnly: true, + behaviors: [] + }, + "shrine": { + color: "#8800ff", + gmOnly: false, + behaviors: [] + }, + "ritual-room": { + color: "#440088", + gmOnly: false, + behaviors: [] + }, + "trap-room": { + color: "#cc4400", + gmOnly: true, + behaviors: [] + }, + "medical": { + color: "#0088cc", + gmOnly: false, + behaviors: [] + }, + "surgery": { + color: "#cc0044", + gmOnly: true, + behaviors: [] + } +}; + +const ENCOUNTER_ROOM_TYPES = new Set([ + "boss-chamber", + "guardroom", + "barracks", + "torture", + "surgery", + "padded-cell", + "basement", + "boiler-room", + "prison", + "security", + "armory", + "armoury", + "brig" +]); + +const ENCOUNTER_ROOM_LABELS = { + "boss-chamber": "BOSS", + guardroom: "GUARD", + barracks: "SOLDIER", + torture: "TORTURER", + surgery: "THREAT", + "padded-cell": "INMATE", + basement: "LURKER", + "boiler-room": "THREAT", + prison: "PRISONER", + security: "SECURITY", + armory: "ARMED", + armoury: "ARMED", + brig: "DETAINEE" +}; + +const DECK_NAME_POOL = [ + "Command Deck", + "Habitation Deck", + "Cargo Deck", + "Engineering Deck", + "Maintenance Deck", + "Operations Deck", + "Docking Deck", + "Research Deck" +]; + +const DUNGEON_LEVEL_NAMES = [ + "Upper Level", + "Lower Level", + "Catacombs", + "Deep Vaults", + "Flooded Passages", + "The Abyss", + "Undercroft", + "Burial Halls" +]; + +const BUILDING_LEVEL_NAMES = [ + "Ground Floor", + "Upper Floor", + "Service Level", + "Executive Floor", + "Basement", + "Upper Offices", + "Storage Level", + "Residential Floor" +]; + +const SHIP_NAME_POOL = [ + "Axiom", + "Erebus", + "Helix", + "Kestrel", + "Meridian", + "Oblique", + "Perseverance", + "Tethys", + "Valiance", + "Vortex", + "Waypoint", + "Peregrine" +]; + +const SHIP_SUFFIX_POOL = [ + "MK-I", + "MK-II", + "MK-III", + "MK-IV", + "IX", + "VII", + "Class C", + "Block 12", + "Run 7", + "Gamma" +]; + +const DUNGEON_NAME_POOL = [ + "Blackstone", + "Grimhollow", + "Ebondeep", + "Cindervault", + "Mirehold", + "Wyrmrest", + "Ashcrypt", + "Hollowmere" +]; + +const DUNGEON_SUFFIX_POOL = [ + "Catacombs", + "Vault", + "Crypt", + "Halls", + "Depths", + "Labyrinth", + "Stronghold", + "Sewers" +]; + +const BUILDING_NAME_POOL = [ + "Meridian", + "Halcyon", + "Northbank", + "Greywall", + "Kingsport", + "Crown", + "Glasswater", + "Market" +]; + +const BUILDING_SUFFIX_POOL = [ + "Offices", + "Warehouse", + "House", + "Hall", + "Tower", + "Tavern", + "Mansion", + "Works" +]; + +const WILDERNESS_NAME_POOL = [ + "Ashwood", + "Blackfen", + "Dreadmere", + "Frostline", + "Ironbrush", + "Mirewatch", + "Rookwood", + "Thornfield" +]; + +const WILDERNESS_SUFFIX_POOL = [ + "Approach", + "Hollow", + "Crossing", + "Wilds", + "March", + "Basin", + "Trail", + "Expanse" +]; + +const GEOMORPH_TILES = [ + { + key: "straight-ns", + connectors: { north: true, east: false, south: true, west: false }, + corridors: [{ x: 2, y: 0, width: 2, height: 6 }], + rooms: [{ x: 1, y: 1, width: 4, height: 2 }] + }, + { + key: "straight-ew", + connectors: { north: false, east: true, south: false, west: true }, + corridors: [{ x: 0, y: 2, width: 6, height: 2 }], + rooms: [{ x: 1, y: 1, width: 2, height: 4 }] + }, + { + key: "corner-ne", + connectors: { north: true, east: true, south: false, west: false }, + corridors: [{ x: 2, y: 0, width: 2, height: 3 }, { x: 2, y: 2, width: 4, height: 2 }], + rooms: [{ x: 1, y: 3, width: 3, height: 2 }] + }, + { + key: "corner-sw", + connectors: { north: false, east: false, south: true, west: true }, + corridors: [{ x: 0, y: 2, width: 4, height: 2 }, { x: 2, y: 2, width: 2, height: 4 }], + rooms: [{ x: 2, y: 0, width: 3, height: 2 }] + }, + { + key: "tee-nes", + connectors: { north: true, east: true, south: true, west: false }, + corridors: [{ x: 2, y: 0, width: 2, height: 6 }, { x: 2, y: 2, width: 4, height: 2 }], + rooms: [{ x: 0, y: 1, width: 2, height: 4 }] + }, + { + key: "hub", + connectors: { north: true, east: true, south: true, west: true }, + corridors: [{ x: 2, y: 0, width: 2, height: 6 }, { x: 0, y: 2, width: 6, height: 2 }], + rooms: [{ x: 1, y: 1, width: 4, height: 4 }] + }, + { + key: "sealed-room", + connectors: { north: false, east: false, south: false, west: false }, + corridors: [], + rooms: [{ x: 1, y: 1, width: 4, height: 4 }] + } +]; + +const FACTION_PRESETS = { + ship: [ + { name: "Crew", color: "#4b8ecf", roomTypes: ["bridge", "quarters", "medical", "galley", "observation"] }, + { name: "Pirates", color: "#b45b42", roomTypes: ["cargo", "armory", "brig", "security"] }, + { name: "Automated", color: "#7b93a8", roomTypes: ["engineering", "reactor", "maintenance", "laboratory"] }, + { name: "Infected", color: "#7a3e59", roomTypes: ["airlock", "escape-pods", "hydroponics", "storage"] } + ], + dungeon: [ + { name: "Delvers", color: "#5678a8", roomTypes: ["entrance", "library", "cistern"] }, + { name: "Cult", color: "#8a4d6f", roomTypes: ["ritual-room", "shrine", "alchemy"] }, + { name: "Undead", color: "#8b8f9e", roomTypes: ["crypt", "vault", "throne-room"] }, + { name: "Guardians", color: "#b38742", roomTypes: ["guardroom", "armoury", "boss-chamber", "prison"] } + ], + building: [ + { name: "Staff", color: "#4d8f7a", roomTypes: ["office", "lobby", "foyer", "common-room", "bar"] }, + { name: "Security", color: "#4f6b94", roomTypes: ["security", "loading", "storage", "armory"] }, + { name: "Residents", color: "#a88153", roomTypes: ["bedroom", "living-room", "kitchen", "bathroom"] }, + { name: "Intruders", color: "#9a4a4a", roomTypes: ["basement", "boiler-room", "attic", "surgery"] } + ], + sla: [ + { name: "Operatives", color: "#5b86c5", roomTypes: ["bridge", "security", "armory", "command"] }, + { name: "Civilians", color: "#7aa073", roomTypes: ["quarters", "medical", "galley", "common-room"] }, + { name: "Hostiles", color: "#b85a4f", roomTypes: ["cargo", "brig", "maintenance", "boiler-room"] }, + { name: "Media", color: "#b28b3c", roomTypes: ["observation", "lobby", "office", "lab"] } + ] +}; + +let roomTablesCache = null; +let shipProfilesCache = null; +let furnitureTablesCache = null; +const encounterPackCache = new Map(); + +Hooks.once("init", () => { + game.scifiDeckPlanGenerator = { + open: () => ScifiDeckPlanGeneratorApp.open() + }; + + game.settings.register(MODULE_ID, "lastFormState", { + scope: "client", + config: false, + default: {} + }); + + game.settings.register(MODULE_ID, "savedPresets", { + scope: "world", + config: false, + type: Array, + default: [] + }); + + game.settings.register(MODULE_ID, "gmOverlayVisible", { + scope: "client", + config: false, + type: Boolean, + default: true + }); + + game.settings.registerMenu(MODULE_ID, "openGenerator", { + name: game.i18n.localize("SDPG.MenuName"), + hint: game.i18n.localize("SDPG.MenuHint"), + label: game.i18n.localize("SDPG.SceneButton"), + icon: "fas fa-rocket", + type: ScifiDeckPlanGeneratorApp, + restricted: true + }); +}); + +Hooks.on("renderSceneDirectory", (_app, html) => installSceneDirectoryButton(html)); +Hooks.on("renderSidebarTab", (app, html) => { + const isScenesTab = app?.tabName === "scenes" || app?.options?.id === "scenes"; + if (isScenesTab) installSceneDirectoryButton(html); +}); +Hooks.on("canvasReady", () => applyGmOverlayVisibility(canvas.scene ?? null)); +Hooks.on("drawTile", (tile) => applyGmOverlayPlaceableVisibility(tile)); +Hooks.on("drawNote", (note) => applyGmOverlayPlaceableVisibility(note)); + +Hooks.on("getSceneControlButtons", (controls) => { + if (!game.user?.isGM) return; + + const buttonTool = { + name: "openScifiDeckPlanGenerator", + title: game.i18n.localize("SDPG.ToolbarOpen"), + icon: "fas fa-rocket", + button: true, + onClick: () => ScifiDeckPlanGeneratorApp.open() + }; + const gmOverlayTool = { + name: "toggleScifiDeckPlanGmOverlay", + title: "Toggle GM Overlay", + icon: "fas fa-user-secret", + toggle: true, + active: game.settings.get(MODULE_ID, "gmOverlayVisible") !== false, + onClick: async (active) => { + await setGmOverlayVisible(active); + } + }; + + const revealShaftTool = { + name: "sdpgRevealMaintenanceShafts", + title: "Reveal Maintenance Shafts", + icon: "fas fa-eye", + button: true, + onClick: async () => { + const scene = game.scenes?.viewed; + if (!scene) return; + const shaftDrawings = scene.drawings.filter((d) => d.flags?.[MODULE_ID]?.isMaintenanceShaft && d.hidden); + const shaftWalls = scene.walls.filter((w) => w.flags?.[MODULE_ID]?.isMaintenanceShaft && w.hidden); + if (!shaftDrawings.length && !shaftWalls.length) { + ui.notifications.info("No hidden maintenance shafts found in this scene."); + return; + } + const confirmed = await Dialog.confirm({ + title: "Reveal Maintenance Shafts", + content: `

    Reveal ${shaftDrawings.length} shaft sections and ${shaftWalls.length} shaft walls to players?

    ` + }); + if (!confirmed) return; + if (shaftDrawings.length) { + await scene.updateEmbeddedDocuments("Drawing", shaftDrawings.map((d) => ({ _id: d.id, hidden: false }))); + } + if (shaftWalls.length) { + await scene.updateEmbeddedDocuments("Wall", shaftWalls.map((w) => ({ _id: w.id, hidden: false }))); + } + ui.notifications.info("Maintenance shafts revealed."); + } + }; + + if (Array.isArray(controls)) { + const wallsControls = controls.find((control) => control.name === "walls"); + if (wallsControls) { + wallsControls.tools ??= []; + if (!wallsControls.tools.some((tool) => tool.name === buttonTool.name)) { + wallsControls.tools.push(buttonTool); + } + if (!wallsControls.tools.some((tool) => tool.name === gmOverlayTool.name)) { + wallsControls.tools.push(gmOverlayTool); + } + if (!wallsControls.tools.some((tool) => tool.name === revealShaftTool.name)) { + wallsControls.tools.push(revealShaftTool); + } + return; + } + + controls.push({ + name: MODULE_ID, + title: game.i18n.localize("SDPG.ToolbarGroup"), + icon: "fas fa-rocket", + layer: "walls", + tools: [buttonTool, gmOverlayTool, revealShaftTool] + }); + return; + } + + if (!controls || typeof controls !== "object") return; + const wallsControls = controls.walls; + if (!wallsControls) return; + wallsControls.tools ??= {}; + wallsControls.tools[buttonTool.name] ??= buttonTool; + wallsControls.tools[gmOverlayTool.name] ??= gmOverlayTool; + wallsControls.tools[revealShaftTool.name] ??= revealShaftTool; +}); + +class ScifiDeckPlanGeneratorApp extends HandlebarsApplicationMixin(ApplicationV2) { + #isGenerating = false; + + static DEFAULT_OPTIONS = { + id: MODULE_ID, + tag: "form", + classes: ["scifi-deckplan-generator"], + window: { + title: "SDPG.AppTitle", + resizable: true, + width: 980, + height: 900 + }, + form: { + handler: ScifiDeckPlanGeneratorApp.formHandler, + submitOnChange: false, + closeOnSubmit: true + } + }; + + static PARTS = { + form: { + template: `modules/${MODULE_ID}/templates/scifi-deckplan-generator.html` + } + }; + + static open() { + return new ScifiDeckPlanGeneratorApp().render({ force: true }); + } + + static async formHandler(event, _form, formData) { + return this.generateFromForm(formData.object, event?.target ?? event?.currentTarget ?? null); + } + + async _prepareContext() { + const saved = game.settings.get(MODULE_ID, "lastFormState") ?? {}; + const profilesData = await getShipProfiles(); + const defaults = { + shipName: buildMapName("bsp"), + mode: "bsp", + profile: "hauler", + columns: 32, + rows: 20, + gridSize: 75, + gridMode: "square", + decks: 1, + seed: "", + theme: "steel", + hullShape: "rectangle", + coordinates: "border", + roomCount: 12, + minRoomSize: 3, + maxRoomSize: 8, + cornerStyle: "chamfer", + chamferSize: 1, + circularSpecialRooms: true, + softRoomOutlines: true, + splitBias: "balanced", + corridorStyle: "spine", + loopFactor: 30, + corridorWidth: 1, + buildingSubtype: "office", + secretDoorDensity: "minimal", + furnitureDensity: "normal", + missionTemplate: "auto", + encounterFaction: "auto", + encounterDensity: "standard", + encounterPack: "", + roomPopulationPack: "", + populateRoomsFromCompendium: false, + compactPrintMode: false, + factionPreset: "none", + savedPreset: "", + activateScenes: true, + regenerateInPlace: false, + regenerateTarget: "none", + targetSceneFolder: "", + showRoomLabels: true, + placeLights: true, + generateMissionBrief: true, + generateMissionPacket: false, + seedEncounters: false, + createRoomNotes: true, + createGmOverlay: true, + exportMetadataJson: true, + mapAge: 0, + exportMothershipJson: false + }; + const merged = { ...defaults, ...saved }; + const themeOptions = getThemeOptionsForMode(merged.mode); + const hullOptions = getHullOptionsForMode(merged.mode); + const sceneFolders = buildSceneFolderOptions(); + const actorPacks = buildActorPackOptions(); + const savedPresets = buildSavedPresetOptions(); + + return { + defaults: merged, + modes: mapOptions(GENERATION_MODES, merged.mode), + profiles: mapOptions( + profilesData.profiles.map((profile) => ({ value: profile.key, label: profile.label })), + merged.profile + ), + themes: mapOptions(themeOptions, merged.theme), + hullShapes: mapOptions(hullOptions, merged.hullShape), + gridSizes: mapOptions(GRID_SIZES, Number(merged.gridSize)), + gridModes: mapOptions(GRID_MODE_OPTIONS, merged.gridMode), + chamferSizes: mapOptions(CHAMFER_SIZES, Number(merged.chamferSize)), + coordinates: mapOptions(COORDINATE_MODES, merged.coordinates), + cornerStyles: mapOptions(CORNER_STYLE_OPTIONS, merged.cornerStyle), + splitBiases: mapOptions(SPLIT_BIASES, merged.splitBias), + corridorStyles: mapOptions(CORRIDOR_STYLES, merged.corridorStyle), + corridorWidths: mapOptions(CORRIDOR_WIDTHS, Number(merged.corridorWidth)), + furnitureDensities: mapOptions(FURNITURE_DENSITIES, merged.furnitureDensity), + secretDoorDensities: mapOptions(SECRET_DOOR_DENSITY_OPTIONS, merged.secretDoorDensity), + maintenanceShaftDensities: mapOptions(MAINTENANCE_SHAFT_DENSITY_OPTIONS, merged.maintenanceShaftDensity), + missionTemplates: mapOptions(MISSION_TEMPLATE_OPTIONS, merged.missionTemplate), + encounterFactions: mapOptions(ENCOUNTER_FACTION_OPTIONS, merged.encounterFaction), + encounterDensities: mapOptions(ENCOUNTER_DENSITY_OPTIONS, merged.encounterDensity), + regenerateTargets: mapOptions(REGENERATE_TARGET_OPTIONS, merged.regenerateTarget ?? "none"), + sceneFolders: mapOptions(sceneFolders, merged.targetSceneFolder ?? ""), + actorPacks: mapOptions(actorPacks, merged.encounterPack ?? ""), + roomPopulationPacks: mapOptions(actorPacks, merged.roomPopulationPack ?? ""), + factionPresets: mapOptions(FACTION_PRESET_OPTIONS, merged.factionPreset ?? "none"), + savedPresets: mapOptions(savedPresets, merged.savedPreset ?? ""), + buildingSubtypes: mapOptions(BUILDING_SUBTYPES, merged.buildingSubtype), + profileCards: profilesData.profiles.map((profile) => ({ + label: profile.label, + summary: `${profile.columns}x${profile.rows}, ${profile.decks} level${profile.decks === 1 ? "" : "s"}, ${profile.mode.toUpperCase()}, ${profile.theme}`, + mode: profile.mode + })) + }; + } + + _onRender(context, options) { + super._onRender(context, options); + const root = this.element; + const form = root.matches?.("form") ? root : root.querySelector("form"); + if (!form) return; + + root.querySelector('[data-action="apply-profile"]')?.addEventListener("click", async (event) => { + event.preventDefault(); + const profilesData = await getShipProfiles(); + const selectedKey = form.querySelector('[name="profile"]')?.value; + const profile = profilesData.profiles.find((entry) => entry.key === selectedKey); + if (!profile) return; + setInputValue(form, "mode", profile.mode); + updateModeVisibility(form, profile.mode); + setInputValue(form, "columns", profile.columns); + setInputValue(form, "rows", profile.rows); + setInputValue(form, "decks", profile.decks); + setInputValue(form, "theme", profile.theme); + setInputValue(form, "hullShape", profile.hullShape ?? "rectangle"); + setInputValue(form, "splitBias", profile.splitBias ?? "balanced"); + setInputValue(form, "corridorStyle", profile.corridorStyle ?? "spine"); + setInputValue(form, "roomCount", profile.roomCount ?? 12); + setInputValue(form, "minRoomSize", profile.minRoomSize ?? 3); + setInputValue(form, "maxRoomSize", profile.maxRoomSize ?? 8); + setInputValue(form, "loopFactor", profile.loopFactor ?? 30); + setInputValue(form, "corridorWidth", profile.corridorWidth ?? 1); + setInputValue(form, "buildingSubtype", profile.buildingSubtype ?? "office"); + setInputValue(form, "secretDoorDensity", profile.secretDoorDensity ?? "minimal"); + setInputValue(form, "furnitureDensity", profile.furnitureDensity ?? "normal"); + setInputValue(form, "shipName", profile.label); + updatePreviewPane(form, root).catch(() => {}); + }); + + root.querySelector('[data-action="load-saved-preset"]')?.addEventListener("click", async () => { + await applySavedPresetToForm(form, root); + }); + root.querySelector('[data-action="save-current-preset"]')?.addEventListener("click", async () => { + await saveFormAsPreset(form, root); + }); + root.querySelector('[data-action="delete-saved-preset"]')?.addEventListener("click", async () => { + await deleteSavedPresetFromForm(form, root); + }); + root.querySelector('[data-action="export-png"]')?.addEventListener("click", async () => { + await exportPreviewPng(form, root); + }); + root.querySelector('[data-action="share-seed"]')?.addEventListener("click", async () => { + await shareFormState(form); + }); + root.querySelector('[data-action="import-seed"]')?.addEventListener("click", async () => { + await importSharedFormState(form, root); + }); + + setInputValue(form, "buildingSubtype", context.defaults.buildingSubtype ?? "office"); + updateModeVisibility(form, form.querySelector('[name="mode"]')?.value ?? context.defaults.mode ?? "bsp"); + + form.querySelector('[name="mode"]')?.addEventListener("change", (event) => { + updateModeVisibility(form, event.currentTarget.value); + updatePreviewPane(form, root).catch(() => {}); + }); + form.querySelector(".sdpg-reroll-seed")?.addEventListener("click", () => { + const seedInput = form.querySelector('[name="seed"]'); + if (seedInput) seedInput.value = String(Math.floor(Math.random() * 1_000_000)); + updatePreviewPane(form, root).catch(() => {}); + }); + root.querySelector('[data-action="preview-map"]')?.addEventListener("click", () => { + updatePreviewPane(form, root).catch(() => {}); + }); + form.querySelectorAll("input, select").forEach((element) => { + element.addEventListener("change", () => updatePreviewPane(form, root).catch(() => {})); + }); + updatePreviewPane(form, root).catch(() => {}); + } + + async generateFromForm(formObject) { + if (this.#isGenerating) return []; + this.#isGenerating = true; + const formState = normalizeFormState(formObject); + try { + await game.settings.set(MODULE_ID, "lastFormState", formState); + ui.notifications.info(`Generating ${formState.shipName}...`); + const roomTables = await getRoomTables(); + const deckPlan = buildDeckPlan(formState, roomTables); + const scenes = await createFoundryScenes(deckPlan, formState); + let metadataPath = null; + + if (formState.exportMothershipJson) { + await exportMothershipViewerJson(deckPlan); + } + if (formState.exportMetadataJson) { + metadataPath = await exportMapMetadataJson(deckPlan, formState, scenes); + } + if (formState.generateMissionPacket) { + await createMissionPacket(deckPlan, formState, scenes, metadataPath); + } + + if (formState.activateScenes && scenes.length) { + await scenes[scenes.length - 1].activate(); + await scenes[scenes.length - 1].view(); + } + + ui.notifications.info(`Generated ${scenes.length} map scene${scenes.length === 1 ? "" : "s"} for ${deckPlan.shipName}.`); + return scenes; + } catch (error) { + console.error(`${MODULE_ID} generation failed`, error); + ui.notifications.error(`Map generation failed: ${error?.message ?? error}`); + throw error; + } finally { + this.#isGenerating = false; + this.close({ force: true }).catch(() => {}); + } + } +} + +function installSceneDirectoryButton(html) { + if (!game.user?.isGM) return; + const root = $(html); + if (root.find(".sdpg-launch").length) return; + + const button = $(` + + `); + button.on("click", () => ScifiDeckPlanGeneratorApp.open()); + const deleteButton = $(` + + `); + deleteButton.on("click", () => deleteGeneratedDocuments(game.scenes?.viewed ?? canvas.scene ?? null)); + const annotateButton = $(` + + `); + annotateButton.on("click", () => openSceneAnnotationDialog(game.scenes?.viewed ?? canvas.scene ?? null)); + const gmOverlayButton = $(` + + `); + gmOverlayButton.on("click", async () => { + await setGmOverlayVisible(!(game.settings.get(MODULE_ID, "gmOverlayVisible") !== false)); + }); + + const createSceneButton = root.find("button[data-action='createScene'], a[data-action='createScene'], button.create-document").first(); + if (createSceneButton.length) { + const row = $('
    '); + row.append(button); + row.append(gmOverlayButton); + row.append(annotateButton); + row.append(deleteButton); + createSceneButton.closest(".header-actions, .action-buttons, .directory-header, header, .tab").first().after(row); + return; + } + + const header = root.find(".directory-header, header").first(); + if (header.length) { + const row = $('
    '); + row.append(button); + row.append(gmOverlayButton); + row.append(annotateButton); + row.append(deleteButton); + header.prepend(row); + } +} + +async function setGmOverlayVisible(visible) { + await game.settings.set(MODULE_ID, "gmOverlayVisible", Boolean(visible)); + applyGmOverlayVisibility(canvas.scene ?? null); + ui.controls?.render?.(true); + ui.notifications.info(`GM overlay ${visible ? "shown" : "hidden"} for your client.`); +} + +function applyGmOverlayVisibility(scene) { + if (!game.user?.isGM || !scene) return; + const visible = game.settings.get(MODULE_ID, "gmOverlayVisible") !== false; + const sceneIsActive = (canvas.scene?.id ?? null) === scene.id; + if (!sceneIsActive) return; + + for (const tile of canvas.tiles?.placeables ?? []) { + applyGmOverlayPlaceableVisibility(tile, visible); + } + + for (const note of canvas.notes?.placeables ?? []) { + applyGmOverlayPlaceableVisibility(note, visible); + } +} + +function applyGmOverlayPlaceableVisibility(placeable, overrideVisible = null) { + if (!game.user?.isGM || !placeable?.document?.flags?.[MODULE_ID]?.gmOverlay) return; + const visible = overrideVisible ?? (game.settings.get(MODULE_ID, "gmOverlayVisible") !== false); + placeable.visible = visible; + placeable.alpha = visible ? 0.95 : 0; + placeable.renderable = visible; +} + +async function deleteGeneratedDocuments(scene) { + if (!scene) { + ui.notifications.warn("No scene active."); + return; + } + + const { wallIds, drawingIds, tileIds, lightIds, tokenIds, noteIds } = collectGeneratedSceneIds(scene); + + if (!wallIds.length && !drawingIds.length && !tileIds.length && !lightIds.length && !tokenIds.length && !noteIds.length) { + ui.notifications.info("No generated elements found in this scene."); + return; + } + + await Dialog.confirm({ + title: "Delete Generated Elements", + content: `

    Remove ${wallIds.length} walls, ${drawingIds.length} drawings, ${tileIds.length} tiles, ${lightIds.length} lights, ${tokenIds.length} encounter tokens, and ${noteIds.length} generated notes from ${escapeXml(scene.name)}?

    `, + yes: async () => { + await deleteGeneratedDocumentsInternal(scene, { wallIds, drawingIds, tileIds, lightIds, tokenIds, noteIds }); + ui.notifications.info(`Deleted generated elements from ${scene.name}.`); + } + }); +} + +async function openSceneAnnotationDialog(scene) { + if (!scene) { + ui.notifications.warn("No scene active."); + return; + } + const roomDrawings = scene.drawings.contents + .filter((drawing) => drawing.flags?.[MODULE_ID]?.roomId) + .sort((a, b) => (a.flags[MODULE_ID].roomLabel || a.flags[MODULE_ID].roomType || "").localeCompare(b.flags[MODULE_ID].roomLabel || b.flags[MODULE_ID].roomType || "")); + if (!roomDrawings.length) { + ui.notifications.warn("No generated room drawings found on this scene."); + return; + } + const value = await Dialog.prompt({ + title: "Annotate Generated Room", + content: ` +
    + + +
    +
    + + +
    + `, + callback: (html) => ({ + roomId: html.find('[name="roomId"]').val(), + note: html.find('[name="annotation"]').val() + }) + }).catch(() => null); + if (!value?.roomId || !String(value.note ?? "").trim()) return; + const drawing = roomDrawings.find((entry) => entry.id === value.roomId); + if (!drawing) return; + const room = drawing.flags[MODULE_ID]; + await scene.createEmbeddedDocuments("Note", [withGeneratedNoteFlag({ + x: round((drawing.x ?? 0) + (drawing.shape?.width ?? 0) / 2), + y: round((drawing.y ?? 0) + (drawing.shape?.height ?? 0) / 2), + iconSize: 36, + text: room.roomLabel || room.roomType || "Room Note", + texture: { src: "icons/svg/book.svg" }, + hidden: true, + flags: { + [MODULE_ID]: { + annotation: true, + gmOverlay: true, + roomId: room.roomId, + roomType: room.roomType, + roomLabel: room.roomLabel, + noteText: String(value.note).trim() + } + } + })]); + ui.notifications.info("Room annotation added."); +} + +function collectGeneratedSceneIds(scene) { + return { + wallIds: scene.walls.contents.filter((wall) => wall.flags?.[MODULE_ID]?.generatedWall).map((wall) => wall.id), + drawingIds: scene.drawings.contents.filter((drawing) => drawing.flags?.[MODULE_ID]?.generatedDrawing).map((drawing) => drawing.id), + tileIds: scene.tiles.contents.filter((tile) => tile.flags?.[MODULE_ID]?.generatedTile).map((tile) => tile.id), + lightIds: scene.lights.contents.filter((light) => light.flags?.[MODULE_ID]?.generatedLight).map((light) => light.id), + tokenIds: scene.tokens.contents.filter((token) => token.flags?.[MODULE_ID]?.generatedToken).map((token) => token.id), + noteIds: scene.notes.contents.filter((note) => note.flags?.[MODULE_ID]?.generatedNote).map((note) => note.id) + }; +} + +async function deleteGeneratedDocumentsInternal(scene, ids = collectGeneratedSceneIds(scene)) { + if (ids.wallIds?.length) await scene.deleteEmbeddedDocuments("Wall", ids.wallIds); + if (ids.drawingIds?.length) await scene.deleteEmbeddedDocuments("Drawing", ids.drawingIds); + if (ids.tileIds?.length) await scene.deleteEmbeddedDocuments("Tile", ids.tileIds); + if (ids.lightIds?.length) await scene.deleteEmbeddedDocuments("AmbientLight", ids.lightIds); + if (ids.tokenIds?.length) await scene.deleteEmbeddedDocuments("Token", ids.tokenIds); + if (ids.noteIds?.length) await scene.deleteEmbeddedDocuments("Note", ids.noteIds); +} + +function normalizeFormState(raw = {}) { + const mode = normalizeChoice(raw.mode, "bsp", GENERATION_MODES.map((entry) => entry.value)); + const seedValue = String(raw.seed ?? "").trim(); + const minRoomSize = clampInt(raw.minRoomSize, 2, 8, 3); + const maxRoomSize = Math.max(minRoomSize, clampInt(raw.maxRoomSize, 4, 12, 8)); + const buildingSubtype = normalizeChoice(raw.buildingSubtype, "office", BUILDING_SUBTYPES.map((entry) => entry.value)); + const generatedName = buildMapName(mode, seedValue || Date.now(), buildingSubtype); + const themeChoices = getThemeOptionsForMode(mode).map((entry) => entry.value); + const hullChoices = getHullOptionsForMode(mode).map((entry) => entry.value); + const compactPrintMode = Boolean(raw.compactPrintMode); + const columns = compactPrintMode ? clampInt(raw.columns, 8, 12, 12) : clampInt(raw.columns, 10, 80, 32); + const rows = compactPrintMode ? clampInt(raw.rows, 6, 8, 8) : clampInt(raw.rows, 6, 50, 20); + const decks = compactPrintMode ? 1 : clampInt(raw.decks, 1, 8, 1); + return { + shipName: String(raw.shipName || generatedName).trim() || generatedName, + mode, + profile: String(raw.profile || "custom"), + savedPreset: String(raw.savedPreset || ""), + columns, + rows, + gridSize: clampInt(raw.gridSize, 50, 100, 75), + gridMode: normalizeChoice(raw.gridMode, "square", GRID_MODE_OPTIONS.map((entry) => entry.value)), + decks, + seed: seedValue || String(Math.floor(Math.random() * 1_000_000)), + theme: normalizeChoice(raw.theme, themeChoices[0], themeChoices), + hullShape: normalizeChoice(raw.hullShape, hullChoices[0], hullChoices), + coordinates: compactPrintMode ? "none" : normalizeChoice(raw.coordinates, "border", COORDINATE_MODES.map((entry) => entry.value)), + roomCount: clampInt(raw.roomCount, 6, 40, 12), + minRoomSize, + maxRoomSize, + cornerStyle: normalizeChoice(raw.cornerStyle, "chamfer", CORNER_STYLE_OPTIONS.map((entry) => entry.value)), + chamferSize: clampInt(raw.chamferSize, 1, 2, 1), + circularSpecialRooms: raw.circularSpecialRooms !== false && raw.circularSpecialRooms !== "false", + softRoomOutlines: raw.softRoomOutlines !== false && raw.softRoomOutlines !== "false", + splitBias: normalizeChoice(raw.splitBias, "balanced", SPLIT_BIASES.map((entry) => entry.value)), + corridorStyle: normalizeChoice(raw.corridorStyle, "spine", CORRIDOR_STYLES.map((entry) => entry.value)), + loopFactor: clampInt(raw.loopFactor, 0, 100, 30), + corridorWidth: clampInt(raw.corridorWidth, 1, 2, 1), + buildingSubtype, + secretDoorDensity: normalizeChoice(raw.secretDoorDensity, "minimal", SECRET_DOOR_DENSITY_OPTIONS.map((e) => e.value)), + maintenanceShaftDensity: normalizeChoice(raw.maintenanceShaftDensity, "sparse", MAINTENANCE_SHAFT_DENSITY_OPTIONS.map((e) => e.value)), + placeRegions: raw.placeRegions !== false && raw.placeRegions !== "false", + furnitureDensity: normalizeChoice(raw.furnitureDensity, "normal", FURNITURE_DENSITIES.map((entry) => entry.value)), + missionTemplate: normalizeChoice(raw.missionTemplate, "auto", MISSION_TEMPLATE_OPTIONS.map((entry) => entry.value)), + encounterFaction: normalizeChoice(raw.encounterFaction, "auto", ENCOUNTER_FACTION_OPTIONS.map((entry) => entry.value)), + encounterDensity: normalizeChoice(raw.encounterDensity, "standard", ENCOUNTER_DENSITY_OPTIONS.map((entry) => entry.value)), + encounterPack: String(raw.encounterPack ?? "").trim(), + roomPopulationPack: String(raw.roomPopulationPack ?? "").trim(), + populateRoomsFromCompendium: Boolean(raw.populateRoomsFromCompendium), + compactPrintMode, + factionPreset: normalizeChoice(raw.factionPreset, "none", FACTION_PRESET_OPTIONS.map((entry) => entry.value)), + activateScenes: Boolean(raw.activateScenes), + regenerateInPlace: Boolean(raw.regenerateInPlace), + regenerateTarget: normalizeChoice(raw.regenerateTarget, "none", REGENERATE_TARGET_OPTIONS.map((entry) => entry.value)), + targetSceneFolder: String(raw.targetSceneFolder ?? "").trim(), + showRoomLabels: Boolean(raw.showRoomLabels), + placeLights: raw.placeLights !== false && raw.placeLights !== "false", + generateMissionBrief: raw.generateMissionBrief !== false && raw.generateMissionBrief !== "false", + generateMissionPacket: Boolean(raw.generateMissionPacket), + seedEncounters: Boolean(raw.seedEncounters), + createRoomNotes: Boolean(raw.createRoomNotes), + createGmOverlay: raw.createGmOverlay !== false && raw.createGmOverlay !== "false", + exportMetadataJson: raw.exportMetadataJson !== false && raw.exportMetadataJson !== "false", + mapAge: clampInt(raw.mapAge, 0, 100, 0), + exportMothershipJson: ["bsp", "corridor"].includes(mode) && Boolean(raw.exportMothershipJson) + }; +} + +function getThemeOptionsForMode(mode) { + return MODE_THEME_OPTIONS[mode] ?? MODE_THEME_OPTIONS.bsp; +} + +function getHullOptionsForMode(mode) { + return MODE_HULL_OPTIONS[mode] ?? MODE_HULL_OPTIONS.bsp; +} + +function updateModeVisibility(form, mode) { + const themeSelect = form.querySelector('[name="theme"]'); + const hullSelect = form.querySelector('[name="hullShape"]'); + syncSelectOptions(themeSelect, getThemeOptionsForMode(mode)); + syncSelectOptions(hullSelect, getHullOptionsForMode(mode)); + + form.querySelectorAll(".sdpg-mode-field").forEach((field) => { + const allowed = (field.dataset.modes ?? "").split(/\s+/).filter(Boolean); + const active = !allowed.length || allowed.includes(mode); + field.classList.toggle("is-hidden", !active); + field.querySelectorAll("input, select").forEach((element) => { + element.disabled = !active; + }); + }); + + const exportToggle = form.querySelector('[name="exportMothershipJson"]'); + if (exportToggle) exportToggle.checked = ["bsp", "corridor"].includes(mode) && exportToggle.checked; +} + +async function updatePreviewPane(form, root) { + const image = root.querySelector("[data-preview-image]"); + const summary = root.querySelector("[data-preview-summary]"); + if (!image || !summary) return; + try { + summary.textContent = "Rendering preview..."; + const formObject = Object.fromEntries(new FormData(form).entries()); + form.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { + formObject[checkbox.name] = checkbox.checked; + }); + const formState = normalizeFormState(formObject); + const roomTables = await getRoomTables(); + const deckPlan = buildDeckPlan(formState, roomTables); + const previewDeck = deckPlan.decks[0]; + const svg = buildDeckSVG(deckPlan, previewDeck, formState); + image.dataset.svg = svg; + image.dataset.filename = `${slugify(deckPlan.shipName)}-preview.png`; + image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + summary.textContent = buildPreviewSummary(deckPlan, previewDeck, formState); + } catch (error) { + console.error(`${MODULE_ID} preview failed`, error); + summary.textContent = `Preview failed: ${error?.message ?? error}`; + } +} + +function buildPreviewSummary(deckPlan, deck, formState) { + const hazards = deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)).length; + const wildernessFeatures = formState.mode === "wilderness" + ? `${deck.wilderness?.treeClusters?.length ?? 0} tree clusters, ${deck.wilderness?.waterFeatures?.length ?? 0} water features, ${deck.wilderness?.encounterZones?.length ?? 0} encounter zones` + : `${deck.rooms.length} rooms, ${deck.corridors.length} corridors, ${countDoors(deck.wallSegments)} doors`; + const targetLabel = { + none: "new scenes", + viewed: "viewed scene", + "viewed-folder": "viewed folder", + folder: formState.targetSceneFolder ? "selected folder" : "new scenes" + }[formState.regenerateTarget === "none" && formState.regenerateInPlace ? "viewed" : formState.regenerateTarget] ?? "new scenes"; + return `${deckPlan.shipName} | ${deck.deckName} | ${deckPlan.decks.length} deck${deckPlan.decks.length === 1 ? "" : "s"} | ${formState.theme} | ${formState.gridMode} | ${wildernessFeatures}${hazards ? ` | ${hazards} danger rooms` : ""} | target: ${targetLabel}`; +} + +async function saveFormAsPreset(form, root) { + const name = (await Dialog.prompt({ + title: "Save Preset", + content: `
    `, + callback: (html) => html.find('[name="presetName"]').val() + }))?.trim(); + if (!name) return; + const formState = readFormState(form); + formState.savedPreset = name; + const presets = [...(game.settings.get(MODULE_ID, "savedPresets") ?? [])].filter((preset) => preset.name !== name); + presets.push({ name, state: formState }); + presets.sort((a, b) => a.name.localeCompare(b.name)); + await game.settings.set(MODULE_ID, "savedPresets", presets); + await game.settings.set(MODULE_ID, "lastFormState", formState); + updatePresetSelect(root, name); + ui.notifications.info(`Saved preset ${name}.`); +} + +async function applySavedPresetToForm(form, root) { + const select = form.querySelector('[name="savedPreset"]'); + const name = String(select?.value ?? "").trim(); + if (!name) return; + const preset = (game.settings.get(MODULE_ID, "savedPresets") ?? []).find((entry) => entry.name === name); + if (!preset?.state) return; + applyStateToForm(form, preset.state); + updateModeVisibility(form, form.querySelector('[name="mode"]')?.value ?? preset.state.mode ?? "bsp"); + updatePresetSelect(root, name); + await game.settings.set(MODULE_ID, "lastFormState", { ...preset.state, savedPreset: name }); + updatePreviewPane(form, root).catch(() => {}); + ui.notifications.info(`Loaded preset ${name}.`); +} + +async function deleteSavedPresetFromForm(form, root) { + const select = form.querySelector('[name="savedPreset"]'); + const name = String(select?.value ?? "").trim(); + if (!name) return; + const confirmed = await Dialog.confirm({ + title: "Delete Preset", + content: `

    Delete saved preset ${escapeXml(name)}?

    ` + }); + if (!confirmed) return; + const presets = [...(game.settings.get(MODULE_ID, "savedPresets") ?? [])].filter((entry) => entry.name !== name); + await game.settings.set(MODULE_ID, "savedPresets", presets); + updatePresetSelect(root, ""); + ui.notifications.info(`Deleted preset ${name}.`); +} + +function applyStateToForm(form, state = {}) { + for (const [key, value] of Object.entries(state)) { + setInputValue(form, key, value); + } +} + +function updatePresetSelect(root, selectedName = "") { + const select = root.querySelector('[name="savedPreset"]'); + if (!select) return; + syncSelectOptions(select, buildSavedPresetOptions()); + select.value = selectedName; +} + +function readFormState(form) { + const formObject = Object.fromEntries(new FormData(form).entries()); + form.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { + formObject[checkbox.name] = checkbox.checked; + }); + return normalizeFormState(formObject); +} + +async function exportPreviewPng(form, root) { + const image = root.querySelector("[data-preview-image]"); + const svg = image?.dataset?.svg; + if (!svg) return; + const widthMatch = svg.match(/width="(\d+)"/); + const heightMatch = svg.match(/height="(\d+)"/); + const width = Number(widthMatch?.[1] ?? 1200); + const height = Number(heightMatch?.[1] ?? 900); + const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(blob); + try { + const pngUrl = await rasterizeSvgToPng(url, width, height); + const link = document.createElement("a"); + link.href = pngUrl; + link.download = image.dataset.filename || "map-preview.png"; + link.click(); + setTimeout(() => URL.revokeObjectURL(pngUrl), 2000); + } finally { + URL.revokeObjectURL(url); + } +} + +function rasterizeSvgToPng(url, width, height) { + return new Promise((resolve, reject) => { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + const img = new Image(); + img.onload = () => { + ctx.clearRect(0, 0, width, height); + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL("image/png")); + }; + img.onerror = reject; + img.src = url; + }); +} + +async function shareFormState(form) { + const state = readFormState(form); + const packed = btoa(unescape(encodeURIComponent(JSON.stringify(state)))); + const share = `${window.location.origin}${window.location.pathname}#sdpg=${packed}`; + await navigator.clipboard.writeText(share).catch(() => {}); + await Dialog.prompt({ + title: "Share Seed URL", + content: ``, + callback: () => share + }).catch(() => null); +} + +async function importSharedFormState(form, root) { + const value = await Dialog.prompt({ + title: "Import Shared Seed", + content: ``, + callback: (html) => html.find('[name="importState"]').val() + }); + const text = String(value ?? "").trim(); + if (!text) return; + const match = text.match(/#sdpg=([^&]+)/); + const packed = match?.[1] ?? text; + try { + const state = JSON.parse(decodeURIComponent(escape(atob(packed)))); + applyStateToForm(form, state); + updateModeVisibility(form, form.querySelector('[name="mode"]')?.value ?? state.mode ?? "bsp"); + updatePreviewPane(form, root).catch(() => {}); + ui.notifications.info("Imported shared generator state."); + } catch (error) { + ui.notifications.error(`Could not import shared state: ${error?.message ?? error}`); + } +} + +function syncSelectOptions(select, options) { + if (!select) return; + const currentValue = select.value; + select.innerHTML = options.map((option) => ``).join(""); + const allowed = new Set(options.map((option) => String(option.value))); + select.value = allowed.has(String(currentValue)) ? String(currentValue) : String(options[0]?.value ?? ""); +} + +async function getRoomTables() { + if (!roomTablesCache) { + roomTablesCache = await loadJson(`modules/${MODULE_ID}/data/room-tables.json`); + } + return roomTablesCache; +} + +async function getShipProfiles() { + if (!shipProfilesCache) { + shipProfilesCache = await loadJson(`modules/${MODULE_ID}/data/ship-profiles.json`); + } + return shipProfilesCache; +} + +async function getFurnitureTables() { + if (!furnitureTablesCache) { + furnitureTablesCache = await loadJson(`modules/${MODULE_ID}/data/furniture-tables.json`); + } + return furnitureTablesCache; +} + +async function loadJson(path) { + const response = await fetch(path); + if (!response.ok) throw new Error(`Failed to load ${path}`); + return response.json(); +} + +function buildDeckPlan(formState, roomTables) { + const seed = hashSeed(formState.seed); + const decks = []; + + for (let deckIndex = 0; deckIndex < formState.decks; deckIndex += 1) { + const rng = mulberry32(seed + deckIndex * 104729); + const deckName = resolveDeckName(deckIndex, formState.mode); + const deck = buildDeckForMode(deckIndex, deckName, formState, roomTables, rng); + decks.push(deck); + } + + if (decks.length > 1) { + applySharedDeckConnectors(decks, formState); + } + + return { + shipName: formState.shipName, + seed: formState.seed, + gridSize: formState.gridSize, + theme: formState.theme, + decks + }; +} + +function buildDeckForMode(deckIndex, deckName, formState, roomTables, rng) { + switch (formState.mode) { + case "corridor": + return buildCorridorDeck(deckIndex, deckName, formState, roomTables, rng); + case "geomorph": + return buildGeomorphDeck(deckIndex, deckName, formState, roomTables, rng); + case "dungeon": + if (formState.theme === "cave") return buildCaveDeck(deckIndex, deckName, formState, roomTables, rng); + return buildDungeonDeck(deckIndex, deckName, formState, roomTables, rng); + case "building": + return formState.corridorStyle === "spine" + ? buildBuildingDeckWithHallway(deckIndex, deckName, formState, roomTables, rng) + : buildBuildingDeck(deckIndex, deckName, formState, roomTables, rng); + case "wilderness": + return buildWildernessDeck(deckIndex, deckName, formState, roomTables, rng); + case "bsp": + default: + return formState.corridorStyle === "spine" + ? buildBspDeckWithSpine(deckIndex, deckName, formState, roomTables, rng) + : buildBspDeck(deckIndex, deckName, formState, roomTables, rng); + } +} + +function buildGeomorphDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, "rectangle", "geomorph"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const tileSize = 6; + const tileCols = Math.max(1, Math.floor((formState.columns - 2) / tileSize)); + const tileRows = Math.max(1, Math.floor((formState.rows - 2) / tileSize)); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + const rooms = []; + const corridors = []; + const connections = []; + const geomorphTiles = []; + + for (let ty = 0; ty < tileRows; ty += 1) { + for (let tx = 0; tx < tileCols; tx += 1) { + const required = { + north: ty > 0 ? (geomorphTiles[(ty - 1) * tileCols + tx]?.connectors.south ?? false) : false, + west: tx > 0 ? (geomorphTiles[ty * tileCols + (tx - 1)]?.connectors.east ?? false) : false + }; + const candidates = GEOMORPH_TILES.filter((tile) => + tile.connectors.north === required.north + && tile.connectors.west === required.west + && (tx < tileCols - 1 || tile.connectors.east === false) + && (ty < tileRows - 1 || tile.connectors.south === false) + ); + const tile = (candidates.length ? candidates : GEOMORPH_TILES)[randomInt(rng, 0, (candidates.length ? candidates : GEOMORPH_TILES).length - 1)]; + geomorphTiles.push(tile); + const baseX = 1 + tx * tileSize; + const baseY = 1 + ty * tileSize; + for (const rect of tile.corridors) { + const corridor = { x: baseX + rect.x, y: baseY + rect.y, width: rect.width, height: rect.height, waypoints: [] }; + corridors.push(corridor); + fillRectCells(occupancy, corridor, true); + fillRectCells(roomType, corridor, "corridor"); + } + for (const rect of tile.rooms) { + const room = { + id: `g${deckIndex}-${rooms.length}`, + x: baseX + rect.x, + y: baseY + rect.y, + width: rect.width, + height: rect.height, + shape: "rect", + markers: [] + }; + rooms.push(room); + fillRectCells(occupancy, room, true); + fillRectCells(roomType, room, "room"); + fillRectCells(roomIdGrid, room, room.id); + } + } + } + + for (let i = 1; i < rooms.length; i += 1) { + connections.push([rooms[i - 1].id, rooms[i].id]); + } + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const wallSegments = buildOccupancyWalls(occupancy, roomType, roomIdGrid); + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors, + wallSegments, + deckConnectors: [], + airlocks: [], + exteriorOpenings: [] + }; +} + +function buildBspDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, formState.mode); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const roomTargets = allocateRoomCounts(hullRegions, formState.roomCount); + const roomRects = hullRegions.flatMap((region, index) => + splitRectExactCount(region, roomTargets[index], formState.splitBias, formState.minRoomSize, rng) + ); + const rooms = roomRects.map((rect, index) => ({ + id: `r${deckIndex}-${index}`, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + shape: "rect", + markers: [] + })); + + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const airlocks = buildHullDoorsFromMask(hullMask, rng); + const airlockDoors = airlocks.map((airlock) => ({ ...airlock, door: true, ds: 2 })); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, airlocks), + ...buildPartitionSegments(rooms, rng), + ...airlockDoors + ]; + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: [], + wallSegments, + deckConnectors: [], + airlocks: airlocks.map((segment, index) => ({ + id: `a${deckIndex}-${index}`, + x: segment.x1, + y: segment.y1, + edge: segment.x1 === segment.x2 ? "V" : "H", + label: `AIRLOCK ${index + 1}` + })) + }; +} + +function buildBspDeckWithSpine(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, formState.mode); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const spineWidth = Math.min(2, Math.max(1, formState.corridorWidth)); + const spineMidY = hull.y + Math.max(2, Math.floor(hull.height / 2) - Math.floor(spineWidth / 2)); + const spineRect = { x: hull.x, y: spineMidY, width: hull.width, height: spineWidth }; + const topBand = { x: hull.x, y: hull.y, width: hull.width, height: Math.max(0, spineRect.y - hull.y) }; + const botBand = { + x: hull.x, + y: spineRect.y + spineRect.height, + width: hull.width, + height: Math.max(0, (hull.y + hull.height) - (spineRect.y + spineRect.height)) + }; + const roomCount = Math.max(4, formState.roomCount); + const topCount = Math.ceil(roomCount / 2); + const botCount = Math.floor(roomCount / 2); + const rooms = []; + const roomRects = []; + if (topBand.height >= formState.minRoomSize) { + roomRects.push(...splitRectExactCount(topBand, topCount, formState.splitBias, formState.minRoomSize, rng)); + } + if (botBand.height >= formState.minRoomSize) { + roomRects.push(...splitRectExactCount(botBand, botCount, formState.splitBias, formState.minRoomSize, rng)); + } + roomRects.forEach((rect, index) => { + rooms.push({ id: `r${deckIndex}-${index}`, ...rect, shape: "rect", markers: [] }); + }); + + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const airlocks = buildHullDoorsFromMask(hullMask, rng); + const airlockDoors = airlocks.map((airlock) => ({ ...airlock, door: true, ds: DOOR_STATE_LOCKED })); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, airlocks), + ...buildPartitionSegments(rooms, rng), + ...buildSpineWalls(spineRect, rooms), + ...airlockDoors + ]; + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: [{ ...spineRect, waypoints: [] }], + wallSegments, + deckConnectors: [], + airlocks: airlocks.map((segment, index) => ({ + id: `a${deckIndex}-${index}`, + x: segment.x1, + y: segment.y1, + edge: segment.x1 === segment.x2 ? "V" : "H", + label: `AIRLOCK ${index + 1}` + })) + }; +} + +function buildCorridorDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, formState.mode); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + const rooms = []; + const attempts = formState.roomCount * 16; + + for (let i = 0; i < attempts && rooms.length < formState.roomCount; i += 1) { + const width = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.width - 2))); + const height = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.height - 2))); + const x = randomInt(rng, hull.x, Math.max(hull.x, hull.x + hull.width - width)); + const y = randomInt(rng, hull.y, Math.max(hull.y, hull.y + hull.height - height)); + const rect = { x, y, width, height }; + if (!rectFitsMask(rect, hullMask)) continue; + if (rooms.some((room) => rectsOverlap(expandRect(room, 1), rect))) continue; + rooms.push({ + id: `r${deckIndex}-${rooms.length}`, + ...rect, + shape: "rect", + markers: [] + }); + fillRectCells(occupancy, rect, true); + fillRectCells(roomType, rect, "room"); + fillRectCells(roomIdGrid, rect, rooms[rooms.length - 1].id); + } + + assignScifiRoomTypes(rooms, roomTables, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + + const corridors = []; + const connections = []; + for (let i = 1; i < rooms.length; i += 1) { + const current = roomCenter(rooms[i]); + const nearestRoom = nearestRoomForIndex(rooms, i); + const nearest = roomCenter(nearestRoom); + const width = corridorWidthForConnection(rooms[i], nearestRoom, formState); + const hall = carveCorridor(nearest, current, occupancy, roomType, width, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([rooms[i].id, nearestRoom.id]); + } + } + + const extraEdges = Math.max(1, Math.floor(rooms.length * (formState.loopFactor / 100) * 0.4)); + for (let i = 0; i < extraEdges; i += 1) { + const a = rooms[randomInt(rng, 0, rooms.length - 1)]; + const b = rooms[randomInt(rng, 0, rooms.length - 1)]; + if (!a || !b || a === b) continue; + const width = corridorWidthForConnection(a, b, formState); + const hall = carveCorridor(roomCenter(a), roomCenter(b), occupancy, roomType, width, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([a.id, b.id]); + } + } + + ensureCorridorConnectivity(rooms, connections, corridors, occupancy, roomType, formState.corridorWidth, hullMask, formState); + + const wallSegments = buildOccupancyWalls(occupancy, roomType, roomIdGrid); + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors, + wallSegments, + deckConnectors: [], + airlocks: [] + }; +} + +function buildCaveDeck(deckIndex, deckName, formState, roomTables, rng) { + const columns = formState.columns; + const rows = formState.rows; + const openMask = makeGrid(columns, rows, false); + + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < columns - 1; x += 1) { + openMask[y][x] = rng() > 0.45; + } + } + + for (let pass = 0; pass < 5; pass += 1) { + const next = makeGrid(columns, rows, false); + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < columns - 1; x += 1) { + const closed = countClosedNeighbours(openMask, x, y, columns, rows); + next[y][x] = closed < 5 ? true : (closed <= 3 ? true : openMask[y][x]); + } + } + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < columns; x += 1) { + openMask[y][x] = next[y][x]; + } + } + } + + const visited = makeGrid(columns, rows, false); + let bestRegion = []; + for (let y = 1; y < rows - 1; y += 1) { + for (let x = 1; x < columns - 1; x += 1) { + if (!openMask[y][x] || visited[y][x]) continue; + const region = floodFillOpenRegion(openMask, visited, x, y, columns, rows); + if (region.length > bestRegion.length) bestRegion = region; + } + } + + const finalMask = makeGrid(columns, rows, false); + for (const cell of bestRegion) { + finalMask[cell.y][cell.x] = true; + } + + const midY = Math.floor(rows / 2); + for (let x = 2; x < columns - 2; x += 1) { + finalMask[midY][x] = true; + } + + const chambers = buildCaveChambers(finalMask, formState.roomCount, formState.minRoomSize, rng, deckIndex); + const connections = buildCaveRoomConnections(chambers); + assignDungeonRoomTypes(chambers, roomTables, connections, finalMask, rng); + applyFactionTerritories(chambers, formState); + applyOrganicRoomShapes(chambers, { ...formState, cornerStyle: "square", circularSpecialRooms: true }, rng); + + return { + deckIndex, + deckName, + columns, + rows, + hull: { x: 1, y: 1, width: columns - 2, height: rows - 2 }, + hullRegions: [{ x: 1, y: 1, width: columns - 2, height: rows - 2 }], + hullMask: finalMask, + rooms: chambers, + corridors: [], + wallSegments: extractGridWallSegments(finalMask, columns, rows), + deckConnectors: [], + airlocks: [], + isCave: true + }; +} + +function buildDungeonDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, "dungeon"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + const rooms = []; + const attempts = formState.roomCount * 18; + + for (let i = 0; i < attempts && rooms.length < formState.roomCount; i += 1) { + const width = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.width - 2))); + const height = randomInt(rng, formState.minRoomSize, Math.max(formState.minRoomSize, Math.min(formState.maxRoomSize, hull.height - 2))); + const x = randomInt(rng, hull.x, Math.max(hull.x, hull.x + hull.width - width)); + const y = randomInt(rng, hull.y, Math.max(hull.y, hull.y + hull.height - height)); + const rect = { x, y, width, height }; + if (!rectFitsMask(rect, hullMask)) continue; + if (rooms.some((room) => rectsOverlap(expandRect(room, 1), rect))) continue; + const room = { + id: `r${deckIndex}-${rooms.length}`, + ...rect, + shape: "rect", + markers: [] + }; + rooms.push(room); + fillRectCells(occupancy, rect, true); + fillRectCells(roomType, rect, "room"); + fillRectCells(roomIdGrid, rect, room.id); + } + + const corridors = []; + const connections = []; + for (let i = 1; i < rooms.length; i += 1) { + const current = roomCenter(rooms[i]); + const nearestRoom = nearestRoomForIndex(rooms, i); + const hall = carveCorridor(roomCenter(nearestRoom), current, occupancy, roomType, formState.corridorWidth, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([rooms[i].id, nearestRoom.id]); + } + } + + const roomDoorTypes = assignDungeonRoomTypes(rooms, roomTables, connections, hullMask, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const extraEdges = Math.max(1, Math.floor(rooms.length * (formState.loopFactor / 100))); + for (let i = 0; i < extraEdges; i += 1) { + const a = rooms[randomInt(rng, 0, rooms.length - 1)]; + const b = rooms[randomInt(rng, 0, rooms.length - 1)]; + if (!a || !b || a === b) continue; + const width = corridorWidthForConnection(a, b, formState); + const hall = carveCorridor(roomCenter(a), roomCenter(b), occupancy, roomType, width, hullMask); + if (hall.length) { + corridors.push(...hall); + connections.push([a.id, b.id]); + } + } + + ensureCorridorConnectivity(rooms, connections, corridors, occupancy, roomType, formState.corridorWidth, hullMask, formState); + let wallSegments = buildOccupancyWalls(occupancy, roomType, roomIdGrid, { roomDoorTypes }); + wallSegments = injectSecretDoors(wallSegments, rng, formState.secretDoorDensity); + if (["bsp", "corridor"].includes(formState.mode)) { + wallSegments = applyBulkheadUpgrades(wallSegments, rooms); + } + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors, + wallSegments, + deckConnectors: [], + airlocks: [] + }; +} + +function buildBuildingDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, "building"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const roomTargets = allocateRoomCounts(hullRegions, formState.roomCount); + const roomRects = hullRegions.flatMap((region, index) => + splitRectExactCount(region, roomTargets[index], formState.splitBias, formState.minRoomSize, rng) + ); + const rooms = roomRects.map((rect, index) => ({ + id: `r${deckIndex}-${index}`, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + shape: "rect", + markers: [] + })); + + const roomDoorTypes = assignBuildingRoomTypes(rooms, roomTables, formState.buildingSubtype, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const exteriorOpenings = buildBuildingExteriorOpenings(hullMask, rng, formState.buildingSubtype); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, exteriorOpenings), + ...buildPartitionSegments(rooms, rng), + ...exteriorOpenings + ]; + const finishedWalls = injectHorrorDecay(applyDoorTypesToPartitions(wallSegments, rooms, roomDoorTypes), rooms, rng, formState.theme); + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: [], + wallSegments: finishedWalls, + deckConnectors: [], + airlocks: [], + exteriorOpenings + }; +} + +function buildBuildingCirculation(region, formState) { + const corridors = []; + const roomBands = []; + const corridorWidth = 1; + const lobbyDepth = clamp(Math.round(region.height * 0.18), 3, Math.max(3, region.height - formState.minRoomSize - 1)); + const lobbyWidth = clamp(Math.round(region.width * 0.28), 3, Math.max(3, region.width - (formState.minRoomSize * 2))); + + if (region.width >= region.height) { + const mainY = region.y + Math.max(formState.minRoomSize, Math.floor(region.height * 0.42)); + const lobbyX = region.x + Math.floor((region.width - lobbyWidth) / 2); + const lobbyY = region.y + region.height - lobbyDepth; + const mainHall = { x: region.x, y: clamp(mainY, region.y + 2, region.y + region.height - 3), width: region.width, height: corridorWidth, waypoints: [] }; + const lobby = { x: lobbyX, y: lobbyY, width: lobbyWidth, height: lobbyDepth, waypoints: [] }; + const connector = { + x: lobbyX + Math.floor(lobbyWidth / 2), + y: mainHall.y + mainHall.height, + width: corridorWidth, + height: Math.max(0, lobby.y - (mainHall.y + mainHall.height)), + waypoints: [] + }; + corridors.push(mainHall, lobby); + if (connector.height > 0) corridors.push(connector); + + roomBands.push({ x: region.x, y: region.y, width: region.width, height: mainHall.y - region.y }); + roomBands.push({ x: region.x, y: mainHall.y + mainHall.height, width: Math.max(0, lobby.x - region.x), height: region.y + region.height - (mainHall.y + mainHall.height) }); + roomBands.push({ + x: lobby.x + lobby.width, + y: mainHall.y + mainHall.height, + width: Math.max(0, region.x + region.width - (lobby.x + lobby.width)), + height: region.y + region.height - (mainHall.y + mainHall.height) + }); + } else { + const mainX = region.x + Math.floor(region.width / 2); + const crossY = region.y + Math.max(formState.minRoomSize, Math.floor(region.height * 0.35)); + const lobby = { + x: region.x + Math.floor((region.width - lobbyWidth) / 2), + y: region.y + region.height - lobbyDepth, + width: Math.min(lobbyWidth, region.width), + height: lobbyDepth, + waypoints: [] + }; + const mainHall = { x: clamp(mainX, region.x + 2, region.x + region.width - 3), y: region.y, width: corridorWidth, height: region.height, waypoints: [] }; + const crossHall = { x: region.x, y: clamp(crossY, region.y + 2, region.y + region.height - 3), width: region.width, height: corridorWidth, waypoints: [] }; + corridors.push(mainHall, crossHall, lobby); + + roomBands.push({ x: region.x, y: region.y, width: mainHall.x - region.x, height: crossHall.y - region.y }); + roomBands.push({ x: mainHall.x + mainHall.width, y: region.y, width: region.x + region.width - (mainHall.x + mainHall.width), height: crossHall.y - region.y }); + roomBands.push({ x: region.x, y: crossHall.y + crossHall.height, width: lobby.x - region.x, height: region.y + region.height - (crossHall.y + crossHall.height) }); + roomBands.push({ + x: lobby.x + lobby.width, + y: crossHall.y + crossHall.height, + width: region.x + region.width - (lobby.x + lobby.width), + height: region.y + region.height - (crossHall.y + crossHall.height) + }); + } + + return { + corridors: corridors.filter((corridor) => corridor.width > 0 && corridor.height > 0), + roomBands: roomBands.filter((band) => band.width > 0 && band.height > 0) + }; +} + +function buildBuildingDeckWithHallway(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = buildHullRegions(formState.columns, formState.rows, formState.hullShape, "building"); + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const hallways = []; + const roomRects = []; + + for (const region of hullRegions) { + const circulation = buildBuildingCirculation(region, formState); + hallways.push(...circulation.corridors); + const candidateBands = circulation.roomBands.filter((band) => band.width >= formState.minRoomSize && band.height >= formState.minRoomSize); + const regionRoomCount = Math.max(2, Math.round(formState.roomCount * ((region.width * region.height) / Math.max(1, hull.width * hull.height)))); + const counts = allocateRoomCounts(candidateBands, regionRoomCount); + candidateBands.forEach((band, index) => { + roomRects.push(...splitRectExactCount(band, counts[index], formState.splitBias, formState.minRoomSize, rng)); + }); + } + + const rooms = roomRects.map((rect, index) => ({ id: `r${deckIndex}-${index}`, ...rect, shape: "rect", markers: [] })); + const roomDoorTypes = assignBuildingRoomTypes(rooms, roomTables, formState.buildingSubtype, rng); + applyFactionTerritories(rooms, formState); + applyOrganicRoomShapes(rooms, formState, rng); + const exteriorOpenings = buildBuildingExteriorOpenings(hullMask, rng, formState.buildingSubtype); + const hallwayWalls = hallways.flatMap((hallRect) => buildHallwayWalls(hallRect, rooms)); + const wallSegments = [ + ...buildHullWallsWithDoorGaps(hullMask, exteriorOpenings), + ...buildPartitionSegments(rooms, rng), + ...hallwayWalls, + ...exteriorOpenings + ]; + const finishedWalls = dedupeSegments(injectHorrorDecay(applyDoorTypesToPartitions(wallSegments, rooms, roomDoorTypes), rooms, rng, formState.theme), true); + + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms, + corridors: hallways, + wallSegments: finishedWalls, + deckConnectors: [], + airlocks: [], + exteriorOpenings + }; +} + +function buildWildernessDeck(deckIndex, deckName, formState, roomTables, rng) { + const hullRegions = [{ x: 1, y: 1, width: formState.columns - 2, height: formState.rows - 2 }]; + const hullMask = buildHullMask(formState.columns, formState.rows, hullRegions); + const hull = boundingRectFromRegions(hullRegions); + const clearings = []; + const targetAreas = Math.max(5, Math.min(18, formState.roomCount)); + const attempts = targetAreas * 24; + + for (let i = 0; i < attempts && clearings.length < targetAreas; i += 1) { + const width = randomInt(rng, Math.max(3, formState.minRoomSize), Math.max(4, formState.maxRoomSize + 2)); + const height = randomInt(rng, Math.max(3, formState.minRoomSize), Math.max(4, formState.maxRoomSize + 2)); + const x = randomInt(rng, hull.x, Math.max(hull.x, hull.x + hull.width - width)); + const y = randomInt(rng, hull.y, Math.max(hull.y, hull.y + hull.height - height)); + const rect = { x, y, width, height }; + if (clearings.some((room) => rectsOverlap(expandRect(room, 2), rect))) continue; + clearings.push({ + id: `w${deckIndex}-${clearings.length}`, + ...rect, + shape: rng() > 0.55 ? "circle" : "rect", + markers: [] + }); + } + + assignWildernessAreaTypes(clearings, roomTables, formState.theme, rng); + applyFactionTerritories(clearings, formState); + const occupancy = makeGrid(formState.columns, formState.rows, false); + const roomType = makeGrid(formState.columns, formState.rows, ""); + const roomIdGrid = makeGrid(formState.columns, formState.rows, ""); + for (const room of clearings) { + fillRectCells(occupancy, room, true); + fillRectCells(roomType, room, "room"); + fillRectCells(roomIdGrid, room, room.id); + } + + const trails = []; + const connections = []; + for (let i = 1; i < clearings.length; i += 1) { + const from = roomCenter(clearings[i]); + const to = roomCenter(nearestRoomForIndex(clearings, i)); + const trail = carveCorridor(to, from, occupancy, roomType, 1, hullMask); + if (trail.length) { + trails.push(...trail); + connections.push([clearings[i].id, nearestRoomForIndex(clearings, i).id]); + } + } + ensureCorridorConnectivity(clearings, connections, trails, occupancy, roomType, 1, hullMask, formState); + + const wilderness = buildWildernessFeatures(clearings, formState, rng); + return { + deckIndex, + deckName, + columns: formState.columns, + rows: formState.rows, + hull, + hullRegions, + hullMask, + rooms: clearings, + corridors: trails, + wallSegments: wilderness.wallSegments, + deckConnectors: [], + airlocks: [], + wilderness + }; +} + +function applyOrganicRoomShapes(rooms, formState, rng) { + if (!rooms?.length) return rooms; + + if (formState.circularSpecialRooms) { + applyCircularRoomShapes(rooms); + } + + if (formState.cornerStyle === "square") return rooms; + + for (const room of rooms) { + if (room.shape === "circle" || room.shape === "cave") continue; + if (formState.cornerStyle === "chamfer") { + const points = buildChamferedPoints(room, formState.chamferSize); + if (points) { + room.shape = "polygon"; + room.points = points; + } + continue; + } + if (formState.cornerStyle === "perturb") { + const points = buildPerturbedPoints(room, rng); + if (points) { + room.shape = "polygon"; + room.points = points; + } + } + } + return rooms; +} + +function applyCircularRoomShapes(rooms) { + for (const room of rooms) { + if (!CIRCULAR_ROOM_TYPES.has(room.type)) continue; + if (room.width < 3 || room.height < 3) continue; + const size = Math.min(room.width, room.height); + const offsetX = (room.width - size) / 2; + const offsetY = (room.height - size) / 2; + room.shape = "circle"; + room.circleX = room.x + offsetX; + room.circleY = room.y + offsetY; + room.circleR = size / 2; + } +} + +function buildChamferedPoints(room, chamferSize = 1) { + if (room.width < chamferSize * 2 + 2 || room.height < chamferSize * 2 + 2) return null; + const c = chamferSize; + const { x, y, width: w, height: h } = room; + return [ + { x: x + c, y }, + { x: x + w - c, y }, + { x: x + w, y: y + c }, + { x: x + w, y: y + h - c }, + { x: x + w - c, y: y + h }, + { x: x + c, y: y + h }, + { x, y: y + h - c }, + { x, y: y + c } + ]; +} + +function buildPerturbedPoints(room, rng, amount = 0.4) { + if (room.width < 3 || room.height < 3) return null; + const jitter = () => round((rng() - 0.5) * 2 * amount, 2); + return [ + { x: room.x + jitter(), y: room.y + jitter() }, + { x: room.x + room.width + jitter(), y: room.y + jitter() }, + { x: room.x + room.width + jitter(), y: room.y + room.height + jitter() }, + { x: room.x + jitter(), y: room.y + room.height + jitter() } + ]; +} + +function countClosedNeighbours(openMask, x, y, columns, rows) { + let closed = 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (!dx && !dy) continue; + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || ny < 0 || nx >= columns || ny >= rows || !openMask[ny]?.[nx]) { + closed += 1; + } + } + } + return closed; +} + +function floodFillOpenRegion(openMask, visited, startX, startY, columns, rows) { + const cells = []; + const stack = [{ x: startX, y: startY }]; + while (stack.length) { + const { x, y } = stack.pop(); + if (x < 0 || y < 0 || x >= columns || y >= rows) continue; + if (visited[y][x] || !openMask[y][x]) continue; + visited[y][x] = true; + cells.push({ x, y }); + stack.push({ x: x + 1, y }, { x: x - 1, y }, { x, y: y + 1 }, { x, y: y - 1 }); + } + return cells; +} + +function buildCaveChambers(openMask, targetCount, minRoomSize, rng, deckIndex) { + const openCells = []; + for (let y = 1; y < openMask.length - 1; y += 1) { + for (let x = 1; x < (openMask[y]?.length ?? 0) - 1; x += 1) { + if (openMask[y][x]) openCells.push({ x, y }); + } + } + if (!openCells.length) return []; + + const seeds = pickSeparatedOpenCells(openCells, Math.min(targetCount, Math.max(4, Math.floor(openCells.length / 18))), Math.max(3, minRoomSize * 2), rng); + if (!seeds.length) seeds.push(openCells[Math.floor(openCells.length / 2)]); + + const chamberCells = Array.from({ length: seeds.length }, () => []); + const chamberIndexByCell = new Map(); + for (const cell of openCells) { + let bestIndex = 0; + let bestDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < seeds.length; index += 1) { + const seed = seeds[index]; + const distance = Math.abs(cell.x - seed.x) + Math.abs(cell.y - seed.y); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + } + chamberCells[bestIndex].push(cell); + chamberIndexByCell.set(`${cell.x},${cell.y}`, bestIndex); + } + + return chamberCells + .filter((cells) => cells.length >= Math.max(6, minRoomSize * minRoomSize)) + .map((cells, index) => { + const xs = cells.map((cell) => cell.x); + const ys = cells.map((cell) => cell.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + return { + id: `c${deckIndex}-${index}`, + x: minX, + y: minY, + width: maxX - minX + 1, + height: maxY - minY + 1, + shape: "cave", + cells, + markers: [], + chamberIndexByCell + }; + }); +} + +function pickSeparatedOpenCells(cells, targetCount, minDistance, rng) { + const pool = [...cells]; + const chosen = []; + while (pool.length && chosen.length < targetCount) { + const pick = pool.splice(randomInt(rng, 0, pool.length - 1), 1)[0]; + if (chosen.every((cell) => Math.abs(cell.x - pick.x) + Math.abs(cell.y - pick.y) >= minDistance)) { + chosen.push(pick); + } + } + return chosen; +} + +function buildCaveRoomConnections(rooms) { + const cellToRoom = new Map(); + for (const room of rooms) { + for (const cell of room.cells ?? []) { + cellToRoom.set(`${cell.x},${cell.y}`, room.id); + } + } + const connections = new Set(); + for (const room of rooms) { + for (const cell of room.cells ?? []) { + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const neighbor = cellToRoom.get(`${cell.x + dx},${cell.y + dy}`); + if (!neighbor || neighbor === room.id) continue; + connections.add([room.id, neighbor].sort().join("|")); + } + } + } + return Array.from(connections, (key) => key.split("|")); +} + +function extractGridWallSegments(openMask, columns, rows) { + const segments = []; + for (let y = 0; y < rows; y += 1) { + for (let x = 0; x < columns; x += 1) { + if (!openMask[y]?.[x]) continue; + if (!openMask[y - 1]?.[x]) segments.push({ x1: x, y1: y, x2: x + 1, y2: y }); + if (!openMask[y + 1]?.[x]) segments.push({ x1: x, y1: y + 1, x2: x + 1, y2: y + 1 }); + if (!openMask[y]?.[x - 1]) segments.push({ x1: x, y1: y, x2: x, y2: y + 1 }); + if (!openMask[y]?.[x + 1]) segments.push({ x1: x + 1, y1: y, x2: x + 1, y2: y + 1 }); + } + } + return mergeCollinearSegments(dedupeSegments(segments)); +} + +function mergeCollinearSegments(segments) { + const horiz = segments.filter((segment) => segment.y1 === segment.y2).sort((a, b) => a.y1 - b.y1 || a.x1 - b.x1); + const vert = segments.filter((segment) => segment.x1 === segment.x2).sort((a, b) => a.x1 - b.x1 || a.y1 - b.y1); + return [...mergeRuns(horiz, true), ...mergeRuns(vert, false)]; +} + +function mergeRuns(segments, horizontal) { + const out = []; + for (const segment of segments) { + const last = out[out.length - 1]; + if (last && horizontal && last.y1 === segment.y1 && last.x2 === segment.x1 && !last.door && !segment.door) { + last.x2 = segment.x2; + continue; + } + if (last && !horizontal && last.x1 === segment.x1 && last.y2 === segment.y1 && !last.door && !segment.door) { + last.y2 = segment.y2; + continue; + } + out.push({ ...segment }); + } + return out; +} + +function assignScifiRoomTypes(rooms, roomTables, rng) { + const systemId = game.system?.id ?? ""; + const overrides = ["sla-mothership", "sla-industries-brp"].includes(systemId) ? roomTables.sla_overrides ?? {} : {}; + const usedKeys = new Set(); + const counters = new Map(); + + const bowSorted = [...rooms].sort((a, b) => a.x - b.x || a.y - b.y); + const sternSorted = [...rooms].sort((a, b) => (b.x + b.width) - (a.x + a.width) || a.y - b.y); + const anySorted = [...rooms].sort((a, b) => (b.width * b.height) - (a.width * a.height)); + + reserveRoom("bridge", bowSorted); + reserveRoom("engineering", sternSorted); + reserveRoom("medical", anySorted); + + const weightedPool = roomTables.rooms.filter((entry) => !entry.unique && entry.key !== "void"); + + for (const room of rooms) { + if (room.type) continue; + const choice = weightedChoice(weightedPool, rng); + room.type = choice.key; + counters.set(choice.key, (counters.get(choice.key) ?? 0) + 1); + const labelBase = overrides[choice.key]?.label ?? choice.label; + room.label = choice.numbered ? `${labelBase} ${counters.get(choice.key)}` : labelBase; + } + + function reserveRoom(key, orderedRooms) { + const entry = roomTables.rooms.find((room) => room.key === key); + if (!entry || usedKeys.has(key)) return; + let target = orderedRooms.find((room) => room.width >= entry.minSize[0] && room.height >= entry.minSize[1] && !room.type); + if (!target) { + target = orderedRooms.find((room) => !room.type); + } + if (!target) return; + usedKeys.add(key); + target.type = key; + const label = overrides[key]?.label ?? entry.label; + target.label = label; + } +} + +function assignDungeonRoomTypes(rooms, roomTables, connections, hullMask, rng) { + const entries = roomTables.dungeon?.rooms ?? []; + const counters = new Map(); + const roomDoorTypes = {}; + const usedKeys = new Set(); + if (!rooms.length || !entries.length) return roomDoorTypes; + + const byAreaDesc = [...rooms].sort((a, b) => (b.width * b.height) - (a.width * a.height)); + const byAreaAsc = [...rooms].sort((a, b) => (a.width * a.height) - (b.width * b.height)); + const edgeRooms = [...rooms].sort((a, b) => edgeDistance(a, hullMask) - edgeDistance(b, hullMask)); + const entranceRoom = reserveRoomEntry(entries.find((entry) => entry.key === "entrance"), edgeRooms, "entrance"); + const distances = computeRoomGraphDistances(rooms, connections, entranceRoom?.id); + const byDistanceDesc = [...rooms].sort((a, b) => (distances.get(b.id) ?? -1) - (distances.get(a.id) ?? -1)); + const byDistanceAsc = [...rooms].sort((a, b) => (distances.get(a.id) ?? 999) - (distances.get(b.id) ?? 999)); + const farLargeRooms = [...rooms] + .sort((a, b) => ((distances.get(b.id) ?? -1) * 100 + (b.width * b.height)) - ((distances.get(a.id) ?? -1) * 100 + (a.width * a.height))); + + const bossRoom = reserveRoomEntry(entries.find((entry) => entry.key === "boss-chamber"), farLargeRooms, "boss-chamber"); + const throneRoom = reserveRoomEntry(entries.find((entry) => entry.key === "throne-room"), sortRoomsByProximity(rooms, bossRoom, distances, "support"), "throne-room"); + const vaultRoom = reserveRoomEntry(entries.find((entry) => entry.key === "vault"), sortRoomsByProximity(rooms, bossRoom ?? throneRoom, distances, "far"), "vault"); + const ritualRoom = reserveRoomEntry(entries.find((entry) => entry.key === "ritual-room"), sortRoomsByProximity(rooms, bossRoom ?? throneRoom, distances, "support"), "ritual-room"); + reserveRoomEntry(entries.find((entry) => entry.key === "shrine"), sortRoomsByDistanceBand(rooms, distances, 0.45, 0.75), "shrine"); + reserveRoomEntry(entries.find((entry) => entry.key === "library"), byAreaDesc, "library"); + reserveRoomEntry(entries.find((entry) => entry.key === "laboratory"), byAreaDesc, "laboratory"); + reserveRoomEntry(entries.find((entry) => entry.key === "alchemy"), sortRoomsByDistanceBand(rooms, distances, 0.35, 0.8), "alchemy"); + reserveRoomEntry(entries.find((entry) => entry.key === "forge"), sortRoomsByDistanceBand(rooms, distances, 0.25, 0.7), "forge"); + reserveRoomEntry(entries.find((entry) => entry.key === "trophy-room"), sortRoomsByProximity(rooms, bossRoom ?? throneRoom, distances, "support"), "trophy-room"); + reserveRoomEntry(entries.find((entry) => entry.key === "cistern"), byAreaDesc, "cistern"); + reserveRoomEntry(entries.find((entry) => entry.key === "prison"), byDistanceDesc, "prison"); + reserveRoomEntry(entries.find((entry) => entry.key === "armoury"), byDistanceAsc, "armoury"); + reserveRoomEntry(entries.find((entry) => entry.key === "guardroom"), byDistanceAsc, "guardroom"); + reserveRoomEntry(entries.find((entry) => entry.key === "antechamber"), sortRoomsByProximity(rooms, entranceRoom, distances, "support"), "antechamber"); + reserveRoomEntry(entries.find((entry) => entry.key === "torture"), byDistanceDesc, "torture"); + reserveRoomEntry(entries.find((entry) => entry.key === "collapsed"), byAreaAsc, "collapsed"); + reserveRoomEntry(entries.find((entry) => entry.key === "trap-room"), byAreaAsc, "trap-room"); + + const weightedPool = entries.filter((entry) => !entry.unique && entry.key !== "void"); + for (const room of rooms) { + if (room.type) continue; + const choice = weightedChoice(weightedPool, rng); + setRoomType(room, choice); + } + + return roomDoorTypes; + + function reserveRoomEntry(entry, orderedRooms, labelKey) { + if (!entry || usedKeys.has(labelKey)) return null; + let target = orderedRooms.find((room) => !room.type && roomMeetsEntry(room, entry)); + if (!target) target = orderedRooms.find((room) => !room.type); + if (!target) return null; + setRoomType(target, entry); + usedKeys.add(labelKey); + return target; + } + + function setRoomType(room, entry) { + room.type = entry.key; + counters.set(entry.key, (counters.get(entry.key) ?? 0) + 1); + room.label = entry.numbered ? `${entry.label} ${counters.get(entry.key)}` : entry.label; + roomDoorTypes[room.id] = entry.doorType ?? "normal"; + } +} + +function sortRoomsByProximity(rooms, anchorRoom, distances, mode = "support") { + if (!anchorRoom) return [...rooms]; + const anchorCenter = roomCenter(anchorRoom); + return [...rooms].sort((a, b) => { + const distA = Math.abs(roomCenter(a).x - anchorCenter.x) + Math.abs(roomCenter(a).y - anchorCenter.y); + const distB = Math.abs(roomCenter(b).x - anchorCenter.x) + Math.abs(roomCenter(b).y - anchorCenter.y); + const scoreA = mode === "far" ? ((distances.get(a.id) ?? 0) * 20 - distA) : ((distances.get(a.id) ?? 0) * 5 - distA); + const scoreB = mode === "far" ? ((distances.get(b.id) ?? 0) * 20 - distB) : ((distances.get(b.id) ?? 0) * 5 - distB); + return scoreB - scoreA; + }); +} + +function sortRoomsByDistanceBand(rooms, distances, lowRatio = 0.3, highRatio = 0.8) { + const maxDistance = Math.max(1, ...Array.from(distances.values(), (value) => value ?? 0)); + const low = maxDistance * lowRatio; + const high = maxDistance * highRatio; + return [...rooms].sort((a, b) => { + const da = distances.get(a.id) ?? 0; + const db = distances.get(b.id) ?? 0; + const bandA = da >= low && da <= high ? 1 : 0; + const bandB = db >= low && db <= high ? 1 : 0; + if (bandA !== bandB) return bandB - bandA; + return db - da; + }); +} + +function assignBuildingRoomTypes(rooms, roomTables, subtype, rng) { + const entries = roomTables.building?.subtypes?.[subtype]?.rooms ?? []; + const counters = new Map(); + const roomDoorTypes = {}; + if (!rooms.length || !entries.length) return roomDoorTypes; + + const ordered = [...rooms].sort((a, b) => a.x - b.x || a.y - b.y); + reserve(entries.find((entry) => entry.placement === "entrance"), ordered); + reserve(entries.find((entry) => entry.key === "lobby" || entry.key === "foyer" || entry.key === "loading" || entry.key === "common-room"), ordered); + + const weightedPool = entries.filter((entry) => !entry.unique && entry.key !== "void"); + for (const room of rooms) { + if (room.type) continue; + const choice = weightedChoice(weightedPool, rng); + assign(choice, room); + } + + return roomDoorTypes; + + function reserve(entry, orderedRooms) { + if (!entry) return; + let target = orderedRooms.find((room) => !room.type && roomMeetsEntry(room, entry)); + if (!target) target = orderedRooms.find((room) => !room.type); + if (!target) return; + assign(entry, target); + } + + function assign(entry, room) { + room.type = entry.key; + counters.set(entry.key, (counters.get(entry.key) ?? 0) + 1); + room.label = entry.numbered ? `${entry.label} ${counters.get(entry.key)}` : entry.label; + roomDoorTypes[room.id] = entry.doorType ?? "normal"; + } +} + +function applyFactionTerritories(rooms, formState) { + const preset = FACTION_PRESETS[formState.factionPreset]; + if (!preset?.length) return; + for (const room of rooms) { + const match = preset.find((faction) => faction.roomTypes.includes(room.type)); + const fallback = preset[hashSeed(room.id || room.label || room.type || "room") % preset.length]; + room.faction = match?.name ?? fallback.name; + room.factionColor = match?.color ?? fallback.color; + } +} + +function assignWildernessAreaTypes(clearings, _roomTables, theme, rng) { + const themedPools = { + forest: [ + { key: "camp", label: "CAMP", weight: 2 }, + { key: "ruins", label: "RUINS", weight: 1 }, + { key: "glade", label: "GLADE", weight: 3 }, + { key: "ambush", label: "AMBUSH SITE", weight: 1 }, + { key: "water", label: "WATER EDGE", weight: 1 } + ], + jungle: [ + { key: "nest", label: "NEST", weight: 2 }, + { key: "glade", label: "JUNGLE GLADE", weight: 2 }, + { key: "ruins", label: "OVERGROWN RUINS", weight: 1 }, + { key: "ambush", label: "AMBUSH SITE", weight: 2 }, + { key: "water", label: "MUDDY POOL", weight: 1 } + ], + plains: [ + { key: "camp", label: "CAMP", weight: 2 }, + { key: "ridge", label: "RIDGE", weight: 2 }, + { key: "crossing", label: "TRAIL CROSSING", weight: 2 }, + { key: "ambush", label: "KILL ZONE", weight: 1 } + ], + swamp: [ + { key: "water", label: "MARSH POOL", weight: 3 }, + { key: "camp", label: "DRY CAMP", weight: 1 }, + { key: "ambush", label: "SUNKEN AMBUSH", weight: 2 }, + { key: "ruins", label: "SUNKEN RUINS", weight: 1 } + ], + tundra: [ + { key: "camp", label: "FROST CAMP", weight: 2 }, + { key: "ridge", label: "ICE RIDGE", weight: 2 }, + { key: "crossing", label: "SNOW TRACKS", weight: 2 }, + { key: "ambush", label: "WHITEOUT SITE", weight: 1 } + ], + badlands: [ + { key: "ridge", label: "RIDGE", weight: 3 }, + { key: "ruins", label: "SCRAP RUINS", weight: 2 }, + { key: "camp", label: "RAIDER CAMP", weight: 2 }, + { key: "ambush", label: "AMBUSH SITE", weight: 2 } + ] + }; + const pool = themedPools[theme] ?? themedPools.forest; + const counters = new Map(); + for (const area of clearings) { + const choice = weightedChoice(pool, rng); + area.type = choice.key; + counters.set(choice.key, (counters.get(choice.key) ?? 0) + 1); + area.label = counters.get(choice.key) > 1 ? `${choice.label} ${counters.get(choice.key)}` : choice.label; + } +} + +function buildWildernessFeatures(clearings, formState, rng) { + const treeClusters = []; + const waterFeatures = []; + const encounterZones = []; + const wallSegments = []; + const ridgelines = []; + const ravines = []; + + for (const area of clearings) { + const center = roomCenter(area); + const clusterCount = Math.max(2, Math.round((area.width * area.height) / 8)); + if (!["plains", "tundra"].includes(formState.theme)) { + treeClusters.push({ + x: center.x, + y: center.y, + radius: Math.max(1.2, Math.min(area.width, area.height) * 0.4), + count: clusterCount + }); + } + if (area.type === "water" || (formState.theme === "swamp" && rng() > 0.55)) { + waterFeatures.push({ + x: center.x, + y: center.y, + width: Math.max(2, Math.round(area.width * 0.7)), + height: Math.max(2, Math.round(area.height * 0.45)) + }); + } + if (["ambush", "nest", "ruins", "camp"].includes(area.type) && rng() > 0.35) { + encounterZones.push({ + roomId: area.id, + x: center.x, + y: center.y, + radius: Math.max(1.5, Math.min(area.width, area.height) * 0.35), + label: area.label + }); + } + if (formState.hullShape === "camp" && area.type === "camp") { + wallSegments.push(...rectangleSegments(area.x, area.y, area.width, area.height)); + } + + const elevationChance = ["ridge", "crossing", "ambush"].includes(area.type) ? 0.75 : 0.28; + if (rng() < elevationChance) { + const feature = buildElevationFeature(area, rng, ["badlands", "tundra"].includes(formState.theme) ? "ridge" : (rng() > 0.52 ? "ridge" : "ravine")); + if (feature) { + if (feature.kind === "ridge") ridgelines.push(feature); + else ravines.push(feature); + wallSegments.push(...feature.segments); + } + } + } + + return { treeClusters, waterFeatures, encounterZones, wallSegments, ridgelines, ravines }; +} + +function buildElevationFeature(area, rng, preferredKind = "ridge") { + const horizontal = area.width >= area.height ? rng() > 0.35 : rng() > 0.65; + const marginX = Math.max(1, Math.floor(area.width * 0.15)); + const marginY = Math.max(1, Math.floor(area.height * 0.15)); + const x1 = area.x + marginX; + const x2 = area.x + area.width - marginX; + const y1 = area.y + marginY; + const y2 = area.y + area.height - marginY; + if (horizontal) { + const y = clampInt(area.y + Math.floor(area.height / 2), area.y + 1, area.y + area.height - 1, area.y + 1); + return { + kind: preferredKind, + x1, + y1: y, + x2, + y2: y, + segments: segmentedBarrier(x1, y, x2, y, preferredKind) + }; + } + const x = clampInt(area.x + Math.floor(area.width / 2), area.x + 1, area.x + area.width - 1, area.x + 1); + return { + kind: preferredKind, + x1: x, + y1, + x2: x, + y2, + segments: segmentedBarrier(x, y1, x, y2, preferredKind) + }; +} + +function segmentedBarrier(x1, y1, x2, y2, terrain = "ridge") { + const out = []; + if (y1 === y2) { + const start = Math.min(x1, x2); + const end = Math.max(x1, x2); + for (let x = start; x < end; x += 1) { + out.push({ + x1: x, + y1, + x2: x + 1, + y2, + movementOnly: true, + terrain + }); + } + return out; + } + + const start = Math.min(y1, y2); + const end = Math.max(y1, y2); + for (let y = start; y < end; y += 1) { + out.push({ + x1, + y1: y, + x2, + y2: y + 1, + movementOnly: true, + terrain + }); + } + return out; +} + +function roomMeetsEntry(room, entry) { + const [minW, minH] = entry.minSize ?? [1, 1]; + return room.width >= minW && room.height >= minH; +} + +function edgeDistance(room, hullMask) { + let best = Number.POSITIVE_INFINITY; + for (let y = room.y; y < room.y + room.height; y += 1) { + for (let x = room.x; x < room.x + room.width; x += 1) { + const distance = Number(!hullMask[y - 1]?.[x]) + Number(!hullMask[y + 1]?.[x]) + Number(!hullMask[y]?.[x - 1]) + Number(!hullMask[y]?.[x + 1]); + if (distance > 0) return 0; + best = Math.min(best, x + y); + } + } + return best; +} + +function computeRoomGraphDistances(rooms, connections, sourceId) { + const adjacency = new Map(rooms.map((room) => [room.id, new Set()])); + for (const [a, b] of connections) { + adjacency.get(a)?.add(b); + adjacency.get(b)?.add(a); + } + const distances = new Map(); + if (!sourceId) return distances; + const queue = [[sourceId, 0]]; + distances.set(sourceId, 0); + while (queue.length) { + const [current, distance] = queue.shift(); + for (const next of adjacency.get(current) ?? []) { + if (distances.has(next)) continue; + distances.set(next, distance + 1); + queue.push([next, distance + 1]); + } + } + return distances; +} + +function injectSecretDoors(wallSegments, rng, densityKey = "minimal") { + const chance = SECRET_DOOR_CHANCE[densityKey] ?? 0.01; + if (chance <= 0) return wallSegments; + return wallSegments.map((segment) => { + if (segment.door || segment.isWindow) return segment; + const length = Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + if (length !== 1) return segment; + if (rng() >= chance) return segment; + return { ...segment, door: DOOR_SECRET, ds: DOOR_STATE_LOCKED }; + }); +} + +function injectHorrorDecay(wallSegments, _rooms, rng, theme) { + if (!["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(theme)) return wallSegments; + return wallSegments.map((segment) => { + if (segment.door || segment.isWindow) return segment; + const length = Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + if (length !== 1) return segment; + const roll = rng(); + if (roll < 0.12) return { ...segment, door: DOOR_BASIC, ds: DOOR_STATE_LOCKED }; + if (roll < 0.18) return { ...segment, door: DOOR_BASIC, ds: DOOR_STATE_OPEN }; + return segment; + }); +} + +function applyBulkheadUpgrades(wallSegments, rooms) { + if (!rooms?.length) return wallSegments; + return wallSegments.map((seg) => { + if (!seg.door) return seg; + const touchedRooms = rooms.filter((r) => segmentTouchesRoom(seg, r)); + const types = touchedRooms.map((r) => r.type); + const isBulkhead = BULKHEAD_PAIRS.some(([a, b]) => + (types.includes(a) && types.includes(b)) || + types.includes("reactor") || types.includes("weapon-bay") + ); + if (!isBulkhead) return seg; + return { ...seg, ds: DOOR_STATE_LOCKED, isBulkhead: true }; + }); +} + +function buildBulkheadSvg(wallSegments, gridSize, palette) { + const bulkheads = wallSegments.filter((s) => s.isBulkhead); + if (!bulkheads.length) return ""; + return bulkheads.map((seg) => { + const x1 = seg.x1 * gridSize, y1 = seg.y1 * gridSize; + const x2 = seg.x2 * gridSize, y2 = seg.y2 * gridSize; + const isH = y1 === y2; + const off = 3; + return `` + + `` + + ``; + }).join(""); +} + +function nearestRoomEdgeCell(room, target) { + const tc = roomCenter(target); + const edgeCells = [ + { x: room.x, y: Math.floor(room.y + room.height / 2) }, + { x: room.x + room.width - 1, y: Math.floor(room.y + room.height / 2) }, + { x: Math.floor(room.x + room.width / 2), y: room.y }, + { x: Math.floor(room.x + room.width / 2), y: room.y + room.height - 1 } + ]; + return edgeCells.sort((a, b) => + (Math.abs(a.x - tc.x) + Math.abs(a.y - tc.y)) - (Math.abs(b.x - tc.x) + Math.abs(b.y - tc.y)) + )[0]; +} + +function traceMaintenanceShaft(src, dest) { + const srcEdge = nearestRoomEdgeCell(src, dest); + const dstEdge = nearestRoomEdgeCell(dest, src); + const segments = []; + const hatchPoints = []; + + // L-shaped route: horizontal then vertical + const midX = srcEdge.x; + const x1 = Math.min(srcEdge.x, midX), x2 = Math.max(srcEdge.x, midX); + for (let x = x1; x <= x2; x++) segments.push({ x, y: srcEdge.y, width: 1, height: 1 }); + const y1 = Math.min(srcEdge.y, dstEdge.y), y2 = Math.max(srcEdge.y, dstEdge.y); + for (let y = y1; y <= y2; y++) segments.push({ x: midX, y, width: 1, height: 1 }); + + hatchPoints.push({ x: srcEdge.x, y: srcEdge.y, roomId: src.id, label: "ACCESS HATCH" }); + hatchPoints.push({ x: dstEdge.x, y: dstEdge.y, roomId: dest.id, label: "ACCESS HATCH" }); + + return { id: `shaft-${src.id}-${dest.id}`, segments, hatchPoints, fromRoom: src.id, toRoom: dest.id }; +} + +function buildMaintenanceShafts(deck, formState, rng) { + if (!["bsp", "corridor"].includes(formState.mode)) return []; + if (formState.maintenanceShaftDensity === "none") return []; + + const densityChance = { sparse: 0.25, normal: 0.5, dense: 0.8 }[formState.maintenanceShaftDensity ?? "sparse"] ?? 0.3; + const sourceRooms = deck.rooms.filter((r) => MAINTENANCE_SHAFT_SOURCES.has(r.type)); + const destRooms = deck.rooms.filter((r) => MAINTENANCE_SHAFT_DESTINATIONS.has(r.type)); + if (!sourceRooms.length || !destRooms.length) return []; + + const shafts = []; + const usedPairs = new Set(); + + for (const src of sourceRooms) { + if (rng() > densityChance) continue; + const candidates = destRooms + .filter((d) => d.id !== src.id) + .sort((a, b) => { + const ca = roomCenter(src); + const da = Math.abs(roomCenter(a).x - ca.x) + Math.abs(roomCenter(a).y - ca.y); + const db = Math.abs(roomCenter(b).x - ca.x) + Math.abs(roomCenter(b).y - ca.y); + return da - db; + }); + const dest = candidates[0]; + if (!dest) continue; + + const pairKey = [src.id, dest.id].sort().join("|"); + if (usedPairs.has(pairKey)) continue; + usedPairs.add(pairKey); + + const shaft = traceMaintenanceShaft(src, dest); + if (shaft) shafts.push(shaft); + } + + return shafts; +} + +function buildMaintenanceShaftDrawings(deck, formState) { + const palette = paletteForFormState(formState); + const shaftFill = palette.maintenanceFill ?? "#1a1a2a"; + const drawings = []; + for (const shaft of deck.maintenanceShafts ?? []) { + for (const seg of shaft.segments) { + drawings.push({ + x: seg.x * formState.gridSize, + y: seg.y * formState.gridSize, + z: 5, + hidden: true, + locked: true, + fillType: 1, + fillColor: shaftFill, + fillAlpha: 0.95, + strokeWidth: 1, + strokeColor: palette.line, + strokeAlpha: 0.5, + shape: { type: "r", width: formState.gridSize, height: formState.gridSize }, + flags: { [MODULE_ID]: { generatedDrawing: true, isMaintenanceShaft: true } } + }); + } + } + return drawings; +} + +function buildCellPerimeterWalls(cx, cy) { + // Returns 4 wall segments (in cell coords) forming a 1×1 cell perimeter + return [ + { x1: cx, y1: cy, x2: cx+1, y2: cy }, + { x1: cx, y1: cy+1, x2: cx+1, y2: cy+1 }, + { x1: cx, y1: cy, x2: cx, y2: cy+1 }, + { x1: cx+1, y1: cy, x2: cx+1, y2: cy+1 } + ]; +} + +function buildMaintenanceShaftWalls(deck, formState) { + const gs = formState.gridSize; + const walls = []; + for (const shaft of deck.maintenanceShafts ?? []) { + for (const seg of shaft.segments) { + for (const w of buildCellPerimeterWalls(seg.x, seg.y)) { + walls.push({ + c: [w.x1 * gs, w.y1 * gs, w.x2 * gs, w.y2 * gs], + move: MOVEMENT_NORMAL, + sight: SENSE_NORMAL, + sound: SENSE_NORMAL, + light: SENSE_NORMAL, + dir: CONST.WALL_DIRECTIONS?.BOTH ?? 0, + door: DOOR_NONE, + hidden: true, + flags: { [MODULE_ID]: { generatedWall: true, isMaintenanceShaft: true } } + }); + } + } + // Hatch doors (secret/locked) on room walls at shaft entry points + for (const hatch of shaft.hatchPoints) { + walls.push({ + c: [hatch.x * gs, hatch.y * gs, (hatch.x + 1) * gs, hatch.y * gs], + move: MOVEMENT_NORMAL, + sight: SENSE_NORMAL, + sound: SENSE_NORMAL, + light: SENSE_NORMAL, + dir: CONST.WALL_DIRECTIONS?.BOTH ?? 0, + door: DOOR_SECRET, + ds: DOOR_STATE_LOCKED, + hidden: true, + flags: { [MODULE_ID]: { generatedWall: true, isMaintenanceShaft: true, isHatch: true } } + }); + } + } + return walls; +} + +function buildBuildingExteriorOpenings(hullMask, rng, subtype) { + const hullWalls = buildHullWallSegments(hullMask); + if (!hullWalls.length) return []; + const hull = boundingRectFromRegions([{ x: 1, y: 1, width: hullMask[0]?.length - 2, height: hullMask.length - 2 }]); + const bySide = classifyBuildingHullSides(hullWalls, hull); + const config = getBuildingExteriorConfig(subtype); + const chosen = []; + const used = new Set(); + + addOpenings(pickSideCandidates(bySide[config.front] ?? [], config.frontBias), config.frontDoors, "door", (segment) => ({ + ...collapseSegmentToSingleCell(segment), + door: DOOR_BASIC, + ds: DOOR_STATE_CLOSED + })); + + addOpenings(pickSideCandidates(bySide[config.secondaryDoorSide] ?? [], "center"), config.secondaryDoors, "door", (segment) => ({ + ...collapseSegmentToSingleCell(segment), + door: DOOR_BASIC, + ds: DOOR_STATE_CLOSED + })); + + const windowSides = config.windowSides.flatMap((side) => pickSideCandidates(bySide[side] ?? [], side === config.front ? "spread" : "edge")); + addOpenings(windowSides, config.windows, "window", (segment) => ({ + ...collapseSegmentToSingleCell(segment), + isWindow: true + })); + + return chosen; + + function addOpenings(candidates, count, type, mapFn) { + let added = 0; + for (const segment of candidates) { + if (added >= count) break; + const opening = mapFn(segment); + if (!opening) continue; + const key = segmentKey(opening); + if (used.has(key)) continue; + used.add(key); + chosen.push(opening); + added += 1; + } + } +} + +function classifyBuildingHullSides(hullWalls, hull) { + const out = { north: [], south: [], east: [], west: [] }; + for (const segment of hullWalls) { + if (segment.y1 === segment.y2) { + if (segment.y1 <= hull.y) out.north.push(segment); + else if (segment.y1 >= hull.y + hull.height) out.south.push(segment); + } else if (segment.x1 === segment.x2) { + if (segment.x1 <= hull.x) out.west.push(segment); + else if (segment.x1 >= hull.x + hull.width) out.east.push(segment); + } + } + for (const key of Object.keys(out)) { + out[key].sort((a, b) => (a.x1 + a.x2 + a.y1 + a.y2) - (b.x1 + b.x2 + b.y1 + b.y2)); + } + return out; +} + +function getBuildingExteriorConfig(subtype) { + switch (subtype) { + case "warehouse": + return { front: "south", secondaryDoorSide: "west", frontDoors: 2, secondaryDoors: 1, windows: 2, windowSides: ["north", "east"], frontBias: "center" }; + case "tavern": + return { front: "south", secondaryDoorSide: "east", frontDoors: 2, secondaryDoors: 1, windows: 5, windowSides: ["south", "east", "west"], frontBias: "center" }; + case "mansion": + return { front: "south", secondaryDoorSide: "north", frontDoors: 2, secondaryDoors: 1, windows: 8, windowSides: ["north", "south", "east", "west"], frontBias: "center" }; + case "horror": + return { front: "south", secondaryDoorSide: "west", frontDoors: 1, secondaryDoors: 1, windows: 4, windowSides: ["north", "east", "west"], frontBias: "center" }; + case "residential": + return { front: "south", secondaryDoorSide: "north", frontDoors: 1, secondaryDoors: 1, windows: 6, windowSides: ["south", "east", "west"], frontBias: "center" }; + case "office": + default: + return { front: "south", secondaryDoorSide: "east", frontDoors: 1, secondaryDoors: 1, windows: 6, windowSides: ["north", "south", "east", "west"], frontBias: "center" }; + } +} + +function pickSideCandidates(segments, bias = "center") { + if (!segments.length) return []; + const sorted = [...segments]; + if (bias === "center") { + const centerScore = averageSegmentPosition(sorted); + sorted.sort((a, b) => Math.abs(segmentPosition(a) - centerScore) - Math.abs(segmentPosition(b) - centerScore)); + return sorted; + } + if (bias === "edge") { + return sorted; + } + if (bias === "spread") { + return spreadSegments(sorted); + } + return sorted; +} + +function spreadSegments(segments) { + if (segments.length <= 2) return segments; + const result = []; + const sorted = [...segments]; + let left = 0; + let right = sorted.length - 1; + while (left <= right) { + if (left === right) { + result.push(sorted[left]); + break; + } + result.push(sorted[left], sorted[right]); + left += 1; + right -= 1; + } + return result; +} + +function segmentPosition(segment) { + return segment.x1 === segment.x2 ? (segment.y1 + segment.y2) / 2 : (segment.x1 + segment.x2) / 2; +} + +function averageSegmentPosition(segments) { + return segments.reduce((sum, segment) => sum + segmentPosition(segment), 0) / Math.max(1, segments.length); +} + +function applyDoorTypesToPartitions(segments, rooms, roomDoorTypes) { + if (!segments.length) return segments; + return segments.map((segment) => { + if (!segment.door || segment.isWindow) return segment; + const room = rooms.find((candidate) => segmentTouchesRoom(segment, candidate)); + if (!room) return segment; + const doorType = roomDoorTypes[room.id] ?? "normal"; + if (doorType === "locked") return { ...segment, ds: DOOR_STATE_LOCKED }; + if (doorType === "none") return { ...segment, door: false, ds: undefined }; + return segment; + }); +} + +function segmentTouchesRoom(segment, room) { + if (segment.x1 === segment.x2) { + return segment.x1 >= room.x && segment.x1 <= room.x + room.width + && Math.max(segment.y1, room.y) < Math.min(segment.y2, room.y + room.height); + } + return segment.y1 >= room.y && segment.y1 <= room.y + room.height + && Math.max(segment.x1, room.x) < Math.min(segment.x2, room.x + room.width); +} + +function segmentLength(segment) { + return Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); +} + +function countDoors(segments = []) { + return segments.filter((segment) => Boolean(segment.door)).length; +} + +function collapseSegmentToSingleCell(segment) { + if (segment.x1 === segment.x2) { + const y = Math.min(segment.y1, segment.y2); + return { x1: segment.x1, y1: y, x2: segment.x2, y2: y + 1 }; + } + const x = Math.min(segment.x1, segment.x2); + return { x1: x, y1: segment.y1, x2: x + 1, y2: segment.y2 }; +} + +async function createFoundryScenes(deckPlan, formState) { + const currentScene = game.scenes?.viewed ?? canvas.scene ?? null; + const connectorJournal = await ensureConnectorJournal(deckPlan, formState); + const connectorPages = new Map((connectorJournal?.pages?.contents ?? []).map((page) => [page.name, page.id])); + const rootFolder = await ensureSceneFolder(ROOT_SCENE_FOLDER); + const target = resolveRegenerationTarget(formState, deckPlan, currentScene); + const furnitureTables = await getFurnitureTables().catch(() => null); + const shipFolder = target.folder ?? await Folder.create({ + name: deckPlan.shipName, + type: "Scene", + color: "#2a4060", + folder: rootFolder.id + }).catch(async () => game.folders.find((folder) => folder.type === "Scene" && folder.name === deckPlan.shipName && folder.folder?.id === rootFolder.id)); + + const scenes = []; + for (let index = 0; index < deckPlan.decks.length; index += 1) { + const deck = deckPlan.decks[index]; + await prepareDeckGenerationArtifacts(deck, formState, furnitureTables, deckPlan); + const existingScene = target.scenes[index] ?? null; + let scene = existingScene; + if (existingScene) { + await regenerateSceneInPlace(existingScene, deckPlan, deck, formState, connectorJournal, connectorPages); + } else { + scene = await Scene.create({ + name: `${deckPlan.shipName} — ${deck.deckName}`, + folder: shipFolder?.id ?? rootFolder.id, + width: deck.columns * formState.gridSize, + height: deck.rows * formState.gridSize, + padding: 0, + tokenVision: true, + fogExploration: false, + grid: { + type: resolveSceneGridType(formState.gridMode), + size: formState.gridSize, + distance: 1, + units: "meter" + }, + backgroundColor: paletteForFormState(formState).base, + background: deck.backgroundSrc ? { src: deck.backgroundSrc } : undefined, + thumb: deck.backgroundSrc ?? undefined + }); + + await populateSceneWithDeck(scene, deckPlan, deck, formState, connectorJournal, connectorPages); + } + + scenes.push(scene); + } + + if (formState.generateMissionBrief) { + await JournalEntry.create({ + name: `${deckPlan.shipName} — Mission Brief`, + pages: [{ + name: "Brief", + type: "text", + text: { + content: buildMissionBriefContent(deckPlan, formState), + format: 1 + } + }] + }).catch(() => null); + } + + return scenes; +} + +function resolveRegenerationTarget(formState, deckPlan, currentScene) { + const mode = formState.regenerateTarget === "none" && formState.regenerateInPlace ? "viewed" : formState.regenerateTarget; + if (mode === "viewed" && deckPlan.decks.length === 1 && currentScene) { + return { scenes: [currentScene], folder: currentScene.folder ?? null }; + } + if (mode === "viewed-folder" && currentScene?.folder) { + return { + scenes: getSortedScenesForFolder(currentScene.folder).slice(0, deckPlan.decks.length), + folder: currentScene.folder + }; + } + if (mode === "folder" && formState.targetSceneFolder) { + const folder = game.folders?.get(formState.targetSceneFolder) ?? null; + if (folder?.type === "Scene") { + return { + scenes: getSortedScenesForFolder(folder).slice(0, deckPlan.decks.length), + folder + }; + } + } + return { scenes: [], folder: null }; +} + +function resolveSceneGridType(gridMode) { + if (gridMode === "hex-row") return CONST.GRID_TYPES.HEXODDR; + return CONST.GRID_TYPES.SQUARE; +} + +function getSortedScenesForFolder(folder) { + return (game.scenes?.contents ?? []) + .filter((scene) => scene.folder?.id === folder.id) + .sort((a, b) => (a.sort - b.sort) || a.name.localeCompare(b.name)); +} + +async function prepareDeckGenerationArtifacts(deck, formState, furnitureTables, deckPlan) { + if (furnitureTables) { + const furnitureSeed = hashSeed(`${formState.seed}:${deck.deckIndex}:${formState.mode}:${formState.furnitureDensity}`); + deck.furniture = buildFurnitureForDeck(deck, formState, furnitureTables, mulberry32(furnitureSeed)); + } else { + deck.furniture = []; + } + + // Generate maintenance shafts (hidden crawlway network between key rooms) + const shaftSeed = hashSeed(`${formState.seed}:${deck.deckIndex}:shafts`); + deck.maintenanceShafts = buildMaintenanceShafts(deck, formState, mulberry32(shaftSeed)); + + deck.backgroundSrc = await createDeckBackgroundAsset(deckPlan, deck, formState); + deck.gmOverlaySrc = formState.createGmOverlay ? await createGmOverlayAsset(deckPlan, deck, formState) : null; +} + +async function populateSceneWithDeck(scene, deckPlan, deck, formState, connectorJournal, connectorPages) { + const drawings = sanitizeGeneratedDrawings([ + ...buildRoomFloorDrawings(deck, formState), + ...buildCorridorFloorDrawings(deck, formState), + ...buildFurnitureDrawings(deck, formState), + ...buildHullOutlineDrawings(deck, formState) + ]); + if (drawings.length) { + await scene.createEmbeddedDocuments("Drawing", drawings.map((drawing) => withGeneratedDrawingFlag(drawing))); + } + + const gmOverlayTile = buildGmOverlayTile(deck, formState); + if (gmOverlayTile) { + await scene.createEmbeddedDocuments("Tile", [withGeneratedTileFlag(gmOverlayTile)]); + } + + const walls = deck.wallSegments.map((segment) => wallSegmentToDocument(segment, formState.gridSize)); + if (walls.length) { + await scene.createEmbeddedDocuments("Wall", walls.map((wall) => withGeneratedWallFlag(wall))); + } + + const furnitureWalls = buildFurnitureOcclusionWalls(deck, formState); + if (furnitureWalls.length) { + await scene.createEmbeddedDocuments("Wall", furnitureWalls); + } + + const notes = [ + ...buildDeckConnectorNotes(deck, formState, connectorJournal?.id, connectorPages), + ...buildRoomZoneNotes(deck, formState) + ]; + if (notes.length) { + await scene.createEmbeddedDocuments("Note", notes.map((note) => withGeneratedNoteFlag(note))); + } + + if (formState.placeLights) { + const lights = buildAmbientLights(deck, formState); + if (lights.length) { + await scene.createEmbeddedDocuments("AmbientLight", lights.map((light) => withGeneratedLightFlag(light))); + } + } + + if (formState.seedEncounters) { + const tokens = await buildEncounterTokens(deck, formState); + if (tokens.length) { + await scene.createEmbeddedDocuments("Token", tokens.map((token) => withGeneratedTokenFlag(token))); + } + } + + if (formState.populateRoomsFromCompendium && formState.roomPopulationPack) { + const roomTokens = await buildRoomPopulationTokens(deck, formState); + if (roomTokens.length) { + await scene.createEmbeddedDocuments("Token", roomTokens.map((token) => withGeneratedTokenFlag(token))); + } + } + + if (formState.placeRegions) { + try { + const regions = buildRegionDocuments(deck, formState); + if (regions.length) { + await scene.createEmbeddedDocuments("Region", regions); + } + } catch (_err) { + // Region documents are v13+ — silently skip on older Foundry versions + } + } + + // Maintenance shaft walls (hidden until GM reveals) + const shaftWalls = buildMaintenanceShaftWalls(deck, formState); + if (shaftWalls.length) { + await scene.createEmbeddedDocuments("Wall", shaftWalls.map((w) => withGeneratedWallFlag(w))); + } + + // Maintenance shaft floor drawings (hidden until GM reveals) + const shaftDrawings = buildMaintenanceShaftDrawings(deck, formState); + if (shaftDrawings.length) { + await scene.createEmbeddedDocuments("Drawing", shaftDrawings.map((d) => withGeneratedDrawingFlag(d))); + } + + applyGmOverlayVisibility(scene); +} + +function buildRegionDocuments(deck, formState) { + const gs = formState.gridSize; + const regions = []; + for (const room of deck.rooms) { + const config = REGION_CONFIG[room.type]; + if (!config) continue; + const pts = room.points + ? room.points.flatMap((p) => [p.x * gs, p.y * gs]) + : [ + room.x * gs, room.y * gs, + (room.x + room.width) * gs, room.y * gs, + (room.x + room.width) * gs, (room.y + room.height) * gs, + room.x * gs, (room.y + room.height) * gs + ]; + regions.push({ + name: room.label ? `Region: ${room.label}` : `Region (${room.type})`, + color: config.color, + visibility: config.gmOnly ? 10 : 0, + shapes: [{ type: "polygon", points: pts }], + behaviors: config.behaviors ?? [], + flags: { [MODULE_ID]: { generatedRegion: true, roomType: room.type } } + }); + } + return regions; +} + +function buildGmOverlayTile(deck, formState) { + if (!formState.createGmOverlay || !deck.gmOverlaySrc) return null; + return { + x: 0, + y: 0, + z: 1000, + width: deck.columns * formState.gridSize, + height: deck.rows * formState.gridSize, + rotation: 0, + hidden: true, + locked: true, + texture: { + src: deck.gmOverlaySrc + }, + flags: { + [MODULE_ID]: { + gmOverlay: true, + deckIndex: deck.deckIndex, + overlayType: "gm-room-overlay" + } + } + }; +} + +async function regenerateSceneInPlace(scene, deckPlan, deck, formState, connectorJournal, connectorPages) { + const furnitureTables = await getFurnitureTables().catch(() => null); + await prepareDeckGenerationArtifacts(deck, formState, furnitureTables, deckPlan); + await deleteGeneratedDocumentsInternal(scene); + await scene.update({ + name: `${deckPlan.shipName} — ${deck.deckName}`, + width: deck.columns * formState.gridSize, + height: deck.rows * formState.gridSize, + padding: 0, + tokenVision: true, + fogExploration: false, + grid: { + type: resolveSceneGridType(formState.gridMode), + size: formState.gridSize, + distance: 1, + units: "meter" + }, + backgroundColor: paletteForFormState(formState).base, + background: deck.backgroundSrc ? { src: deck.backgroundSrc } : null, + thumb: deck.backgroundSrc ?? null + }); + await populateSceneWithDeck(scene, deckPlan, deck, formState, connectorJournal, connectorPages); +} + +async function ensureConnectorJournal(deckPlan, formState) { + if (deckPlan.decks.length < 2 || !["dungeon", "building"].includes(formState.mode)) return null; + const pages = []; + for (const deck of deckPlan.decks) { + for (const connector of deck.deckConnectors ?? []) { + pages.push({ + name: connectorNoteKey(deck, connector), + type: "text", + text: { + content: `

    ${escapeXml(connector.label)}

    ${escapeXml(connector.access ?? "")}

    ${escapeXml(buildConnectorNoteCopy(formState.mode, deck, connector))}

    ` + } + }); + } + } + return JournalEntry.create({ + name: `${deckPlan.shipName} Level Connections`, + pages + }).catch(() => null); +} + +function buildDeckConnectorNotes(deck, formState, entryId, connectorPages) { + if (!entryId || !["dungeon", "building"].includes(formState.mode)) return []; + return (deck.deckConnectors ?? []).map((connector) => ({ + entryId, + pageId: connectorPages.get(connectorNoteKey(deck, connector)), + x: round((connector.x + 0.5) * formState.gridSize), + y: round((connector.y + 0.5) * formState.gridSize), + iconSize: Math.max(36, Math.round(formState.gridSize * 0.55)), + text: connector.label, + hidden: true, + texture: { + src: "icons/svg/book.svg" + }, + flags: { + [MODULE_ID]: { + gmOverlay: true, + connectorId: connector.id, + deckIndex: deck.deckIndex, + regionKind: "connector", + noteText: buildConnectorNoteCopy(formState.mode, deck, connector) + } + } + })).filter((note) => note.pageId); +} + +function buildRoomZoneNotes(deck, formState) { + if (!formState.createRoomNotes) return []; + const notes = deck.rooms.map((room) => { + const center = roomCenter(room); + const bounds = roomBounds(room); + return ({ + x: round(center.x * formState.gridSize), + y: round(center.y * formState.gridSize), + iconSize: Math.max(24, Math.round(formState.gridSize * 0.35)), + text: room.label || humanizeRoomType(room.type) || "Zone", + texture: { + src: "icons/svg/circle.svg" + }, + hidden: true, + flags: { + [MODULE_ID]: { + gmOverlay: true, + roomId: room.id, + roomType: room.type ?? "zone", + roomLabel: room.label ?? "", + deckIndex: deck.deckIndex, + regionKind: "room", + noteText: buildAutomaticRoomNote(deck, room), + bounds: { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height + }, + markers: room.markers ?? [] + } + } + })}); + + if (deck.wilderness?.encounterZones?.length) { + notes.push(...deck.wilderness.encounterZones.map((zone, index) => ({ + x: round(zone.x * formState.gridSize), + y: round(zone.y * formState.gridSize), + iconSize: Math.max(28, Math.round(formState.gridSize * 0.4)), + text: zone.label || `Encounter Zone ${index + 1}`, + texture: { + src: "icons/svg/target.svg" + }, + hidden: true, + flags: { + [MODULE_ID]: { + gmOverlay: true, + deckIndex: deck.deckIndex, + regionKind: "encounter-zone", + roomLabel: zone.label ?? "", + noteText: `Encounter zone on ${deck.deckName}. Radius ${zone.radius} cells.`, + bounds: { + x: zone.x - zone.radius, + y: zone.y - zone.radius, + width: zone.radius * 2, + height: zone.radius * 2 + }, + radius: zone.radius + } + } + }))); + } + + return notes; +} + +function connectorNoteKey(deck, connector) { + return `${deck.deckIndex}:${connector.id}`; +} + +function buildConnectorNoteCopy(mode, deck, connector) { + if (mode === "dungeon") { + return `${connector.label} links ${deck.deckName} to ${connector.access || "the adjoining level"}. Use this point for vertical movement between dungeon levels.`; + } + return `${connector.label} links ${deck.deckName} to ${connector.access || "the adjoining floor"}. Use this point for stairwell movement between building floors.`; +} + +async function exportMothershipViewerJson(deckPlan) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return; + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const json = JSON.stringify(buildMothershipMapViewerJSON(deckPlan), null, 2); + const filename = `${slugify(deckPlan.shipName)}-${Date.now()}-mosh-map.json`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([json], filename, { type: "application/json" }); + await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); +} + +async function exportMapMetadataJson(deckPlan, formState, scenes = []) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return null; + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const filename = `${slugify(deckPlan.shipName)}-${Date.now()}-metadata.json`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const payload = JSON.stringify(buildMapMetadata(deckPlan, formState, scenes), null, 2); + const file = new File([payload], filename, { type: "application/json" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + const path = response?.path ?? `${targetDir}/${filename}`; + for (let index = 0; index < scenes.length; index += 1) { + await scenes[index].update({ + [`flags.${MODULE_ID}.metadataExport`]: { + path, + deckIndex: index, + mode: formState.mode, + theme: formState.theme + } + }).catch(() => {}); + } + return path; +} + +async function createMissionPacket(deckPlan, formState, scenes = [], metadataPath = null) { + const html = buildMissionPacketHtml(deckPlan, formState, scenes, metadataPath); + const exportedPath = await exportMissionPacketHtml(deckPlan, html).catch(() => null); + await JournalEntry.create({ + name: `${deckPlan.shipName} — Mission Packet`, + pages: [{ + name: "Packet", + type: "text", + text: { + content: html, + format: 1 + } + }] + }).catch(() => null); + if (exportedPath) { + ui.notifications.info(`Mission packet exported to ${exportedPath}.`); + } + return exportedPath; +} + +async function exportMissionPacketHtml(deckPlan, html) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return null; + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const filename = `${slugify(deckPlan.shipName)}-${Date.now()}-mission-packet.html`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([html], filename, { type: "text/html" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + return response?.path ?? `${targetDir}/${filename}`; +} + +function buildMissionPacketHtml(deckPlan, formState, scenes = [], metadataPath = null) { + const brief = buildMissionBriefContent(deckPlan, formState); + const previewCards = deckPlan.decks.map((deck, index) => { + const scene = scenes[index]; + const sceneName = scene?.name ?? `${deckPlan.shipName} — ${deck.deckName}`; + const imagePath = scene?.background?.src ?? deck.backgroundSrc ?? ""; + return ` +
    +

    ${escapeXml(sceneName)}

    + ${imagePath ? `${escapeXml(sceneName)} thumbnail` : ""} +

    Rooms: ${deck.rooms.length} Corridors: ${deck.corridors.length} Doors: ${countDoors(deck.wallSegments)}

    +

    ${escapeXml(summarizeDeckThreats(deck))}

    +
    + `; + }).join(""); + const summaries = deckPlan.decks.map((deck) => ` + + ${escapeXml(deck.deckName)} + ${deck.rooms.length} + ${deck.corridors.length} + ${countDoors(deck.wallSegments)} + ${(deck.wilderness?.encounterZones?.length ?? deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)).length)} + + `).join(""); + return ` + +
    + ${brief} +
    + Metadata export: ${escapeXml(metadataPath ?? "Not exported")}
    + Encounter source: ${escapeXml(formState.encounterPack || resolveEncounterPreset(formState).key)}
    + Generated scenes: ${scenes.length} +
    +

    Scene List & Preview

    +
    ${previewCards}
    +

    Metadata Summary

    + + + ${summaries} +
    DeckRoomsCorridorsDoorsThreat Nodes
    +
    + `.replace(/\n\s+/g, ""); +} + +function buildMapMetadata(deckPlan, formState, scenes = []) { + return { + module: MODULE_ID, + version: game.modules?.get(MODULE_ID)?.version ?? "dev", + name: deckPlan.shipName, + seed: deckPlan.seed, + mode: formState.mode, + theme: formState.theme, + missionTemplate: formState.missionTemplate, + encounterFaction: resolveEncounterPreset(formState).key, + encounterPack: formState.encounterPack || null, + gridSize: formState.gridSize, + decks: deckPlan.decks.map((deck, index) => ({ + deckIndex: deck.deckIndex, + deckName: deck.deckName, + sceneId: scenes[index]?.id ?? null, + sceneName: scenes[index]?.name ?? null, + rooms: deck.rooms.map((room) => ({ + id: room.id, + label: room.label ?? "", + type: room.type ?? "", + faction: room.faction ?? "", + factionColor: room.factionColor ?? "", + x: room.x, + y: room.y, + width: room.width, + height: room.height, + shape: room.shape ?? "rect", + markers: room.markers ?? [] + })), + corridors: (deck.corridors ?? []).map((corridor) => ({ + x: corridor.x, + y: corridor.y, + width: corridor.width, + height: corridor.height, + waypoints: corridor.waypoints ?? [] + })), + doors: (deck.wallSegments ?? []).filter((segment) => segment.door).map((segment) => ({ + x1: segment.x1, + y1: segment.y1, + x2: segment.x2, + y2: segment.y2, + state: segment.ds ?? DOOR_STATE_CLOSED, + secret: segment.door === DOOR_SECRET + })), + walls: (deck.wallSegments ?? []).filter((segment) => !segment.door).map((segment) => ({ + x1: segment.x1, + y1: segment.y1, + x2: segment.x2, + y2: segment.y2, + isWindow: Boolean(segment.isWindow), + movementOnly: Boolean(segment.movementOnly), + terrain: segment.terrain ?? null + })), + wilderness: deck.wilderness ?? null + })) + }; +} + +function buildMothershipMapViewerJSON(deckPlan) { + const gs = deckPlan.gridSize; + return { + name: deckPlan.shipName, + floors: deckPlan.decks.map((deck, index) => ({ + floorNumber: index + 1, + floorName: deck.deckName, + rooms: deck.rooms.map((room) => ({ + id: room.id, + type: room.shape === "circle" ? "circle" : "rect", + x: room.x * gs, + y: room.y * gs, + width: room.width * gs, + height: room.height * gs, + label: room.label ?? "", + markers: (room.markers ?? []).map((marker) => ({ + ...marker, + x: Number.isFinite(marker.x) ? marker.x * gs : marker.x, + y: Number.isFinite(marker.y) ? marker.y * gs : marker.y + })), + labels: room.label + ? [{ + text: room.label, + x: (room.x + room.width / 2) * gs, + y: (room.y + room.height / 2) * gs, + visible: true + }] + : [] + })), + hallways: deck.corridors.map((corridor) => ({ + waypoints: (corridor.waypoints ?? []).map((wp) => ({ + x: wp.x * gs, + y: wp.y * gs + })), + startType: corridor.startDoor ?? "door", + endType: corridor.endDoor ?? "door" + })), + walls: deck.wallSegments.map((segment) => ({ + x1: segment.x1 * gs, + y1: segment.y1 * gs, + x2: segment.x2 * gs, + y2: segment.y2 * gs, + door: Boolean(segment.door) + })), + labels: deck.rooms + .filter((room) => room.label) + .map((room) => ({ + text: room.label, + x: (room.x + room.width / 2) * gs, + y: (room.y + room.height / 2) * gs + })) + })) + }; +} + +function buildRoomFloorDrawings(deck, formState) { + if (formState.mode === "wilderness" || deck.isCave) return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + return deck.rooms.flatMap((room) => { + const bounds = roomBounds(room); + const base = { + x: bounds.x * formState.gridSize, + y: bounds.y * formState.gridSize, + z: 0, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 2, + strokeColor: room.factionColor ?? palette.roomStroke, + strokeAlpha: 1, + fillType: 1, + fillColor: room.factionColor ?? palette.roomFill, + fillAlpha: round(0.8 * ageOpacity, 3), + textAlpha: 0, + bezierFactor: 0, + flags: { + [MODULE_ID]: { + roomId: room.id, + roomType: room.type ?? "", + roomLabel: room.label ?? "", + faction: room.faction ?? "", + factionColor: room.factionColor ?? "" + } + } + }; + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + const size = room.circleR * 2 * formState.gridSize; + return [{ + ...base, + x: room.circleX * formState.gridSize, + y: room.circleY * formState.gridSize, + shape: { + type: "e", + width: size, + height: size + } + }]; + } + if (room.shape === "polygon" && room.points?.length >= 3) { + return [{ + ...base, + shape: { + type: "p", + width: bounds.width * formState.gridSize, + height: bounds.height * formState.gridSize, + points: room.points.flatMap((point) => [ + round((point.x - bounds.x) * formState.gridSize, 2), + round((point.y - bounds.y) * formState.gridSize, 2) + ]) + } + }]; + } + return [{ + ...base, + shape: { + type: "r", + width: bounds.width * formState.gridSize, + height: bounds.height * formState.gridSize + } + }]; + }); +} + +function buildCorridorFloorDrawings(deck, formState) { + if (formState.mode === "wilderness") return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + const drawings = []; + for (const corridor of deck.corridors) { + drawings.push({ + x: corridor.x * formState.gridSize, + y: corridor.y * formState.gridSize, + z: 0, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 0, + strokeColor: palette.corridorFill, + strokeAlpha: 0, + fillType: 1, + fillColor: palette.corridorFill, + fillAlpha: round(0.86 * ageOpacity, 3), + textAlpha: 0, + bezierFactor: 0, + shape: { + type: "r", + width: corridor.width * formState.gridSize, + height: corridor.height * formState.gridSize + } + }); + if (corridor.width >= 2 || corridor.height >= 2) { + const gs = formState.gridSize; + const isHorizontal = corridor.width >= corridor.height; + drawings.push({ + x: isHorizontal ? corridor.x * gs : round((corridor.x + corridor.width / 2 - 0.08) * gs), + y: isHorizontal ? round((corridor.y + corridor.height / 2 - 0.08) * gs) : corridor.y * gs, + z: 1, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 0, + strokeColor: palette.corridorStripe ?? palette.line, + strokeAlpha: 0, + fillType: 1, + fillColor: palette.corridorStripe ?? palette.line, + fillAlpha: round(0.25 * ageOpacity, 3), + textAlpha: 0, + bezierFactor: 0, + shape: { + type: "r", + width: isHorizontal ? corridor.width * gs : Math.max(8, Math.round(gs * 0.16)), + height: isHorizontal ? Math.max(8, Math.round(gs * 0.16)) : corridor.height * gs + } + }); + } + } + return drawings; +} + +function buildAmbientLights(deck, formState) { + const config = THEME_LIGHT_CONFIG[formState.theme] ?? THEME_LIGHT_CONFIG.steel; + const ageOpacity = getAgeOpacity(formState); + const gs = formState.gridSize; + const lights = []; + + if (config.ambient) { + lights.push({ + x: Math.round((deck.columns * gs) / 2), + y: Math.round((deck.rows * gs) / 2), + hidden: false, + config: { + dim: Math.round(deck.columns * gs * 0.75), + bright: 0, + color: config.color, + alpha: round(config.alpha * Math.max(0.45, ageOpacity), 3), + luminosity: config.luminosity ?? 0.5, + walls: true, + vision: false + } + }); + } + + for (const room of deck.rooms) { + if (!config.roomTypes?.includes(room.type)) continue; + const radius = Math.min(room.width, room.height) * gs; + lights.push({ + x: Math.round((room.x + room.width / 2) * gs), + y: Math.round((room.y + room.height / 2) * gs), + hidden: false, + config: { + dim: Math.round(radius * 1.5), + bright: Math.round(radius * 0.5), + color: config.accentColor ?? config.color, + alpha: round(0.58 * Math.max(0.45, ageOpacity), 3), + luminosity: config.roomLuminosity ?? 0.65, + walls: true, + vision: false + } + }); + } + + return dedupeLights(lights); +} + +function buildFurnitureDrawings(deck, formState) { + if (!deck.furniture?.length) return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + return deck.furniture.map((piece) => { + const isSolid = piece.occlusion === "solid"; + return { + x: piece.x * formState.gridSize, + y: piece.y * formState.gridSize, + z: 10, + hidden: false, + locked: false, + rotation: piece.rotation ?? 0, + strokeWidth: isSolid ? 4 : 2, + strokeColor: palette.furnitureStroke, + strokeAlpha: round((isSolid ? 0.98 : 0.94) * ageOpacity, 3), + fillType: 1, + fillColor: palette.furnitureFill, + fillAlpha: round((isSolid ? 0.92 : 0.78) * ageOpacity, 3), + textAlpha: piece.label ? 1 : 0, + text: piece.label ?? "", + fontSize: Math.max(8, Math.round(formState.gridSize * 0.12)), + fontFamily: "Courier New", + bezierFactor: 0, + shape: { + type: piece.shape === "e" ? "e" : "r", + width: piece.w * formState.gridSize, + height: piece.h * formState.gridSize + } + }; + }); +} + +function buildFurnitureOcclusionWalls(deck, formState) { + const walls = []; + const gs = formState.gridSize; + + for (const piece of deck.furniture ?? []) { + const occlusion = piece.occlusion ?? "none"; + if (occlusion === "none") continue; + + const isSight = occlusion === "sight" || occlusion === "solid"; + const isMove = occlusion === "move" || occlusion === "solid"; + + const x = piece.x, y = piece.y, w = piece.w, h = piece.h; + const perimeter = [ + { x1: x, y1: y, x2: x + w, y2: y }, + { x1: x, y1: y + h, x2: x + w, y2: y + h }, + { x1: x, y1: y, x2: x, y2: y + h }, + { x1: x + w, y1: y, x2: x + w, y2: y + h } + ]; + + for (const seg of perimeter) { + walls.push({ + c: [ + Math.round(seg.x1 * gs), Math.round(seg.y1 * gs), + Math.round(seg.x2 * gs), Math.round(seg.y2 * gs) + ], + move: isMove ? MOVEMENT_NORMAL : SENSE_NONE, + sight: isSight ? SENSE_NORMAL : SENSE_NONE, + sound: isSight ? SENSE_NORMAL : SENSE_NONE, + light: isSight ? SENSE_NORMAL : SENSE_NONE, + dir: CONST.WALL_DIRECTIONS?.BOTH ?? 0, + door: DOOR_NONE, + flags: { [MODULE_ID]: { generatedWall: true, furnitureWall: true } } + }); + } + } + + return walls; +} + +function buildHullOutlineDrawings(deck, formState) { + if (formState.mode === "wilderness") return []; + if (!["corridor", "dungeon"].includes(formState.mode) || !deck.hull) return []; + const palette = paletteForFormState(formState); + const ageOpacity = getAgeOpacity(formState); + return [{ + x: deck.hull.x * formState.gridSize, + y: deck.hull.y * formState.gridSize, + z: -10, + hidden: false, + locked: false, + rotation: 0, + strokeWidth: 6, + strokeColor: palette.hull, + strokeAlpha: round(0.35 * ageOpacity, 3), + fillType: 0, + fillColor: palette.hull, + fillAlpha: 0, + textAlpha: 0, + bezierFactor: 0, + shape: { + type: "r", + width: deck.hull.width * formState.gridSize, + height: deck.hull.height * formState.gridSize + } + }]; +} + +function buildDeckSVG(deckPlan, deck, formState) { + const width = deck.columns * formState.gridSize; + const height = deck.rows * formState.gridSize; + const palette = paletteForFormState(formState); + const defs = buildThemePattern(formState.theme, palette, formState); + const labels = formState.coordinates === "none" + ? "" + : buildCoordinateSvg(deck.columns, deck.rows, formState.gridSize, width, height, palette, formState.coordinates); + const roomFloors = formState.mode === "wilderness" ? "" : buildRoomFillSvg(deck.rooms, formState.gridSize, palette, formState); + const corridorFloors = formState.mode === "wilderness" + ? "" + : buildOrganicCorridorSvg(deck.corridors, formState.gridSize, palette, formState); + const softOutlines = formState.softRoomOutlines && formState.mode !== "wilderness" + ? buildSoftRoomOutlinesSvg(deck.rooms, formState.gridSize, palette, formState) + : ""; + const roomLabels = formState.showRoomLabels + ? buildRoomLabelSvg(deck.rooms, formState.gridSize, palette) + : ""; + const connectorLabels = buildDeckConnectorSvg(deck.deckConnectors ?? [], formState.gridSize, palette); + const hullPath = formState.mode === "wilderness" ? "" : buildHullPathSvg(deck.hullMask ?? [], formState.gridSize, palette); + const exteriorOpenings = formState.mode === "wilderness" ? "" : buildExteriorOpeningsSvg(deck, formState.gridSize, palette); + // Secret doors are GM-only — moved to buildGmOverlaySVG + const bulkheads = ["bsp", "corridor"].includes(formState.mode) ? buildBulkheadSvg(deck.wallSegments ?? [], formState.gridSize, palette) : ""; + const roomOverlays = formState.mode === "wilderness" + ? buildWildernessOverlaySvg(deck, formState.gridSize, palette, formState) + : ["bsp", "corridor"].includes(formState.mode) + ? buildScifiRoomOverlaySvg(deck.rooms, formState.gridSize, palette) + : buildDungeonRoomOverlaySvg(deck.rooms, formState.gridSize, palette, formState.theme, formState); + const weathering = buildWeatheringOverlay(width, height, palette, formState); + const ageOpacity = getAgeOpacity(formState); + + return ` + + ${defs} + + + ${hullPath} + ${exteriorOpenings} + ${corridorFloors} + ${roomFloors} + ${softOutlines} + ${roomOverlays} + ${bulkheads} + ${weathering} + ${labels} + ${roomLabels} + ${connectorLabels} + `.replace(/\n+/g, " ").trim(); +} + +function buildGmTrapMarkerSvg(rooms, gridSize) { + return rooms.filter((r) => r.type === "trap-room" || r.isTrap).map((room) => { + const cx = (room.x + room.width / 2) * gridSize; + const cy = (room.y + room.height / 2) * gridSize; + const r = Math.round(gridSize * 0.22); + return `⚠`; + }).join(""); +} + +function buildGmUnexploredTintSvg(rooms, gridSize) { + return rooms.filter((r) => !r.label && r.type !== "void").map((room) => { + return ``; + }).join(""); +} + +function buildGmNpcBadgeSvg(rooms, gridSize) { + const badgeMap = { "boss-chamber": "BOSS", "guardroom": "GUARD", "barracks": "SQUAD", "brig": "DETAINEE", "padded-cell": "INMATE", "surgery": "THREAT" }; + return rooms.filter((r) => badgeMap[r.type]).map((room) => { + const cx = (room.x + room.width - 0.5) * gridSize; + const cy = (room.y + 0.5) * gridSize; + const label = badgeMap[room.type]; + return `${label}`; + }).join(""); +} + +function buildHatchGlyphSvg(furniture, gridSize, palette) { + return (furniture ?? []).filter((p) => p.isHatch).map((p) => { + const cx = (p.x + p.w / 2) * gridSize; + const cy = (p.y + p.h / 2) * gridSize; + const r = Math.round(gridSize * 0.28); + return ``; + }).join(""); +} + +function buildMaintenanceShaftSvg(shafts, gridSize, palette) { + const color = palette.maintenanceFill ?? "#cc4400"; + return shafts.flatMap((shaft) => shaft.segments.map((seg) => { + return ``; + })).join(""); +} + +function buildGmOverlaySVG(_deckPlan, deck, formState) { + const width = deck.columns * formState.gridSize; + const height = deck.rows * formState.gridSize; + const palette = paletteForFormState(formState); + const gs = formState.gridSize; + const title = escapeXml(deck.deckName || "GM Overlay"); + const roomBadges = deck.rooms.map((room) => buildGmOverlayRoomBadge(room, formState, palette)).join(""); + const connectorBadges = (deck.deckConnectors ?? []).map((connector) => buildGmOverlayConnectorBadge(connector, formState, palette)).join(""); + const zoneBadges = (deck.wilderness?.encounterZones ?? []).map((zone, index) => buildGmOverlayZoneBadge(zone, index, formState, palette)).join(""); + // GM-only elements — never appear in the player SVG + const secretDoors = formState.mode === "wilderness" ? "" : buildSecretDoorSvg(deck.wallSegments ?? [], gs, palette); + const trapMarkers = buildGmTrapMarkerSvg(deck.rooms, gs); + const unexploredTint = buildGmUnexploredTintSvg(deck.rooms, gs); + const npcBadges = buildGmNpcBadgeSvg(deck.rooms, gs); + const hatchGlyphs = buildHatchGlyphSvg(deck.furniture ?? [], gs, palette); + const maintShafts = buildMaintenanceShaftSvg(deck.maintenanceShafts ?? [], gs, palette); + + return ` + + ${unexploredTint} + ${secretDoors} + ${trapMarkers} + ${hatchGlyphs} + ${maintShafts} + + + ${title} + + ${roomBadges} + ${connectorBadges} + ${zoneBadges} + ${npcBadges} + `.replace(/\n+/g, " ").trim(); +} + +function buildRoomFillSvg(rooms, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return rooms.map((room) => { + const fill = room.factionColor ?? palette.roomFill; + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + const cx = (room.circleX + room.circleR) * gridSize; + const cy = (room.circleY + room.circleR) * gridSize; + const r = room.circleR * gridSize; + return ``; + } + if (room.shape === "polygon" && room.points?.length >= 3) { + const points = room.points.map((point) => `${round(point.x * gridSize, 2)},${round(point.y * gridSize, 2)}`).join(" "); + return ``; + } + if (room.shape === "cave" && room.cells?.length) { + return compressCellsToRects(room.cells).map((rect) => ``).join(""); + } + return ``; + }).join(""); +} + +function buildCorridorFillSvg(corridors, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return corridors.map((corridor) => ``).join(""); +} + +function buildOrganicCorridorSvg(corridors, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return corridors.map((corridor, index) => { + const px = corridor.x * gridSize; + const py = corridor.y * gridSize; + const pw = corridor.width * gridSize; + const ph = corridor.height * gridSize; + const isHorizontal = pw >= ph; + const bow = Math.max(4, Math.round(Math.min(pw, ph) * 0.35)) * (((index % 3) - 1) || 0); + let path; + if (isHorizontal) { + const midX = px + pw / 2; + path = `M ${px} ${py} Q ${midX} ${py + bow} ${px + pw} ${py} L ${px + pw} ${py + ph} Q ${midX} ${py + ph - bow} ${px} ${py + ph} Z`; + } else { + const midY = py + ph / 2; + path = `M ${px} ${py} L ${px + pw} ${py} Q ${px + pw + bow} ${midY} ${px + pw} ${py + ph} L ${px} ${py + ph} Q ${px - bow} ${midY} ${px} ${py} Z`; + } + return ``; + }).join(""); +} + +function buildSoftRoomOutlinesSvg(rooms, gridSize, palette, formState) { + const ageOpacity = getAgeOpacity(formState); + return rooms.map((room) => { + if (room.shape === "cave") return ""; + if (room.shape === "polygon" && room.points?.length >= 3) { + const points = room.points.map((point) => `${round(point.x * gridSize, 2)},${round(point.y * gridSize, 2)}`).join(" "); + return ``; + } + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + const cx = (room.circleX + room.circleR) * gridSize; + const cy = (room.circleY + room.circleR) * gridSize; + const r = room.circleR * gridSize; + return ``; + } + const px = room.x * gridSize; + const py = room.y * gridSize; + const pw = room.width * gridSize; + const ph = room.height * gridSize; + const radius = Math.min(pw, ph) * 0.18; + const path = `M ${px + radius} ${py} L ${px + pw - radius} ${py} Q ${px + pw} ${py} ${px + pw} ${py + radius} L ${px + pw} ${py + ph - radius} Q ${px + pw} ${py + ph} ${px + pw - radius} ${py + ph} L ${px + radius} ${py + ph} Q ${px} ${py + ph} ${px} ${py + ph - radius} L ${px} ${py + radius} Q ${px} ${py} ${px + radius} ${py} Z`; + return ``; + }).join(""); +} + +function buildThemePattern(theme, palette, formState = {}) { + const ageOpacity = getAgeOpacity(formState); + if (theme === "stone") { + return ` + + + + + + + + + + + + + + `; + } + if (theme === "cave") { + return ` + + + + + + + + + + `; + } + if (theme === "wood" || theme === "wood-floor") { + return ` + + + + + `; + } + if (theme === "crypt") { + return ` + + + + + + + + + + `; + } + if (theme === "sewer") { + return ` + + + + + + + + `; + } + if (theme === "brick") { + return ` + + + + + + + + + + `; + } + if (theme === "carpet" || theme === "tile" || theme === "concrete-office") { + return ` + + + + + + `; + } + if (theme === "alien-organic") { + return ` + + + + + `; + } + if (theme === "clean-corporate") { + return ` + + + + + + `; + } + if (theme === "derelict-house") { + return ` + + + + + + + + `; + } + if (theme === "asylum") { + return ` + + + + + + + + + `; + } + if (theme === "haunted-manor") { + return ` + + + + + + `; + } + if (theme === "bloodbath") { + return ` + + + + + + + `; + } + if (theme === "forest" || theme === "jungle") { + return ` + + + + + + + `; + } + if (theme === "plains" || theme === "badlands") { + return ` + + + + + `; + } + if (theme === "swamp") { + return ` + + + + + + `; + } + if (theme === "tundra") { + return ` + + + + + `; + } + return ` + + + + + + + + `; +} + +function buildCoordinateSvg(columns, rows, gridSize, width, height, palette, mode) { + const out = []; + const fontSize = Math.max(11, Math.round(gridSize * 0.18)); + const coordStroke = textStrokeFor(palette.coordText); + const cellStroke = textStrokeFor(palette.coordCell); + for (let col = 0; col < columns; col += 1) { + const x = col * gridSize + gridSize / 2; + const label = columnLabel(col); + out.push(`${label}`); + } + for (let row = 0; row < rows; row += 1) { + const y = row * gridSize + gridSize / 2 + fontSize * 0.35; + out.push(`${row + 1}`); + } + if (mode === "full") { + const cellFont = Math.max(8, Math.round(gridSize * 0.11)); + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < columns; col += 1) { + out.push(`${columnLabel(col)}${row + 1}`); + } + } + } + return out.join(""); +} + +function buildRoomLabelSvg(rooms, gridSize, palette) { + const labelStroke = textStrokeFor(palette.label); + return rooms.filter((room) => room.label).map((room) => { + const center = roomCenter(room); + const bounds = roomBounds(room); + const x = center.x * gridSize; + const y = center.y * gridSize; + const roomPxW = bounds.width * gridSize; + const fontSize = Math.max(9, Math.min(16, Math.round(Math.min(bounds.width, bounds.height) * gridSize * 0.12))); + const approxCharW = fontSize * 0.6; + const charsPerLine = Math.max(4, Math.floor(roomPxW / Math.max(1, approxCharW)) - 2); + const words = room.label.split(/\s+/).filter(Boolean); + const lines = []; + let current = ""; + for (const word of words) { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length > charsPerLine && current) { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current) lines.push(current); + const lineHeight = fontSize * 1.22; + const totalHeight = lines.length * lineHeight; + return lines.map((line, index) => { + const lineY = y - totalHeight / 2 + index * lineHeight + lineHeight / 2; + return `${escapeXml(line)}`; + }).join(""); + }).join(""); +} + +function buildGmOverlayRoomBadge(room, formState, palette) { + const label = room.label || humanizeRoomType(room.type) || "Zone"; + const section = buildRoomSectionLine(room); + const center = roomCenter(room); + const bounds = roomBounds(room); + const x = center.x * formState.gridSize; + const y = center.y * formState.gridSize; + const roomPxW = Math.max(120, Math.min(bounds.width * formState.gridSize - 12, 260)); + const badgeW = Math.max(120, Math.min(roomPxW, Math.max(120, label.length * 10 + 36))); + const badgeH = section ? 44 : 30; + const left = round(x - badgeW / 2); + const top = round(y - badgeH / 2); + const stroke = room.factionColor ?? palette.connectorStroke; + const subText = section ? `${escapeXml(section)}` : ""; + return ` + + + ${escapeXml(label)} + ${subText} + + `.replace(/\n+/g, " "); +} + +function buildGmOverlayConnectorBadge(connector, formState, palette) { + const cx = connector.x * formState.gridSize + formState.gridSize / 2; + const cy = connector.y * formState.gridSize + formState.gridSize / 2; + return ` + + + ${escapeXml((connector.label || "C").charAt(0))} + + `.replace(/\n+/g, " "); +} + +function buildGmOverlayZoneBadge(zone, index, formState, palette) { + const x = zone.x * formState.gridSize; + const y = zone.y * formState.gridSize; + const label = zone.label || `Encounter Zone ${index + 1}`; + return ` + + + ${escapeXml(label)} + + `.replace(/\n+/g, " "); +} + +function buildRoomSectionLine(room) { + const parts = []; + if (room.type) parts.push(humanizeRoomType(room.type)); + if (room.factionName) parts.push(room.factionName); + return parts.join(" • "); +} + +function buildAutomaticRoomNote(deck, room) { + const lines = [ + `Section: ${room.label || humanizeRoomType(room.type) || "Zone"}`, + `Type: ${humanizeRoomType(room.type) || "Unassigned"}`, + `Deck: ${deck.deckName}` + ]; + if (room.factionName) lines.push(`Faction: ${room.factionName}`); + if (Array.isArray(room.markers) && room.markers.length) { + const markers = room.markers.map((marker) => marker.text || marker.label).filter(Boolean); + if (markers.length) lines.push(`Markers: ${markers.join(", ")}`); + } + return lines.join("\n"); +} + +function humanizeRoomType(value) { + return String(value ?? "") + .trim() + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function buildDeckConnectorSvg(connectors, gridSize, palette) { + const connectorTextStroke = textStrokeFor(palette.connectorText); + return connectors.map((connector) => { + const cx = connector.x * gridSize + gridSize / 2; + const cy = connector.y * gridSize + gridSize / 2; + const size = Math.max(12, Math.round(gridSize * 0.5)); + const labelY = cy + size * 0.95; + const accessY = labelY + Math.max(8, Math.round(gridSize * 0.14)); + const glyph = connector.type === "ladder" + ? "L" + : connector.type === "stairs" + ? "S" + : "E"; + return ` + + + ${glyph} + ${escapeXml(connector.label)} + ${escapeXml(connector.access ?? "")} + `; + }).join(""); +} + +function buildHullPathSvg(hullMask, gridSize, palette) { + return buildHullWallSegments(hullMask).map((segment) => ` + + `).join(""); +} + +function buildSecretDoorSvg(wallSegments, gridSize, palette) { + return wallSegments + .filter((segment) => segment.door === DOOR_SECRET) + .map((segment) => { + const cx = ((segment.x1 + segment.x2) / 2) * gridSize; + const cy = ((segment.y1 + segment.y2) / 2) * gridSize; + const r = Math.max(5, Math.round(gridSize * 0.1)); + return `S`; + }) + .join(""); +} + +function buildExteriorOpeningsSvg(deck, gridSize, palette) { + const windows = (deck.exteriorOpenings ?? []).filter((segment) => segment.isWindow).map((segment) => { + const x1 = segment.x1 * gridSize; + const y1 = segment.y1 * gridSize; + const x2 = segment.x2 * gridSize; + const y2 = segment.y2 * gridSize; + return ``; + }); + + const doors = (deck.exteriorOpenings ?? []).filter((segment) => segment.door).map((segment) => buildExteriorDoorGlyph(segment, gridSize, palette)); + const airlocks = (deck.airlocks ?? []).map((airlock) => { + const x = airlock.x * gridSize; + const y = airlock.y * gridSize; + const isVertical = airlock.edge === "V"; + const width = isVertical ? Math.max(8, Math.round(gridSize * 0.14)) : gridSize; + const height = isVertical ? gridSize : Math.max(8, Math.round(gridSize * 0.14)); + const cx = isVertical ? x : x + gridSize / 2; + const cy = isVertical ? y + gridSize / 2 : y; + const box = ``; + const stripe1 = isVertical + ? `` + : ``; + const stripe2 = isVertical + ? `` + : ``; + return `${box}${stripe1}${stripe2}`; + }); + + return [...windows, ...doors, ...airlocks].join(""); +} + +function buildExteriorDoorGlyph(segment, gridSize, palette) { + const x1 = segment.x1 * gridSize; + const y1 = segment.y1 * gridSize; + const x2 = segment.x2 * gridSize; + const y2 = segment.y2 * gridSize; + const doorStroke = Math.max(4, Math.round(gridSize * 0.08)); + return ``; +} + +function buildScifiRoomOverlaySvg(rooms, gridSize, palette) { + return rooms.map((room) => { + const bounds = roomBounds(room); + const center = roomCenter(room); + const px = bounds.x * gridSize; + const py = bounds.y * gridSize; + const pw = bounds.width * gridSize; + const ph = bounds.height * gridSize; + const cx = center.x * gridSize; + const cy = center.y * gridSize; + switch (room.type) { + case "reactor": + return buildReactorOverlay(cx, cy, Math.min(pw, ph) * 0.38, palette); + case "airlock": + case "escape-pods": + case "weapon-bay": + return buildHazardStripeOverlay(px, py, pw, ph, palette); + case "cargo": + return buildCargoHatchOverlay(px, py, pw, ph, palette); + case "medical": + return buildMedicalCrossOverlay(cx, cy, Math.min(pw, ph) * 0.22, palette); + case "bridge": + return buildBridgeDisplayOverlay(cx, cy, pw, ph, palette); + case "brig": + return buildBrigBarOverlay(px, py, pw, ph, palette); + case "observation": + return buildViewportOverlay(cx, cy, Math.min(pw, ph) * 0.3, palette); + default: + return ""; + } + }).join(""); +} + +function buildDungeonRoomOverlaySvg(rooms, gridSize, palette, theme, formState = {}) { + const overlays = rooms.map((room) => { + const bounds = roomBounds(room); + const center = roomCenter(room); + const px = bounds.x * gridSize; + const py = bounds.y * gridSize; + const pw = bounds.width * gridSize; + const ph = bounds.height * gridSize; + const cx = center.x * gridSize; + const cy = center.y * gridSize; + const r = Math.min(pw, ph) * 0.3; + switch (room.type) { + case "shrine": + case "ritual-room": + return buildRitualCircleOverlay(cx, cy, r, palette); + case "boss-chamber": + case "throne-room": + return buildBossFrameOverlay(px, py, pw, ph, palette); + case "cistern": + return buildWaterPoolOverlay(cx, cy, r * 1.1, palette); + case "vault": + return buildVaultFloorOverlay(px, py, pw, ph, palette); + case "crypt": + return buildCryptStoneSlotsOverlay(px, py, pw, ph, gridSize, palette); + case "torture": + return buildTortureStainOverlay(cx, cy, r * 0.6, palette); + case "collapsed": + case "collapse": + return buildRubbleOverlay(px, py, pw, ph, palette); + case "laboratory": + case "alchemy": + return buildLabBenchOverlay(px, py, pw, ph, palette); + case "living-room": + case "entrance-hall": + case "surgery": + case "padded-cell": + return ["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(theme) + ? buildHorrorRoomOverlay(room.type, px, py, pw, ph, cx, cy, palette, formState) + : ""; + default: + return ""; + } + }); + return overlays.join(""); +} + +function buildReactorOverlay(cx, cy, r, palette) { + const glow = palette.detail; + return ``; +} + +function buildHazardStripeOverlay(px, py, pw, ph, palette) { + const sw = 12; + const stripes = []; + for (let x = px - ph; x < px + pw; x += sw * 2) { + stripes.push(``); + } + return `${stripes.join("")}`; +} + +function buildCargoHatchOverlay(px, py, pw, ph, palette) { + const lines = []; + const step = Math.max(12, Math.round(Math.min(pw, ph) * 0.18)); + for (let i = 1; i * step < pw; i += 1) { + lines.push(``); + } + return lines.join(""); +} + +function buildMedicalCrossOverlay(cx, cy, r, palette) { + const t = r * 0.35; + return ``; +} + +function buildBridgeDisplayOverlay(cx, cy, pw, ph, palette) { + const r = Math.min(pw, ph) * 0.28; + return ``; +} + +function buildBrigBarOverlay(px, py, pw, ph, palette) { + const barCount = Math.max(3, Math.round(pw / 18)); + const barSpacing = pw / (barCount + 1); + const bars = []; + for (let i = 1; i <= barCount; i += 1) { + bars.push(``); + } + return bars.join(""); +} + +function buildViewportOverlay(cx, cy, r, palette) { + return ``; +} + +function buildRitualCircleOverlay(cx, cy, r, palette) { + const pts = 12; + const star = Array.from({ length: pts }, (_, index) => { + const angle = (Math.PI / 6) * index - Math.PI / 2; + const radius = index % 2 === 0 ? r : r * 0.45; + return `${Math.round(cx + Math.cos(angle) * radius)},${Math.round(cy + Math.sin(angle) * radius)}`; + }).join(" "); + return ``; +} + +function buildBossFrameOverlay(px, py, pw, ph, palette) { + const m = Math.min(pw, ph) * 0.08; + return ``; +} + +function buildWaterPoolOverlay(cx, cy, r, palette) { + return ``; +} + +function buildVaultFloorOverlay(px, py, pw, ph, palette) { + const step = Math.max(14, Math.round(Math.min(pw, ph) * 0.16)); + const parts = []; + for (let x = px + step; x < px + pw - step / 2; x += step) parts.push(``); + for (let y = py + step; y < py + ph - step / 2; y += step) parts.push(``); + return `${parts.join("")}`; +} + +function buildCryptStoneSlotsOverlay(px, py, pw, ph, gridSize, palette) { + const parts = []; + const slotHeight = Math.max(10, Math.round(gridSize * 0.4)); + for (let y = py + slotHeight; y < py + ph - slotHeight / 2; y += slotHeight * 1.8) { + parts.push(``); + } + return `${parts.join("")}`; +} + +function buildTortureStainOverlay(cx, cy, r, palette) { + return ``; +} + +function buildRubbleOverlay(px, py, pw, ph, palette) { + const rubble = []; + for (let i = 0; i < 8; i += 1) { + const x = px + ((i * 37) % Math.max(40, pw - 12)); + const y = py + ((i * 29) % Math.max(40, ph - 12)); + rubble.push(``); + } + return `${rubble.join("")}`; +} + +function buildLabBenchOverlay(px, py, pw, ph, palette) { + const inset = Math.max(8, Math.round(Math.min(pw, ph) * 0.1)); + return ``; +} + +function buildHorrorRoomOverlay(type, px, py, pw, ph, cx, cy, palette, formState) { + if (type === "surgery") return ``; + if (type === "padded-cell") return ``; + return ``; +} + +function buildWeatheringOverlay(width, height, palette, formState) { + if (!formState.mapAge) return ""; + const opacity = (formState.mapAge / 100) * 0.24; + const cracks = []; + for (let index = 0; index < 8; index += 1) { + const x = ((index * 113) % Math.max(80, width - 30)) + 12; + const y = ((index * 79) % Math.max(80, height - 30)) + 12; + cracks.push(``); + } + const stains = []; + for (let index = 0; index < 5; index += 1) { + const x = ((index * 151) % Math.max(90, width - 60)) + 30; + const y = ((index * 97) % Math.max(90, height - 50)) + 20; + stains.push(``); + } + return `${cracks.join("")}${stains.join("")}`; +} + +function getAgeOpacity(formState = {}) { + return 1 - ((Number(formState.mapAge) || 0) / 100) * 0.35; +} + +function buildWildernessOverlaySvg(deck, gridSize, palette, formState) { + const parts = []; + const ageOpacity = getAgeOpacity(formState); + + for (const corridor of deck.corridors ?? []) { + const x = corridor.x * gridSize; + const y = corridor.y * gridSize; + const w = corridor.width * gridSize; + const h = corridor.height * gridSize; + parts.push(``); + const isHorizontal = corridor.width >= corridor.height; + if (isHorizontal) { + parts.push(``); + } else { + parts.push(``); + } + } + + for (const room of deck.rooms ?? []) { + const px = room.x * gridSize; + const py = room.y * gridSize; + const pw = room.width * gridSize; + const ph = room.height * gridSize; + parts.push(``); + } + + for (const cluster of deck.wilderness?.treeClusters ?? []) { + for (let i = 0; i < cluster.count; i += 1) { + const angle = (Math.PI * 2 * i) / Math.max(1, cluster.count); + const dist = cluster.radius * 0.55 * ((i % 3) + 1) / 3; + const x = (cluster.x + Math.cos(angle) * dist) * gridSize; + const y = (cluster.y + Math.sin(angle) * dist) * gridSize; + const r = Math.max(6, gridSize * 0.22); + parts.push(``); + parts.push(``); + } + } + + for (const water of deck.wilderness?.waterFeatures ?? []) { + parts.push(``); + parts.push(``); + } + + for (const ridge of deck.wilderness?.ridgelines ?? []) { + parts.push(``); + parts.push(``); + } + + for (const ravine of deck.wilderness?.ravines ?? []) { + parts.push(``); + parts.push(``); + } + + for (const zone of deck.wilderness?.encounterZones ?? []) { + parts.push(``); + } + + return parts.join(""); +} + +function buildFurnitureForDeck(deck, formState, furnitureTables, rng) { + if (formState.furnitureDensity === "off") return []; + const densityMultiplier = { sparse: 0.5, normal: 1, dense: 1.6 }[formState.furnitureDensity] ?? 1; + const modeKey = ["bsp", "corridor"].includes(formState.mode) ? "scifi" : formState.mode; + const tableSource = furnitureTables[modeKey] ?? {}; + const doorZonesByRoom = buildRoomDoorAvoidanceZones(deck); + const furniture = []; + + for (const room of deck.rooms) { + if (!room.type || room.type === "void") continue; + const archetypes = tableSource[room.type]; + if (!archetypes?.length) continue; + const placed = []; + for (const archetype of archetypes) { + const [minQty, maxQty] = archetype.qty ?? [0, 0]; + const archDensityMod = archetype.densityMod ?? 1.0; + let qty = Math.round(randomInt(rng, minQty, maxQty) * densityMultiplier * archDensityMod); + if (archetype.placement === "corners") qty = Math.min(qty, 4); + for (let index = 0; index < qty; index += 1) { + const piece = tryPlaceFurniturePiece(archetype, room, placed, doorZonesByRoom.get(room.id) ?? [], rng); + if (!piece) continue; + furniture.push(piece); + placed.push({ x: piece.x, y: piece.y, width: piece.w, height: piece.h }); + } + } + } + + return furniture; +} + +function tryPlaceFurniturePiece(archetype, room, placed, doorZones, rng) { + const margin = 1; + const maxAttempts = archetype.placement === "corners" ? 12 : 8; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const pos = pickFurniturePosition(archetype, room, margin, rng, placed); + if (!pos) continue; + const rect = { x: pos.x, y: pos.y, width: pos.w, height: pos.h }; + if (rect.x < room.x + margin || rect.y < room.y + margin) continue; + if (rect.x + rect.width > room.x + room.width - margin) continue; + if (rect.y + rect.height > room.y + room.height - margin) continue; + if (placed.some((existing) => rectsOverlap(existing, rect))) continue; + if (doorZones.some((zone) => rectsOverlap(zone, expandRect(rect, 0)))) continue; + return { ...archetype, x: rect.x, y: rect.y, w: rect.width, h: rect.height, rotation: pos.rotation ?? 0, roomId: room.id }; + } + return null; +} + +function pickFurniturePosition(archetype, room, margin, rng, placed = []) { + const innerX1 = room.x + margin; + const innerY1 = room.y + margin; + const randomRotation = archetype.w !== archetype.h && rng() > 0.5 ? 90 : 0; + + switch (archetype.placement) { + case "wall": { + const side = Math.floor(rng() * 4); + const dims = side >= 2 && archetype.w !== archetype.h + ? { w: archetype.h, h: archetype.w, rotation: 90 } + : { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + if (side === 0) return { x: randomInt(rng, innerX1, innerX2), y: innerY1, ...dims }; + if (side === 1) return { x: randomInt(rng, innerX1, innerX2), y: room.y + room.height - margin - dims.h, ...dims }; + if (side === 2) return { x: innerX1, y: randomInt(rng, innerY1, innerY2), ...dims }; + return { x: room.x + room.width - margin - dims.w, y: randomInt(rng, innerY1, innerY2), ...dims }; + } + case "far-wall": { + const dims = { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + const farY = room.y + room.height - margin - dims.h; + return { + x: clamp(Math.floor(room.x + (room.width - dims.w) / 2), innerX1, innerX2), + y: clamp(farY, innerY1, innerY2), + ...dims + }; + } + case "center": { + const dims = randomRotation === 90 ? { w: archetype.h, h: archetype.w, rotation: 90 } : { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + const cx = room.x + Math.floor((room.width - dims.w) / 2) + randomInt(rng, -1, 1); + const cy = room.y + Math.floor((room.height - dims.h) / 2) + randomInt(rng, -1, 1); + return { x: clamp(cx, innerX1, innerX2), y: clamp(cy, innerY1, innerY2), ...dims }; + } + case "corners": { + const dims = { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + const corners = [ + { x: innerX1, y: innerY1, ...dims }, + { x: innerX2, y: innerY1, ...dims }, + { x: innerX1, y: innerY2, ...dims }, + { x: innerX2, y: innerY2, ...dims } + ]; + const unused = corners.filter((corner) => !placed.some((existing) => existing.x === corner.x && existing.y === corner.y)); + const pool = unused.length ? unused : corners; + return pool[Math.floor(rng() * pool.length)]; + } + case "scatter": + default: + { + const dims = randomRotation === 90 ? { w: archetype.h, h: archetype.w, rotation: 90 } : { w: archetype.w, h: archetype.h, rotation: 0 }; + const innerX2 = room.x + room.width - margin - dims.w; + const innerY2 = room.y + room.height - margin - dims.h; + if (innerX2 < innerX1 || innerY2 < innerY1) return null; + return { x: randomInt(rng, innerX1, innerX2), y: randomInt(rng, innerY1, innerY2), ...dims }; + } + } +} + +function buildRoomDoorAvoidanceZones(deck) { + const out = new Map(); + for (const room of deck.rooms) out.set(room.id, []); + for (const segment of deck.wallSegments ?? []) { + if (!segment.door) continue; + for (const room of deck.rooms) { + const zone = buildDoorAvoidanceZone(room, segment); + if (zone) out.get(room.id)?.push(zone); + } + } + return out; +} + +function buildDoorAvoidanceZone(room, segment) { + if (!segmentTouchesRoom(segment, room)) return null; + if (segment.x1 === segment.x2) { + const doorY = Math.min(segment.y1, segment.y2); + if (segment.x1 === room.x) return clampRectToRoom(room, { x: room.x, y: doorY - 1, width: 2, height: 3 }); + if (segment.x1 === room.x + room.width) return clampRectToRoom(room, { x: room.x + room.width - 2, y: doorY - 1, width: 2, height: 3 }); + } else { + const doorX = Math.min(segment.x1, segment.x2); + if (segment.y1 === room.y) return clampRectToRoom(room, { x: doorX - 1, y: room.y, width: 3, height: 2 }); + if (segment.y1 === room.y + room.height) return clampRectToRoom(room, { x: doorX - 1, y: room.y + room.height - 2, width: 3, height: 2 }); + } + return null; +} + +function clampRectToRoom(room, rect) { + const x1 = Math.max(room.x, rect.x); + const y1 = Math.max(room.y, rect.y); + const x2 = Math.min(room.x + room.width, rect.x + rect.width); + const y2 = Math.min(room.y + room.height, rect.y + rect.height); + if (x2 <= x1 || y2 <= y1) return null; + return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; +} + +async function createDeckBackgroundAsset(deckPlan, deck, formState) { + return createSvgAsset(`${slugify(deckPlan.shipName)}-deck-${deck.deckIndex + 1}`, buildDeckSVG(deckPlan, deck, formState)); +} + +async function createGmOverlayAsset(deckPlan, deck, formState) { + return createSvgAsset(`${slugify(deckPlan.shipName)}-deck-${deck.deckIndex + 1}-gm-overlay`, buildGmOverlaySVG(deckPlan, deck, formState)); +} + +async function createSvgAsset(baseName, svg) { + const FilePickerImpl = foundry.applications?.apps?.FilePicker?.implementation ?? globalThis.FilePicker; + const worldId = game.world?.id; + if (!worldId) return null; + + const targetDir = `worlds/${worldId}/${ASSET_DIR}`; + const filename = `${slugify(baseName)}-${Date.now()}.svg`; + await FilePickerImpl.createDirectory("data", targetDir).catch(() => {}); + const file = new File([svg], filename, { type: "image/svg+xml" }); + const response = await FilePickerImpl.upload("data", targetDir, file, {}, { notify: false }); + return response?.path ?? `${targetDir}/${filename}`; +} + +function wallSegmentToDocument(segment, gridSize) { + const isWindow = Boolean(segment.isWindow); + const movementOnly = Boolean(segment.movementOnly); + const doorValue = segment.door === DOOR_SECRET + ? DOOR_SECRET + : (segment.door ? DOOR_BASIC : DOOR_NONE); + const wall = { + c: [ + round(segment.x1 * gridSize), + round(segment.y1 * gridSize), + round(segment.x2 * gridSize), + round(segment.y2 * gridSize) + ], + move: segment.move ?? MOVEMENT_NORMAL, + sight: movementOnly || isWindow ? SENSE_NONE : SENSE_NORMAL, + sound: movementOnly ? SENSE_NONE : SENSE_NORMAL, + light: movementOnly || isWindow ? SENSE_NONE : SENSE_NORMAL, + dir: CONST.WALL_DIRECTIONS.BOTH, + door: doorValue, + ds: segment.ds ?? DOOR_STATE_CLOSED + }; + return wall; +} + +function withGeneratedWallFlag(wall) { + return { + ...wall, + flags: { + ...(wall.flags ?? {}), + [MODULE_ID]: { + ...(wall.flags?.[MODULE_ID] ?? {}), + generatedWall: true + } + } + }; +} + +function withGeneratedDrawingFlag(drawing) { + return { + ...drawing, + flags: { + ...(drawing.flags ?? {}), + [MODULE_ID]: { + ...(drawing.flags?.[MODULE_ID] ?? {}), + generatedDrawing: true + } + } + }; +} + +function withGeneratedTileFlag(tile) { + return { + ...tile, + flags: { + ...(tile.flags ?? {}), + [MODULE_ID]: { + ...(tile.flags?.[MODULE_ID] ?? {}), + generatedTile: true + } + } + }; +} + +function sanitizeGeneratedDrawings(drawings = []) { + return drawings.filter((drawing) => { + const polygonPoints = Array.isArray(drawing?.shape?.points) ? drawing.shape.points : []; + const width = Number(drawing?.shape?.width ?? 0); + const height = Number(drawing?.shape?.height ?? 0); + const hasPolygon = drawing?.shape?.type === "p" && polygonPoints.length >= 6; + if (!hasPolygon && (!(width > 0) || !(height > 0))) return false; + + const textVisible = Number(drawing?.textAlpha ?? 0) > 0 && String(drawing?.text ?? "").trim().length > 0; + const fillVisible = Number(drawing?.fillAlpha ?? 0) > 0 && Number(drawing?.fillType ?? 0) !== 0; + const strokeVisible = Number(drawing?.strokeAlpha ?? 0) > 0 && Number(drawing?.strokeWidth ?? 0) > 0; + + return textVisible || fillVisible || strokeVisible; + }); +} + +function withGeneratedLightFlag(light) { + return { + ...light, + flags: { + ...(light.flags ?? {}), + [MODULE_ID]: { + ...(light.flags?.[MODULE_ID] ?? {}), + generatedLight: true + } + } + }; +} + +function withGeneratedTokenFlag(token) { + return { + ...token, + flags: { + ...(token.flags ?? {}), + [MODULE_ID]: { + ...(token.flags?.[MODULE_ID] ?? {}), + generatedToken: true + } + } + }; +} + +function withGeneratedNoteFlag(note) { + return { + ...note, + flags: { + ...(note.flags ?? {}), + [MODULE_ID]: { + ...(note.flags?.[MODULE_ID] ?? {}), + generatedNote: true + } + } + }; +} + +async function buildEncounterTokens(deck, formState) { + const preset = resolveEncounterPreset(formState); + const packActors = await getEncounterPackActors(formState.encounterPack); + const targets = formState.mode === "wilderness" + ? (deck.wilderness?.encounterZones ?? []).map((zone) => ({ + type: "encounter-zone", + label: zone.label, + x: zone.x, + y: zone.y, + width: 2, + height: 2 + })) + : deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)); + const densityBase = { light: 1, standard: 2, heavy: 3 }[formState.encounterDensity] ?? 2; + const tokens = []; + let counter = 0; + for (const room of targets) { + const count = determineEncounterCount(room, densityBase); + const center = roomCenter(room); + for (let index = 0; index < count; index += 1) { + const offset = encounterOffset(index, count); + const actor = packActors.length ? packActors[counter % packActors.length] : null; + tokens.push({ + x: Math.round((center.x - 0.5 + offset.x) * formState.gridSize), + y: Math.round((center.y - 0.5 + offset.y) * formState.gridSize), + width: 1, + height: 1, + hidden: true, + name: actor?.name || buildEncounterName(preset, room, counter), + texture: { + src: actor?.texture || preset.icon + }, + flags: actor + ? { + [MODULE_ID]: { + encounterPack: formState.encounterPack, + sourceActorUuid: actor.uuid, + sourceActorId: actor.id + } + } + : undefined + }); + counter += 1; + } + } + return tokens; +} + +async function getEncounterPackActors(packId) { + const key = String(packId ?? "").trim(); + if (!key) return []; + if (encounterPackCache.has(key)) return encounterPackCache.get(key); + const pack = game.packs?.get(key); + if (!pack || pack.documentName !== "Actor") { + encounterPackCache.set(key, []); + return []; + } + const docs = await pack.getDocuments().catch(() => []); + const actors = docs + .filter((doc) => doc) + .map((doc) => ({ + id: doc.id, + uuid: doc.uuid, + name: doc.name, + texture: doc.prototypeToken?.texture?.src || doc.img || "icons/svg/mystery-man.svg" + })); + encounterPackCache.set(key, actors); + return actors; +} + +async function buildRoomPopulationTokens(deck, formState) { + const actors = await getEncounterPackActors(formState.roomPopulationPack); + if (!actors.length) return []; + const tokens = []; + for (const room of deck.rooms) { + const actor = matchActorToRoom(actors, room); + if (!actor) continue; + const center = roomCenter(room); + tokens.push({ + x: Math.round((center.x - 0.5) * formState.gridSize), + y: Math.round((center.y - 0.5) * formState.gridSize), + width: 1, + height: 1, + hidden: false, + name: actor.name, + texture: { + src: actor.texture + }, + flags: { + [MODULE_ID]: { + roomPopulation: true, + roomId: room.id, + sourceActorUuid: actor.uuid, + sourceActorId: actor.id, + sourceRoomType: room.type ?? "", + sourceRoomLabel: room.label ?? "" + } + } + }); + } + return tokens; +} + +function matchActorToRoom(actors, room) { + const tokens = roomMatchTokens(room); + return actors.find((actor) => { + const name = normalizeMatchText(actor.name); + return tokens.some((token) => name.includes(token)); + }) ?? null; +} + +function roomMatchTokens(room) { + const values = [room.type, room.label, ...(room.markers ?? []).map((marker) => marker.text || marker.label)].filter(Boolean); + const set = new Set(); + for (const value of values) { + const normalized = normalizeMatchText(value); + if (!normalized) continue; + set.add(normalized); + normalized.split(/\s+/).filter((part) => part.length > 2).forEach((part) => set.add(part)); + } + return Array.from(set); +} + +function normalizeMatchText(value) { + return String(value ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function resolveEncounterPreset(formState) { + const auto = (() => { + if (formState.encounterFaction !== "auto") return formState.encounterFaction; + if (["sla-mothership", "sla-industries-brp"].includes(game.system?.id ?? "")) return "sla"; + if (formState.mode === "wilderness") return "wildlife"; + if (formState.mode === "dungeon") return formState.theme === "crypt" ? "undead" : "cult"; + if (["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(formState.theme)) return "cult"; + return "marines"; + })(); + + const presets = { + corporate: { key: "corporate", icon: "icons/svg/shield.svg", names: ["Guard", "Security", "Response Team"] }, + marines: { key: "marines", icon: "icons/svg/sword.svg", names: ["Trooper", "Marine", "Assault Team"] }, + cult: { key: "cult", icon: "icons/svg/eye.svg", names: ["Cultist", "Fanatic", "Acolyte"] }, + undead: { key: "undead", icon: "icons/svg/skull.svg", names: ["Undead", "Wight", "Ghoul"] }, + wildlife: { key: "wildlife", icon: "icons/svg/pawprint.svg", names: ["Beast", "Predator", "Lurker"] }, + sla: { key: "sla", icon: "icons/svg/target.svg", names: ["Operative", "Contractor", "Sector Team"] } + }; + return presets[auto] ?? presets.marines; +} + +function determineEncounterCount(room, densityBase) { + const bonus = ["boss-chamber", "barracks", "camp", "encounter-zone"].includes(room.type) ? 1 : 0; + return Math.max(1, densityBase + bonus - (["vault", "basement"].includes(room.type) ? 1 : 0)); +} + +function encounterOffset(index, count) { + if (count <= 1) return { x: 0, y: 0 }; + const positions = [ + { x: -0.35, y: -0.2 }, + { x: 0.35, y: -0.2 }, + { x: -0.25, y: 0.28 }, + { x: 0.25, y: 0.28 } + ]; + return positions[index % positions.length]; +} + +function buildEncounterName(preset, room, counter) { + const base = preset.names[counter % preset.names.length]; + const suffix = ENCOUNTER_ROOM_LABELS[room.type] ?? room.label ?? "Hostile"; + return `${base} (${suffix})`; +} + +function splitRectExactCount(root, targetCount, splitBias, minRoomSize, rng) { + const rooms = [{ ...root }]; + while (rooms.length < targetCount) { + const candidates = rooms + .map((room, index) => ({ room, index })) + .filter(({ room }) => room.width >= minRoomSize * 2 || room.height >= minRoomSize * 2) + .sort((a, b) => (b.room.width * b.room.height) - (a.room.width * a.room.height)); + const candidate = candidates[0]; + if (!candidate) break; + const room = rooms.splice(candidate.index, 1)[0]; + const splitVertical = chooseSplitOrientation(room, splitBias, rng); + if (splitVertical && room.width >= minRoomSize * 2) { + const split = randomInt(rng, minRoomSize, room.width - minRoomSize); + rooms.push( + { x: room.x, y: room.y, width: split, height: room.height }, + { x: room.x + split, y: room.y, width: room.width - split, height: room.height } + ); + continue; + } + if (room.height >= minRoomSize * 2) { + const split = randomInt(rng, minRoomSize, room.height - minRoomSize); + rooms.push( + { x: room.x, y: room.y, width: room.width, height: split }, + { x: room.x, y: room.y + split, width: room.width, height: room.height - split } + ); + continue; + } + rooms.push(room); + break; + } + return rooms.slice(0, targetCount); +} + +function buildSpineWalls(spineRect, rooms) { + const northEdge = spineRect.y; + const southEdge = spineRect.y + spineRect.height; + const northRooms = rooms.filter((room) => room.y + room.height === northEdge); + const southRooms = rooms.filter((room) => room.y === southEdge); + const segments = []; + + segments.push(...edgeWithGaps( + { x1: spineRect.x, y1: northEdge, x2: spineRect.x + spineRect.width, y2: northEdge }, + northRooms.map((room) => ({ x1: room.x + 1, y1: northEdge, x2: room.x + Math.max(1, room.width - 1), y2: northEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + + segments.push(...edgeWithGaps( + { x1: spineRect.x, y1: southEdge, x2: spineRect.x + spineRect.width, y2: southEdge }, + southRooms.map((room) => ({ x1: room.x + 1, y1: southEdge, x2: room.x + Math.max(1, room.width - 1), y2: southEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + + return segments; +} + +function buildHallwayWalls(hallRect, rooms) { + const segments = []; + const isHorizontal = hallRect.width >= hallRect.height; + if (isHorizontal) { + const northEdge = hallRect.y; + const southEdge = hallRect.y + hallRect.height; + const northRooms = rooms.filter((room) => room.y + room.height === northEdge); + const southRooms = rooms.filter((room) => room.y === southEdge); + segments.push(...edgeWithGaps( + { x1: hallRect.x, y1: northEdge, x2: hallRect.x + hallRect.width, y2: northEdge }, + northRooms.map((room) => ({ x1: room.x + 1, y1: northEdge, x2: room.x + Math.max(1, room.width - 1), y2: northEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + segments.push(...edgeWithGaps( + { x1: hallRect.x, y1: southEdge, x2: hallRect.x + hallRect.width, y2: southEdge }, + southRooms.map((room) => ({ x1: room.x + 1, y1: southEdge, x2: room.x + Math.max(1, room.width - 1), y2: southEdge })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + } else { + const westEdge = hallRect.x; + const eastEdge = hallRect.x + hallRect.width; + const westRooms = rooms.filter((room) => room.x + room.width === westEdge); + const eastRooms = rooms.filter((room) => room.x === eastEdge); + segments.push(...edgeWithGaps( + { x1: westEdge, y1: hallRect.y, x2: westEdge, y2: hallRect.y + hallRect.height }, + westRooms.map((room) => ({ x1: westEdge, y1: room.y + 1, x2: westEdge, y2: room.y + Math.max(1, room.height - 1) })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + segments.push(...edgeWithGaps( + { x1: eastEdge, y1: hallRect.y, x2: eastEdge, y2: hallRect.y + hallRect.height }, + eastRooms.map((room) => ({ x1: eastEdge, y1: room.y + 1, x2: eastEdge, y2: room.y + Math.max(1, room.height - 1) })) + ).map((segment) => segment.gap + ? { ...segment, door: true, ds: DOOR_STATE_CLOSED } + : { ...segment })); + } + return segments; +} + +function edgeWithGaps(edge, gaps) { + if (edge.y1 === edge.y2) { + return splitLinearEdge(edge.x1, edge.x2, edge.y1, gaps.map((gap) => [gap.x1, gap.x2]), "h"); + } + return splitLinearEdge(edge.y1, edge.y2, edge.x1, gaps.map((gap) => [gap.y1, gap.y2]), "v"); +} + +function splitLinearEdge(start, end, fixed, gaps, orientation) { + const normalized = gaps + .map(([a, b]) => [Math.max(start, Math.min(a, b)), Math.min(end, Math.max(a, b))]) + .filter(([a, b]) => b > a) + .sort((a, b) => a[0] - b[0]); + const segments = []; + let cursor = start; + for (const [gapStart, gapEnd] of normalized) { + if (gapStart > cursor) { + segments.push(orientation === "h" + ? { x1: cursor, y1: fixed, x2: gapStart, y2: fixed } + : { x1: fixed, y1: cursor, x2: fixed, y2: gapStart }); + } + segments.push(orientation === "h" + ? { x1: gapStart, y1: fixed, x2: gapEnd, y2: fixed, gap: true } + : { x1: fixed, y1: gapStart, x2: fixed, y2: gapEnd, gap: true }); + cursor = gapEnd; + } + if (cursor < end) { + segments.push(orientation === "h" + ? { x1: cursor, y1: fixed, x2: end, y2: fixed } + : { x1: fixed, y1: cursor, x2: fixed, y2: end }); + } + return segments; +} + +function chooseSplitOrientation(room, splitBias, rng) { + if (splitBias === "horizontal") return false; + if (splitBias === "vertical") return true; + if (room.width === room.height) return rng() > 0.5; + return room.width > room.height; +} + +function buildPartitionSegments(rooms, rng) { + const segments = []; + const seen = new Set(); + for (let i = 0; i < rooms.length; i += 1) { + for (let j = i + 1; j < rooms.length; j += 1) { + const shared = sharedWall(rooms[i], rooms[j]); + if (!shared) continue; + const key = shared.orientation === "v" + ? `v:${shared.x}:${shared.y1}:${shared.y2}` + : `h:${shared.y}:${shared.x1}:${shared.x2}`; + if (seen.has(key)) continue; + seen.add(key); + segments.push(...splitSharedWall(shared, rng)); + } + } + return segments; +} + +function dedupeLights(lights) { + const seen = new Set(); + return lights.filter((light) => { + const key = `${light.x},${light.y},${light.config?.dim ?? 0},${light.config?.bright ?? 0}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function sharedWall(a, b) { + if (a.x + a.width === b.x || b.x + b.width === a.x) { + const x = a.x + a.width === b.x ? b.x : a.x; + const y1 = Math.max(a.y, b.y); + const y2 = Math.min(a.y + a.height, b.y + b.height); + if (y2 - y1 >= 2) return { orientation: "v", x, y1, y2 }; + } + if (a.y + a.height === b.y || b.y + b.height === a.y) { + const y = a.y + a.height === b.y ? b.y : a.y; + const x1 = Math.max(a.x, b.x); + const x2 = Math.min(a.x + a.width, b.x + b.width); + if (x2 - x1 >= 2) return { orientation: "h", y, x1, x2 }; + } + return null; +} + +function splitSharedWall(shared, rng) { + if (shared.orientation === "v") { + const doorY = randomInt(rng, shared.y1, shared.y2 - 1); + const out = []; + if (doorY > shared.y1) out.push({ x1: shared.x, y1: shared.y1, x2: shared.x, y2: doorY }); + out.push({ x1: shared.x, y1: doorY, x2: shared.x, y2: doorY + 1, door: true, ds: 0 }); + if (shared.y2 > doorY + 1) out.push({ x1: shared.x, y1: doorY + 1, x2: shared.x, y2: shared.y2 }); + return out; + } + const doorX = randomInt(rng, shared.x1, shared.x2 - 1); + const out = []; + if (doorX > shared.x1) out.push({ x1: shared.x1, y1: shared.y, x2: doorX, y2: shared.y }); + out.push({ x1: doorX, y1: shared.y, x2: doorX + 1, y2: shared.y, door: true, ds: 0 }); + if (shared.x2 > doorX + 1) out.push({ x1: doorX + 1, y1: shared.y, x2: shared.x2, y2: shared.y }); + return out; +} + +function buildHullDoors(hull, rng) { + const options = [ + { x1: hull.x + Math.floor(hull.width / 2), y1: hull.y, x2: hull.x + Math.floor(hull.width / 2) + 1, y2: hull.y }, + { x1: hull.x + Math.floor(hull.width / 2), y1: hull.y + hull.height, x2: hull.x + Math.floor(hull.width / 2) + 1, y2: hull.y + hull.height }, + { x1: hull.x, y1: hull.y + Math.floor(hull.height / 2), x2: hull.x, y2: hull.y + Math.floor(hull.height / 2) + 1 }, + { x1: hull.x + hull.width, y1: hull.y + Math.floor(hull.height / 2), x2: hull.x + hull.width, y2: hull.y + Math.floor(hull.height / 2) + 1 } + ]; + return [options[randomInt(rng, 0, options.length - 1)]]; +} + +function buildHullDoorsFromMask(hullMask, rng) { + const segments = buildHullWallSegments(hullMask).filter((segment) => { + const length = Math.abs(segment.x2 - segment.x1) + Math.abs(segment.y2 - segment.y1); + return length >= 1; + }); + if (!segments.length) return []; + const picks = []; + const desiredCount = segments.length > 40 ? 2 : 1; + const used = new Set(); + while (picks.length < desiredCount && used.size < segments.length) { + const segment = segments[randomInt(rng, 0, segments.length - 1)]; + const key = `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`; + if (used.has(key)) continue; + used.add(key); + if (segment.x1 === segment.x2) { + const y = Math.min(segment.y1, segment.y2); + picks.push({ x1: segment.x1, y1: y, x2: segment.x2, y2: y + 1 }); + } else { + const x = Math.min(segment.x1, segment.x2); + picks.push({ x1: x, y1: segment.y1, x2: x + 1, y2: segment.y2 }); + } + } + return picks.length ? picks : buildHullDoors(boundingRectFromRegions([{ x: 1, y: 1, width: hullMask[0]?.length ?? 10, height: hullMask.length }]), rng); +} + +function rectangleSegments(x, y, width, height, door = false) { + return [ + { x1: x, y1: y, x2: x + width, y2: y, door }, + { x1: x + width, y1: y, x2: x + width, y2: y + height, door }, + { x1: x + width, y1: y + height, x2: x, y2: y + height, door }, + { x1: x, y1: y + height, x2: x, y2: y, door } + ]; +} + +function buildOccupancyWalls(occupancy, roomType, roomIdGrid, options = {}) { + const edges = []; + const thresholds = []; + const roomDoorTypes = options.roomDoorTypes ?? {}; + const height = occupancy.length; + const width = occupancy[0]?.length ?? 0; + const dirs = [ + { dx: 0, dy: -1, key: "n" }, + { dx: 1, dy: 0, key: "e" }, + { dx: 0, dy: 1, key: "s" }, + { dx: -1, dy: 0, key: "w" } + ]; + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (!occupancy[y]?.[x]) continue; + for (const dir of dirs) { + const nx = x + dir.dx; + const ny = y + dir.dy; + if (nx < 0 || ny < 0 || nx >= width || ny >= height || !occupancy[ny]?.[nx]) { + edges.push(cellEdge(x, y, dir.key)); + continue; + } + if (roomType[y]?.[x] === "room" && roomType[ny]?.[nx] === "corridor") { + thresholds.push({ + ...cellEdge(x, y, dir.key), + roomId: roomIdGrid[y]?.[x] || "" + }); + } + } + } + } + + const uniqueEdges = dedupeSegments(edges); + const thresholdEdges = dedupeSegments(thresholds, true); + const doorEdges = chooseCorridorDoorEdges(thresholdEdges); + const doorKeys = new Set(doorEdges.map((edge) => segmentKey(edge))); + const thresholdWalls = thresholdEdges + .filter((edge) => !doorKeys.has(segmentKey(edge))) + .map(({ roomId, ...edge }) => edge); + const wallSegments = [...uniqueEdges.map((edge) => ({ ...edge })), ...thresholdWalls]; + for (const edge of doorEdges) { + const { roomId, ...doorEdge } = edge; + const doorType = roomDoorTypes[roomId] ?? "normal"; + if (doorType === "none") { + wallSegments.push(doorEdge); + continue; + } + const ds = doorType === "locked" ? DOOR_STATE_LOCKED : DOOR_STATE_OPEN; + wallSegments.push({ ...doorEdge, door: true, ds }); + } + return dedupeSegments(wallSegments, true); +} + +function carveCorridor(from, to, occupancy, roomType, width, hullMask) { + const path = findHullPath(from, to, hullMask); + if (!path.length) return []; + + const painted = new Set(); + for (const point of path) { + const brush = corridorBrush(point, width, hullMask); + for (const cell of brush) { + painted.add(`${cell.x},${cell.y}`); + if (occupancy[cell.y]?.[cell.x] !== undefined) occupancy[cell.y][cell.x] = true; + if (roomType[cell.y]?.[cell.x] !== undefined && roomType[cell.y][cell.x] !== "room") { + roomType[cell.y][cell.x] = "corridor"; + } + } + } + + const rects = compressCellsToRects(Array.from(painted).map((key) => { + const [x, y] = key.split(",").map(Number); + return { x, y }; + })); + + return rects.map((rect) => ({ + ...rect, + waypoints: path.map((point) => ({ x: point.x, y: point.y })), + startDoor: "door", + endDoor: "door" + })); +} + +function ensureCorridorConnectivity(rooms, connections, corridors, occupancy, roomType, corridorWidth, hullMask, formState = null) { + if (rooms.length <= 1) return; + + const connected = reachableRoomIds(rooms, connections); + const reachable = new Set(connected); + + while (reachable.size < rooms.length) { + const unreachableRooms = rooms.filter((room) => !reachable.has(room.id)); + if (!unreachableRooms.length) break; + + let repaired = false; + for (const room of unreachableRooms) { + const rescueTarget = nearestReachableRoom(room, rooms, reachable); + if (!rescueTarget) continue; + const width = formState ? corridorWidthForConnection(room, rescueTarget, formState) : corridorWidth; + const hall = carveCorridor(roomCenter(room), roomCenter(rescueTarget), occupancy, roomType, width, hullMask); + if (!hall.length) continue; + corridors.push(...hall); + connections.push([room.id, rescueTarget.id]); + const updated = reachableRoomIds(rooms, connections); + reachable.clear(); + for (const id of updated) reachable.add(id); + repaired = true; + break; + } + + if (!repaired) break; + } +} + +function corridorWidthForConnection(roomA, roomB, formState) { + const important = new Set(["bridge", "engineering", "boss-chamber", "throne-room", "entrance", "reactor"]); + if (important.has(roomA?.type) || important.has(roomB?.type)) return Math.max(2, formState.corridorWidth); + return formState.corridorWidth; +} + +function roomCenter(room) { + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + return { + x: room.circleX + room.circleR, + y: room.circleY + room.circleR + }; + } + return { + x: room.x + Math.floor(room.width / 2), + y: room.y + Math.floor(room.height / 2) + }; +} + +function roomBounds(room) { + if (room.shape === "circle" && Number.isFinite(room.circleR)) { + return { + x: room.circleX, + y: room.circleY, + width: room.circleR * 2, + height: room.circleR * 2 + }; + } + if (room.shape === "polygon" && room.points?.length) { + const xs = room.points.map((point) => point.x); + const ys = room.points.map((point) => point.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + } + return { + x: room.x, + y: room.y, + width: room.width, + height: room.height + }; +} + +function pointInRoom(point, room) { + return point.x >= room.x + && point.x < room.x + room.width + && point.y >= room.y + && point.y < room.y + room.height; +} + +function nearestRoomForIndex(rooms, index) { + const current = roomCenter(rooms[index]); + let nearest = rooms[0]; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < index; i += 1) { + const centre = roomCenter(rooms[i]); + const distance = Math.abs(current.x - centre.x) + Math.abs(current.y - centre.y); + if (distance < bestDistance) { + bestDistance = distance; + nearest = rooms[i]; + } + } + return nearest; +} + +function nearestReachableRoom(room, rooms, reachableIds) { + const current = roomCenter(room); + let nearest = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const candidate of rooms) { + if (!reachableIds.has(candidate.id) || candidate.id === room.id) continue; + const centre = roomCenter(candidate); + const distance = Math.abs(current.x - centre.x) + Math.abs(current.y - centre.y); + if (distance < bestDistance) { + bestDistance = distance; + nearest = candidate; + } + } + return nearest; +} + +function makeGrid(width, height, value) { + return Array.from({ length: height }, () => Array.from({ length: width }, () => value)); +} + +function fillRectCells(grid, rect, value) { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + if (grid[y]?.[x] !== undefined) grid[y][x] = value; + } + } +} + +function rectsOverlap(a, b) { + return !(a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y); +} + +function expandRect(rect, amount) { + return { + x: rect.x - amount, + y: rect.y - amount, + width: rect.width + amount * 2, + height: rect.height + amount * 2 + }; +} + +function cellEdge(x, y, side) { + if (side === "n") return { x1: x, y1: y, x2: x + 1, y2: y }; + if (side === "s") return { x1: x, y1: y + 1, x2: x + 1, y2: y + 1 }; + if (side === "e") return { x1: x + 1, y1: y, x2: x + 1, y2: y + 1 }; + return { x1: x, y1: y, x2: x, y2: y + 1 }; +} + +function dedupeSegments(segments, preserveDoor = false) { + const map = new Map(); + for (const segment of segments) { + const key = `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`; + if (!preserveDoor || !map.has(key) || segment.door) { + map.set(key, segment); + } + } + return Array.from(map.values()); +} + +function chooseCorridorDoorEdges(thresholdEdges) { + const groupedByRoom = new Map(); + for (const edge of thresholdEdges) { + const roomId = edge.roomId || "unknown"; + if (!groupedByRoom.has(roomId)) groupedByRoom.set(roomId, []); + groupedByRoom.get(roomId).push(edge); + } + + const doorEdges = []; + for (const edges of groupedByRoom.values()) { + const runs = groupContiguousDoorRuns(edges); + for (const run of runs) { + doorEdges.push(run[Math.floor(run.length / 2)]); + } + } + return dedupeSegments(doorEdges, true); +} + +function groupContiguousDoorRuns(edges) { + const buckets = new Map(); + for (const edge of edges) { + const orientation = edge.y1 === edge.y2 ? "h" : "v"; + const line = orientation === "h" ? edge.y1 : edge.x1; + const bucketKey = `${orientation}:${line}`; + if (!buckets.has(bucketKey)) buckets.set(bucketKey, []); + buckets.get(bucketKey).push(edge); + } + + const runs = []; + for (const bucket of buckets.values()) { + bucket.sort((a, b) => { + if (a.y1 === a.y2) return a.x1 - b.x1; + return a.y1 - b.y1; + }); + + let currentRun = []; + for (const edge of bucket) { + if (!currentRun.length) { + currentRun.push(edge); + continue; + } + const previous = currentRun[currentRun.length - 1]; + const contiguous = previous.y1 === previous.y2 + ? previous.x2 === edge.x1 + : previous.y2 === edge.y1; + if (contiguous) { + currentRun.push(edge); + } else { + runs.push(currentRun); + currentRun = [edge]; + } + } + if (currentRun.length) runs.push(currentRun); + } + + return runs; +} + +function reachableRoomIds(rooms, connections) { + if (!rooms.length) return new Set(); + const adjacency = new Map(rooms.map((room) => [room.id, new Set()])); + for (const [a, b] of connections) { + adjacency.get(a)?.add(b); + adjacency.get(b)?.add(a); + } + + const reachable = new Set([rooms[0].id]); + const queue = [rooms[0].id]; + while (queue.length) { + const current = queue.shift(); + for (const next of adjacency.get(current) ?? []) { + if (reachable.has(next)) continue; + reachable.add(next); + queue.push(next); + } + } + return reachable; +} + +function weightedChoice(entries, rng) { + const total = entries.reduce((sum, entry) => sum + (entry.weight ?? 1), 0); + let roll = rng() * total; + for (const entry of entries) { + roll -= entry.weight ?? 1; + if (roll <= 0) return entry; + } + return entries[entries.length - 1]; +} + +function paletteForFormState(formState) { + if (!formState?.compactPrintMode) return themePalette(formState?.theme); + return { + base: "#ffffff", + fill: "#ffffff", + line: "#d4d4d4", + detail: "#888888", + hull: "#222222", + roomFill: "#f7f7f7", + roomStroke: "#222222", + corridorFill: "#ececec", + corridorStripe: "#c8c8c8", + label: "#111111", + coordText: "#444444", + coordCell: "#777777", + connectorFill: "#ffffff", + connectorStroke: "#444444", + connectorText: "#111111", + furnitureFill: "#dcdcdc", + furnitureStroke: "#666666", + furnitureLabel: "#111111", + secretDoor: "#555555", + bloodStain: "#999999", + water: "#c9d7e5", + treeColor: "#aeb8a0" + }; +} + +function themePalette(theme) { + switch (theme) { + case "stone": + return { base: "#4a453e", fill: "#5c5650", line: "#2e2b26", detail: "#8c8278", hull: "#1a1714", roomFill: "#c8c0b4", roomStroke: "#1a1714", corridorFill: "#a89e94", corridorStripe: "#c5bbaf", label: "#f5f0e8", coordText: "#ede6da", coordCell: "#ddd4c4", connectorFill: "#57443b", connectorStroke: "#d3b798", connectorText: "#fff5e6", furnitureFill: "#5a5040", furnitureStroke: "#8a7860", furnitureLabel: "#fff5e6", secretDoor: "#884422", water: "#33485b", bloodStain: "#5a2720" }; + case "cave": + return { base: "#2e3530", fill: "#3d4840", line: "#181f1c", detail: "#6e7f72", hull: "#0d110e", roomFill: "#8fa690", roomStroke: "#0d110e", corridorFill: "#6d8070", corridorStripe: "#91a094", label: "#e8f5ec", coordText: "#d8eadd", coordCell: "#c4d8c8", connectorFill: "#30453a", connectorStroke: "#9fd0af", connectorText: "#effff4", furnitureFill: "#3a3028", furnitureStroke: "#7a6a50", furnitureLabel: "#effff4", secretDoor: "#9c5f3a", water: "#1f4960", bloodStain: "#4a1714" }; + case "wood": + case "wood-floor": + return { base: "#4a3828", fill: "#5c4a35", line: "#2b2015", detail: "#9c7c56", hull: "#160f08", roomFill: "#c8aa88", roomStroke: "#1a1008", corridorFill: "#a88c6c", label: "#fff4e2", coordText: "#f5e4c4", coordCell: "#e8d0a8", connectorFill: "#5d3922", connectorStroke: "#dca86a", connectorText: "#fff1dd", furnitureFill: "#6a4a28", furnitureStroke: "#9a7040", furnitureLabel: "#fff1dd", secretDoor: "#a45d2d" }; + case "crypt": + return { base: "#2a2e35", fill: "#373d46", line: "#151820", detail: "#6070a0", hull: "#0a0c12", roomFill: "#a0aac0", roomStroke: "#0a0c12", corridorFill: "#808898", corridorStripe: "#a4adbf", label: "#e8eeff", coordText: "#d4d8f0", coordCell: "#c0c8e0", connectorFill: "#283152", connectorStroke: "#91a7de", connectorText: "#eef3ff", furnitureFill: "#3a3040", furnitureStroke: "#7a6a8a", furnitureLabel: "#eef3ff", secretDoor: "#b36b58", water: "#27395a", bloodStain: "#4c151d" }; + case "sewer": + return { base: "#2e3828", fill: "#3c4835", line: "#1a2018", detail: "#7e9060", hull: "#0e1410", roomFill: "#a0b890", roomStroke: "#0e1410", corridorFill: "#7c9870", corridorStripe: "#9bb38f", label: "#eafff0", coordText: "#d4f0da", coordCell: "#b8e0c0", connectorFill: "#3c4a28", connectorStroke: "#b5d06d", connectorText: "#f6ffe2", furnitureFill: "#2a3a28", furnitureStroke: "#5a7a50", furnitureLabel: "#f6ffe2", secretDoor: "#996644", water: "#2b4731", bloodStain: "#4f241c" }; + case "concrete-office": + return { base: "#5a5f64", fill: "#767b80", line: "#2f3438", detail: "#a5adb7", hull: "#171a1d", roomFill: "#dddfe1", roomStroke: "#23272a", corridorFill: "#cacdd1", corridorStripe: "#9fc7f1", label: "#ffffff", coordText: "#f0f4f8", coordCell: "#d8dfe6", connectorFill: "#42515e", connectorStroke: "#9fc7f1", connectorText: "#eef7ff", furnitureFill: "#c0c8d0", furnitureStroke: "#8090a0", furnitureLabel: "#243442", secretDoor: "#b54b38", bloodStain: "#781d1d" }; + case "brick": + return { base: "#4c3a36", fill: "#604944", line: "#2b1f1b", detail: "#9e7568", hull: "#150f0d", roomFill: "#d7c4bf", roomStroke: "#1c1412", corridorFill: "#c0aaa4", label: "#fff2ef", coordText: "#f1ddd8", coordCell: "#e3c8c0", connectorFill: "#5b3429", connectorStroke: "#d59e88", connectorText: "#fff0eb", furnitureFill: "#7a5040", furnitureStroke: "#a06050", furnitureLabel: "#fff0eb", secretDoor: "#b84d3a" }; + case "carpet": + return { base: "#3d2e44", fill: "#4d3a55", line: "#24192a", detail: "#8f73a1", hull: "#110b15", roomFill: "#d6cbdd", roomStroke: "#1e1623", corridorFill: "#c0b0ca", label: "#faf3ff", coordText: "#eadcf0", coordCell: "#dbc9e3", connectorFill: "#493660", connectorStroke: "#c4a7e6", connectorText: "#fbf3ff", furnitureFill: "#6a4a6a", furnitureStroke: "#9a7090", furnitureLabel: "#fbf3ff", secretDoor: "#d26767" }; + case "tile": + return { base: "#405055", fill: "#57666b", line: "#223036", detail: "#95b4b9", hull: "#11181b", roomFill: "#d9e3e4", roomStroke: "#1a2529", corridorFill: "#c3d1d2", label: "#f5ffff", coordText: "#dcedef", coordCell: "#cae0e2", connectorFill: "#2f5962", connectorStroke: "#8fd3dc", connectorText: "#eeffff", furnitureFill: "#c0c8c0", furnitureStroke: "#808888", furnitureLabel: "#274156", secretDoor: "#c05042" }; + case "industrial": + return { base: "#45413d", fill: "#5c5751", line: "#2f2c29", detail: "#bb8b3d", hull: "#121110", roomFill: "#c8c3ba", roomStroke: "#151515", corridorFill: "#b8b1a6", corridorStripe: "#d5a852", label: "#f7efe2", coordText: "#f6efe4", coordCell: "#eee6da", connectorFill: "#4b3c23", connectorStroke: "#d5a852", connectorText: "#fff1c8", furnitureFill: "#4a3a2a", furnitureStroke: "#7a6040", furnitureLabel: "#fff1c8", secretDoor: "#c65f40", bloodStain: "#7a1e10" }; + case "derelict": + return { base: "#313538", fill: "#44484b", line: "#1f2123", detail: "#6e4a3b", hull: "#0d0e10", roomFill: "#b8b1aa", roomStroke: "#111111", corridorFill: "#8e857d", corridorStripe: "#b38b72", label: "#f4dcc8", coordText: "#e8d7c6", coordCell: "#dcc9b4", connectorFill: "#402a24", connectorStroke: "#c38b72", connectorText: "#fde6db", furnitureFill: "#2a2a2a", furnitureStroke: "#5a5050", furnitureLabel: "#fde6db", secretDoor: "#aa5b4a", bloodStain: "#632420" }; + case "alien-organic": + return { base: "#232b28", fill: "#31413b", line: "#18211d", detail: "#7db39a", hull: "#09100d", roomFill: "#98b7aa", roomStroke: "#101715", corridorFill: "#7c9e92", label: "#e2fff1", coordText: "#d7f8eb", coordCell: "#c7ebdd", connectorFill: "#28483d", connectorStroke: "#96d7ba", connectorText: "#effff7", furnitureFill: "#1a3a1a", furnitureStroke: "#3a7a3a", furnitureLabel: "#effff7", secretDoor: "#c44f44" }; + case "clean-corporate": + return { base: "#d6dde2", fill: "#e5ecef", line: "#bcc8cf", detail: "#8fb7cf", hull: "#58656d", roomFill: "#f8fbfd", roomStroke: "#65717a", corridorFill: "#ebf1f4", corridorStripe: "#8fb7cf", label: "#35424c", coordText: "#5e6b76", coordCell: "#74828d", connectorFill: "#e7f1f8", connectorStroke: "#5f8cad", connectorText: "#274156", furnitureFill: "#d0d8e0", furnitureStroke: "#8090a0", furnitureLabel: "#274156", secretDoor: "#c45446", bloodStain: "#8f3e3e" }; + case "derelict-house": + return { base: "#40352f", fill: "#5a4a42", line: "#2b221e", detail: "#8d776c", hull: "#140f0d", roomFill: "#d1c1b1", roomStroke: "#1d1613", corridorFill: "#b4a293", corridorStripe: "#d9c8b6", label: "#fff1e6", coordText: "#e9d6cb", coordCell: "#d5bfb1", connectorFill: "#5d453a", connectorStroke: "#c7a78b", connectorText: "#fff2e6", furnitureFill: "#6a5248", furnitureStroke: "#8f7564", furnitureLabel: "#fff2e6", secretDoor: "#b4654a", bloodStain: "#5e1818" }; + case "asylum": + return { base: "#566165", fill: "#d6ddd9", line: "#8c9893", detail: "#7a6f67", hull: "#1d2325", roomFill: "#f2f4f1", roomStroke: "#384246", corridorFill: "#dde2de", corridorStripe: "#b5bdb8", label: "#243136", coordText: "#4b585e", coordCell: "#728086", connectorFill: "#77868d", connectorStroke: "#dbe8e2", connectorText: "#122026", furnitureFill: "#b9c1bc", furnitureStroke: "#7f8884", furnitureLabel: "#122026", secretDoor: "#8e3c3c", bloodStain: "#3a1010" }; + case "haunted-manor": + return { base: "#2d2230", fill: "#4b3640", line: "#24161c", detail: "#9d7a64", hull: "#120c10", roomFill: "#cfbcae", roomStroke: "#1d1317", corridorFill: "#b19a8e", corridorStripe: "#d7c0a1", label: "#fff0e2", coordText: "#ead8cb", coordCell: "#d7c2b4", connectorFill: "#523844", connectorStroke: "#d3b59a", connectorText: "#fff3e6", furnitureFill: "#6a5045", furnitureStroke: "#8c6c5c", furnitureLabel: "#fff3e6", secretDoor: "#a34747", bloodStain: "#4f0f17" }; + case "bloodbath": + return { base: "#26191b", fill: "#4a3030", line: "#1a0f11", detail: "#8e6666", hull: "#0e0708", roomFill: "#c9b7b4", roomStroke: "#180f11", corridorFill: "#9f8480", corridorStripe: "#d6aaaa", label: "#fff0ef", coordText: "#f0dada", coordCell: "#d9bcbc", connectorFill: "#5d2c2c", connectorStroke: "#e19c9c", connectorText: "#fff2f2", furnitureFill: "#6a3a3a", furnitureStroke: "#9a6666", furnitureLabel: "#fff2f2", secretDoor: "#d05050", bloodStain: "#650d0d" }; + case "forest": + return { base: "#3f5136", fill: "#67855a", line: "#2b3926", detail: "#91b57e", hull: "#162013", roomFill: "#b5c8a3", roomStroke: "#23301f", corridorFill: "#8f7a54", corridorStripe: "#c8b07a", label: "#f3ffe8", coordText: "#e0f3d7", coordCell: "#cde2c5", connectorFill: "#405935", connectorStroke: "#a8d08d", connectorText: "#f6fff1", furnitureFill: "#6f5a3b", furnitureStroke: "#8d744e", furnitureLabel: "#f6fff1", secretDoor: "#8c4d2c", bloodStain: "#5c221b", water: "#2b5a78", treeColor: "#3d6a35" }; + case "jungle": + return { base: "#2e4a34", fill: "#4e7a4c", line: "#1c2f21", detail: "#7eb86a", hull: "#122017", roomFill: "#98b78d", roomStroke: "#1d2d1f", corridorFill: "#7a6a48", corridorStripe: "#b59d63", label: "#f1ffe9", coordText: "#d7f0d0", coordCell: "#c3dfbc", connectorFill: "#2f5332", connectorStroke: "#95d07a", connectorText: "#f3fff0", furnitureFill: "#5a4a31", furnitureStroke: "#7a6846", furnitureLabel: "#f3fff0", secretDoor: "#7d4927", bloodStain: "#5b2018", water: "#1f5b52", treeColor: "#2f7f33" }; + case "plains": + return { base: "#7d8d5a", fill: "#a8bb70", line: "#647045", detail: "#d9d08a", hull: "#3c4327", roomFill: "#d7d8ac", roomStroke: "#55603b", corridorFill: "#8e7b55", corridorStripe: "#d0bb82", label: "#fffde7", coordText: "#f3f0d6", coordCell: "#e2ddb9", connectorFill: "#7f7448", connectorStroke: "#e0cd8f", connectorText: "#fffde7", furnitureFill: "#8b754c", furnitureStroke: "#a68f60", furnitureLabel: "#fffde7", secretDoor: "#945930", bloodStain: "#6a2b20", water: "#5d8ba3", treeColor: "#6f8b42" }; + case "swamp": + return { base: "#3d4632", fill: "#5e7051", line: "#27301f", detail: "#8ea16d", hull: "#151b11", roomFill: "#aab08a", roomStroke: "#283120", corridorFill: "#6d6243", corridorStripe: "#a19563", label: "#f0f8e2", coordText: "#dbe7cb", coordCell: "#c7d6b8", connectorFill: "#4b5030", connectorStroke: "#b9c58a", connectorText: "#f7ffe8", furnitureFill: "#5f5839", furnitureStroke: "#7c7152", furnitureLabel: "#f7ffe8", secretDoor: "#8d5134", bloodStain: "#58271f", water: "#304e43", treeColor: "#4d6f39" }; + case "tundra": + return { base: "#7d8896", fill: "#b9c4ce", line: "#66707d", detail: "#eef4fa", hull: "#3f4750", roomFill: "#dde5ea", roomStroke: "#55606b", corridorFill: "#a9b6bf", corridorStripe: "#e8edf2", label: "#ffffff", coordText: "#eef4fa", coordCell: "#dbe4ec", connectorFill: "#6b7e8d", connectorStroke: "#eef4fa", connectorText: "#ffffff", furnitureFill: "#9eaab4", furnitureStroke: "#c6d0d8", furnitureLabel: "#2b3440", secretDoor: "#8a4b4b", bloodStain: "#7a2929", water: "#7fb4d3", treeColor: "#b7cbd5" }; + case "badlands": + return { base: "#6c5642", fill: "#9a7a57", line: "#4a3828", detail: "#c49a63", hull: "#2a1e15", roomFill: "#d0bc9a", roomStroke: "#463424", corridorFill: "#8a6946", corridorStripe: "#d1a76d", label: "#fff4e6", coordText: "#f0ddc8", coordCell: "#e2cab1", connectorFill: "#7a5a38", connectorStroke: "#deb17a", connectorText: "#fff4e6", furnitureFill: "#7d6246", furnitureStroke: "#a88864", furnitureLabel: "#fff4e6", secretDoor: "#aa6138", bloodStain: "#6b2318", water: "#5a7485", treeColor: "#7c6d35" }; + case "steel": + default: + return { base: "#384048", fill: "#505860", line: "#242a30", detail: "#9ba7b0", hull: "#111418", roomFill: "#d7dbe0", roomStroke: "#15191d", corridorFill: "#c5ccd2", corridorStripe: "#8fd4ff", label: "#eff7ff", coordText: "#e9f4fb", coordCell: "#d9e5ef", connectorFill: "#24405c", connectorStroke: "#8fd4ff", connectorText: "#eff8ff", furnitureFill: "#3a4a5a", furnitureStroke: "#5a7090", furnitureLabel: "#eff8ff", secretDoor: "#cc4444", bloodStain: "#7a1e1e" }; + } +} + +const THEME_LIGHT_CONFIG = { + steel: { ambient: true, color: "#a0c0ff", alpha: 0.4, luminosity: 0.5, roomTypes: ["bridge", "engineering", "reactor"] }, + industrial: { ambient: true, color: "#ffb060", alpha: 0.35, luminosity: 0.45, roomTypes: ["engineering", "reactor"] }, + derelict: { ambient: true, color: "#404820", alpha: 0.25, luminosity: 0.3, roomTypes: [] }, + "alien-organic": { ambient: true, color: "#20a040", alpha: 0.3, luminosity: 0.35, roomTypes: ["reactor"] }, + "clean-corporate": { ambient: true, color: "#ffffff", alpha: 0.6, luminosity: 0.7, roomTypes: [] }, + stone: { ambient: false, color: "#ff8020", alpha: 0.5, luminosity: 0.4, roomTypes: ["shrine", "entrance", "guardroom"] }, + cave: { ambient: false, color: "#ff6010", alpha: 0.4, luminosity: 0.35, roomTypes: [] }, + wood: { ambient: false, color: "#ffb040", alpha: 0.55, luminosity: 0.45, roomTypes: ["shrine", "entrance"] }, + "wood-floor": { ambient: true, color: "#ffe8c0", alpha: 0.55, luminosity: 0.6, roomTypes: [] }, + crypt: { ambient: false, color: "#4020a0", alpha: 0.3, luminosity: 0.25, roomTypes: ["ritual-room", "crypt"] }, + sewer: { ambient: false, color: "#204010", alpha: 0.25, luminosity: 0.3, roomTypes: [] }, + "concrete-office": { ambient: true, color: "#ffffff", alpha: 0.7, luminosity: 0.75, roomTypes: [] }, + brick: { ambient: true, color: "#ffe0c0", alpha: 0.5, luminosity: 0.55, roomTypes: ["common-room", "bar"] }, + carpet: { ambient: true, color: "#fff0e0", alpha: 0.6, luminosity: 0.65, roomTypes: [] }, + tile: { ambient: true, color: "#e0f0ff", alpha: 0.65, luminosity: 0.7, roomTypes: [] }, + "derelict-house": { ambient: true, color: "#801010", alpha: 0.18, luminosity: 0.2, roomTypes: ["basement", "boiler-room"] }, + asylum: { ambient: true, color: "#c0c0a0", alpha: 0.22, luminosity: 0.25, roomTypes: ["surgery", "padded-cell"] }, + "haunted-manor": { ambient: false, color: "#4020a0", alpha: 0.15, luminosity: 0.18, roomTypes: ["library", "ballroom"] }, + bloodbath: { ambient: false, color: "#800808", alpha: 0.15, luminosity: 0.15, roomTypes: [] }, + forest: { ambient: true, color: "#b8d88a", alpha: 0.14, luminosity: 0.12, roomTypes: ["camp", "glade"] }, + jungle: { ambient: true, color: "#8cc26c", alpha: 0.12, luminosity: 0.1, roomTypes: ["nest", "ruins"] }, + plains: { ambient: true, color: "#f2df98", alpha: 0.08, luminosity: 0.08, roomTypes: ["camp", "crossing"] }, + swamp: { ambient: true, color: "#8da86a", alpha: 0.12, luminosity: 0.08, roomTypes: ["water", "ambush"] }, + tundra: { ambient: true, color: "#d8ecff", alpha: 0.1, luminosity: 0.1, roomTypes: ["ridge", "camp"] }, + badlands: { ambient: true, color: "#d9a36a", alpha: 0.1, luminosity: 0.08, roomTypes: ["ruins", "ambush"] } +}; + +function applySharedDeckConnectors(decks, formState) { + const connectors = buildSharedDeckConnectorBlueprints(decks[0], formState); + for (let deckIndex = 0; deckIndex < decks.length; deckIndex += 1) { + decks[deckIndex].deckConnectors = connectors.map((connector) => ({ + ...connector, + access: deckAccessLabel(deckIndex, decks.length) + })); + decorateDeckRoomsWithConnectors(decks[deckIndex]); + } +} + +function buildSharedDeckConnectorBlueprints(deck, formState) { + const points = []; + const hullMask = deck.hullMask ?? buildHullMask(deck.columns, deck.rows, deck.hullRegions ?? []); + const primary = findNearestHullCell(hullMask, Math.floor(deck.columns / 2), Math.floor(deck.rows / 2)); + const connectorType = ["dungeon", "building"].includes(formState.mode) ? "stairs" : "elevator"; + const connectorLabel = ["dungeon", "building"].includes(formState.mode) ? "STAIR A" : "LIFT A"; + if (primary) { + points.push({ id: "primary-a", x: primary.x, y: primary.y, label: connectorLabel, type: connectorType }); + } + if (formState.columns >= 24 && formState.rows >= 14) { + const secondary = findNearestHullCell(hullMask, Math.floor(deck.columns * 0.7), Math.floor(deck.rows * 0.35)); + if (secondary && (!primary || secondary.x !== primary.x || secondary.y !== primary.y)) { + const secondaryType = formState.mode === "building" ? "stairs" : "ladder"; + const secondaryLabel = formState.mode === "building" ? "STAIR B" : "LADDER B"; + points.push({ id: "secondary-b", x: secondary.x, y: secondary.y, label: secondaryLabel, type: secondaryType }); + } + } + return points; +} + +function deckAccessLabel(index, deckCount) { + const up = index < deckCount - 1 ? `UP ${index + 2}` : null; + const down = index > 0 ? `DOWN ${index}` : null; + return [up, down].filter(Boolean).join(" / ") || "LINK"; +} + +function decorateDeckRoomsWithConnectors(deck) { + for (const connector of deck.deckConnectors ?? []) { + const room = deck.rooms.find((candidate) => pointInRoom(connector, candidate)); + if (!room) continue; + room.markers ??= []; + room.markers.push({ + type: connector.type === "ladder" ? "Ladder" : connector.type === "stairs" ? "Stairs" : "Elevator", + label: connector.label, + x: connector.x, + y: connector.y, + access: connector.access + }); + } +} + +function buildHullRegions(columns, rows, hullShape, mode) { + const interiorWidth = Math.max(8, columns - 2); + const interiorHeight = Math.max(8, rows - 2); + const x0 = 1; + const y0 = 1; + let shape = mode === "bsp" && hullShape === "ring" ? "cross" : hullShape; + if (shape === "rect") shape = "rectangle"; + + switch (shape) { + case "tapered": { + const frontWidth = Math.max(4, Math.round(interiorWidth * 0.2)); + const midWidth = Math.max(8, Math.round(interiorWidth * 0.55)); + const aftWidth = Math.max(4, interiorWidth - frontWidth - midWidth); + return [ + { x: x0, y: y0 + Math.max(1, Math.floor(interiorHeight * 0.22)), width: frontWidth, height: Math.max(4, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.44))) }, + { x: x0 + frontWidth, y: y0 + Math.max(1, Math.floor(interiorHeight * 0.1)), width: midWidth, height: Math.max(6, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.2))) }, + { x: x0 + frontWidth + midWidth, y: y0 + Math.max(1, Math.floor(interiorHeight * 0.2)), width: aftWidth, height: Math.max(4, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.4))) } + ]; + } + case "hammerhead": { + const headWidth = Math.max(6, Math.round(interiorWidth * 0.28)); + const neckWidth = Math.max(4, Math.round(interiorWidth * 0.16)); + const bodyWidth = Math.max(6, interiorWidth - headWidth - neckWidth); + const headY = y0 + 1; + const headH = Math.max(6, interiorHeight - 2); + const neckH = Math.max(4, Math.round(interiorHeight * 0.42)); + const neckY = y0 + Math.floor((interiorHeight - neckH) / 2); + const bodyY = y0 + Math.max(1, Math.floor(interiorHeight * 0.12)); + const bodyH = Math.max(6, interiorHeight - Math.max(2, Math.floor(interiorHeight * 0.24))); + return [ + { x: x0, y: headY, width: headWidth, height: headH }, + { x: x0 + headWidth, y: neckY, width: neckWidth, height: neckH }, + { x: x0 + headWidth + neckWidth, y: bodyY, width: bodyWidth, height: bodyH } + ]; + } + case "cross": { + const stemWidth = Math.max(6, Math.round(interiorWidth * 0.34)); + const barHeight = Math.max(6, Math.round(interiorHeight * 0.34)); + const stemX = x0 + Math.floor((interiorWidth - stemWidth) / 2); + const barY = y0 + Math.floor((interiorHeight - barHeight) / 2); + return [ + { x: stemX, y: y0, width: stemWidth, height: barY - y0 }, + { x: x0, y: barY, width: interiorWidth, height: barHeight }, + { x: stemX, y: barY + barHeight, width: stemWidth, height: y0 + interiorHeight - (barY + barHeight) } + ].filter((region) => region.width >= 3 && region.height >= 3); + } + case "l-shape": { + const leftWidth = Math.max(6, Math.round(interiorWidth * 0.42)); + const bottomHeight = Math.max(6, Math.round(interiorHeight * 0.42)); + return [ + { x: x0, y: y0, width: leftWidth, height: interiorHeight }, + { x: x0 + leftWidth, y: y0 + interiorHeight - bottomHeight, width: interiorWidth - leftWidth, height: bottomHeight } + ]; + } + case "t-shape": { + const stemWidth = Math.max(6, Math.round(interiorWidth * 0.36)); + const barHeight = Math.max(6, Math.round(interiorHeight * 0.34)); + const stemX = x0 + Math.floor((interiorWidth - stemWidth) / 2); + return [ + { x: x0, y: y0, width: interiorWidth, height: barHeight }, + { x: stemX, y: y0 + barHeight, width: stemWidth, height: interiorHeight - barHeight } + ]; + } + case "u-shape": { + const legWidth = Math.max(5, Math.round(interiorWidth * 0.24)); + const baseHeight = Math.max(6, Math.round(interiorHeight * 0.32)); + return [ + { x: x0, y: y0, width: legWidth, height: interiorHeight }, + { x: x0 + interiorWidth - legWidth, y: y0, width: legWidth, height: interiorHeight }, + { x: x0, y: y0 + interiorHeight - baseHeight, width: interiorWidth, height: baseHeight } + ]; + } + case "ring": { + const thickness = Math.max(3, Math.round(Math.min(interiorWidth, interiorHeight) * 0.18)); + return [ + { x: x0, y: y0, width: interiorWidth, height: thickness }, + { x: x0, y: y0 + thickness, width: thickness, height: interiorHeight - thickness * 2 }, + { x: x0 + interiorWidth - thickness, y: y0 + thickness, width: thickness, height: interiorHeight - thickness * 2 }, + { x: x0, y: y0 + interiorHeight - thickness, width: interiorWidth, height: thickness } + ].filter((region) => region.width >= 3 && region.height >= 3); + } + case "rectangle": + default: + return [{ x: x0, y: y0, width: interiorWidth, height: interiorHeight }]; + } +} + +function buildHullMask(columns, rows, regions) { + const mask = makeGrid(columns, rows, false); + for (const region of regions) { + fillRectCells(mask, region, true); + } + return mask; +} + +function boundingRectFromRegions(regions) { + const minX = Math.min(...regions.map((region) => region.x)); + const minY = Math.min(...regions.map((region) => region.y)); + const maxX = Math.max(...regions.map((region) => region.x + region.width)); + const maxY = Math.max(...regions.map((region) => region.y + region.height)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +} + +function allocateRoomCounts(regions, targetCount) { + if (regions.length === 1) return [targetCount]; + const areas = regions.map((region) => region.width * region.height); + const totalArea = areas.reduce((sum, area) => sum + area, 0) || 1; + const counts = regions.map(() => 1); + let remaining = Math.max(0, targetCount - counts.length); + + while (remaining > 0) { + let bestIndex = 0; + let bestScore = Number.NEGATIVE_INFINITY; + for (let index = 0; index < regions.length; index += 1) { + const score = (areas[index] / totalArea) - (counts[index] / Math.max(1, targetCount)); + if (score > bestScore) { + bestScore = score; + bestIndex = index; + } + } + counts[bestIndex] += 1; + remaining -= 1; + } + + return counts; +} + +function buildHullWallSegments(hullMask) { + const segments = []; + for (let y = 0; y < hullMask.length; y += 1) { + for (let x = 0; x < (hullMask[y]?.length ?? 0); x += 1) { + if (!hullMask[y]?.[x]) continue; + const edges = [ + { side: "n", nx: x, ny: y - 1 }, + { side: "e", nx: x + 1, ny: y }, + { side: "s", nx: x, ny: y + 1 }, + { side: "w", nx: x - 1, ny: y } + ]; + for (const edge of edges) { + if (!hullMask[edge.ny]?.[edge.nx]) { + segments.push(cellEdge(x, y, edge.side)); + } + } + } + } + return dedupeSegments(segments); +} + +function buildHullWallsWithDoorGaps(hullMask, doorSegments) { + const blocked = new Set( + doorSegments.map((segment) => segmentKey(segment)) + ); + return buildHullWallSegments(hullMask).filter((segment) => !blocked.has(segmentKey(segment))); +} + +function rectFitsMask(rect, mask) { + for (let y = rect.y; y < rect.y + rect.height; y += 1) { + for (let x = rect.x; x < rect.x + rect.width; x += 1) { + if (!mask[y]?.[x]) return false; + } + } + return true; +} + +function segmentKey(segment) { + return `${segment.x1},${segment.y1},${segment.x2},${segment.y2}`; +} + +function findHullPath(from, to, hullMask) { + const start = `${from.x},${from.y}`; + const goal = `${to.x},${to.y}`; + const queue = [from]; + const seen = new Set([start]); + const parent = new Map(); + const dirs = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 } + ]; + + while (queue.length) { + const current = queue.shift(); + const key = `${current.x},${current.y}`; + if (key === goal) break; + for (const dir of dirs) { + const next = { x: current.x + dir.x, y: current.y + dir.y }; + const nextKey = `${next.x},${next.y}`; + if (seen.has(nextKey) || !hullMask[next.y]?.[next.x]) continue; + seen.add(nextKey); + parent.set(nextKey, key); + queue.push(next); + } + } + + if (!seen.has(goal)) return []; + const path = []; + let currentKey = goal; + while (currentKey) { + const [x, y] = currentKey.split(",").map(Number); + path.push({ x, y }); + currentKey = parent.get(currentKey); + } + return path.reverse(); +} + +function corridorBrush(point, width, hullMask) { + const cells = []; + const radius = width - 1; + for (let dy = -radius; dy <= radius; dy += 1) { + for (let dx = -radius; dx <= radius; dx += 1) { + const cell = { x: point.x + dx, y: point.y + dy }; + if (hullMask[cell.y]?.[cell.x]) cells.push(cell); + } + } + return cells; +} + +function compressCellsToRects(cells) { + const byRow = new Map(); + for (const cell of cells) { + const row = byRow.get(cell.y) ?? []; + row.push(cell.x); + byRow.set(cell.y, row); + } + + const rects = []; + for (const [y, xs] of byRow.entries()) { + xs.sort((a, b) => a - b); + let start = xs[0]; + let prev = xs[0]; + for (let index = 1; index <= xs.length; index += 1) { + const x = xs[index]; + if (x === prev + 1) { + prev = x; + continue; + } + rects.push({ x: start, y, width: prev - start + 1, height: 1 }); + start = x; + prev = x; + } + } + return rects; +} + +function findNearestHullCell(mask, targetX, targetY) { + let best = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (let y = 0; y < mask.length; y += 1) { + for (let x = 0; x < (mask[y]?.length ?? 0); x += 1) { + if (!mask[y]?.[x]) continue; + const distance = Math.abs(targetX - x) + Math.abs(targetY - y); + if (distance < bestDistance) { + bestDistance = distance; + best = { x, y }; + } + } + } + return best; +} + +async function ensureSceneFolder(name) { + let folder = game.folders?.find((entry) => entry.type === "Scene" && entry.name === name && !entry.folder); + if (!folder) { + folder = await Folder.create({ name, type: "Scene", color: "#40637a" }); + } + return folder; +} + +function buildSceneFolderOptions() { + const folders = game.folders?.filter((entry) => entry.type === "Scene") ?? []; + const sorted = [...folders].sort((a, b) => describeFolderPath(a).localeCompare(describeFolderPath(b))); + return [ + { value: "", label: "Choose a scene folder..." }, + ...sorted.map((folder) => ({ + value: folder.id, + label: describeFolderPath(folder) + })) + ]; +} + +function describeFolderPath(folder) { + const parts = []; + let current = folder; + while (current) { + parts.unshift(current.name); + current = current.folder ?? null; + } + return parts.join(" / "); +} + +function buildActorPackOptions() { + const packs = (game.packs?.contents ?? []) + .filter((pack) => pack.documentName === "Actor") + .sort((a, b) => `${a.metadata.packageName}.${a.metadata.label}`.localeCompare(`${b.metadata.packageName}.${b.metadata.label}`)); + return [ + { value: "", label: "Use generated placeholder encounters" }, + ...packs.map((pack) => ({ + value: pack.collection, + label: `${pack.metadata.label} (${pack.collection})` + })) + ]; +} + +function buildSavedPresetOptions() { + const presets = (game.settings.get(MODULE_ID, "savedPresets") ?? []) + .filter((preset) => preset?.name) + .sort((a, b) => a.name.localeCompare(b.name)); + return [ + { value: "", label: "Choose a saved preset..." }, + ...presets.map((preset) => ({ value: preset.name, label: preset.name })) + ]; +} + +function mapOptions(options, selectedValue) { + return options.map((option) => ({ + ...option, + selected: String(option.value) === String(selectedValue) + })); +} + +function setInputValue(form, name, value) { + const element = form.querySelector(`[name="${name}"]`); + if (!element) return; + if (element.type === "checkbox") { + element.checked = Boolean(value); + return; + } + element.value = String(value); +} + +function buildMapName(mode = "bsp", seedInput = Date.now(), buildingSubtype = "office") { + const hash = hashSeed(String(seedInput)); + if (mode === "wilderness") { + const baseName = WILDERNESS_NAME_POOL[hash % WILDERNESS_NAME_POOL.length]; + const suffix = WILDERNESS_SUFFIX_POOL[Math.floor(hash / WILDERNESS_NAME_POOL.length) % WILDERNESS_SUFFIX_POOL.length]; + return `${baseName} ${suffix}`; + } + if (mode === "dungeon") { + const baseName = DUNGEON_NAME_POOL[hash % DUNGEON_NAME_POOL.length]; + const suffix = DUNGEON_SUFFIX_POOL[Math.floor(hash / DUNGEON_NAME_POOL.length) % DUNGEON_SUFFIX_POOL.length]; + return `${baseName} ${suffix}`; + } + if (mode === "building") { + const baseName = BUILDING_NAME_POOL[hash % BUILDING_NAME_POOL.length]; + const suffixPool = { + office: ["Corporate HQ", "Office Block", "Business Centre", "Bureau"], + warehouse: ["Warehouse", "Distribution Centre", "Storage Facility", "Depot"], + residential: ["Apartment", "Residence", "Estate", "Housing Block"], + tavern: ["The Rusty Flagon", "The Black Horse", "The Wanderer's Rest", "The Crooked Crown"], + mansion: ["Manor", "Estate", "Hall", "House"], + horror: ["Derelict House", "Asylum Wing", "Haunted Hall", "Murder Site"] + }[buildingSubtype] ?? BUILDING_SUFFIX_POOL; + const suffix = suffixPool[Math.floor(hash / BUILDING_NAME_POOL.length) % suffixPool.length]; + return `${baseName} ${suffix}`; + } + const baseName = SHIP_NAME_POOL[hash % SHIP_NAME_POOL.length]; + const suffix = SHIP_SUFFIX_POOL[Math.floor(hash / SHIP_NAME_POOL.length) % SHIP_SUFFIX_POOL.length]; + return `${baseName} ${suffix}`; +} + +function resolveDeckName(index, mode = "bsp") { + if (mode === "wilderness") return "Surface Map"; + if (mode === "dungeon") return DUNGEON_LEVEL_NAMES[index] ?? `Level ${index + 1}`; + if (mode === "building") return BUILDING_LEVEL_NAMES[index] ?? `Floor ${index + 1}`; + return DECK_NAME_POOL[index] ?? `Deck ${index + 1}`; +} + +function buildMissionBriefContent(deckPlan, formState) { + const chosenTemplate = resolveMissionTemplate(formState); + const introByTemplate = { + scifi: "The following operational map has been assembled from technical schematics, survey sweeps, and local traffic records. Expect compartment deviations and obstructed routes.", + dungeon: { + stone: "The following dungeon complex has been mapped from available intelligence and survivor accounts. Treat all information as provisional.", + crypt: "The catacombs below are ancient. What was once a place of rest for the honoured dead has become something else entirely.", + cave: "A naturally formed cave complex, partially occupied. The passages are unstable in places.", + sewer: "The lower channels are flooded, foul, and likely inhabited. Maintain sealed gear and expect poor visibility." + }, + horror: { + "derelict-house": "The structure shows clear signs of decay and breach. Expect unreliable doors, unstable interiors, and poor sightlines.", + asylum: "Institutional records indicate sealed wards and restricted treatment rooms. Interior movement may be obstructed and threats concealed.", + "haunted-manor": "The manor layout is intact but poorly maintained. Expect dead ends, servant routes, and compromised access points.", + bloodbath: "The site is an active or recent kill zone. Visibility may be poor, surfaces contaminated, and rooms psychologically destabilising." + }, + sla: "The following contract-zone map has been compiled for operative deployment. Media exposure, collateral risk, and sector politics may alter mission value without warning.", + wilderness: { + forest: "The following area has been surveyed from aerial observation. Ground-truth data should be established before committing forces.", + swamp: "The terrain is treacherous and visibility is limited. Standard movement rates should be halved.", + jungle: "Vegetation density is extreme and lines of sight are unreliable. Maintain visual contact between elements at all times." + } + }; + const introSource = introByTemplate[chosenTemplate] ?? introByTemplate.scifi; + const intro = typeof introSource === "string" + ? introSource + : introSource?.[formState.theme] ?? "The following location has been mapped from the best data currently available. Treat all positions as approximate until confirmed on the ground."; + const deckLines = deckPlan.decks.map((deck) => `
  • ${escapeXml(deck.deckName)}: ${deck.rooms.length} rooms, ${deck.corridors.length} corridors, ${escapeXml(summarizeDeckThreats(deck))}
  • `).join(""); + return ` +

    ${escapeXml(deckPlan.shipName)} Mission Brief

    +

    ${escapeXml(intro)}

    +

    Seed: ${escapeXml(deckPlan.seed)}
    Theme: ${escapeXml(formState.theme)}
    Mode: ${escapeXml(formState.mode)}

    +

    Operational Notes

    +
      +
    • Generated with ${deckPlan.decks.length} level${deckPlan.decks.length === 1 ? "" : "s"} at ${formState.gridSize}px grid resolution.
    • +
    • Room labels and openings are intended as tactical guides; final conditions should be confirmed on scene entry.
    • +
    • ${escapeXml(briefSpecialInstruction(formState, chosenTemplate))}
    • +
    +

    Deck Summary

    +
      ${deckLines}
    + `.replace(/\n\s+/g, ""); +} + +function resolveMissionTemplate(formState) { + if (formState.missionTemplate !== "auto") return formState.missionTemplate; + if (["sla-mothership", "sla-industries-brp"].includes(game.system?.id ?? "")) return "sla"; + if (formState.mode === "wilderness") return "wilderness"; + if (formState.mode === "dungeon") return "dungeon"; + if (["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(formState.theme)) return "horror"; + return "scifi"; +} + +function summarizeDeckThreats(deck) { + const dangerous = deck.rooms.filter((room) => ENCOUNTER_ROOM_TYPES.has(room.type)); + if (!dangerous.length && !(deck.wilderness?.encounterZones?.length)) return "no explicit threat nodes marked"; + if (!dangerous.length && deck.wilderness?.encounterZones?.length) { + return `${deck.wilderness.encounterZones.length} wilderness encounter zone${deck.wilderness.encounterZones.length === 1 ? "" : "s"} flagged`; + } + const labels = dangerous.slice(0, 3).map((room) => room.label || room.type.toUpperCase()); + return `${dangerous.length} high-risk room${dangerous.length === 1 ? "" : "s"} flagged (${labels.join(", ")}${dangerous.length > 3 ? ", ..." : ""})`; +} + +function briefSpecialInstruction(formState, chosenTemplate = resolveMissionTemplate(formState)) { + if (chosenTemplate === "sla") return "Contract conditions remain fluid. Expect media scrutiny, unstable civilians, and rapid shifts in mission value if the situation escalates."; + if (chosenTemplate === "horror") return "Assume compromised lighting, psychological contamination, and hostile movement from behind unsecured doors or soft barriers."; + if (chosenTemplate === "dungeon") return "Advance with scouts, secure retreats, and expect locked chambers to signal either treasure, ritual focus, or concentrated resistance."; + if (chosenTemplate === "wilderness") return "Ridgelines, ravines, and water obstacles are treated as movement-affecting terrain and should shape approach routes."; + if (formState.seedEncounters) return "Encounter seed markers were placed in hostile or high-risk rooms for GM staging."; + if (formState.mapAge >= 60) return "The map has been weathered heavily; assume locked, broken, or compromised access points are more common."; + if (["derelict-house", "asylum", "haunted-manor", "bloodbath"].includes(formState.theme)) return "Horror decay modifiers are active: doors may be barricaded, breached, or partially collapsed."; + return "Maintain standard caution and update the plan with local observations during play."; +} + +function slugify(value) { + return String(value ?? MODULE_ID) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || MODULE_ID; +} + +function hashSeed(value) { + const text = String(value ?? ""); + let hash = 1779033703; + for (let i = 0; i < text.length; i += 1) { + hash = Math.imul(hash ^ text.charCodeAt(i), 3432918353); + hash = (hash << 13) | (hash >>> 19); + } + return hash >>> 0; +} + +function mulberry32(seed) { + let current = seed >>> 0; + return function rng() { + current |= 0; + current = (current + 0x6D2B79F5) | 0; + let t = Math.imul(current ^ (current >>> 15), 1 | current); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randomInt(rng, min, max) { + if (max <= min) return min; + return Math.floor(rng() * (max - min + 1)) + min; +} + +function clampInt(value, min, max, fallback) { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.max(min, Math.min(max, Math.round(number))); +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, Number(value))); +} + +function normalizeChoice(value, fallback, choices) { + return choices.includes(value) ? value : fallback; +} + +function columnLabel(index) { + let value = Number(index ?? 0); + let out = ""; + do { + out = String.fromCharCode(65 + (value % 26)) + out; + value = Math.floor(value / 26) - 1; + } while (value >= 0); + return out; +} + +function round(value) { + return Math.round(Number(value) || 0); +} + +function escapeXml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} + +function textStrokeFor(fillColor) { + const rgb = hexToRgb(fillColor); + if (!rgb) return "rgba(10, 14, 18, 0.96)"; + const luminance = ((0.2126 * rgb.r) + (0.7152 * rgb.g) + (0.0722 * rgb.b)) / 255; + return luminance > 0.62 ? "rgba(12, 16, 20, 0.96)" : "rgba(248, 251, 255, 0.96)"; +} + +function hexToRgb(color) { + const value = String(color ?? "").trim(); + const short = value.match(/^#([0-9a-f]{3})$/i); + if (short) { + const [r, g, b] = short[1].split("").map((char) => parseInt(char + char, 16)); + return { r, g, b }; + } + const full = value.match(/^#([0-9a-f]{6})$/i); + if (full) { + return { + r: parseInt(full[1].slice(0, 2), 16), + g: parseInt(full[1].slice(2, 4), 16), + b: parseInt(full[1].slice(4, 6), 16) + }; + } + return null; +} diff --git a/styles/scifi-deckplan-generator.css b/styles/scifi-deckplan-generator.css new file mode 100644 index 0000000..cf59db5 --- /dev/null +++ b/styles/scifi-deckplan-generator.css @@ -0,0 +1,214 @@ +.scifi-deckplan-generator .window-content, +.sdpg-app { + background: linear-gradient(180deg, #0d1520 0%, #111e2c 100%); + color: #e9f4fb; +} + +.scifi-deckplan-generator { + min-height: 0; +} + +.scifi-deckplan-generator .window-content { + overflow-y: auto; + overflow-x: hidden; + max-height: calc(100vh - 120px); + min-height: 0; + padding: 0; +} + +.sdpg-app { + display: grid; + gap: 14px; + padding: 10px; + min-height: min-content; +} + +.sdpg-section { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(74, 156, 200, 0.24); + border-radius: 12px; + padding: 14px; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03); +} + +.sdpg-section h2 { + margin: 0 0 12px; + color: #8fd4ff; + font-size: 1rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.sdpg-grid { + display: grid; + gap: 10px; +} + +.sdpg-grid-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.sdpg-grid label, +.sdpg-checks label { + display: grid; + gap: 4px; +} + +.sdpg-mode-field.is-hidden { + display: none; +} + +.sdpg-grid label span { + font-size: 0.84rem; + color: rgba(233, 244, 251, 0.82); +} + +.sdpg-seed-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.sdpg-grid input, +.sdpg-grid select { + width: 100%; + padding: 7px 9px; + border-radius: 8px; + border: 1px solid rgba(143, 212, 255, 0.18); + background: rgba(6, 11, 18, 0.88); + color: #f7fcff; +} + +.sdpg-grid input[type="range"] { + padding: 0; + accent-color: #8fd4ff; + min-height: 36px; +} + +.sdpg-reroll-seed { + min-width: 40px; + height: 36px; + border-radius: 8px; + border: 1px solid rgba(143, 212, 255, 0.22); + background: rgba(24, 43, 59, 0.96); + color: #f4fbff; +} + +.sdpg-grid input:disabled, +.sdpg-grid select:disabled, +.sdpg-checks input:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.sdpg-checks { + display: grid; + gap: 6px; + margin-top: 12px; +} + +.sdpg-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.sdpg-actions-secondary { + margin-top: 12px; + align-items: center; +} + +.sdpg-actions-secondary select { + min-width: 240px; + max-width: 340px; + padding: 7px 9px; + border-radius: 8px; + border: 1px solid rgba(143, 212, 255, 0.18); + background: rgba(6, 11, 18, 0.88); + color: #f7fcff; +} + +.sdpg-actions button { + border-radius: 10px; + padding: 8px 12px; + color: #f4fbff; + background: linear-gradient(180deg, #2b5f7c 0%, #1b3e55 100%); + border: 1px solid rgba(143, 212, 255, 0.22); +} + +.sdpg-preset-list { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.sdpg-preset-card { + display: grid; + gap: 4px; + padding: 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); +} + +.sdpg-preset-card strong { + color: #c8ecff; +} + +.sdpg-preset-card span { + font-size: 0.8rem; + color: rgba(233, 244, 251, 0.76); + line-height: 1.35; +} + +.sdpg-preset-card em { + font-size: 0.72rem; + color: rgba(143, 212, 255, 0.74); + font-style: normal; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.sdpg-preview { + display: grid; + gap: 10px; +} + +.sdpg-preview-frame { + min-height: 180px; + max-height: 260px; + border-radius: 12px; + border: 1px solid rgba(143, 212, 255, 0.24); + background: linear-gradient(180deg, rgba(8, 12, 18, 0.92), rgba(18, 28, 40, 0.98)); + display: grid; + place-items: center; + overflow: hidden; +} + +.sdpg-preview-frame img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.sdpg-preview-summary { + font-size: 0.84rem; + line-height: 1.45; + color: rgba(233, 244, 251, 0.82); +} + +.sdpg-scene-directory-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +@media (max-width: 980px) { + .sdpg-grid-2, + .sdpg-preset-list { + grid-template-columns: 1fr; + } +} diff --git a/templates/scifi-deckplan-generator.html b/templates/scifi-deckplan-generator.html new file mode 100644 index 0000000..37a6de3 --- /dev/null +++ b/templates/scifi-deckplan-generator.html @@ -0,0 +1,304 @@ +
    +
    +

    Generation

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + + + + + + + + + + + + +
    +
    + + + + +
    +
    + + + + + + +
    +
    + +
    +

    Preview

    +
    +
    + Map preview +
    +
    Preview will appear here.
    +
    +
    + +
    +

    Profiles

    +
    + {{#each profileCards as |profile|}} +
    + {{profile.label}} + {{profile.mode}} + {{profile.summary}} +
    + {{/each}} +
    +
    +