205 lines
9.1 KiB
JavaScript
Executable File
205 lines
9.1 KiB
JavaScript
Executable File
(function (root, factory) {
|
|
"use strict";
|
|
const data = root.PrincessLimaData || (typeof require === "function" ? require("./princess-lima-data.js") : null);
|
|
const api = factory(data);
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
root.PrincessLimaState = api;
|
|
}(typeof globalThis !== "undefined" ? globalThis : this, function (Data) {
|
|
"use strict";
|
|
|
|
const VERSION = 1;
|
|
const STORAGE_KEY = "zxh_princess_lima_rpg_v1";
|
|
const APPEARANCES = Object.freeze(["azure", "ember", "pine"]);
|
|
const FACES = Object.freeze(["north", "south", "east", "west"]);
|
|
const START = Object.freeze({
|
|
region: "village",
|
|
spawn: "start",
|
|
x: Data.MAPS.village.spawns.start.x,
|
|
y: Data.MAPS.village.spawns.start.y,
|
|
facing: "north"
|
|
});
|
|
|
|
function validName(value) {
|
|
const name = String(value || "").trim().replace(/\s+/g, " ");
|
|
return name.length >= 1 && name.length <= 20 ? name : null;
|
|
}
|
|
|
|
function finite(value, fallback, min, max) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
|
|
}
|
|
|
|
function unique(values, allowed) {
|
|
return Array.isArray(values) ? Array.from(new Set(values.filter((id) => allowed.includes(id)))) : [];
|
|
}
|
|
|
|
function questDefaults() {
|
|
return Object.fromEntries(Data.QUEST_IDS.map((id) => [id, { status: "locked", count: 0, rewarded: false }]));
|
|
}
|
|
|
|
function fresh(name, appearance) {
|
|
return {
|
|
version: VERSION,
|
|
player: { name: validName(name) || "", appearance: APPEARANCES.includes(appearance) ? appearance : "azure" },
|
|
health: 100,
|
|
maxHealth: 100,
|
|
attack: 1,
|
|
defence: 0,
|
|
region: START.region,
|
|
position: { spawn: START.spawn, x: START.x, y: START.y, facing: START.facing },
|
|
checkpoint: { region: START.region, spawn: START.spawn, x: START.x, y: START.y },
|
|
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: [],
|
|
unlockedRoutes: [],
|
|
rescued: false,
|
|
introSeen: false,
|
|
playTimeSeconds: 0,
|
|
settings: {
|
|
soundEnabled: false,
|
|
master: 0.8,
|
|
music: 0.45,
|
|
effects: 0.7,
|
|
voice: 0.85,
|
|
narrationEnabled: true,
|
|
subtitles: true,
|
|
reducedMotion: null,
|
|
screenShake: true,
|
|
highContrast: false,
|
|
textSpeed: "normal"
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizePosition(regionId, candidate, fallback) {
|
|
const region = Data.MAPS[regionId] || Data.MAPS.village;
|
|
const named = region.spawns[candidate && candidate.spawn] || region.spawns[fallback.spawn] || Object.values(region.spawns)[0];
|
|
return {
|
|
spawn: String(candidate && candidate.spawn || fallback.spawn).slice(0, 32),
|
|
x: finite(candidate && candidate.x, named.x, 24, Data.WIDTH - 24),
|
|
y: finite(candidate && candidate.y, named.y, 24, Data.HEIGHT - 24),
|
|
facing: candidate && FACES.includes(candidate.facing) ? candidate.facing : fallback.facing || "south"
|
|
};
|
|
}
|
|
|
|
function normalizeInventory(value) {
|
|
const quantities = new Map();
|
|
if (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.floor(finite(entry.quantity, 1, 1, cap))));
|
|
});
|
|
return Array.from(quantities, ([id, quantity]) => ({ id, quantity }));
|
|
}
|
|
|
|
function normalizeQuests(value) {
|
|
const quests = questDefaults();
|
|
Data.QUEST_IDS.forEach((id) => {
|
|
const source = value && value[id];
|
|
if (!source) return;
|
|
quests[id] = {
|
|
status: ["locked", "active", "complete"].includes(source.status) ? source.status : "locked",
|
|
count: Math.floor(finite(source.count, 0, 0, 99)),
|
|
rewarded: source.rewarded === true
|
|
};
|
|
});
|
|
return quests;
|
|
}
|
|
|
|
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 region = Data.MAP_IDS.includes(candidate.region) ? candidate.region : START.region;
|
|
const position = normalizePosition(region, candidate.position, START);
|
|
const checkpointRegion = candidate.checkpoint && Data.MAP_IDS.includes(candidate.checkpoint.region)
|
|
? candidate.checkpoint.region : START.region;
|
|
const checkpointPosition = normalizePosition(checkpointRegion, candidate.checkpoint, START);
|
|
const inventory = normalizeInventory(candidate.inventory);
|
|
const has = (id) => inventory.some((entry) => entry.id === id);
|
|
const equipment = {
|
|
weapon: has("tempered_sword") ? "tempered_sword" : has("village_sword") ? "village_sword" : null,
|
|
armour: has("buckler") ? "buckler" : null,
|
|
boots: has("trail_boots") ? "trail_boots" : null,
|
|
charm: has("forest_charm") ? "forest_charm" : null
|
|
};
|
|
const attack = equipment.weapon ? Data.ITEMS[equipment.weapon].attack : 1;
|
|
const defence = equipment.armour ? Data.ITEMS[equipment.armour].defence : 0;
|
|
const maxHealth = finite(candidate.maxHealth, 100, 100, 160);
|
|
return {
|
|
version: VERSION,
|
|
player: { name, appearance },
|
|
health: finite(candidate.health, maxHealth, 0, maxHealth),
|
|
maxHealth,
|
|
attack,
|
|
defence,
|
|
region,
|
|
position,
|
|
checkpoint: { region: checkpointRegion, spawn: checkpointPosition.spawn, x: checkpointPosition.x, y: checkpointPosition.y },
|
|
chapter: Math.floor(finite(candidate.chapter, 1, 1, 4)),
|
|
story: String(candidate.story || "arrival").slice(0, 48),
|
|
quests: normalizeQuests(candidate.quests),
|
|
inventory,
|
|
equipment,
|
|
flags: candidate.flags && typeof candidate.flags === "object" && !Array.isArray(candidate.flags)
|
|
? Object.fromEntries(Object.entries(candidate.flags).filter(([key, val]) => /^[a-z0-9_-]{1,48}$/.test(key) && typeof val === "boolean").slice(0, 96))
|
|
: {},
|
|
solvedPuzzles: unique(candidate.solvedPuzzles, ["forest_stones", "ruin_braziers", "bridge_winches", "shadow_wards", "sun_pedestals"]),
|
|
defeatedBosses: unique(candidate.defeatedBosses, ["briar_wolf", "stone_guardian", "captain", "malrec"]),
|
|
openedChests: Array.isArray(candidate.openedChests) ? Array.from(new Set(candidate.openedChests.filter((id) => typeof id === "string"))).slice(0, 64) : [],
|
|
unlockedRoutes: unique(candidate.unlockedRoutes, ["village_defended", "guide_found", "ruins_complete", "briar_defeated", "bridge_repaired", "guardian_defeated", "emblem_found", "wards_broken", "malrec_defeated"]),
|
|
rescued: candidate.rescued === true,
|
|
// Existing v1 saves predate the cinematic and must continue directly.
|
|
introSeen: candidate.introSeen !== false,
|
|
playTimeSeconds: Math.floor(finite(candidate.playTimeSeconds, 0, 0, 999999)),
|
|
settings: {
|
|
soundEnabled: candidate.settings && candidate.settings.soundEnabled === true,
|
|
master: finite(candidate.settings && candidate.settings.master, 0.8, 0, 1),
|
|
music: finite(candidate.settings && candidate.settings.music, 0.45, 0, 1),
|
|
effects: finite(candidate.settings && candidate.settings.effects, 0.7, 0, 1),
|
|
voice: finite(candidate.settings && candidate.settings.voice, 0.85, 0, 1),
|
|
narrationEnabled: !(candidate.settings && candidate.settings.narrationEnabled === false),
|
|
subtitles: !(candidate.settings && candidate.settings.subtitles === false),
|
|
reducedMotion: candidate.settings && typeof candidate.settings.reducedMotion === "boolean" ? candidate.settings.reducedMotion : null,
|
|
screenShake: !(candidate.settings && candidate.settings.screenShake === false),
|
|
highContrast: candidate.settings && candidate.settings.highContrast === true,
|
|
textSpeed: candidate.settings && ["slow", "normal", "fast", "instant"].includes(candidate.settings.textSpeed) ? candidate.settings.textSpeed : "normal"
|
|
}
|
|
};
|
|
}
|
|
|
|
function parse(raw) {
|
|
try { return raw ? normalize(JSON.parse(raw)) : null; } catch (_error) { return null; }
|
|
}
|
|
|
|
function withPosition(state, region, spawn, x, y, facing) {
|
|
const next = normalize(state);
|
|
if (!next || !Data.MAPS[region]) return next;
|
|
next.region = region;
|
|
next.position = normalizePosition(region, { spawn, x, y, facing }, START);
|
|
return normalize(next);
|
|
}
|
|
|
|
function withCheckpoint(state, region, spawn, x, y) {
|
|
const next = normalize(state);
|
|
if (!next || !Data.MAPS[region]) return next;
|
|
const point = normalizePosition(region, { spawn, x, y, facing: "south" }, START);
|
|
next.checkpoint = { region, spawn: point.spawn, x: point.x, y: point.y };
|
|
return normalize(next);
|
|
}
|
|
|
|
return Object.freeze({
|
|
VERSION, STORAGE_KEY, APPEARANCES, FACES, START,
|
|
validName, fresh, normalize, parse, normalizeInventory, normalizeQuests,
|
|
normalizePosition, withPosition, withCheckpoint
|
|
});
|
|
}));
|