Files
org_web/assets/scripts/pages/princess-lima-v2-state.js
gitea-actions 563d4b63cc
All checks were successful
Build Org Website / build (push) Successful in 37s
Tune Princess Lima v2 boss balance and Sun Crystal aid
2026-07-30 15:36:59 +01:00

244 lines
14 KiB
JavaScript
Executable File

(function (root, factory) {
"use strict";
const data = root.PrincessLimaV2Data || (typeof require === "function" ? require("./princess-lima-v2-data.js") : null);
const api = factory(data);
if (typeof module === "object" && module.exports) module.exports = api;
root.PrincessLimaV2State = api;
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data) {
"use strict";
const VERSION = 2;
const STORAGE_KEY = "zxh_princess_lima_rpg_v2";
const LEGACY_KEY = "zxh_princess_lima_rpg_v1";
const APPEARANCES = Object.freeze(["azure", "ember", "pine"]);
const FACES = Object.freeze(["north", "east", "south", "west"]);
const SAFE_SPAWNS = Object.freeze({
village: { start: [496, 624], square: [496, 400], forestRoad: [496, 80] },
forest: { villagePath: [80, 400], ruinsPath: [784, 656], mountainPath: [784, 112] },
ruins: { forestDoor: [496, 656] }, mountain: { forestTrail: [80, 592], campRoad: [880, 592] },
camp: { mountainRoad: [80, 400], fortressRoad: [880, 400] },
fortressExterior: { campGate: [80, 592], innerGate: [496, 240] },
fortressInterior: { frontHall: [496, 656], throneDoor: [496, 80] },
bossArena: { entrance: [496, 592] }, chamber: { door: [496, 592] }
});
const LEGACY_REGION_BY_CHAPTER = Object.freeze({ 1: "village", 2: "forest", 3: "mountain", 4: "fortressInterior" });
const LEGACY_QUEST_MAP = Object.freeze({
aftermath: "aftermath", village_defence: "village_defence", healer_herbs: "healer_herbs",
find_guide: "find_guide", ruins_light: "ruins_light", wolf_miniboss: "wolf_miniboss",
repair_bridge: "repair_bridge", stone_guardian: "stone_guardian", free_scout: "rally_resistance",
free_prisoners: "free_prisoners", break_wards: "break_wards", defeat_malrec: "defeat_malrec"
});
function validName(value) {
const name = String(value || "").trim().replace(/\s+/g, " ");
return name.length >= 1 && name.length <= 20 ? name : null;
}
function finite(value, fallback, minimum, maximum) {
const number = Number(value);
return Number.isFinite(number) ? Math.max(minimum, Math.min(maximum, number)) : fallback;
}
function questDefaults() {
return Object.fromEntries(Object.keys(Data.QUESTS).map((id) => [id, { status: "locked", count: 0, rewarded: false }]));
}
function defaultSettings() {
return {
soundEnabled: false, master: 0.8, music: 0.48, effects: 0.75, voice: 0.8,
narrationEnabled: true, subtitles: true, reducedMotion: null, highContrast: false,
effectsQuality: "full", blur: true, particles: true, screenShake: true, lighting: true,
textSpeed: "normal"
};
}
function fresh(name, appearance) {
return {
version: VERSION,
player: { name: validName(name) || "Traveller", appearance: APPEARANCES.includes(appearance) ? appearance : "azure" },
health: 100, maxHealth: 100, attack: 1, defence: 0,
region: "village", position: { spawn: "start", x: 496, y: 624, facing: "north" },
checkpoint: { region: "village", spawn: "start", x: 496, y: 624 },
chapter: 1, story: "arrival", quests: questDefaults(),
inventory: [{ id: "healing_tonic", quantity: 2 }],
equipment: { weapon: null, armour: null, boots: null, charm: null },
flags: {}, solvedPuzzles: [], defeatedBosses: [], openedChests: [], discoveredSecrets: [],
unlockedRoutes: [], dialogueHistory: [], completedCutscenes: [], rescued: false, introSeen: false,
playTimeSeconds: 0, settings: defaultSettings()
};
}
function normalizeInventory(value) {
const quantities = new Map();
(Array.isArray(value) ? value : []).forEach((entry) => {
const item = entry && Data.ITEMS[entry.id];
if (!item) return;
const cap = item.stack || 1;
quantities.set(entry.id, Math.min(cap, (quantities.get(entry.id) || 0) + Math.max(1, Math.floor(Number(entry.quantity) || 1))));
});
return Array.from(quantities, ([id, quantity]) => ({ id, quantity }));
}
function normalizeQuests(value) {
const output = questDefaults();
Object.keys(output).forEach((id) => {
const source = value && value[id];
if (!source) return;
output[id] = {
status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked",
count: Math.floor(finite(source.count, 0, 0, Data.QUESTS[id].target)),
rewarded: source.rewarded === true
};
});
return output;
}
function safeSpawn(region, spawn) {
const regionId = Data.MAP_IDS.includes(region) ? region : "village";
const entries = SAFE_SPAWNS[regionId];
const spawnId = entries[spawn] ? spawn : Object.keys(entries)[0];
return { region: regionId, spawn: spawnId, x: entries[spawnId][0], y: entries[spawnId][1] };
}
function normalize(candidate) {
if (!candidate || candidate.version !== VERSION) return null;
const name = validName(candidate.player && candidate.player.name);
const appearance = candidate.player && APPEARANCES.includes(candidate.player.appearance) ? candidate.player.appearance : null;
if (!name || !appearance) return null;
const safe = safeSpawn(candidate.region, candidate.position && candidate.position.spawn);
const checkpoint = safeSpawn(candidate.checkpoint && candidate.checkpoint.region, candidate.checkpoint && candidate.checkpoint.spawn);
const inventory = normalizeInventory(candidate.inventory);
const has = (id) => inventory.some((entry) => entry.id === id);
const weapon = has("tempered_sword") ? "tempered_sword" : has("village_sword") ? "village_sword" : null;
const armour = has("reinforced_buckler") ? "reinforced_buckler" : has("buckler") ? "buckler" : null;
const settings = Object.assign(defaultSettings(), candidate.settings || {});
settings.effectsQuality = ["full", "reduced", "minimal"].includes(settings.effectsQuality) ? settings.effectsQuality : "full";
settings.textSpeed = ["slow", "normal", "fast", "instant"].includes(settings.textSpeed) ? settings.textSpeed : "normal";
const maxHealth = finite(candidate.maxHealth, 100, 100, 160);
return {
version: VERSION, player: { name, appearance },
health: finite(candidate.health, maxHealth, 0, maxHealth), maxHealth,
attack: weapon ? Data.ITEMS[weapon].attack : 1, defence: armour ? Data.ITEMS[armour].defence : 0,
region: safe.region,
position: { spawn: safe.spawn, x: finite(candidate.position && candidate.position.x, safe.x, 32, 928), y: finite(candidate.position && candidate.position.y, safe.y, 32, 672), facing: FACES.includes(candidate.position && candidate.position.facing) ? candidate.position.facing : "south" },
checkpoint,
chapter: Math.floor(finite(candidate.chapter, 1, 1, 4)),
story: String(candidate.story || "arrival").slice(0, 64),
quests: normalizeQuests(candidate.quests), inventory,
equipment: { weapon, armour, boots: has("trail_boots") ? "trail_boots" : null, charm: has("forest_charm") ? "forest_charm" : null },
flags: candidate.flags && typeof candidate.flags === "object" && !Array.isArray(candidate.flags)
? Object.fromEntries(Object.entries(candidate.flags).filter(([key, value]) => /^[a-z0-9_-]{1,64}$/.test(key) && typeof value === "boolean").slice(0, 160)) : {},
solvedPuzzles: cleanIds(candidate.solvedPuzzles, 32), defeatedBosses: cleanIds(candidate.defeatedBosses, 16),
openedChests: cleanIds(candidate.openedChests, 64), discoveredSecrets: cleanIds(candidate.discoveredSecrets, 64),
unlockedRoutes: cleanIds(candidate.unlockedRoutes, 64), dialogueHistory: cleanIds(candidate.dialogueHistory, 128),
completedCutscenes: cleanIds(candidate.completedCutscenes, 64),
rescued: candidate.rescued === true || Boolean(candidate.flags && candidate.flags.rescued),
introSeen: candidate.introSeen === true, playTimeSeconds: Math.floor(finite(candidate.playTimeSeconds, 0, 0, 999999)),
settings
};
}
function cleanIds(value, cap) {
return Array.isArray(value) ? Array.from(new Set(value.filter((id) => typeof id === "string" && /^[a-z0-9_-]{1,64}$/.test(id)))).slice(0, cap) : [];
}
function parse(raw) {
try { return normalize(JSON.parse(raw)); } catch (_error) { return null; }
}
function addItem(state, id, amount) {
const next = normalize(state);
if (!next || !Data.ITEMS[id]) return next;
next.inventory = normalizeInventory(next.inventory.concat([{ id, quantity: amount || 1 }]));
return normalize(next);
}
function quantity(state, id) {
const entry = state.inventory.find((item) => item.id === id);
return entry ? entry.quantity : 0;
}
function startQuest(state, id) {
const next = normalize(state);
if (next && Data.QUESTS[id] && next.quests[id].status === "locked") next.quests[id].status = "active";
return normalize(next);
}
function completeQuest(state, id) {
let next = normalize(state);
if (!next || !Data.QUESTS[id]) return next;
next.quests[id] = { status: "complete", count: Data.QUESTS[id].target, rewarded: true };
Data.QUESTS[id].reward.forEach(([item, amount]) => { next = addItem(next, item, amount); });
const consequences = {
aftermath: ["aftermath_complete"], village_defence: ["village_defended", "village_resolved"],
find_guide: ["guide_found"], ruins_light: ["ruins_complete", "ruins_resolved"],
wolf_miniboss: ["briar_defeated", "forest_resolved"], repair_bridge: ["bridge_repaired", "mountain_resolved"],
stone_guardian: ["guardian_defeated"], rally_resistance: ["emblem_found", "camp_resolved"],
disable_defences: ["defences_disabled", "fortressExterior_resolved"], free_prisoners: ["prisoners_freed"],
break_wards: ["wards_broken", "fortressInterior_resolved"], defeat_malrec: ["malrec_defeated", "bossArena_resolved"]
};
(consequences[id] || []).forEach((flag) => { next.flags[flag] = true; });
if (id === "village_defence") next.chapter = 2;
if (id === "stone_guardian") next.chapter = 3;
if (id === "rally_resistance") next.chapter = 4;
return normalize(next);
}
function progressQuest(state, id, amount) {
let next = normalize(state);
if (!next || !Data.QUESTS[id]) return next;
if (next.quests[id].status === "locked") next = startQuest(next, id);
if (next.quests[id].status === "complete") return next;
next.quests[id].count = Math.min(Data.QUESTS[id].target, next.quests[id].count + Math.max(1, amount || 1));
return next.quests[id].count >= Data.QUESTS[id].target ? completeQuest(next, id) : normalize(next);
}
function applyEffects(state, effects) {
let next = normalize(state);
(effects || []).forEach((effect) => {
if (effect.type === "startQuest") next = startQuest(next, effect.id);
if (effect.type === "completeQuest") next = completeQuest(next, effect.id);
if (effect.type === "progressQuest") next = progressQuest(next, effect.id, effect.amount);
if (effect.type === "item") next = addItem(next, effect.id, effect.amount);
if (effect.type === "flag" && /^[a-z0-9_-]+$/.test(effect.id)) next.flags[effect.id] = effect.value !== false;
});
return normalize(next);
}
function useTonic(state) {
let next = normalize(state);
if (!next || next.health >= next.maxHealth || quantity(next, "healing_tonic") < 1) return { state: next, used: false };
next.health = Math.min(next.maxHealth, next.health + Data.ITEMS.healing_tonic.heal);
next.inventory = next.inventory.map((item) => item.id === "healing_tonic" ? { id: item.id, quantity: item.quantity - 1 } : item).filter((item) => item.quantity > 0);
return { state: normalize(next), used: true };
}
function migrateLegacy(candidate) {
if (!candidate || candidate.version !== 1) return null;
const next = fresh(validName(candidate.player && candidate.player.name) || "Traveller",
candidate.player && APPEARANCES.includes(candidate.player.appearance) ? candidate.player.appearance : "azure");
next.chapter = Math.floor(finite(candidate.chapter, 1, 1, 4));
const region = Data.MAP_IDS.includes(candidate.region) ? candidate.region : LEGACY_REGION_BY_CHAPTER[next.chapter];
const safe = safeSpawn(region, Object.keys(SAFE_SPAWNS[region])[0]);
next.region = region;
next.position = Object.assign(safe, { facing: "south" });
next.checkpoint = safe;
Object.entries(LEGACY_QUEST_MAP).forEach(([oldId, newId]) => {
const source = candidate.quests && candidate.quests[oldId];
if (source) next.quests[newId] = {
status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked",
count: Math.min(Data.QUESTS[newId].target, Math.max(0, Number(source.count) || 0)),
rewarded: source.rewarded === true
};
});
next.inventory = normalizeInventory(candidate.inventory);
next.defeatedBosses = cleanIds(candidate.defeatedBosses, 16);
next.flags = Object.assign({}, candidate.flags || {}, { legacy_imported: true });
next.introSeen = candidate.introSeen !== false;
next.rescued = candidate.rescued === true;
next.settings = Object.assign(defaultSettings(), candidate.settings || {});
return normalize(next);
}
function load(storage) {
const current = parse(storage.getItem(STORAGE_KEY));
if (current) return { state: current, migrated: false };
try {
const legacy = JSON.parse(storage.getItem(LEGACY_KEY) || "null");
const migrated = migrateLegacy(legacy);
if (migrated) {
storage.setItem(STORAGE_KEY, JSON.stringify(migrated));
return { state: migrated, migrated: true };
}
} catch (_error) { /* invalid legacy data is intentionally ignored */ }
return { state: null, migrated: false };
}
return Object.freeze({
VERSION, STORAGE_KEY, LEGACY_KEY, APPEARANCES, FACES, SAFE_SPAWNS,
validName, fresh, normalize, parse, migrateLegacy, load, safeSpawn,
addItem, quantity, startQuest, progressQuest, completeQuest, applyEffects, useTonic
});
}));