(function (root, factory) { "use strict"; const data = root.ArchiveWorldData || (typeof require === "function" ? require("./archive-world-data.js") : null); const api = factory(data); if (typeof module === "object" && module.exports) module.exports = api; root.ArchiveWorldState = api; }(typeof globalThis !== "undefined" ? globalThis : this, function (Data) { "use strict"; const VERSION = 2; const STORAGE_KEY = "zxh_archive_world_v2"; const LEGACY_KEY = "zxh_archive_world_v1"; const PALETTES = Object.freeze(["brass", "moss", "berry"]); const FACES = Object.freeze(["north", "south", "east", "west"]); const SPAWN = Object.freeze({ area: "town", spawn: "gate", x: 724, y: 965 }); const LANDMARK_IDS = Data ? Data.LANDMARK_IDS : Object.freeze(["gate", "library", "study", "inn", "workshop", "playroom", "museum", "garden"]); const QUEST_IDS = Data ? Data.QUEST_IDS : Object.freeze(LANDMARK_IDS.concat("lost_letter")); const ITEM_IDS = Data ? Data.ITEM_IDS : Object.freeze(["living_bookmark", "lantern", "archive_key", "swiftstep_boots", "ink_vial", "memory_fragment", "garden_seed", "workshop_cog", "museum_lens", "recall_stew", "lore_page", "archive_sigil"]); const AREA_IDS = Data ? Data.AREA_IDS : Object.freeze(["town", "vault", "workshop", "playroom", "boss"]); const BOSS_IDS = Object.freeze(["sentinel", "redactor"]); const LEVELS = Object.freeze([0, 80, 200, 380, 620]); function validateName(value) { const name = String(value || "").trim().replace(/\s+/g, " "); return name.length >= 1 && name.length <= 20 ? name : null; } function validatePalette(value) { return PALETTES.includes(value) ? value : null; } function finite(value, fallback, min, max) { const number = Number(value); return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback; } function unique(values, allowed) { return Array.isArray(values) ? Array.from(new Set(values.filter((id) => allowed.includes(id)))) : []; } function questDefaults() { return Object.fromEntries(QUEST_IDS.map((id) => [id, { status: "locked", step: 0, count: 0 }])); } function fresh(name, palette) { return { version: VERSION, name: validateName(name) || "", palette: validatePalette(palette) || "brass", health: 100, maxHealth: 100, level: 1, xp: 0, attack: 1, position: { area: SPAWN.area, spawn: SPAWN.spawn, x: SPAWN.x, y: SPAWN.y, facing: "north" }, checkpoint: { area: SPAWN.area, spawn: SPAWN.spawn, x: SPAWN.x, y: SPAWN.y }, discovered: [], complete: false, story: { stage: "arrival", ending: false, midpointSeen: false }, quests: questDefaults(), inventory: [{ id: "living_bookmark", quantity: 1 }, { id: "ink_vial", quantity: 2 }], selectedItem: "ink_vial", equipment: { bookmark: true, boots: false, lens: false }, sigils: [], defeatedBosses: [], openedChests: [], solvedPuzzles: [], npcFlags: {}, settings: { soundEnabled: false, ambience: 0.35, music: 0.3, effects: 0.65, assist: false, reducedMotion: null } }; } function normalizePosition(candidate, fallback) { const area = candidate && AREA_IDS.includes(candidate.area) ? candidate.area : fallback.area; const areaData = Data && Data.AREAS[area]; const defaultSpawn = areaData ? areaData.spawn : { x: fallback.x, y: fallback.y }; const width = areaData ? areaData.width : 1448; const height = areaData ? areaData.height : 1086; return { area, spawn: String(candidate && candidate.spawn || fallback.spawn || "entrance").slice(0, 30), x: finite(candidate && candidate.x, defaultSpawn.x, 24, width - 24), y: finite(candidate && candidate.y, defaultSpawn.y, 24, height - 24), facing: candidate && FACES.includes(candidate.facing) ? candidate.facing : "north" }; } function normalizeInventory(value) { const quantities = new Map(); if (Array.isArray(value)) value.forEach((entry) => { if (!entry || !ITEM_IDS.includes(entry.id)) return; const cap = Data && Data.ITEMS[entry.id] && Data.ITEMS[entry.id].stack || 1; quantities.set(entry.id, Math.min(cap, (quantities.get(entry.id) || 0) + Math.floor(finite(entry.quantity, 1, 1, cap)))); }); if (!quantities.has("living_bookmark")) quantities.set("living_bookmark", 1); return Array.from(quantities, ([id, quantity]) => ({ id, quantity })); } function normalizeQuests(value) { const defaults = questDefaults(); QUEST_IDS.forEach((id) => { const source = value && value[id]; if (!source) return; defaults[id] = { status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked", step: Math.floor(finite(source.step, 0, 0, 20)), count: Math.floor(finite(source.count, 0, 0, 99)) }; }); return defaults; } function normalize(candidate) { if (!candidate || candidate.version !== VERSION) return null; const name = validateName(candidate.name); const palette = validatePalette(candidate.palette); if (!name || !palette) return null; const discovered = unique(candidate.discovered, LANDMARK_IDS); const sigils = unique(candidate.sigils, LANDMARK_IDS); const xp = Math.floor(finite(candidate.xp, 0, 0, 99999)); const level = levelForXp(xp); const expectedMax = 100 + (level >= 2 ? 20 : 0) + (level >= 4 ? 20 : 0); const maxHealth = finite(candidate.maxHealth, expectedMax, expectedMax, expectedMax); const position = normalizePosition(candidate.position, SPAWN); const checkpoint = normalizePosition(candidate.checkpoint, SPAWN); const inventory = normalizeInventory(candidate.inventory); const selected = ITEM_IDS.includes(candidate.selectedItem) ? candidate.selectedItem : "ink_vial"; return { version: VERSION, name, palette, health: finite(candidate.health, maxHealth, 0, maxHealth), maxHealth, level, xp, attack: sigils.includes("workshop") ? 2 : 1, position, checkpoint: { area: checkpoint.area, spawn: checkpoint.spawn, x: checkpoint.x, y: checkpoint.y }, discovered, complete: LANDMARK_IDS.every((id) => discovered.includes(id)), story: { stage: ["arrival", "first_act", "midpoint", "escalation", "vault", "postgame"].includes(candidate.story && candidate.story.stage) ? candidate.story.stage : "arrival", ending: candidate.story && candidate.story.ending === true, midpointSeen: candidate.story && candidate.story.midpointSeen === true }, quests: normalizeQuests(candidate.quests), inventory, selectedItem: selected, equipment: { bookmark: true, boots: candidate.equipment && candidate.equipment.boots === true, lens: candidate.equipment && candidate.equipment.lens === true }, sigils, defeatedBosses: unique(candidate.defeatedBosses, BOSS_IDS), openedChests: Array.isArray(candidate.openedChests) ? Array.from(new Set(candidate.openedChests.filter((id) => typeof id === "string"))).slice(0, 64) : [], solvedPuzzles: unique(candidate.solvedPuzzles, QUEST_IDS), npcFlags: candidate.npcFlags && typeof candidate.npcFlags === "object" && !Array.isArray(candidate.npcFlags) ? Object.fromEntries(Object.entries(candidate.npcFlags).filter(([key, value]) => /^[a-z0-9_-]{1,40}$/.test(key) && ["string", "number", "boolean"].includes(typeof value)).slice(0, 64)) : {}, settings: { soundEnabled: candidate.settings && candidate.settings.soundEnabled === true, ambience: finite(candidate.settings && candidate.settings.ambience, 0.35, 0, 1), music: finite(candidate.settings && candidate.settings.music, 0.3, 0, 1), effects: finite(candidate.settings && candidate.settings.effects, 0.65, 0, 1), assist: candidate.settings && candidate.settings.assist === true, reducedMotion: candidate.settings && typeof candidate.settings.reducedMotion === "boolean" ? candidate.settings.reducedMotion : null } }; } function migrateV1(candidate) { if (!candidate || candidate.version !== 1) return null; const name = validateName(candidate.name); const palette = validatePalette(candidate.palette); if (!name || !palette) return null; const migrated = fresh(name, palette); migrated.discovered = unique(candidate.discovered, LANDMARK_IDS); migrated.complete = LANDMARK_IDS.every((id) => migrated.discovered.includes(id)); migrated.position = normalizePosition({ area: "town", spawn: "gate", x: candidate.position && candidate.position.x, y: candidate.position && candidate.position.y, facing: "north" }, SPAWN); migrated.checkpoint = { area: "town", spawn: "gate", x: SPAWN.x, y: SPAWN.y }; migrated.settings.soundEnabled = candidate.soundEnabled === true; return normalize(migrated); } function parse(raw) { if (!raw) return null; try { const candidate = JSON.parse(raw); return candidate.version === 1 ? migrateV1(candidate) : normalize(candidate); } catch (_error) { return null; } } function levelForXp(xp) { let level = 1; LEVELS.forEach((threshold, index) => { if (xp >= threshold) level = index + 1; }); return Math.min(5, level); } function withPosition(state, area, x, y, facing, spawn) { const current = normalize(state); if (!current) return null; current.position = normalizePosition({ area, x, y, facing, spawn }, current.position); return current; } function withCheckpoint(state, area, spawn, x, y) { const current = normalize(state); if (!current) return null; const next = normalizePosition({ area, spawn, x, y }, SPAWN); current.checkpoint = { area: next.area, spawn: next.spawn, x: next.x, y: next.y }; return current; } return Object.freeze({ VERSION, STORAGE_KEY, LEGACY_KEY, PALETTES, LANDMARK_IDS, QUEST_IDS, ITEM_IDS, AREA_IDS, BOSS_IDS, LEVELS, SPAWN, validateName, validatePalette, fresh, normalize, parse, migrateV1, normalizePosition, normalizeInventory, normalizeQuests, levelForXp, withPosition, withCheckpoint }); }));