244 lines
14 KiB
JavaScript
244 lines
14 KiB
JavaScript
(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
|
|
});
|
|
}));
|