Release v0.1.3 2026-07-11

This commit is contained in:
Warren H
2026-07-11 23:23:02 -04:00
parent 076f6cf97c
commit b48b3a4f8f
103 changed files with 6708 additions and 454 deletions
+18 -6
View File
@@ -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
View File
@@ -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: "14 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(),
};
}
+30 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {