Release v0.1.3 2026-07-11
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MODE_COPY, selectRandomBoss, selectRandomBossPair } from "./data";
|
||||
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";
|
||||
|
||||
describe("game mode configuration", () => {
|
||||
it("separates randomized PVE from selectable Dungeons", () => {
|
||||
@@ -8,16 +11,25 @@ describe("game mode configuration", () => {
|
||||
});
|
||||
|
||||
it("selects a boss across the full encounter pool", () => {
|
||||
expect(selectRandomBoss(() => 0)).toBe("bulldrome");
|
||||
expect(selectRandomBoss(() => 0.34)).toBe("vexa");
|
||||
expect(selectRandomBoss(() => 0.7)).toBe("cindermaw");
|
||||
expect(selectRandomBoss(() => 0.99)).toBe("ember-mantis-duelist");
|
||||
for (let index = 0; index < BOSS_ORDER.length; index += 1) {
|
||||
expect(selectRandomBoss(() => (index + 0.5) / BOSS_ORDER.length)).toBe(BOSS_ORDER[index]);
|
||||
}
|
||||
});
|
||||
|
||||
it("selects two distinct bosses for PVE", () => {
|
||||
const values = [0, 0];
|
||||
const pair = selectRandomBossPair(() => values.shift() ?? 0);
|
||||
const pair = selectRandomBossPair([], () => values.shift() ?? 0);
|
||||
expect(pair).toEqual(["bulldrome", "vexa"]);
|
||||
expect(new Set(pair)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("derives collection entries from canonical boss drop tables", () => {
|
||||
const collections = buildCollections(createEmptyCollectionLog(), {});
|
||||
expect(collections.map((boss) => boss.bossId)).toEqual(BOSS_ORDER);
|
||||
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),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+54
-63
@@ -1,7 +1,15 @@
|
||||
import type { BossCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
|
||||
import { BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { BOSS_DEFINITIONS, BOSS_ORDER } 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";
|
||||
|
||||
export const DEFAULT_SETTINGS: GameSettings = {
|
||||
masterVolume: 80,
|
||||
@@ -10,52 +18,39 @@ export const DEFAULT_SETTINGS: GameSettings = {
|
||||
largeText: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_COLLECTIONS: BossCollection[] = [
|
||||
{
|
||||
bossId: "bulldrome",
|
||||
bossName: "Bulldrome",
|
||||
defeated: true,
|
||||
drops: [
|
||||
{ id: "bull-horn", name: "Cinder Horn", icon: "♜", rarity: "Common", count: 7 },
|
||||
{ id: "bull-hide", name: "Ember Hide", icon: "▧", rarity: "Uncommon", count: 3 },
|
||||
{ id: "bull-idol", name: "Vault Idol", icon: "◇", rarity: "Rare", count: 1 },
|
||||
{ id: "bull-heart", name: "Furnace Heart", icon: "✦", rarity: "Mythic", count: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
bossId: "vexa",
|
||||
bossName: "Vexa",
|
||||
defeated: false,
|
||||
drops: [
|
||||
{ id: "vexa-silk", name: "Living Silk", icon: "⌁", rarity: "Common", count: 0 },
|
||||
{ id: "vexa-venom", name: "Widow Venom", icon: "✣", rarity: "Uncommon", count: 0 },
|
||||
{ id: "vexa-eye", name: "Loom Eye", icon: "◉", rarity: "Rare", count: 0 },
|
||||
{ id: "vexa-heart", name: "Webmother Heart", icon: "✦", rarity: "Mythic", count: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
bossId: "cindermaw",
|
||||
bossName: "Cindermaw",
|
||||
defeated: true,
|
||||
drops: [
|
||||
{ id: "maw-scale", name: "Soot Scale", icon: "◈", rarity: "Common", count: 4 },
|
||||
{ id: "maw-gland", name: "Mending Gland", icon: "+", rarity: "Uncommon", count: 2 },
|
||||
{ id: "maw-crest", name: "Ashen Crest", icon: "⌁", rarity: "Rare", count: 0 },
|
||||
{ id: "maw-breath", name: "Bottled Breath", icon: "☀", rarity: "Mythic", count: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
bossId: "ember-mantis-duelist",
|
||||
bossName: "Ember Mantis Duelist",
|
||||
defeated: false,
|
||||
drops: [
|
||||
{ id: "mantis-chitin", name: "Ember Chitin", icon: "◇", rarity: "Common", count: 0 },
|
||||
{ id: "mantis-edge", name: "Cinderblade Edge", icon: "⚔", rarity: "Uncommon", count: 0 },
|
||||
{ id: "mantis-antenna", name: "Duelist Antenna", icon: "⌁", rarity: "Rare", count: 0 },
|
||||
{ id: "mantis-core", name: "Molten Mantis Core", icon: "✦", rarity: "Mythic", count: 0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const RARITY_LABELS: Record<LootRarity, BossCollection["drops"][number]["rarity"]> = {
|
||||
common: "Common",
|
||||
uncommon: "Uncommon",
|
||||
rare: "Rare",
|
||||
epic: "Epic",
|
||||
legendary: "Legendary",
|
||||
};
|
||||
|
||||
export function buildCollections(collectionLog: CollectionLog, bossKills: Record<string, number>): BossCollection[] {
|
||||
return BOSS_ORDER.map((bossId) => {
|
||||
const table = BOSS_DROP_TABLES[bossId];
|
||||
return {
|
||||
bossId,
|
||||
bossName: BOSS_DEFINITIONS[bossId].name,
|
||||
defeated: (bossKills[bossId] ?? 0) > 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,
|
||||
chance: drop.chanceLabel,
|
||||
itemLevel: drop.kind === "coin" ? drop.itemLevel : undefined,
|
||||
kind: drop.kind,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const DEFAULT_COLLECTION_LOG: CollectionLog = createEmptyCollectionLog();
|
||||
export const DEFAULT_COLLECTIONS: BossCollection[] = buildCollections(DEFAULT_COLLECTION_LOG, {});
|
||||
|
||||
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
|
||||
"roguelike-pve": {
|
||||
@@ -69,7 +64,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: "Bulldrome · Vexa · Cindermaw · Ember Mantis",
|
||||
detail: "Ten prototype guardians available",
|
||||
status: "Playable now",
|
||||
},
|
||||
"roguelike-pvp": {
|
||||
@@ -92,12 +87,6 @@ export function selectRandomBoss(random: () => number = Math.random): BossId {
|
||||
return BOSS_ORDER[Math.floor(random() * BOSS_ORDER.length)] ?? BOSS_ORDER[0];
|
||||
}
|
||||
|
||||
export function selectRandomBossPair(random: () => number = Math.random): readonly [BossId, BossId] {
|
||||
const firstIndex = Math.floor(random() * BOSS_ORDER.length) % BOSS_ORDER.length;
|
||||
const secondOffset = 1 + Math.floor(random() * (BOSS_ORDER.length - 1));
|
||||
return [BOSS_ORDER[firstIndex], BOSS_ORDER[(firstIndex + secondOffset) % BOSS_ORDER.length]];
|
||||
}
|
||||
|
||||
export const MAX_HUNTER_NAME_LENGTH = 20;
|
||||
|
||||
export function normalizeHunterName(value: string): string {
|
||||
@@ -112,25 +101,27 @@ export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: st
|
||||
const normalizedName = normalizeHunterName(hunterName);
|
||||
if (!normalizedName) throw new Error("Hunter name is required.");
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 4,
|
||||
slotId,
|
||||
hunterName: normalizedName,
|
||||
activeClassId: "priest",
|
||||
healers: {
|
||||
priest: { level: 12, inventory: createClassInventory("priest") },
|
||||
priest: { level: 1, inventory: createClassInventory("priest") },
|
||||
druid: { level: 1, inventory: createClassInventory("druid") },
|
||||
shaman: { level: 1, inventory: createClassInventory("shaman") },
|
||||
},
|
||||
location: "Ember Vault Approach",
|
||||
playSeconds: 8 * 60 * 60 + 42 * 60,
|
||||
playSeconds: 0,
|
||||
updatedAt: now,
|
||||
stats: {
|
||||
totalBossKills: 16,
|
||||
flawlessClears: 5,
|
||||
alliesSaved: 143,
|
||||
healingDone: 284_650,
|
||||
bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4, "Ember Mantis Duelist": 0 },
|
||||
totalBossKills: 0,
|
||||
flawlessClears: 0,
|
||||
alliesSaved: 0,
|
||||
healingDone: 0,
|
||||
bossKills: {},
|
||||
},
|
||||
collections: structuredClone(DEFAULT_COLLECTIONS),
|
||||
materials: [] as MaterialStack[],
|
||||
collectionLog: createEmptyCollectionLog(),
|
||||
gearProgress: createDefaultGearProgress(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
||||
import { buildCollections, DEFAULT_COLLECTIONS } from "./data";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
@@ -50,11 +51,11 @@ describe("SaveRepository", () => {
|
||||
}));
|
||||
|
||||
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(40);
|
||||
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(12);
|
||||
expect(repository.list("healer@example.com")[0].online?.healers.priest.level).toBe(1);
|
||||
|
||||
now = "2026-07-10T14:00:00.000Z";
|
||||
repository.download(1, "healer@example.com");
|
||||
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(12);
|
||||
expect(repository.list("healer@example.com")[0].local?.healers.priest.level).toBe(1);
|
||||
expect(repository.list("healer@example.com")[0].local?.updatedAt).toBe(now);
|
||||
});
|
||||
|
||||
@@ -85,7 +86,7 @@ describe("SaveRepository", () => {
|
||||
expect(save.hunterName).toBe("Aelia");
|
||||
expect(save.activeClassId).toBe("druid");
|
||||
expect(save.healers.druid.level).toBe(8);
|
||||
expect(save.healers.priest.level).toBe(12);
|
||||
expect(save.healers.priest.level).toBe(1);
|
||||
expect(save.healers.druid.inventory).toHaveLength(4);
|
||||
expect(save.healers.priest.inventory).toHaveLength(4);
|
||||
});
|
||||
@@ -100,7 +101,7 @@ describe("SaveRepository", () => {
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(2);
|
||||
expect(migrated.schemaVersion).toBe(4);
|
||||
expect(migrated.hunterName).toBe("Legacy");
|
||||
expect(migrated.activeClassId).toBe("priest");
|
||||
expect(migrated.healers.priest.level).toBe(27);
|
||||
@@ -108,15 +109,35 @@ describe("SaveRepository", () => {
|
||||
expect(migrated.healers.shaman.inventory.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("adds newly shipped bosses to existing schema v2 collection logs", () => {
|
||||
it("derives newly shipped bosses from drop tables after migrating schema v2 collections", () => {
|
||||
const storage = memoryStorage();
|
||||
const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z");
|
||||
const created = repository.create(1, "Veteran");
|
||||
storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({
|
||||
1: { ...created, collections: created.collections.filter((boss) => boss.bossId !== "vexa") },
|
||||
}));
|
||||
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 migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.collections.some((boss) => boss.bossId === "vexa")).toBe(true);
|
||||
expect(buildCollections(migrated.collectionLog, migrated.stats.bossKills).some((boss) => boss.bossId === "vexa")).toBe(true);
|
||||
});
|
||||
|
||||
it("migrates valid 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");
|
||||
created.gearProgress.priest.infusionAbilityId = "priest-sanctuary";
|
||||
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 } }));
|
||||
|
||||
const migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.schemaVersion).toBe(4);
|
||||
expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary");
|
||||
expect(migrated.gearProgress.priest.passiveInfusionId).toBe("restoring-grace");
|
||||
expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull();
|
||||
expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+136
-9
@@ -4,6 +4,14 @@ import { SaveRepository } from "./saveRepository";
|
||||
import { AccountRepository, type AccountResult } from "./accountRepository";
|
||||
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
|
||||
import { upgradeGearSlot, type GearOwnerId, type GearSlotId } from "../game/progression/gear";
|
||||
import {
|
||||
equipActiveInfusion,
|
||||
equipPassiveInfusion,
|
||||
infusionsForOwner,
|
||||
} from "../game/progression/infusions";
|
||||
import type { RunBuffId } from "../game/types";
|
||||
import { normalizeDifficultySlug, rollBossReward, type BossRewardAward, type DifficultySlug } from "../game/progression/loot";
|
||||
|
||||
const repository = new SaveRepository();
|
||||
const accounts = new AccountRepository();
|
||||
@@ -46,6 +54,12 @@ export interface FrontendState {
|
||||
activeSlotId: SaveSlotId | null;
|
||||
selectedMode: GameModeId;
|
||||
selectedBossId: BossId;
|
||||
selectedDifficultySlug: DifficultySlug;
|
||||
selectedGearOwnerId: GearOwnerId;
|
||||
selectedGearSlotId: GearSlotId;
|
||||
gearWorkshopMode: "upgrade" | "infusion";
|
||||
selectedInfusionId: string;
|
||||
recentRewards: BossRewardAward[];
|
||||
settings: GameSettings;
|
||||
notice: string;
|
||||
signIn: (username: string, password: string) => Promise<boolean>;
|
||||
@@ -62,11 +76,20 @@ export interface FrontendState {
|
||||
downloadSlot: (slotId: SaveSlotId) => void;
|
||||
selectMode: (mode: GameModeId) => void;
|
||||
selectBoss: (bossId: BossId) => void;
|
||||
selectDifficulty: (difficultySlug: DifficultySlug) => void;
|
||||
selectGearOwner: (ownerId: GearOwnerId) => void;
|
||||
selectGearSlot: (slotId: GearSlotId) => void;
|
||||
selectGearWorkshopMode: (mode: "upgrade" | "infusion") => void;
|
||||
selectInfusion: (infusionId: string) => void;
|
||||
upgradeSelectedGear: () => boolean;
|
||||
equipSelectedInfusion: () => boolean;
|
||||
equipPassiveInfusion: (passiveId: RunBuffId) => boolean;
|
||||
selectHealerClass: (classId: HealerClassId) => void;
|
||||
updateActiveHealerInventory: (inventory: InventoryItem[]) => void;
|
||||
updateSetting: <K extends keyof GameSettings>(key: K, value: GameSettings[K]) => void;
|
||||
touchActiveSave: () => void;
|
||||
recordBossVictory: (bossName: string) => void;
|
||||
recordBossVictory: (bossId: BossId, difficultySlug: DifficultySlug) => BossRewardAward | null;
|
||||
clearRecentRewards: () => void;
|
||||
clearNotice: () => void;
|
||||
}
|
||||
|
||||
@@ -82,6 +105,12 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
activeSlotId: null,
|
||||
selectedMode: "roguelike-pve",
|
||||
selectedBossId: "bulldrome",
|
||||
selectedDifficultySlug: "initiate",
|
||||
selectedGearOwnerId: "priest",
|
||||
selectedGearSlotId: "weapon",
|
||||
gearWorkshopMode: "upgrade",
|
||||
selectedInfusionId: infusionsForOwner("priest")[0].id,
|
||||
recentRewards: [],
|
||||
settings: loadSettings(),
|
||||
notice: "",
|
||||
|
||||
@@ -149,12 +178,84 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
},
|
||||
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
|
||||
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
|
||||
selectDifficulty: (selectedDifficultySlug) => set({ selectedDifficultySlug: normalizeDifficultySlug(selectedDifficultySlug), notice: "" }),
|
||||
selectGearOwner: (selectedGearOwnerId) => set({
|
||||
selectedGearOwnerId,
|
||||
selectedInfusionId: infusionsForOwner(selectedGearOwnerId)[0].id,
|
||||
notice: "",
|
||||
}),
|
||||
selectGearSlot: (selectedGearSlotId) => set({ selectedGearSlotId, notice: "" }),
|
||||
selectGearWorkshopMode: (gearWorkshopMode) => set({ gearWorkshopMode, notice: "" }),
|
||||
selectInfusion: (selectedInfusionId) => set({ selectedInfusionId, notice: "" }),
|
||||
upgradeSelectedGear: () => {
|
||||
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId } = get();
|
||||
if (!activeSlotId) return false;
|
||||
let message = "Gear upgrade failed.";
|
||||
let upgraded = false;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
try {
|
||||
const result = upgradeGearSlot(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId);
|
||||
upgraded = true;
|
||||
message = `${selectedGearOwnerId} ${selectedGearSlotId} upgraded to +${result.gearProgress[selectedGearOwnerId].slots[selectedGearSlotId].level}.`;
|
||||
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : message;
|
||||
return save;
|
||||
}
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: message });
|
||||
return upgraded;
|
||||
},
|
||||
equipSelectedInfusion: () => {
|
||||
const { activeSlotId, accountId, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId } = get();
|
||||
if (!activeSlotId) return false;
|
||||
let message = "Infusion failed.";
|
||||
let equipped = false;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
try {
|
||||
const wasEquipped = save.gearProgress[selectedGearOwnerId].infusionAbilityId === selectedInfusionId;
|
||||
const result = equipActiveInfusion(save.gearProgress, save.materials, selectedGearOwnerId, selectedGearSlotId, selectedInfusionId);
|
||||
equipped = true;
|
||||
message = wasEquipped ? "Infusion already equipped." : `${selectedGearOwnerId} infusion equipped.`;
|
||||
return { ...save, gearProgress: result.gearProgress, materials: result.inventory };
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : message;
|
||||
return save;
|
||||
}
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: message });
|
||||
return equipped;
|
||||
},
|
||||
equipPassiveInfusion: (passiveId) => {
|
||||
const { activeSlotId, accountId, selectedGearOwnerId } = get();
|
||||
if (!activeSlotId) return false;
|
||||
let message = "Passive infusion failed.";
|
||||
let equipped = false;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
try {
|
||||
const gearProgress = equipPassiveInfusion(save.gearProgress, selectedGearOwnerId, passiveId);
|
||||
equipped = true;
|
||||
message = "Passive infusion equipped. Applies next encounter.";
|
||||
return { ...save, gearProgress };
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : message;
|
||||
return save;
|
||||
}
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: message });
|
||||
return equipped;
|
||||
},
|
||||
selectHealerClass: (classId) => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
if (!activeSlotId) return;
|
||||
const updated = repository.updateLocal(activeSlotId, (save) => ({ ...save, activeClassId: classId }));
|
||||
if (!updated) return;
|
||||
set({ slots: repository.list(accountId), notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.` });
|
||||
set({
|
||||
slots: repository.list(accountId),
|
||||
selectedGearOwnerId: classId,
|
||||
selectedInfusionId: infusionsForOwner(classId)[0].id,
|
||||
notice: `${updated.healers[classId].level > 1 ? "Level " + updated.healers[classId].level + " " : ""}${classId[0].toUpperCase() + classId.slice(1)} selected.`,
|
||||
});
|
||||
},
|
||||
updateActiveHealerInventory: (inventory) => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
@@ -179,21 +280,29 @@ export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
repository.touch(activeSlotId);
|
||||
set({ slots: repository.list(accountId) });
|
||||
},
|
||||
recordBossVictory: (bossName) => {
|
||||
recordBossVictory: (bossId, difficultySlug) => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
if (!activeSlotId) return;
|
||||
if (!activeSlotId) return null;
|
||||
let awarded: BossRewardAward | null = null;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
const bossKills = { ...save.stats.bossKills, [bossName]: (save.stats.bossKills[bossName] ?? 0) + 1 };
|
||||
const reward = rollBossReward(bossId, difficultySlug, save.materials, save.collectionLog);
|
||||
awarded = reward.award;
|
||||
const bossKills = { ...save.stats.bossKills, [bossId]: (save.stats.bossKills[bossId] ?? 0) + 1 };
|
||||
return {
|
||||
...save,
|
||||
stats: { ...save.stats, totalBossKills: save.stats.totalBossKills + 1, flawlessClears: save.stats.flawlessClears + 1, bossKills },
|
||||
collections: save.collections.map((boss) => boss.bossName === bossName
|
||||
? { ...boss, defeated: true, drops: boss.drops.map((drop, index) => index === 0 ? { ...drop, count: drop.count + 1 } : drop) }
|
||||
: boss),
|
||||
materials: reward.inventory,
|
||||
collectionLog: reward.collectionLog,
|
||||
};
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: `${bossName} clear saved offline.` });
|
||||
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.",
|
||||
}));
|
||||
return awarded;
|
||||
},
|
||||
clearRecentRewards: () => set({ recentRewards: [] }),
|
||||
clearNotice: () => set({ notice: "" }),
|
||||
}));
|
||||
|
||||
@@ -212,11 +321,20 @@ export type FrontendSnapshot = Omit<FrontendState,
|
||||
| "downloadSlot"
|
||||
| "selectMode"
|
||||
| "selectBoss"
|
||||
| "selectDifficulty"
|
||||
| "selectGearOwner"
|
||||
| "selectGearSlot"
|
||||
| "selectGearWorkshopMode"
|
||||
| "selectInfusion"
|
||||
| "upgradeSelectedGear"
|
||||
| "equipSelectedInfusion"
|
||||
| "equipPassiveInfusion"
|
||||
| "selectHealerClass"
|
||||
| "updateActiveHealerInventory"
|
||||
| "updateSetting"
|
||||
| "touchActiveSave"
|
||||
| "recordBossVictory"
|
||||
| "clearRecentRewards"
|
||||
| "clearNotice"
|
||||
>;
|
||||
|
||||
@@ -236,11 +354,20 @@ export function getFrontendSnapshot(): FrontendSnapshot {
|
||||
downloadSlot: _downloadSlot,
|
||||
selectMode: _selectMode,
|
||||
selectBoss: _selectBoss,
|
||||
selectDifficulty: _selectDifficulty,
|
||||
selectGearOwner: _selectGearOwner,
|
||||
selectGearSlot: _selectGearSlot,
|
||||
selectGearWorkshopMode: _selectGearWorkshopMode,
|
||||
selectInfusion: _selectInfusion,
|
||||
upgradeSelectedGear: _upgradeSelectedGear,
|
||||
equipSelectedInfusion: _equipSelectedInfusion,
|
||||
equipPassiveInfusion: _equipPassiveInfusion,
|
||||
selectHealerClass: _selectHealerClass,
|
||||
updateActiveHealerInventory: _updateActiveHealerInventory,
|
||||
updateSetting: _updateSetting,
|
||||
touchActiveSave: _touchActiveSave,
|
||||
recordBossVictory: _recordBossVictory,
|
||||
clearRecentRewards: _clearRecentRewards,
|
||||
clearNotice: _clearNotice,
|
||||
...snapshot
|
||||
} = useFrontendStore.getState();
|
||||
|
||||
+11
-4
@@ -1,15 +1,20 @@
|
||||
import type { HealerClassId, InventoryItem } from "../game/types";
|
||||
import type { GearProgress } from "../game/progression/gear";
|
||||
import type { CollectionLog, MaterialStack } from "../game/progression/loot";
|
||||
|
||||
export type SaveSlotId = 1 | 2 | 3;
|
||||
export type AppScreen = "login" | "saves" | "home" | "profile" | "settings" | "mode" | "game";
|
||||
export type AppScreen = "login" | "saves" | "home" | "profile" | "gear" | "settings" | "mode" | "game";
|
||||
export type GameModeId = "roguelike-pve" | "dungeons" | "roguelike-pvp" | "stadium-pvp";
|
||||
|
||||
export interface CollectionDrop {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
rarity: "Common" | "Uncommon" | "Rare" | "Mythic";
|
||||
rarity: "Common" | "Uncommon" | "Rare" | "Epic" | "Legendary";
|
||||
count: number;
|
||||
chance: string;
|
||||
itemLevel?: number;
|
||||
kind: "coin" | "pet";
|
||||
}
|
||||
|
||||
export interface BossCollection {
|
||||
@@ -33,7 +38,7 @@ export interface HealerProgress {
|
||||
}
|
||||
|
||||
export interface HunterSave {
|
||||
schemaVersion: 2;
|
||||
schemaVersion: 4;
|
||||
slotId: SaveSlotId;
|
||||
hunterName: string;
|
||||
activeClassId: HealerClassId;
|
||||
@@ -42,7 +47,9 @@ export interface HunterSave {
|
||||
playSeconds: number;
|
||||
updatedAt: string;
|
||||
stats: HunterStats;
|
||||
collections: BossCollection[];
|
||||
materials: MaterialStack[];
|
||||
collectionLog: CollectionLog;
|
||||
gearProgress: GearProgress;
|
||||
}
|
||||
|
||||
export interface SaveSlotState {
|
||||
|
||||
Reference in New Issue
Block a user