Update 3D game 2026-07-10 21:20
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MODE_COPY, selectRandomBoss, selectRandomBossPair } from "./data";
|
||||
|
||||
describe("game mode configuration", () => {
|
||||
it("separates randomized PVE from selectable Dungeons", () => {
|
||||
expect(MODE_COPY["roguelike-pve"].title).toBe("PVE");
|
||||
expect(MODE_COPY.dungeons.title).toBe("Dungeons");
|
||||
});
|
||||
|
||||
it("selects a boss across the full encounter pool", () => {
|
||||
expect(selectRandomBoss(() => 0)).toBe("bulldrome");
|
||||
expect(selectRandomBoss(() => 0.34)).toBe("vexa");
|
||||
expect(selectRandomBoss(() => 0.99)).toBe("cindermaw");
|
||||
});
|
||||
|
||||
it("selects two distinct bosses for PVE", () => {
|
||||
const values = [0, 0];
|
||||
const pair = selectRandomBossPair(() => values.shift() ?? 0);
|
||||
expect(pair).toEqual(["bulldrome", "vexa"]);
|
||||
expect(new Set(pair)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { BossCollection, GameModeId, GameSettings, HunterSave, SaveSlotId } from "./types";
|
||||
import { BOSS_ORDER } from "../game/bossCatalog";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import type { BossId } from "../game/types";
|
||||
|
||||
export const DEFAULT_SETTINGS: GameSettings = {
|
||||
masterVolume: 80,
|
||||
reducedMotion: false,
|
||||
damageNumbers: true,
|
||||
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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const MODE_COPY: Record<GameModeId, { eyebrow: string; title: string; description: string; detail: string; status: string }> = {
|
||||
"roguelike-pve": {
|
||||
eyebrow: "1–4 hunters · randomized PVE",
|
||||
title: "PVE",
|
||||
description: "Enter without an encounter briefing, adapt to two randomized guardians, and build toward a full roguelike run.",
|
||||
detail: "Two bosses selected when the run begins",
|
||||
status: "Playable prototype",
|
||||
},
|
||||
dungeons: {
|
||||
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",
|
||||
status: "Playable now",
|
||||
},
|
||||
"roguelike-pvp": {
|
||||
eyebrow: "3v3 · mirrored expeditions",
|
||||
title: "Roguelike PvP",
|
||||
description: "Race a rival squad through shifting rooms. Send hazards across the veil while keeping your own formation alive.",
|
||||
detail: "Draft order, rival pressure, and sudden-death rules",
|
||||
status: "Mode shell ready",
|
||||
},
|
||||
"stadium-pvp": {
|
||||
eyebrow: "5v5 · objective arena",
|
||||
title: "Stadium PvP",
|
||||
description: "Bring a prepared loadout into short team battles where positioning, interrupts, and clutch healing decide the round.",
|
||||
detail: "Best of five rounds / normalized gear",
|
||||
status: "Mode shell ready",
|
||||
},
|
||||
};
|
||||
|
||||
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 {
|
||||
return value
|
||||
.replace(/[\u0000-\u001f\u007f]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, MAX_HUNTER_NAME_LENGTH);
|
||||
}
|
||||
|
||||
export function createHunterSave(slotId: SaveSlotId, now: string, hunterName: string): HunterSave {
|
||||
const normalizedName = normalizeHunterName(hunterName);
|
||||
if (!normalizedName) throw new Error("Hunter name is required.");
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
slotId,
|
||||
hunterName: normalizedName,
|
||||
activeClassId: "priest",
|
||||
healers: {
|
||||
priest: { level: 12, 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,
|
||||
updatedAt: now,
|
||||
stats: {
|
||||
totalBossKills: 16,
|
||||
flawlessClears: 5,
|
||||
alliesSaved: 143,
|
||||
healingDone: 284_650,
|
||||
bossKills: { Bulldrome: 12, Vexa: 0, Cindermaw: 4 },
|
||||
},
|
||||
collections: structuredClone(DEFAULT_COLLECTIONS),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SaveRepository, type StorageAdapter } from "./saveRepository";
|
||||
|
||||
function memoryStorage(): StorageAdapter {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key) => data.get(key) ?? null,
|
||||
setItem: (key, value) => { data.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
||||
describe("SaveRepository", () => {
|
||||
it("creates exactly three offline-first slot views with timestamps", () => {
|
||||
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
|
||||
repository.create(2, "Seraphine");
|
||||
|
||||
const slots = repository.list(null);
|
||||
expect(slots.map((slot) => slot.id)).toEqual([1, 2, 3]);
|
||||
expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z");
|
||||
expect(slots[1].local?.hunterName).toBe("Seraphine");
|
||||
expect(slots[1].online).toBeNull();
|
||||
});
|
||||
|
||||
it("copies a local save into another slot without sharing nested state", () => {
|
||||
let now = "2026-07-10T12:00:00.000Z";
|
||||
const repository = new SaveRepository(memoryStorage(), () => now);
|
||||
repository.create(1, "Aelia");
|
||||
now = "2026-07-10T13:00:00.000Z";
|
||||
repository.copyLocal(1, 3);
|
||||
repository.updateLocal(3, (save) => ({
|
||||
...save,
|
||||
healers: { ...save.healers, druid: { ...save.healers.druid, level: 99 } },
|
||||
}));
|
||||
|
||||
const slots = repository.list(null);
|
||||
expect(slots[0].local?.healers.druid.level).toBe(1);
|
||||
expect(slots[2].local?.healers.druid.level).toBe(99);
|
||||
expect(slots[2].local?.slotId).toBe(3);
|
||||
});
|
||||
|
||||
it("uploads local state and can later overwrite it with the online version", () => {
|
||||
let now = "2026-07-10T12:00:00.000Z";
|
||||
const repository = new SaveRepository(memoryStorage(), () => now);
|
||||
repository.create(1, "Aelia");
|
||||
now = "2026-07-10T13:00:00.000Z";
|
||||
repository.upload(1, "healer@example.com");
|
||||
repository.updateLocal(1, (save) => ({
|
||||
...save,
|
||||
healers: { ...save.healers, priest: { ...save.healers.priest, level: 40 } },
|
||||
}));
|
||||
|
||||
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);
|
||||
|
||||
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?.updatedAt).toBe(now);
|
||||
});
|
||||
|
||||
it("deletes only the local copy so the online record can restore it", () => {
|
||||
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
|
||||
repository.create(1, "Aelia");
|
||||
repository.upload(1, "healer");
|
||||
repository.deleteLocal(1);
|
||||
|
||||
const slot = repository.list("healer")[0];
|
||||
expect(slot.local).toBeNull();
|
||||
expect(slot.online).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps class progression and inventories independent under one hunter name", () => {
|
||||
const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z");
|
||||
repository.create(1, "Aelia");
|
||||
repository.updateLocal(1, (save) => ({
|
||||
...save,
|
||||
activeClassId: "druid",
|
||||
healers: {
|
||||
...save.healers,
|
||||
druid: { level: 8, inventory: [...save.healers.druid.inventory, { ...save.healers.druid.inventory[0], id: "druid-drop" }] },
|
||||
},
|
||||
}));
|
||||
|
||||
const save = repository.list(null)[0].local!;
|
||||
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.druid.inventory).toHaveLength(4);
|
||||
expect(save.healers.priest.inventory).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("migrates schema v1 saves into Priest progress without losing the hunter name", () => {
|
||||
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;
|
||||
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.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);
|
||||
});
|
||||
|
||||
it("adds newly shipped bosses to existing schema v2 collection logs", () => {
|
||||
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 migrated = repository.list(null)[0].local!;
|
||||
expect(migrated.collections.some((boss) => boss.bossId === "vexa")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { createHunterSave, DEFAULT_COLLECTIONS } from "./data";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import type { HealerClassId } from "../game/types";
|
||||
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
|
||||
export interface StorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
type SaveMap = Partial<Record<SaveSlotId, HunterSave>>;
|
||||
|
||||
const LOCAL_KEY = "i-want-to-heal:saves:local:v1";
|
||||
const CLOUD_KEY = (accountId: string) => `i-want-to-heal:saves:cloud:v1:${accountId.toLowerCase()}`;
|
||||
const SLOT_IDS: SaveSlotId[] = [1, 2, 3];
|
||||
|
||||
const fallbackMemory = new Map<string, string>();
|
||||
const fallbackStorage: StorageAdapter = {
|
||||
getItem: (key) => fallbackMemory.get(key) ?? null,
|
||||
setItem: (key, value) => { fallbackMemory.set(key, value); },
|
||||
};
|
||||
|
||||
function browserStorage(): StorageAdapter {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") return localStorage;
|
||||
} catch {
|
||||
// Android WebView can deny storage before its host is ready.
|
||||
}
|
||||
return fallbackStorage;
|
||||
}
|
||||
|
||||
interface LegacyHunterSave extends Omit<HunterSave, "schemaVersion" | "activeClassId" | "healers"> {
|
||||
schemaVersion: 1;
|
||||
level: number;
|
||||
}
|
||||
|
||||
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 normalizeSave(value: unknown): HunterSave | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<HunterSave> & Partial<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;
|
||||
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") },
|
||||
},
|
||||
location: legacy.location,
|
||||
playSeconds: legacy.playSeconds,
|
||||
updatedAt: legacy.updatedAt,
|
||||
stats: legacy.stats,
|
||||
collections: normalizeCollections(legacy.collections),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSaveMap(raw: string | null): SaveMap {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
return Object.fromEntries(Object.entries(parsed).flatMap(([id, value]) => {
|
||||
const save = normalizeSave(value);
|
||||
return save ? [[id, save]] : [];
|
||||
})) as SaveMap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function cloneSave(save: HunterSave): HunterSave {
|
||||
return structuredClone(save);
|
||||
}
|
||||
|
||||
export class SaveRepository {
|
||||
constructor(
|
||||
private readonly storage: StorageAdapter = browserStorage(),
|
||||
private readonly now: () => string = () => new Date().toISOString(),
|
||||
) {}
|
||||
|
||||
list(accountId: string | null): SaveSlotState[] {
|
||||
const local = this.read(LOCAL_KEY);
|
||||
const online = accountId ? this.read(CLOUD_KEY(accountId)) : {};
|
||||
return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: online[id] ?? null }));
|
||||
}
|
||||
|
||||
create(slotId: SaveSlotId, hunterName: string): HunterSave {
|
||||
const save = createHunterSave(slotId, this.now(), hunterName);
|
||||
this.setLocal(save);
|
||||
return save;
|
||||
}
|
||||
|
||||
touch(slotId: SaveSlotId): HunterSave | null {
|
||||
return this.updateLocal(slotId, (save) => ({ ...save, updatedAt: this.now() }));
|
||||
}
|
||||
|
||||
updateLocal(slotId: SaveSlotId, update: (save: HunterSave) => HunterSave): HunterSave | null {
|
||||
const saves = this.read(LOCAL_KEY);
|
||||
const source = saves[slotId];
|
||||
if (!source) return null;
|
||||
const next = { ...update(cloneSave(source)), slotId, updatedAt: this.now() };
|
||||
saves[slotId] = next;
|
||||
this.write(LOCAL_KEY, saves);
|
||||
return next;
|
||||
}
|
||||
|
||||
deleteLocal(slotId: SaveSlotId): void {
|
||||
const saves = this.read(LOCAL_KEY);
|
||||
delete saves[slotId];
|
||||
this.write(LOCAL_KEY, saves);
|
||||
}
|
||||
|
||||
copyLocal(sourceId: SaveSlotId, targetId: SaveSlotId): HunterSave | null {
|
||||
const saves = this.read(LOCAL_KEY);
|
||||
const source = saves[sourceId];
|
||||
if (!source || sourceId === targetId) return null;
|
||||
const copy = { ...cloneSave(source), slotId: targetId, updatedAt: this.now() };
|
||||
saves[targetId] = copy;
|
||||
this.write(LOCAL_KEY, saves);
|
||||
return copy;
|
||||
}
|
||||
|
||||
upload(slotId: SaveSlotId, accountId: string): HunterSave | null {
|
||||
const local = this.read(LOCAL_KEY)[slotId];
|
||||
if (!local) return null;
|
||||
const cloud = this.read(CLOUD_KEY(accountId));
|
||||
const uploaded = { ...cloneSave(local), updatedAt: this.now() };
|
||||
cloud[slotId] = uploaded;
|
||||
this.write(CLOUD_KEY(accountId), cloud);
|
||||
this.setLocal(uploaded);
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
download(slotId: SaveSlotId, accountId: string): HunterSave | null {
|
||||
const cloud = this.read(CLOUD_KEY(accountId))[slotId];
|
||||
if (!cloud) return null;
|
||||
const downloaded = { ...cloneSave(cloud), slotId, updatedAt: this.now() };
|
||||
this.setLocal(downloaded);
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
private setLocal(save: HunterSave): void {
|
||||
const saves = this.read(LOCAL_KEY);
|
||||
saves[save.slotId] = save;
|
||||
this.write(LOCAL_KEY, saves);
|
||||
}
|
||||
|
||||
private read(key: string): SaveMap {
|
||||
return parseSaveMap(this.storage.getItem(key));
|
||||
}
|
||||
|
||||
private write(key: string, saves: SaveMap): void {
|
||||
this.storage.setItem(key, JSON.stringify(saves));
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSaveTimestamp(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "Unknown time";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatPlayTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { create } from "zustand";
|
||||
import { DEFAULT_SETTINGS, normalizeHunterName } from "./data";
|
||||
import { SaveRepository } from "./saveRepository";
|
||||
import type { AppScreen, GameModeId, GameSettings, HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
import type { BossId, HealerClassId, InventoryItem } from "../game/types";
|
||||
|
||||
const repository = new SaveRepository();
|
||||
const SETTINGS_KEY = "i-want-to-heal:settings:v1";
|
||||
|
||||
function loadSettings(): GameSettings {
|
||||
try {
|
||||
const saved = localStorage.getItem(SETTINGS_KEY);
|
||||
return saved ? { ...DEFAULT_SETTINGS, ...JSON.parse(saved) } : DEFAULT_SETTINGS;
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettings(settings: GameSettings) {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
} catch {
|
||||
// Settings remain active for this session when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
interface FrontendState {
|
||||
screen: AppScreen;
|
||||
accountId: string | null;
|
||||
slots: SaveSlotState[];
|
||||
selectedSlotId: SaveSlotId;
|
||||
activeSlotId: SaveSlotId | null;
|
||||
selectedMode: GameModeId;
|
||||
selectedBossId: BossId;
|
||||
settings: GameSettings;
|
||||
notice: string;
|
||||
signIn: (accountId: string) => void;
|
||||
continueOffline: () => void;
|
||||
signOut: () => void;
|
||||
navigate: (screen: AppScreen) => void;
|
||||
selectSlot: (slotId: SaveSlotId) => void;
|
||||
createSlot: (slotId: SaveSlotId, hunterName: string) => boolean;
|
||||
playSlot: (slotId: SaveSlotId) => void;
|
||||
deleteSlot: (slotId: SaveSlotId) => void;
|
||||
copySlot: (sourceId: SaveSlotId, targetId: SaveSlotId) => void;
|
||||
uploadSlot: (slotId: SaveSlotId) => void;
|
||||
downloadSlot: (slotId: SaveSlotId) => void;
|
||||
selectMode: (mode: GameModeId) => void;
|
||||
selectBoss: (bossId: BossId) => void;
|
||||
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;
|
||||
clearNotice: () => void;
|
||||
}
|
||||
|
||||
function activeSave(slots: SaveSlotState[], activeSlotId: SaveSlotId | null): HunterSave | null {
|
||||
return slots.find((slot) => slot.id === activeSlotId)?.local ?? null;
|
||||
}
|
||||
|
||||
export const useFrontendStore = create<FrontendState>((set, get) => ({
|
||||
screen: "login",
|
||||
accountId: null,
|
||||
slots: repository.list(null),
|
||||
selectedSlotId: 1,
|
||||
activeSlotId: null,
|
||||
selectedMode: "roguelike-pve",
|
||||
selectedBossId: "bulldrome",
|
||||
settings: loadSettings(),
|
||||
notice: "",
|
||||
|
||||
signIn: (rawAccountId) => {
|
||||
const accountId = rawAccountId.trim() || "wayfinder";
|
||||
set({ accountId, slots: repository.list(accountId), screen: "saves", notice: `Online sync connected as ${accountId}.` });
|
||||
},
|
||||
continueOffline: () => set({ accountId: null, slots: repository.list(null), screen: "saves", notice: "Offline saves ready." }),
|
||||
signOut: () => set({ accountId: null, slots: repository.list(null), activeSlotId: null, screen: "login", notice: "Signed out. Offline saves remain on this device." }),
|
||||
navigate: (screen) => set({ screen, notice: "" }),
|
||||
selectSlot: (selectedSlotId) => set({ selectedSlotId, notice: "" }),
|
||||
createSlot: (slotId, rawHunterName) => {
|
||||
const hunterName = normalizeHunterName(rawHunterName);
|
||||
if (!hunterName) {
|
||||
set({ notice: "Enter a hunter name before creating the save." });
|
||||
return false;
|
||||
}
|
||||
repository.create(slotId, hunterName);
|
||||
set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: slotId, notice: `${hunterName} created in offline slot ${slotId}.` }));
|
||||
return true;
|
||||
},
|
||||
playSlot: (slotId) => {
|
||||
const local = repository.touch(slotId);
|
||||
if (!local) return;
|
||||
set((state) => ({ activeSlotId: slotId, selectedSlotId: slotId, slots: repository.list(state.accountId), screen: "home", notice: "Offline save loaded." }));
|
||||
},
|
||||
deleteSlot: (slotId) => {
|
||||
repository.deleteLocal(slotId);
|
||||
set((state) => ({
|
||||
slots: repository.list(state.accountId),
|
||||
activeSlotId: state.activeSlotId === slotId ? null : state.activeSlotId,
|
||||
notice: `Local slot ${slotId} deleted. Online copy preserved.`,
|
||||
}));
|
||||
},
|
||||
copySlot: (sourceId, targetId) => {
|
||||
const copy = repository.copyLocal(sourceId, targetId);
|
||||
if (!copy) return;
|
||||
set((state) => ({ slots: repository.list(state.accountId), selectedSlotId: targetId, notice: `Slot ${sourceId} copied to slot ${targetId}.` }));
|
||||
},
|
||||
uploadSlot: (slotId) => {
|
||||
const { accountId } = get();
|
||||
if (!accountId) return set({ notice: "Sign in before syncing online." });
|
||||
const uploaded = repository.upload(slotId, accountId);
|
||||
set({ slots: repository.list(accountId), notice: uploaded ? `Slot ${slotId} synced to server.` : "No offline save to sync." });
|
||||
},
|
||||
downloadSlot: (slotId) => {
|
||||
const { accountId } = get();
|
||||
if (!accountId) return set({ notice: "Sign in before downloading an online save." });
|
||||
const downloaded = repository.download(slotId, accountId);
|
||||
set({ slots: repository.list(accountId), notice: downloaded ? `Slot ${slotId} overwritten with online version.` : "No online version exists for this slot." });
|
||||
},
|
||||
selectMode: (selectedMode) => set({ selectedMode, screen: "mode", notice: "" }),
|
||||
selectBoss: (selectedBossId) => set({ selectedBossId, notice: "" }),
|
||||
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.` });
|
||||
},
|
||||
updateActiveHealerInventory: (inventory) => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
if (!activeSlotId) return;
|
||||
repository.updateLocal(activeSlotId, (save) => ({
|
||||
...save,
|
||||
healers: {
|
||||
...save.healers,
|
||||
[save.activeClassId]: { ...save.healers[save.activeClassId], inventory: structuredClone(inventory) },
|
||||
},
|
||||
}));
|
||||
set({ slots: repository.list(accountId) });
|
||||
},
|
||||
updateSetting: (key, value) => set((state) => {
|
||||
const settings = { ...state.settings, [key]: value };
|
||||
persistSettings(settings);
|
||||
return { settings, notice: "Settings saved offline." };
|
||||
}),
|
||||
touchActiveSave: () => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
if (!activeSlotId) return;
|
||||
repository.touch(activeSlotId);
|
||||
set({ slots: repository.list(accountId) });
|
||||
},
|
||||
recordBossVictory: (bossName) => {
|
||||
const { activeSlotId, accountId } = get();
|
||||
if (!activeSlotId) return;
|
||||
repository.updateLocal(activeSlotId, (save) => {
|
||||
const bossKills = { ...save.stats.bossKills, [bossName]: (save.stats.bossKills[bossName] ?? 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),
|
||||
};
|
||||
});
|
||||
set({ slots: repository.list(accountId), notice: `${bossName} clear saved offline.` });
|
||||
},
|
||||
clearNotice: () => set({ notice: "" }),
|
||||
}));
|
||||
|
||||
export function useActiveHunter(): HunterSave | null {
|
||||
return useFrontendStore((state) => activeSave(state.slots, state.activeSlotId));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { HealerClassId, InventoryItem } from "../game/types";
|
||||
|
||||
export type SaveSlotId = 1 | 2 | 3;
|
||||
export type AppScreen = "login" | "saves" | "home" | "profile" | "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";
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BossCollection {
|
||||
bossId: string;
|
||||
bossName: string;
|
||||
defeated: boolean;
|
||||
drops: CollectionDrop[];
|
||||
}
|
||||
|
||||
export interface HunterStats {
|
||||
totalBossKills: number;
|
||||
flawlessClears: number;
|
||||
alliesSaved: number;
|
||||
healingDone: number;
|
||||
bossKills: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface HealerProgress {
|
||||
level: number;
|
||||
inventory: InventoryItem[];
|
||||
}
|
||||
|
||||
export interface HunterSave {
|
||||
schemaVersion: 2;
|
||||
slotId: SaveSlotId;
|
||||
hunterName: string;
|
||||
activeClassId: HealerClassId;
|
||||
healers: Record<HealerClassId, HealerProgress>;
|
||||
location: string;
|
||||
playSeconds: number;
|
||||
updatedAt: string;
|
||||
stats: HunterStats;
|
||||
collections: BossCollection[];
|
||||
}
|
||||
|
||||
export interface SaveSlotState {
|
||||
id: SaveSlotId;
|
||||
local: HunterSave | null;
|
||||
online: HunterSave | null;
|
||||
}
|
||||
|
||||
export interface GameSettings {
|
||||
masterVolume: number;
|
||||
reducedMotion: boolean;
|
||||
damageNumbers: boolean;
|
||||
largeText: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user