Release v0.1.4 2026-07-12
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCollections, MODE_COPY, selectRandomBoss } from "./data";
|
||||
import { selectRandomBossPair } from "../game/roguelike";
|
||||
import { BOSS_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
|
||||
import { BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { GROUP_DROP_TABLES, createEmptyCollectionLog } from "../game/progression/loot";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_GROUPS } from "../game/bossCatalog";
|
||||
|
||||
describe("game mode configuration", () => {
|
||||
it("separates randomized PVE from selectable Dungeons", () => {
|
||||
@@ -11,24 +11,25 @@ describe("game mode configuration", () => {
|
||||
});
|
||||
|
||||
it("selects a boss across the full encounter pool", () => {
|
||||
for (let index = 0; index < BOSS_ORDER.length; index += 1) {
|
||||
expect(selectRandomBoss(() => (index + 0.5) / BOSS_ORDER.length)).toBe(BOSS_ORDER[index]);
|
||||
for (let index = 0; index < AVAILABLE_BOSS_IDS.length; index += 1) {
|
||||
expect(selectRandomBoss(() => (index + 0.5) / AVAILABLE_BOSS_IDS.length)).toBe(AVAILABLE_BOSS_IDS[index]);
|
||||
}
|
||||
});
|
||||
|
||||
it("selects two distinct bosses for PVE", () => {
|
||||
const values = [0, 0];
|
||||
const pair = selectRandomBossPair([], () => values.shift() ?? 0);
|
||||
expect(pair).toEqual(["bulldrome", "vexa"]);
|
||||
expect(pair).toEqual(["bulldrome", "sandglass-scorpion"]);
|
||||
expect(new Set(pair)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("derives collection entries from canonical boss drop tables", () => {
|
||||
it("derives group collection entries from canonical group drop tables", () => {
|
||||
const collections = buildCollections(createEmptyCollectionLog(), {});
|
||||
expect(collections.map((boss) => boss.bossId)).toEqual(BOSS_ORDER);
|
||||
expect(collections.map((group) => group.groupId)).toEqual(BOSS_GROUPS.map((group) => group.id));
|
||||
expect(collections.flatMap((group) => group.bosses.map((boss) => boss.bossId)).sort()).toEqual([...AVAILABLE_BOSS_IDS].sort());
|
||||
for (const collection of collections) {
|
||||
expect(collection.drops.map((drop) => drop.id)).toEqual(
|
||||
BOSS_DROP_TABLES[collection.bossId as keyof typeof BOSS_DROP_TABLES].entries.map((drop) => drop.id),
|
||||
GROUP_DROP_TABLES[collection.groupId].entries.map((drop) => drop.id),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+36
-24
@@ -1,15 +1,9 @@
|
||||
import type { BossCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import type { BossGroupCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS, BOSS_GROUPS } from "../game/bossCatalog";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import type { BossId } from "../game/types";
|
||||
import { createDefaultGearProgress } from "../game/progression/gear";
|
||||
import {
|
||||
BOSS_DROP_TABLES,
|
||||
createEmptyCollectionLog,
|
||||
type CollectionLog,
|
||||
type LootRarity,
|
||||
type MaterialStack,
|
||||
} from "../game/progression/loot";
|
||||
import { BOSS_PET_DROPS, GROUP_DROP_TABLES, createEmptyCollectionLog, type CollectionLog, type LootRarity, type MaterialStack } from "../game/progression/loot";
|
||||
|
||||
export const DEFAULT_SETTINGS: GameSettings = {
|
||||
masterVolume: 80,
|
||||
@@ -18,7 +12,7 @@ export const DEFAULT_SETTINGS: GameSettings = {
|
||||
largeText: false,
|
||||
};
|
||||
|
||||
const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"]> = {
|
||||
const RARITY_LABELS: Record<LootRarity, BossGroupCollection["drops"][number]["rarity"]> = {
|
||||
common: "Common",
|
||||
uncommon: "Uncommon",
|
||||
rare: "Rare",
|
||||
@@ -26,31 +20,49 @@ const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"
|
||||
legendary: "Legendary",
|
||||
};
|
||||
|
||||
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossCollection[] {
|
||||
return BOSS_ORDER.map((bossId) => {
|
||||
const table = BOSS_DROP_TABLES[bossId];
|
||||
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossGroupCollection[] {
|
||||
return BOSS_GROUPS.map((group) => {
|
||||
const table = GROUP_DROP_TABLES[group.id];
|
||||
const bosses = group.bossIds.map((bossId) => {
|
||||
const pet = BOSS_PET_DROPS[bossId];
|
||||
return {
|
||||
bossId,
|
||||
bossName: BOSS_DEFINITIONS[bossId].name,
|
||||
kills: bossKills[bossId] ?? 0,
|
||||
pet: {
|
||||
id: pet.id,
|
||||
name: pet.name,
|
||||
icon: pet.glyph,
|
||||
rarity: RARITY_LABELS[pet.rarity],
|
||||
count: collectionLog.petsFound[pet.id] ?? 0,
|
||||
chance: pet.chanceLabel,
|
||||
kind: pet.kind,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
bossId,
|
||||
bossName: BOSS_DEFINITIONS[bossId].name,
|
||||
defeated: (bossKills[bossId] ?? 0) > 0,
|
||||
groupId: group.id,
|
||||
groupLetter: group.letter,
|
||||
groupName: group.name,
|
||||
coreMechanic: group.coreMechanic,
|
||||
defeated: bosses.some((boss) => boss.kills > 0),
|
||||
drops: table.entries.map((drop) => ({
|
||||
id: drop.id,
|
||||
name: drop.name,
|
||||
icon: drop.glyph,
|
||||
rarity: RARITY_LABELS[drop.rarity],
|
||||
count: drop.kind === "coin"
|
||||
? collectionLog.dropsFound[drop.id] ?? 0
|
||||
: collectionLog.petsFound[drop.id] ?? 0,
|
||||
count: collectionLog.dropsFound[drop.id] ?? 0,
|
||||
chance: drop.chanceLabel,
|
||||
itemLevel: drop.kind === "coin" ? drop.itemLevel : undefined,
|
||||
itemLevel: drop.itemLevel,
|
||||
kind: drop.kind,
|
||||
})),
|
||||
bosses,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const DEFAULT_COLLECTION_LOG: CollectionLog = createEmptyCollectionLog();
|
||||
export const DEFAULT_COLLECTIONS: BossCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
|
||||
export const DEFAULT_COLLECTIONS: BossGroupCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
|
||||
|
||||
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
|
||||
"roguelike-pve": {
|
||||
@@ -64,7 +76,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
|
||||
eyebrow: "1–4 hunters · chosen encounter",
|
||||
title: "Dungeons",
|
||||
description: "Choose a guardian, review its mechanics, and bring a prepared healing loadout into a focused encounter.",
|
||||
detail: "Ten prototype guardians available",
|
||||
detail: `${AVAILABLE_BOSS_IDS.length} animated guardians available`,
|
||||
status: "Playable now",
|
||||
},
|
||||
"roguelike-pvp": {
|
||||
@@ -84,7 +96,7 @@ export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; des
|
||||
};
|
||||
|
||||
export function selectRandomBoss(random: () => number = Math.random): BossId {
|
||||
return BOSS_ORDER[Math.floor(random() * BOSS_ORDER.length)] ?? BOSS_ORDER[0];
|
||||
return AVAILABLE_BOSS_IDS[Math.floor(random() * AVAILABLE_BOSS_IDS.length)] ?? AVAILABLE_BOSS_IDS[0];
|
||||
}
|
||||
|
||||
export const MAX_HUNTER_NAME_LENGTH = 20;
|
||||
@@ -101,7 +113,7 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
||||
const normalizedName = normalizeHunterName(hunterName);
|
||||
if (!normalizedName) throw new Error("Hunter name is required.");
|
||||
return {
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 5,
|
||||
slotId,
|
||||
hunterName: normalizedName,
|
||||
activeClassId: "priest",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
||||
import { buildCollections, DEFAULT_COLLECTIONS } from "./data";
|
||||
import { groupDrop } from "../game/progression/loot";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
@@ -91,39 +91,80 @@ describe("SaveRepository", () => {
|
||||
expect(save.healers.priest.inventory).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("migrates schema v1 saves into Priest progress without losing the hunter name", () => {
|
||||
it("resets every legacy save into fresh v5 progression while preserving identity and timestamp", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Legacy");
|
||||
const legacy = { ...created, schemaVersion: 1, level: 27 } as Record<string, unknown>;
|
||||
delete legacy.activeClassId;
|
||||
delete legacy.healers;
|
||||
const legacy = {
|
||||
...created,
|
||||
schemaVersion: 4,
|
||||
activeClassId: "druid",
|
||||
playSeconds: 999,
|
||||
healers: Object.fromEntries(Object.entries(created.healers).map(([id, healer]) => [id, { ...healer, level: 27 }])),
|
||||
stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 } },
|
||||
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 99, rarity: "common", itemLevel: 1, glyph: "R" }],
|
||||
collectionLog: { dropsFound: { "legacy-boss-coin": 99 }, petsFound: { "bulldrome-pet": 1 } },
|
||||
gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, {
|
||||
...owner,
|
||||
slots: Object.fromEntries(Object.entries(owner.slots).map(([slotId]) => [slotId, { level: 10 }])),
|
||||
infusionAbilityId: "priest-sanctuary",
|
||||
}])),
|
||||
} as Record<string, unknown>;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(4);
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.hunterName).toBe("Legacy");
|
||||
expect(migrated.activeClassId).toBe("priest");
|
||||
expect(migrated.healers.priest.level).toBe(27);
|
||||
expect(migrated.healers.druid.level).toBe(1);
|
||||
expect(migrated.healers.shaman.inventory.length).toBeGreaterThan(0);
|
||||
expect(migrated.playSeconds).toBe(0);
|
||||
expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true);
|
||||
expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {} });
|
||||
expect(migrated.materials).toEqual([]);
|
||||
expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} });
|
||||
expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true);
|
||||
expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5);
|
||||
});
|
||||
|
||||
it("derives newly shipped bosses from drop tables after migrating schema v2 collections", () => {
|
||||
it("resets and persists legacy cloud saves when they are listed", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Cloud Legacy");
|
||||
const legacy = {
|
||||
...created,
|
||||
schemaVersion: 4,
|
||||
stats: { ...created.stats, totalBossKills: 8, bossKills: { bulldrome: 8 } },
|
||||
materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 8, rarity: "common", itemLevel: 1, glyph: "R" }],
|
||||
};
|
||||
const cloudKey = "i-want-to-heal:saves:cloud:v1:cloud@example.com";
|
||||
storage.setItem(cloudKey, JSON.stringify({ 1: legacy }));
|
||||
|
||||
const online = repository.list("cloud@example.com")[0].online!;
|
||||
expect(online.schemaVersion).toBe(5);
|
||||
expect(online.stats.totalBossKills).toBe(0);
|
||||
expect(online.materials).toEqual([]);
|
||||
expect(JSON.parse(storage.getItem(cloudKey) ?? "{}")["1"].schemaVersion).toBe(5);
|
||||
});
|
||||
|
||||
it("preserves valid v5 progression and group-drop inventory", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Veteran");
|
||||
const legacy = { ...created, schemaVersion: 2, collections: DEFAULT_COLLECTIONS.filter((boss) => boss.bossId !== "vexa") } as Record<string, unknown>;
|
||||
delete legacy.collectionLog;
|
||||
delete legacy.materials;
|
||||
delete legacy.gearProgress;
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
const drop = groupDrop("charge", "veteran");
|
||||
created.healers.priest.level = 8;
|
||||
created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } };
|
||||
created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }];
|
||||
created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } };
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(buildCollections(migrated.collectionLog, migrated.stats.bossKills).some((boss) => boss.bossId === "vexa")).toBe(true);
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.healers.priest.level).toBe(8);
|
||||
expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 });
|
||||
expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 });
|
||||
expect(migrated.collectionLog).toEqual(created.collectionLog);
|
||||
});
|
||||
|
||||
it("migrates valid infusion choices and discards stale ids", () => {
|
||||
it("normalizes valid v5 infusion choices and discards stale ids", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Infused");
|
||||
@@ -131,10 +172,10 @@ describe("SaveRepository", () => {
|
||||
created.gearProgress.priest.passiveInfusionId = "restoring-grace";
|
||||
created.gearProgress.brann.infusionAbilityId = "removed-infusion";
|
||||
created.gearProgress.brann.passiveInfusionId = "deep-wells";
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: { ...created, schemaVersion: 3 } }));
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(4);
|
||||
expect(migrated.schemaVersion).toBe(5);
|
||||
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
|
||||
expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace");
|
||||
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createHunterSave } from "./data";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } 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 { GROUP_DROP_TABLES, type CollectionLog, type MaterialStack } from "../game/progression/loot";
|
||||
import type { BossId, HealerClassId } from "../game/types";
|
||||
import type { BossCollection, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
|
||||
export interface StorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
@@ -44,7 +44,6 @@ interface LegacyHunterSave {
|
||||
playSeconds?: number;
|
||||
updatedAt?: string;
|
||||
stats?: HunterSave["stats"];
|
||||
collections?: BossCollection[];
|
||||
materials?: MaterialStack[];
|
||||
collectionLog?: CollectionLog;
|
||||
gearProgress?: GearProgress;
|
||||
@@ -61,26 +60,16 @@ function positiveCounts(value: unknown): Record<string, number> {
|
||||
}
|
||||
|
||||
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;
|
||||
return {
|
||||
dropsFound: positiveCounts(candidate.collectionLog?.dropsFound),
|
||||
petsFound: positiveCounts(candidate.collectionLog?.petsFound),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
for (const table of Object.values(GROUP_DROP_TABLES)) {
|
||||
const drop = Object.values(table.drops).find((candidate) => candidate.id === id);
|
||||
if (drop) return drop;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -99,8 +88,8 @@ function normalizeMaterials(value: unknown, collectionLog: CollectionLog): Mater
|
||||
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 }] : [];
|
||||
const drop = knownMaterial(id);
|
||||
return drop ? [{ id, quantity, name: drop.name, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,7 +112,7 @@ 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);
|
||||
const bossId = AVAILABLE_BOSS_IDS.find((id) => id === key || BOSS_DEFINITIONS[id].name === key);
|
||||
result[bossId ?? key] = (result[bossId ?? key] ?? 0) + quantity;
|
||||
}
|
||||
return result;
|
||||
@@ -133,11 +122,18 @@ function normalizeSave(value: unknown): HunterSave | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as LegacyHunterSave;
|
||||
if (!candidate.slotId || !candidate.hunterName) return null;
|
||||
if (candidate.schemaVersion !== 5) {
|
||||
try {
|
||||
return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
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: 4,
|
||||
schemaVersion: 5,
|
||||
slotId: candidate.slotId,
|
||||
hunterName: candidate.hunterName,
|
||||
activeClassId,
|
||||
@@ -253,7 +249,11 @@ export class SaveRepository {
|
||||
}
|
||||
|
||||
private read(key: string): SaveMap {
|
||||
return parseSaveMap(this.storage.getItem(key));
|
||||
const raw = this.storage.getItem(key);
|
||||
const saves = parseSaveMap(raw);
|
||||
const normalized = JSON.stringify(saves);
|
||||
if (raw !== normalized) this.storage.setItem(key, normalized);
|
||||
return saves;
|
||||
}
|
||||
|
||||
private write(key: string, saves: SaveMap): void {
|
||||
|
||||
@@ -298,7 +298,7 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
set((state) => ({
|
||||
slots: repository.list(accountId),
|
||||
recentRewards: awarded ? [...state.recentRewards, awarded] : state.recentRewards,
|
||||
notice: awarded ? `${awarded.coin.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
|
||||
notice: awarded ? `${awarded.drop.name} x${awarded.quantity} saved offline.` : "Boss clear saved offline.",
|
||||
}));
|
||||
return awarded;
|
||||
},
|
||||
|
||||
+16
-5
@@ -1,4 +1,5 @@
|
||||
import type { HealerClassId, InventoryItem } from "../game/types";
|
||||
import type { BossGroupId } from "../game/bossCatalog";
|
||||
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
|
||||
import type { GearProgress } from "../game/progression/gear";
|
||||
import type { CollectionLog, MaterialStack } from "../game/progression/loot";
|
||||
|
||||
@@ -14,14 +15,24 @@ export interface CollectionDrop {
|
||||
count: number;
|
||||
chance: string;
|
||||
itemLevel?: number;
|
||||
kind: "coin" | "pet";
|
||||
kind: "group-drop" | "pet";
|
||||
}
|
||||
|
||||
export interface BossCollection {
|
||||
bossId: string;
|
||||
export interface GroupBossCollection {
|
||||
bossId: BossId;
|
||||
bossName: string;
|
||||
kills: number;
|
||||
pet: CollectionDrop;
|
||||
}
|
||||
|
||||
export interface BossGroupCollection {
|
||||
groupId: BossGroupId;
|
||||
groupLetter: string;
|
||||
groupName: string;
|
||||
coreMechanic: string;
|
||||
defeated: boolean;
|
||||
drops: CollectionDrop[];
|
||||
bosses: GroupBossCollection[];
|
||||
}
|
||||
|
||||
export interface HunterStats {
|
||||
@@ -38,7 +49,7 @@ export interface HealerProgress {
|
||||
}
|
||||
|
||||
export interface HunterSave {
|
||||
schemaVersion: 4;
|
||||
schemaVersion: 5;
|
||||
slotId: SaveSlotId;
|
||||
hunterName: string;
|
||||
activeClassId: HealerClassId;
|
||||
|
||||
Reference in New Issue
Block a user