Release v0.1.3 2026-07-11
This commit is contained in:
+121
-40
@@ -1,7 +1,11 @@
|
||||
import { createHunterSave, DEFAULT_COLLECTIONS } from "./data";
|
||||
import { createHunterSave } from "./data";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import type { HealerClassId } from "../game/types";
|
||||
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear";
|
||||
import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions";
|
||||
import { BOSS_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type MaterialStack } from "../game/progression/loot";
|
||||
import type { BossId, HealerClassId } from "../game/types";
|
||||
import type { BossCollection, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
|
||||
export interface StorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
@@ -29,54 +33,131 @@ function browserStorage(): StorageAdapter {
|
||||
return fallbackStorage;
|
||||
}
|
||||
|
||||
interface LegacyHunterSave extends Omit<HunterSave, "schemaVersion" | "activeClassId" | "healers"> {
|
||||
schemaVersion: 1;
|
||||
level: number;
|
||||
interface LegacyHunterSave {
|
||||
schemaVersion?: number;
|
||||
slotId?: SaveSlotId;
|
||||
hunterName?: string;
|
||||
activeClassId?: HealerClassId;
|
||||
healers?: HunterSave["healers"];
|
||||
level?: number;
|
||||
location?: string;
|
||||
playSeconds?: number;
|
||||
updatedAt?: string;
|
||||
stats?: HunterSave["stats"];
|
||||
collections?: BossCollection[];
|
||||
materials?: MaterialStack[];
|
||||
collectionLog?: CollectionLog;
|
||||
gearProgress?: GearProgress;
|
||||
}
|
||||
|
||||
const HEALER_IDS: HealerClassId[] = ["priest", "druid", "shaman"];
|
||||
|
||||
function normalizeCollections(collections: HunterSave["collections"] | undefined) {
|
||||
const source = collections ?? [];
|
||||
const knownIds = new Set(DEFAULT_COLLECTIONS.map((boss) => boss.bossId));
|
||||
const current = DEFAULT_COLLECTIONS.map((fallback) => source.find((boss) => boss.bossId === fallback.bossId) ?? structuredClone(fallback));
|
||||
return [...current, ...source.filter((boss) => !knownIds.has(boss.bossId))];
|
||||
function positiveCounts(value: unknown): Record<string, number> {
|
||||
if (!value || typeof value !== "object") return {};
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).flatMap(([id, rawQuantity]) => {
|
||||
const quantity = Math.max(0, Math.floor(Number(rawQuantity) || 0));
|
||||
return quantity > 0 ? [[id, quantity]] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeCollectionLog(candidate: LegacyHunterSave): CollectionLog {
|
||||
if (candidate.collectionLog) {
|
||||
return {
|
||||
dropsFound: positiveCounts(candidate.collectionLog.dropsFound),
|
||||
petsFound: positiveCounts(candidate.collectionLog.petsFound),
|
||||
};
|
||||
}
|
||||
const result = createEmptyCollectionLog();
|
||||
for (const legacyBoss of candidate.collections ?? []) {
|
||||
if (!BOSS_ORDER.includes(legacyBoss.bossId as BossId)) continue;
|
||||
const bossId = legacyBoss.bossId as BossId;
|
||||
const quantity = legacyBoss.drops.reduce((sum, drop) => sum + Math.max(0, Math.floor(drop.count || 0)), 0);
|
||||
if (quantity > 0) result.dropsFound[BOSS_DROP_TABLES[bossId].coins.initiate.id] = quantity;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function knownMaterial(id: string) {
|
||||
for (const bossId of BOSS_ORDER) {
|
||||
const coin = Object.values(BOSS_DROP_TABLES[bossId].coins).find((candidate) => candidate.id === id);
|
||||
if (coin) return coin;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeMaterials(value: unknown, collectionLog: CollectionLog): MaterialStack[] {
|
||||
const quantities = new Map<string, number>();
|
||||
if (Array.isArray(value)) {
|
||||
for (const raw of value) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const item = raw as Partial<MaterialStack>;
|
||||
if (!item.id) continue;
|
||||
const quantity = Math.max(0, Math.floor(Number(item.quantity) || 0));
|
||||
if (quantity > 0) quantities.set(item.id, (quantities.get(item.id) ?? 0) + quantity);
|
||||
}
|
||||
} else {
|
||||
for (const [id, quantity] of Object.entries(collectionLog.dropsFound)) quantities.set(id, quantity);
|
||||
}
|
||||
return [...quantities].flatMap(([id, quantity]) => {
|
||||
const coin = knownMaterial(id);
|
||||
return coin ? [{ id, quantity, name: coin.name, rarity: coin.rarity, itemLevel: coin.itemLevel, glyph: coin.glyph }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeGearProgress(value: unknown): GearProgress {
|
||||
const defaults = createDefaultGearProgress();
|
||||
if (!value || typeof value !== "object") return defaults;
|
||||
const candidate = value as Partial<GearProgress>;
|
||||
for (const ownerId of GEAR_OWNER_ORDER) {
|
||||
for (const slotId of GEAR_SLOT_ORDER) {
|
||||
const level = Math.max(0, Math.min(MAX_GEAR_LEVEL, Math.floor(Number(candidate[ownerId]?.slots?.[slotId]?.level) || 0)));
|
||||
defaults[ownerId].slots[slotId].level = level as GearProgress[typeof ownerId]["slots"][typeof slotId]["level"];
|
||||
}
|
||||
defaults[ownerId].infusionAbilityId = normalizeActiveInfusionId(ownerId, candidate[ownerId]?.infusionAbilityId);
|
||||
defaults[ownerId].passiveInfusionId = normalizePassiveInfusionId(ownerId, candidate[ownerId]?.passiveInfusionId);
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
function normalizeBossKills(value: unknown): Record<string, number> {
|
||||
const source = positiveCounts(value);
|
||||
const result: Record<string, number> = {};
|
||||
for (const [key, quantity] of Object.entries(source)) {
|
||||
const bossId = BOSS_ORDER.find((id) => id === key || BOSS_DEFINITIONS[id].name === key);
|
||||
result[bossId ?? key] = (result[bossId ?? key] ?? 0) + quantity;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeSave(value: unknown): HunterSave | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<HunterSave> & Partial<LegacyHunterSave>;
|
||||
const candidate = value as LegacyHunterSave;
|
||||
if (!candidate.slotId || !candidate.hunterName) return null;
|
||||
|
||||
if (candidate.schemaVersion === 2 && candidate.healers) {
|
||||
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
|
||||
return {
|
||||
...(candidate as HunterSave),
|
||||
activeClassId,
|
||||
collections: normalizeCollections(candidate.collections),
|
||||
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
|
||||
level: Math.max(1, candidate.healers?.[classId]?.level ?? 1),
|
||||
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
|
||||
}])) as HunterSave["healers"],
|
||||
};
|
||||
}
|
||||
|
||||
const legacy = candidate as LegacyHunterSave;
|
||||
const collectionLog = normalizeCollectionLog(candidate);
|
||||
const bossKills = normalizeBossKills(candidate.stats?.bossKills);
|
||||
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
slotId: legacy.slotId,
|
||||
hunterName: legacy.hunterName,
|
||||
activeClassId: "priest",
|
||||
healers: {
|
||||
priest: { level: Math.max(1, legacy.level || 1), inventory: createClassInventory("priest") },
|
||||
druid: { level: 1, inventory: createClassInventory("druid") },
|
||||
shaman: { level: 1, inventory: createClassInventory("shaman") },
|
||||
schemaVersion: 4,
|
||||
slotId: candidate.slotId,
|
||||
hunterName: candidate.hunterName,
|
||||
activeClassId,
|
||||
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
|
||||
level: Math.max(1, candidate.healers?.[classId]?.level ?? (classId === "priest" ? candidate.level ?? 1 : 1)),
|
||||
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
|
||||
}])) as HunterSave["healers"],
|
||||
location: candidate.location ?? "Ember Vault Approach",
|
||||
playSeconds: Math.max(0, candidate.playSeconds ?? 0),
|
||||
updatedAt: candidate.updatedAt ?? new Date(0).toISOString(),
|
||||
stats: {
|
||||
totalBossKills: Math.max(0, candidate.stats?.totalBossKills ?? Object.values(bossKills).reduce((sum, count) => sum + count, 0)),
|
||||
flawlessClears: Math.max(0, candidate.stats?.flawlessClears ?? 0),
|
||||
alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0),
|
||||
healingDone: Math.max(0, candidate.stats?.healingDone ?? 0),
|
||||
bossKills,
|
||||
},
|
||||
location: legacy.location,
|
||||
playSeconds: legacy.playSeconds,
|
||||
updatedAt: legacy.updatedAt,
|
||||
stats: legacy.stats,
|
||||
collections: normalizeCollections(legacy.collections),
|
||||
materials: normalizeMaterials(candidate.materials, collectionLog),
|
||||
collectionLog,
|
||||
gearProgress: normalizeGearProgress(candidate.gearProgress),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user