(function (root, factory) { "use strict"; const Data = root.ArchiveWorldData || (typeof require === "function" ? require("./archive-world-data.js") : null); const State = root.ArchiveWorldState || (typeof require === "function" ? require("./archive-world-state.js") : null); const api = factory(Data, State); root.ArchiveWorldSystems = api; if (typeof module === "object" && module.exports) module.exports = api; }(typeof globalThis !== "undefined" ? globalThis : this, function (Data, State) { "use strict"; function copy(state) { return State.normalize(JSON.parse(JSON.stringify(state))); } function normalizedVector(x, y, speed) { const length = Math.hypot(Number(x) || 0, Number(y) || 0); if (!length) return { x: 0, y: 0 }; return { x: (x / length) * speed, y: (y / length) * speed }; } function moveToward(value, target, maximumDelta) { if (Math.abs(target - value) <= maximumDelta) return target; return value + Math.sign(target - value) * maximumDelta; } function approachVelocity(currentX, currentY, inputX, inputY, movement, deltaMs, hasBoots) { const config = movement || {}; const speed = Math.max(1, Number(config.speed) || 160); const moving = Boolean(Number(inputX) || Number(inputY)); const target = moving ? normalizedVector(inputX, inputY, speed) : { x: 0, y: 0 }; const rate = moving ? (hasBoots ? Number(config.bootAcceleration) || 1700 : Number(config.acceleration) || 1300) : Number(config.deceleration) || 1900; const step = Math.max(0, Math.min(50, Number(deltaMs) || 0)) / 1000 * rate; let x = moveToward(Number(currentX) || 0, target.x, step); let y = moveToward(Number(currentY) || 0, target.y, step); const length = Math.hypot(x, y); if (length > speed) { x = x / length * speed; y = y / length * speed; } return { x, y }; } function discover(state, landmarkId) { const next = copy(state); if (!next || !Data.LANDMARK_IDS.includes(landmarkId)) return next; if (!next.discovered.includes(landmarkId)) next.discovered.push(landmarkId); next.complete = Data.LANDMARK_IDS.every((id) => next.discovered.includes(id)); return State.normalize(next); } function startQuest(state, questId) { const next = copy(state); if (!next || !Data.QUESTS[questId]) return next; if (next.quests[questId].status === "locked") next.quests[questId] = { status: "active", step: 0, count: 0 }; if (next.story.stage === "arrival" && questId === "gate") next.story.stage = "first_act"; return State.normalize(next); } function progressQuest(state, questId, amount) { const next = startQuest(state, questId); if (!next || next.quests[questId].status === "complete") return next; next.quests[questId].count = Math.min(99, next.quests[questId].count + Math.max(1, Number(amount) || 1)); return State.normalize(next); } function completeQuest(state, questId) { let next = startQuest(state, questId); if (!next || next.quests[questId].status === "complete") return next; const quest = Data.QUESTS[questId]; next.quests[questId] = { status: "complete", step: 99, count: next.quests[questId].count }; if (Data.LANDMARK_IDS.includes(quest.landmark) && !next.sigils.includes(quest.landmark)) next.sigils.push(quest.landmark); if (!next.solvedPuzzles.includes(questId)) next.solvedPuzzles.push(questId); const rewards = { gate: [["lantern", 1], ["ink_vial", 1]], library: [["archive_key", 1], ["lore_page", 1]], study: [["ink_vial", 2]], inn: [["recall_stew", 1]], workshop: [["workshop_cog", 3]], playroom: [["swiftstep_boots", 1]], museum: [["museum_lens", 1], ["lore_page", 1]], garden: [["garden_seed", 1], ["memory_fragment", 1]], lost_letter: [["lore_page", 1]] }; (rewards[questId] || []).forEach(([id, quantity]) => { next = addItem(next, id, quantity); }); if (questId === "playroom") next.equipment.boots = true; if (questId === "museum") next.equipment.lens = true; if (questId === "workshop" && !next.defeatedBosses.includes("sentinel")) next.defeatedBosses.push("sentinel"); next = grantXp(next, questId === "workshop" ? 120 : questId === "museum" ? 100 : questId === "lost_letter" ? 40 : 90); return updateStory(next); } function updateStory(state) { const next = copy(state); if (!next) return next; if (next.sigils.length >= 4 && !next.story.midpointSeen) next.story.stage = "midpoint"; if (next.story.midpointSeen && next.sigils.length < 8) next.story.stage = "escalation"; if (next.sigils.length === 8 && !next.story.ending) next.story.stage = "vault"; if (next.story.ending) next.story.stage = "postgame"; return State.normalize(next); } function addItem(state, itemId, quantity) { const next = copy(state); if (!next || !Data.ITEMS[itemId]) return next; const cap = Data.ITEMS[itemId].stack || 1; const found = next.inventory.find((item) => item.id === itemId); if (found) found.quantity = Math.min(cap, found.quantity + Math.max(1, Math.floor(Number(quantity) || 1))); else next.inventory.push({ id: itemId, quantity: Math.min(cap, Math.max(1, Math.floor(Number(quantity) || 1))) }); return State.normalize(next); } function itemQuantity(state, itemId) { const found = state && state.inventory && state.inventory.find((item) => item.id === itemId); return found ? found.quantity : 0; } function consumeItem(state, itemId) { let next = copy(state); const item = Data.ITEMS[itemId]; if (!next || !item || item.type !== "consumable" || itemQuantity(next, itemId) < 1 || next.health >= next.maxHealth) { return { state: next, used: false, amount: 0 }; } const amount = itemId === "recall_stew" ? next.maxHealth : 35; next.health = Math.min(next.maxHealth, next.health + amount); const found = next.inventory.find((entry) => entry.id === itemId); found.quantity -= 1; next.inventory = next.inventory.filter((entry) => entry.quantity > 0 || entry.id === "living_bookmark"); next = State.normalize(next); return { state: next, used: true, amount }; } function grantXp(state, amount) { const next = copy(state); if (!next) return next; const oldLevel = next.level; next.xp = Math.min(99999, next.xp + Math.max(0, Math.floor(Number(amount) || 0))); next.level = State.levelForXp(next.xp); next.maxHealth = 100 + (next.level >= 2 ? 20 : 0) + (next.level >= 4 ? 20 : 0); if (next.level > oldLevel) next.health = next.maxHealth; return State.normalize(next); } function takeDamage(state, amount, now, lastHitAt) { const next = copy(state); const invulnerability = next && next.settings.assist ? 1400 : 850; if (!next || Number(now) - Number(lastHitAt || 0) < invulnerability) return { state: next, hit: false, defeated: false }; const scale = next.settings.assist ? 0.5 : 1; next.health = Math.max(0, next.health - Math.max(1, Math.round(amount * scale))); return { state: State.normalize(next), hit: true, defeated: next.health <= 0 }; } function respawn(state) { const next = copy(state); if (!next) return next; next.health = Math.max(50, Math.ceil(next.maxHealth * 0.6)); next.position = { area: next.checkpoint.area, spawn: next.checkpoint.spawn, x: next.checkpoint.x, y: next.checkpoint.y, facing: "north" }; return State.normalize(next); } function recordBoss(state, bossId) { let next = copy(state); if (!next || !Data.BOSS_IDS.includes(bossId)) return next; if (!next.defeatedBosses.includes(bossId)) next.defeatedBosses.push(bossId); if (bossId === "sentinel") next = completeQuest(next, "workshop"); if (bossId === "redactor") { next.story.ending = true; next.story.midpointSeen = true; next = grantXp(next, 250); next = updateStory(next); } return State.normalize(next); } function pointInRect(x, y, item, padding) { const pad = Number(padding) || 0; return x >= item.x - pad && x <= item.x + item.width + pad && y >= item.y - pad && y <= item.y + item.height + pad; } function isSafe(areaId, x, y) { const area = Data.AREAS[areaId]; if (!area || x < 24 || y < 24 || x > area.width - 24 || y > area.height - 24) return false; return !area.collisions.some((item) => pointInRect(x, y, item, 10)); } function isCombatSafe(areaId, x, y) { const area = Data.AREAS[areaId]; return Boolean(area && (area.safeZones || []).some((item) => pointInRect(x, y, item, 0))); } function nearestSafeSpawn(areaId, x, y) { const area = Data.AREAS[areaId] || Data.AREAS.town; const points = Object.entries(area.safeSpawns || { entrance: area.spawn }); const safeCurrent = isSafe(areaId, x, y); if (safeCurrent) return { area: areaId, spawn: "saved", x, y }; return nearestNamedSafeSpawn(areaId, x, y); } function nearestNamedSafeSpawn(areaId, x, y) { const area = Data.AREAS[areaId] || Data.AREAS.town; const points = Object.entries(area.safeSpawns || { entrance: area.spawn }); const best = points.sort((a, b) => Math.hypot(a[1].x - x, a[1].y - y) - Math.hypot(b[1].x - x, b[1].y - y))[0]; return { area: areaId, spawn: best[0], x: best[1].x, y: best[1].y }; } function currentObjective(state) { if (!state) return "Choose a traveler."; if (state.story.ending) return "Archive Town remembers. Explore freely."; if (state.sigils.length === 8) return "Enter the Grand Library Vault and confront the Redactor."; const active = Data.QUEST_IDS.find((id) => state.quests[id] && state.quests[id].status === "active"); if (active) return Data.QUESTS[active].objective; if (!state.sigils.includes("gate")) return "Speak with Gatekeeper Orin at the Town Gate."; if (state.sigils.length >= 4 && !state.story.midpointSeen) return "Bring four sigils to Curator Lima."; return `Recover the Archive Sigils (${state.sigils.length} / 8).`; } return Object.freeze({ normalizedVector, approachVelocity, discover, startQuest, progressQuest, completeQuest, updateStory, addItem, itemQuantity, consumeItem, grantXp, takeDamage, respawn, recordBoss, isSafe, isCombatSafe, nearestSafeSpawn, nearestNamedSafeSpawn, currentObjective }); }));