(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 }); }));