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: "sla-industries-compendium.creatures", roomPopulationPack: "sla-industries-compendium.creatures", 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 ids = collectGeneratedSceneIds(scene); const total = ids.wallIds.length + ids.drawingIds.length + ids.tileIds.length + ids.lightIds.length + ids.tokenIds.length + ids.noteIds.length + ids.soundIds.length + ids.regionIds.length; if (total === 0) { ui.notifications.info("No generated elements found in this scene."); return; } await Dialog.confirm({ title: "Delete Generated Elements", content: `

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

`, yes: async () => { await deleteGeneratedDocumentsInternal(scene, ids); 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), soundIds: scene.sounds.contents.filter((sound) => sound.flags?.[MODULE_ID]?.generatedSound).map((sound) => sound.id), regionIds: scene.regions?.contents?.filter((region) => region.flags?.[MODULE_ID]?.generatedRegion)?.map((region) => region.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); if (ids.soundIds?.length) await scene.deleteEmbeddedDocuments("AmbientSound", ids.soundIds); if (ids.regionIds?.length) await scene.deleteEmbeddedDocuments("Region", ids.regionIds); } 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 lootTiles = buildLootTiles(deck, formState); if (lootTiles.length) { await scene.createEmbeddedDocuments("Tile", lootTiles.map((t) => withGeneratedTileFlag(t))); } 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))); } } const sounds = buildAmbientSounds(deck, formState); if (sounds.length) { await scene.createEmbeddedDocuments("AmbientSound", sounds.map((s) => withGeneratedSoundFlag(s))); } 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, deckPlan), ...buildHazardRegions(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, deckPlan = null) { 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 } } }); } // Multi-Deck Elevator/Ladder/Stairs Teleporters (v13+) if (deckPlan && deckPlan.decks.length > 1 && deck.deckConnectors?.length) { for (const connector of deck.deckConnectors) { const currentDeckIndex = deck.deckIndex; let targetDeckIndex = null; if (connector.type === "elevator") { targetDeckIndex = currentDeckIndex < deckPlan.decks.length - 1 ? currentDeckIndex + 1 : currentDeckIndex - 1; } else { const labelUpper = String(connector.label || "").toUpperCase(); if (labelUpper.includes("UP")) { targetDeckIndex = currentDeckIndex + 1; } else if (labelUpper.includes("DOWN")) { targetDeckIndex = currentDeckIndex - 1; } else { targetDeckIndex = currentDeckIndex < deckPlan.decks.length - 1 ? currentDeckIndex + 1 : currentDeckIndex - 1; } } const targetDeck = deckPlan.decks[targetDeckIndex]; if (targetDeck && targetDeck.sceneId) { const targetConnector = targetDeck.deckConnectors?.find((tc) => tc.label === connector.label) ?? targetDeck.deckConnectors?.[0]; if (targetConnector) { const cx = connector.x * gs; const cy = connector.y * gs; const pts = [ cx, cy, cx + gs, cy, cx + gs, cy + gs, cx, cy + gs ]; const targetX = Math.round(targetConnector.x * gs + gs / 2); const targetY = Math.round(targetConnector.y * gs + gs / 2); regions.push({ name: `Transit: ${connector.label}`, color: "#00e5ff", visibility: 0, shapes: [{ type: "polygon", points: pts }], behaviors: [{ type: "teleportToken", name: `Transit to ${targetDeck.deckName}`, disabled: false, destination: { scene: targetDeck.sceneId, x: targetX, y: targetY, elevation: 0 } }], flags: { [MODULE_ID]: { generatedRegion: true, isTransit: true } } }); } } } } 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) { const lightDoc = { 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 } }; if (config.animation) { lightDoc.config.animation = config.animation; } lights.push(lightDoc); } for (const room of deck.rooms) { if (!config.roomTypes?.includes(room.type)) continue; const radius = Math.min(room.width, room.height) * gs; const roomLightDoc = { 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 } }; if (config.roomAnimation ?? config.animation) { roomLightDoc.config.animation = config.roomAnimation ?? config.animation; } lights.push(roomLightDoc); } 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 } } }; } function withGeneratedSoundFlag(sound) { return { ...sound, flags: { ...(sound.flags ?? {}), [MODULE_ID]: { ...(sound.flags?.[MODULE_ID] ?? {}), generatedSound: 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: "#303515", // Sickly dim survival green-grey alpha: 0.22, luminosity: 0.22, roomTypes: ["bridge", "engineering", "reactor", "medical", "comms"], accentColor: "#cc2222", // Flickering red alarms in key rooms! animation: { type: "flicker", speed: 9, intensity: 8 } }, "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"], animation: { type: "pulse", speed: 3, intensity: 4 } }, 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: "#601010", // Dim bloody red alpha: 0.2, luminosity: 0.2, roomTypes: ["basement", "boiler-room"], accentColor: "#cc2222", animation: { type: "flicker", speed: 8, intensity: 8 } }, asylum: { ambient: true, color: "#a0a080", // Clinical sickness alpha: 0.24, luminosity: 0.25, roomTypes: ["surgery", "padded-cell", "medical"], accentColor: "#ece8aa", // Clinical flickering fluorescent bulbs animation: { type: "flicker", speed: 9, intensity: 6 } }, "haunted-manor": { ambient: false, color: "#301540", // Sinister purple alpha: 0.2, luminosity: 0.2, roomTypes: ["library", "ballroom"], accentColor: "#502070", animation: { type: "pulse", speed: 2, intensity: 6 } }, bloodbath: { ambient: false, color: "#700505", // Blood wash alpha: 0.35, luminosity: 0.2, roomTypes: [], animation: { type: "pulse", speed: 3, intensity: 5 } }, 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."; if (chosenTemplate === "sla") { const seedNum = hashSeed(deckPlan.seed); const sectorCode = `SEC-${(seedNum % 900 + 100)}-${String.fromCharCode(65 + (seedNum % 26))}${String.fromCharCode(65 + ((seedNum >> 8) % 26))}`; const threatLevel = { light: { label: "GREEN", color: "#4cd964", desc: "MINIMAL RESISTANCE / LOW RISK" }, standard: { label: "YELLOW", color: "#ffcc00", desc: "ACTIVE THREATS / CAUTION REQUIRED" }, heavy: { label: "RED", color: "#ff3b30", desc: "WAR ZONE / IMMEDIATE DANGER" } }[formState.encounterDensity] ?? { label: "AMBER", color: "#ff9500", desc: "UNSTABLE ENVIRONMENT" }; const payout = (15000 + (seedNum % 35000) + (deckPlan.decks.length * 10000)).toLocaleString(); const deckLines = deckPlan.decks.map((deck) => `
${escapeXml(deck.deckName)}
Status: ${deck.rooms.length} Compartments, ${deck.corridors.length} Transit Junctions
Threat Profile: ${escapeXml(summarizeDeckThreats(deck))}
`).join(""); return `
SLA INDUSTRIES
BLUEPRINT NEWS (BPN) CONTRACT DIVISION
THREAT LEVEL: ${threatLevel.label}
CONTRACT REF: BPN-${sectorCode}-${deckPlan.seed.slice(-4).toUpperCase()}
OPERATIONAL ZONE: ${sectorCode}
ALLOTMENT REWARD: ${payout}c (Net)
SPONSORING DEPT: Dept 7 (Enforcement)

