Files
org_web/assets/scripts/pages/archive-world-systems.js
gitea-actions c06af38d80
All checks were successful
Build Org Website / build (push) Successful in 39s
Harden archive-world setup and refresh site links
2026-07-30 09:08:12 +01:00

193 lines
8.6 KiB
JavaScript
Executable File

(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 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 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 };
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, discover, startQuest, progressQuest, completeQuest, updateStory, addItem, itemQuantity,
consumeItem, grantXp, takeDamage, respawn, recordBoss, isSafe, nearestSafeSpawn, currentObjective
});
}));