1. Operational Mandate

${escapeXml(intro)} Operatives must secure the designated sectors and neutralize any flagged threats.

MANDATORY INSTRUCTION: ${escapeXml(briefSpecialInstruction(formState, chosenTemplate))}

2. Sector Analysis & High-Risk Zones

${deckLines}
NOTICE TO OPERATIVES: Contract fulfillment requires 100% confirmation of hazard containment and threat elimination. Sponsoring department accepts no responsibility for operational casualty, psychological trauma, or equipment loss. Unauthorized media interviews, recordings, or telemetry leaks to civilian networks are severe breaches of corporate security and subject to immediate asset liquidation.
// OWN THE CITY. DO THE JOB. //
`.replace(/\n\s+/g, ""); } 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; } function buildAmbientSounds(deck, formState) { const gs = formState.gridSize; const sounds = []; const theme = formState.theme; // 1. Theme-based base atmospheric hum for the whole scene (center-positioned) const maxRadius = Math.max(deck.columns, deck.rows) * gs; if (theme === "derelict") { sounds.push({ x: Math.round((deck.columns * gs) / 2), y: Math.round((deck.rows * gs) / 2), radius: maxRadius, path: "modules/scifi-deckplan-generator/assets/sounds/derelict-groan.mp3", repeat: true, volume: 0.35, type: "global", easing: true }); } else if (theme === "alien-organic") { sounds.push({ x: Math.round((deck.columns * gs) / 2), y: Math.round((deck.rows * gs) / 2), radius: maxRadius, path: "modules/scifi-deckplan-generator/assets/sounds/organic-pulse.mp3", repeat: true, volume: 0.4, type: "global", easing: true }); } else { sounds.push({ x: Math.round((deck.columns * gs) / 2), y: Math.round((deck.rows * gs) / 2), radius: maxRadius, path: "modules/scifi-deckplan-generator/assets/sounds/ship-hum.mp3", repeat: true, volume: 0.3, type: "global", easing: true }); } // 2. Specific room ambient sounds (reactor cores, engineering, bridge) for (const room of deck.rooms) { const rx = Math.round((room.x + room.width / 2) * gs); const ry = Math.round((room.y + room.height / 2) * gs); const rRadius = Math.min(room.width, room.height) * gs * 1.5; if (room.type === "reactor" || room.type === "engineering") { sounds.push({ x: rx, y: ry, radius: rRadius, path: "modules/scifi-deckplan-generator/assets/sounds/reactor-hum.mp3", repeat: true, volume: 0.5, type: "local", easing: true }); } else if (room.type === "bridge" || room.type === "comms") { sounds.push({ x: rx, y: ry, radius: rRadius, path: "modules/scifi-deckplan-generator/assets/sounds/static-crackle.mp3", repeat: true, volume: 0.35, type: "local", easing: true }); } } return sounds; } function buildLootTiles(deck, formState) { const gs = formState.gridSize; const tiles = []; for (const room of deck.rooms) { // Cargo bays and storage rooms get crates/lockers if (room.type === "storage" || room.type === "cargo") { const crateX = Math.round((room.x + 1) * gs); const crateY = Math.round((room.y + 1) * gs); tiles.push({ x: crateX, y: crateY, width: gs, height: gs, texture: { src: "icons/containers/boxes/box-metal-grey.webp" }, locked: false, flags: { [MODULE_ID]: { generatedTile: true, lootType: "crate", roomName: room.label } } }); } // Bridge, Comms, and Research Labs get computer terminals else if (room.type === "bridge" || room.type === "comms" || room.type === "lab") { const termX = Math.round((room.x + room.width - 2) * gs); const termY = Math.round((room.y + 1) * gs); tiles.push({ x: termX, y: termY, width: gs, height: gs, texture: { src: "icons/commodities/tech/sensor-blue.webp" }, locked: false, flags: { [MODULE_ID]: { generatedTile: true, lootType: "terminal", roomName: room.label } } }); } } return tiles; } function buildHazardRegions(deck, formState) { const gs = formState.gridSize; const regions = []; const theme = formState.theme; if (!["derelict", "sewer", "derelict-house"].includes(theme)) return []; for (const room of deck.rooms) { if (room.type === "storage" || room.type === "maintenance" || room.type === "reactor") { const hx = Math.round((room.x + 1) * gs); const hy = Math.round((room.y + 1) * gs); const pts = [ hx, hy, hx + gs * 2, hy, hx + gs * 2, hy + gs * 2, hx, hy + gs * 2 ]; const hazardType = theme === "sewer" ? "Sewage Outflow" : "Acidic Leak"; const hazardColor = theme === "sewer" ? "#5a4525" : "#00ff33"; // Green glow for acid, brown for sewage regions.push({ name: `Hazard: ${hazardType}`, color: hazardColor, visibility: 0, shapes: [{ type: "polygon", points: pts }], behaviors: [ { type: "adjustDarknessLevel", name: "Hazard Gloom", disabled: false, darkness: 0.3 // v13 adjusts local darkness inside the region! } ], flags: { [MODULE_ID]: { generatedRegion: true, isHazard: true, hazardType } } }); } } return regions; }