generated from nhcarrigan/template
Compare commits
4 Commits
48120e0789
...
48477ee286
| Author | SHA1 | Date | |
|---|---|---|---|
|
48477ee286
|
|||
|
b3d257048f
|
|||
|
3735cff23f
|
|||
|
a09280470e
|
@@ -102,12 +102,23 @@ prestigeRouter.post("/", async(context) => {
|
|||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
await prisma.gameState.update({
|
const { updatedAt } = record;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use the record's current updatedAt as an optimistic lock — if another
|
||||||
|
* concurrent prestige request already committed, this update will match
|
||||||
|
* 0 rows and we can safely reject the duplicate without a double webhook.
|
||||||
|
*/
|
||||||
|
const updateResult = await prisma.gameState.updateMany({
|
||||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||||
data: { state: finalState as object, updatedAt: now },
|
data: { state: finalState as object, updatedAt: now },
|
||||||
where: { discordId },
|
where: { discordId, updatedAt },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (updateResult.count === 0) {
|
||||||
|
return context.json({ error: "Prestige already in progress" }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.player.update({
|
await prisma.player.update({
|
||||||
data: {
|
data: {
|
||||||
characterName: state.player.characterName,
|
characterName: state.player.characterName,
|
||||||
|
|||||||
@@ -595,6 +595,18 @@ describe("debug route", () => {
|
|||||||
expect(adventurer?.unlocked).toBe(true);
|
expect(adventurer?.unlocked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 100, goldPerSecond: 0.7, essencePerSecond: 0, combatPower: 3, level: 2, name: "Old Name", class: "warrior" }] as GameState["adventurers"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { adventurerStatsPatched: number };
|
||||||
|
expect(body.adventurerStatsPatched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips adventurer stat patching for adventurers not in defaults", async () => {
|
it("skips adventurer stat patching for adventurers not in defaults", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"],
|
adventurers: [{ id: "nonexistent_adventurer", count: 0, unlocked: false, baseCost: 1, goldPerSecond: 1, essencePerSecond: 1, combatPower: 1, level: 1, name: "Ghost", class: "warrior" }] as GameState["adventurers"],
|
||||||
@@ -816,6 +828,18 @@ describe("debug route", () => {
|
|||||||
expect(quest?.status).toBe("available");
|
expect(quest?.status).toBe("available");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("patches quest stats when only combatPowerRequired has changed (exercises all earlier OR conditions)", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
quests: [{ id: "haunted_mine", status: "available", rewards: [], durationSeconds: 900, name: "The Haunted Mine", description: "An abandoned mine is rich with crystal deposits — if you dare brave its ghosts.", prerequisiteIds: ["goblin_camp"], zoneId: "verdant_vale", combatPowerRequired: 0 }] as GameState["quests"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { questsPatched: number };
|
||||||
|
expect(body.questsPatched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips quest stat patching for quests not in defaults", async () => {
|
it("skips quest stat patching for quests not in defaults", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
|
quests: [{ id: "nonexistent_quest_xyz", status: "available", rewards: [], durationSeconds: 1, name: "Ghost", description: "Old", prerequisiteIds: [], zoneId: "old_zone", combatPowerRequired: 0 }] as GameState["quests"],
|
||||||
@@ -845,6 +869,18 @@ describe("debug route", () => {
|
|||||||
expect(boss?.currentHp).toBe(100);
|
expect(boss?.currentHp).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("patches boss stats when only bountyRunestones has changed (exercises all earlier OR conditions)", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [{ id: "troll_king", status: "available", currentHp: 1000, maxHp: 1000, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 5, goldReward: 10_000, essenceReward: 25, crystalReward: 0, equipmentRewards: ["iron_sword", "chainmail", "mages_focus"], prestigeRequirement: 0, zoneId: "verdant_vale", bountyRunestones: 99, name: "The Troll King", description: "Gruk the Immovable has terrorised the trade roads for decades. Merchants will pay handsomely for his head." }] as GameState["bosses"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { bossesPatched: number };
|
||||||
|
expect(body.bossesPatched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips boss stat patching for bosses not in defaults", async () => {
|
it("skips boss stat patching for bosses not in defaults", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
|
bosses: [{ id: "nonexistent_boss_xyz", status: "available", currentHp: 100, maxHp: 1, upgradeRewards: [], bountyRunestonesClaimed: false, damagePerSecond: 1, goldReward: 1, essenceReward: 1, crystalReward: 1, equipmentRewards: [], prestigeRequirement: 0, zoneId: "old_zone", bountyRunestones: 0, name: "Ghost", description: "Old" }] as GameState["bosses"],
|
||||||
@@ -872,6 +908,18 @@ describe("debug route", () => {
|
|||||||
expect(zone?.status).toBe("unlocked");
|
expect(zone?.status).toBe("unlocked");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("patches zone stats when only unlockQuestId has changed (exercises all earlier OR conditions)", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
zones: [{ id: "verdant_vale", status: "unlocked", name: "The Verdant Vale", description: "Rolling green hills and ancient forests stretch to the horizon. This is where your guild takes its first steps — trade roads in need of clearing, goblin camps to rout, and an undead queen stirring in the north.", emoji: "🌿", unlockBossId: null, unlockQuestId: "wrong_quest" }] as GameState["zones"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { zonesPatched: number };
|
||||||
|
expect(body.zonesPatched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips zone stat patching for zones not in defaults", async () => {
|
it("skips zone stat patching for zones not in defaults", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"],
|
zones: [{ id: "nonexistent_zone_xyz", status: "unlocked", name: "Ghost", description: "Old", emoji: "❓", unlockBossId: null, unlockQuestId: null }] as GameState["zones"],
|
||||||
@@ -901,6 +949,18 @@ describe("debug route", () => {
|
|||||||
expect(upgrade?.unlocked).toBe(true);
|
expect(upgrade?.unlocked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("patches upgrade stats when only costCrystals has changed (exercises all earlier OR conditions)", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
upgrades: [{ id: "click_2", purchased: false, unlocked: false, multiplier: 2, name: "Battle Hardened", description: "Years of combat sharpen your instincts. Doubles click power again.", target: "click", adventurerId: undefined, costGold: 1000, costEssence: 0, costCrystals: 99 }] as GameState["upgrades"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { upgradesPatched: number };
|
||||||
|
expect(body.upgradesPatched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips upgrade stat patching for upgrades not in defaults", async () => {
|
it("skips upgrade stat patching for upgrades not in defaults", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
|
upgrades: [{ id: "nonexistent_upgrade_xyz", purchased: false, unlocked: false, multiplier: 0.1, name: "Ghost", description: "Old", target: "click", adventurerId: undefined, costGold: 1, costEssence: 0, costCrystals: 0 }] as GameState["upgrades"],
|
||||||
@@ -929,6 +989,30 @@ describe("debug route", () => {
|
|||||||
expect(item?.equipped).toBe(false);
|
expect(item?.equipped).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("patches equipment stats when only cost has changed (exercises name/desc/type/rarity/bonus OR conditions)", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
equipment: [{ id: "shadow_dagger", owned: true, equipped: false, name: "Shadow Dagger", description: "Forged in the Shadow Marshes from condensed darkness. It strikes before it is seen.", type: "weapon", rarity: "epic", bonus: { combatMultiplier: 1.65 }, cost: { crystals: 99, essence: 500, gold: 0 }, setId: "shadow_infiltrator" }] as GameState["equipment"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { equipmentPatched: number };
|
||||||
|
expect(body.equipmentPatched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches equipment stats when only setId has changed (exercises all earlier OR conditions)", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
equipment: [{ id: "iron_sword", owned: true, equipped: false, name: "Iron Sword", description: "A sturdy weapon issued to veterans of the guild.", type: "weapon", rarity: "rare", bonus: { combatMultiplier: 1.25 }, cost: undefined, setId: "old_set" }] as GameState["equipment"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { equipmentPatched: number };
|
||||||
|
expect(body.equipmentPatched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips equipment stat patching for items not in defaults", async () => {
|
it("skips equipment stat patching for items not in defaults", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
|
equipment: [{ id: "nonexistent_item_xyz", owned: false, equipped: false, name: "Ghost Sword", description: "Old", type: "weapon", rarity: "common", bonus: { type: "click_power", value: 0 }, cost: undefined, setId: undefined }] as GameState["equipment"],
|
||||||
@@ -957,6 +1041,18 @@ describe("debug route", () => {
|
|||||||
expect(achievement?.unlockedAt).toBeNull();
|
expect(achievement?.unlockedAt).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("patches achievement stats when only reward has changed (exercises all earlier OR conditions)", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
achievements: [{ id: "first_click", unlockedAt: null, name: "First Strike", description: "Click the Guild Hall for the first time.", icon: "👆", condition: { amount: 1, type: "totalClicks" }, reward: { crystals: 999 } }] as GameState["achievements"],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await syncNewContent();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { achievementsPatched: number };
|
||||||
|
expect(body.achievementsPatched).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("skips achievement stat patching for achievements not in defaults", async () => {
|
it("skips achievement stat patching for achievements not in defaults", async () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"],
|
achievements: [{ id: "nonexistent_achievement_xyz", unlockedAt: null, name: "Ghost", description: "Old", icon: "❓", condition: { type: "totalClicks", amount: 1 }, reward: undefined }] as GameState["achievements"],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { GameState } from "@elysium/types";
|
|||||||
vi.mock("../../src/db/client.js", () => ({
|
vi.mock("../../src/db/client.js", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
player: { update: vi.fn() },
|
player: { update: vi.fn() },
|
||||||
gameState: { findUnique: vi.fn(), update: vi.fn() },
|
gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ describe("prestige route", () => {
|
|||||||
let app: Hono;
|
let app: Hono;
|
||||||
let prisma: {
|
let prisma: {
|
||||||
player: { update: ReturnType<typeof vi.fn> };
|
player: { update: ReturnType<typeof vi.fn> };
|
||||||
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> };
|
gameState: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn> };
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -83,8 +83,8 @@ describe("prestige route", () => {
|
|||||||
|
|
||||||
it("returns runestones on successful prestige", async () => {
|
it("returns runestones on successful prestige", async () => {
|
||||||
const state = makeState();
|
const state = makeState();
|
||||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
const res = await post("");
|
const res = await post("");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -93,6 +93,14 @@ describe("prestige route", () => {
|
|||||||
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
expect(body.runestones).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 409 when a concurrent prestige already committed", async () => {
|
||||||
|
const state = makeState();
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||||
|
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 0 } as never);
|
||||||
|
const res = await post("");
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 500 when the database throws during prestige", async () => {
|
it("returns 500 when the database throws during prestige", async () => {
|
||||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
const res = await post("");
|
const res = await post("");
|
||||||
@@ -112,8 +120,8 @@ describe("prestige route", () => {
|
|||||||
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
|
challenges: [{ id: "prestige_challenge", type: "prestige", target: 2, progress: 0, completed: false, crystalReward: 5 }],
|
||||||
} as GameState["dailyChallenges"],
|
} as GameState["dailyChallenges"],
|
||||||
});
|
});
|
||||||
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never);
|
||||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never);
|
||||||
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never);
|
||||||
const res = await post("");
|
const res = await post("");
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const categoryOrder: Array<PrestigeUpgradeCategory> = [
|
|||||||
const PrestigePanel = (): JSX.Element => {
|
const PrestigePanel = (): JSX.Element => {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
reload,
|
reloadSilent,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
buyPrestigeUpgrade,
|
buyPrestigeUpgrade,
|
||||||
enableNotifications,
|
enableNotifications,
|
||||||
@@ -141,7 +141,7 @@ const PrestigePanel = (): JSX.Element => {
|
|||||||
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
`You've reached prestige level ${data.newPrestigeCount.toString()}!`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await reload();
|
await reloadSilent();
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
setPrestigeError(
|
setPrestigeError(
|
||||||
error_ instanceof Error
|
error_ instanceof Error
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
transcend as transcendApi,
|
transcend as transcendApi,
|
||||||
} from "../api/client.js";
|
} from "../api/client.js";
|
||||||
import { CODEX_ENTRIES } from "../data/codex.js";
|
import { CODEX_ENTRIES } from "../data/codex.js";
|
||||||
|
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||||
import { RECIPES } from "../data/recipes.js";
|
import { RECIPES } from "../data/recipes.js";
|
||||||
import {
|
import {
|
||||||
RESOURCE_CAP,
|
RESOURCE_CAP,
|
||||||
@@ -116,6 +117,9 @@ const applyBossResult = (
|
|||||||
}).
|
}).
|
||||||
filter(Boolean),
|
filter(Boolean),
|
||||||
);
|
);
|
||||||
|
const newlyUnlockedZoneIds = new Set(unlockedZones.map((z) => {
|
||||||
|
return z.id;
|
||||||
|
}));
|
||||||
|
|
||||||
const challengeUpdate
|
const challengeUpdate
|
||||||
= previous.dailyChallenges === undefined
|
= previous.dailyChallenges === undefined
|
||||||
@@ -216,6 +220,23 @@ const applyBossResult = (
|
|||||||
? { ...u, unlocked: true }
|
? { ...u, unlocked: true }
|
||||||
: u;
|
: u;
|
||||||
}),
|
}),
|
||||||
|
...newlyUnlockedZoneIds.size === 0 || previous.exploration === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
exploration: {
|
||||||
|
...previous.exploration,
|
||||||
|
areas: previous.exploration.areas.map((area) => {
|
||||||
|
const areaDefinition = EXPLORATION_AREAS.find((definition) => {
|
||||||
|
return definition.id === area.id;
|
||||||
|
});
|
||||||
|
return areaDefinition !== undefined
|
||||||
|
&& newlyUnlockedZoneIds.has(areaDefinition.zoneId)
|
||||||
|
&& area.status === "locked"
|
||||||
|
? { ...area, status: "available" as const }
|
||||||
|
: area;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +310,12 @@ interface GameContextValue {
|
|||||||
*/
|
*/
|
||||||
reload: ()=> Promise<void>;
|
reload: ()=> Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload state from the server without showing the loading screen (used
|
||||||
|
* after prestige to avoid the visible flash/hang).
|
||||||
|
*/
|
||||||
|
reloadSilent: ()=> Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unix timestamp of the last successful cloud save (null until first save response).
|
* Unix timestamp of the last successful cloud save (null until first save response).
|
||||||
*/
|
*/
|
||||||
@@ -697,6 +724,10 @@ export const GameProvider = ({
|
|||||||
|
|
||||||
/* No-op placeholder */
|
/* No-op placeholder */
|
||||||
});
|
});
|
||||||
|
const reloadSilentReference = useRef<()=> Promise<void>>(async() => {
|
||||||
|
|
||||||
|
/* No-op placeholder */
|
||||||
|
});
|
||||||
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
|
const [ schemaOutdated, setSchemaOutdated ] = useState(false);
|
||||||
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
|
const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0);
|
||||||
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
|
const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0);
|
||||||
@@ -784,6 +815,32 @@ export const GameProvider = ({
|
|||||||
|
|
||||||
reloadReference.current = reload;
|
reloadReference.current = reload;
|
||||||
|
|
||||||
|
const reloadSilent = useCallback(async() => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await loadGame();
|
||||||
|
setState(data.state);
|
||||||
|
setLastSavedAt(data.state.player.lastSavedAt);
|
||||||
|
if (data.signature !== undefined) {
|
||||||
|
signatureReference.current = data.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", data.signature);
|
||||||
|
}
|
||||||
|
setLoginStreak(data.loginStreak);
|
||||||
|
setSchemaOutdated(data.schemaOutdated);
|
||||||
|
setSaveSchemaVersion(data.state.schemaVersion ?? 0);
|
||||||
|
setCurrentSchemaVersion(data.currentSchemaVersion);
|
||||||
|
setInGuild(data.inGuild);
|
||||||
|
} catch (error_: unknown) {
|
||||||
|
setError(
|
||||||
|
error_ instanceof Error
|
||||||
|
? error_.message
|
||||||
|
: "Failed to load game",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
reloadSilentReference.current = reloadSilent;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
enableSoundsReference.current = enableSounds;
|
enableSoundsReference.current = enableSounds;
|
||||||
}, [ enableSounds ]);
|
}, [ enableSounds ]);
|
||||||
@@ -1294,7 +1351,7 @@ export const GameProvider = ({
|
|||||||
if (enableNotificationsReference.current) {
|
if (enableNotificationsReference.current) {
|
||||||
sendNotification("⭐ Prestige!", "You have ascended!");
|
sendNotification("⭐ Prestige!", "You have ascended!");
|
||||||
}
|
}
|
||||||
await reloadReference.current();
|
await reloadSilentReference.current();
|
||||||
}).
|
}).
|
||||||
catch(() => {
|
catch(() => {
|
||||||
|
|
||||||
@@ -1810,7 +1867,18 @@ export const GameProvider = ({
|
|||||||
|
|
||||||
const collectExploration = useCallback(
|
const collectExploration = useCallback(
|
||||||
async(areaId: string): Promise<ExploreCollectResponse> => {
|
async(areaId: string): Promise<ExploreCollectResponse> => {
|
||||||
|
isSyncingReference.current = true;
|
||||||
const result = await collectExplorationApi({ areaId });
|
const result = await collectExplorationApi({ areaId });
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Collect mutates server state outside the normal save flow — clear the
|
||||||
|
* stale HMAC signature and reset the timer so the next auto-save fires
|
||||||
|
* after React has re-rendered with the new materials in stateReference.
|
||||||
|
*/
|
||||||
|
signatureReference.current = null;
|
||||||
|
localStorage.removeItem("elysium_save_signature");
|
||||||
|
lastSaveReference.current = Date.now();
|
||||||
|
isSyncingReference.current = false;
|
||||||
setState((previous) => {
|
setState((previous) => {
|
||||||
if (previous?.exploration === undefined) {
|
if (previous?.exploration === undefined) {
|
||||||
return previous;
|
return previous;
|
||||||
@@ -2341,6 +2409,7 @@ export const GameProvider = ({
|
|||||||
offlineEssence,
|
offlineEssence,
|
||||||
offlineGold,
|
offlineGold,
|
||||||
reload,
|
reload,
|
||||||
|
reloadSilent,
|
||||||
resetProgress,
|
resetProgress,
|
||||||
saveSchemaVersion,
|
saveSchemaVersion,
|
||||||
schemaOutdated,
|
schemaOutdated,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getActiveCompanionBonus,
|
getActiveCompanionBonus,
|
||||||
} from "@elysium/types";
|
} from "@elysium/types";
|
||||||
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
import { EQUIPMENT_SETS } from "../data/equipmentSets.js";
|
||||||
|
import { EXPLORATION_AREAS } from "../data/explorations.js";
|
||||||
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
import { updateChallengeProgress } from "../utils/dailyChallenges.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -753,6 +754,23 @@ export const applyTick = (
|
|||||||
...updatedDailyChallenges === undefined
|
...updatedDailyChallenges === undefined
|
||||||
? {}
|
? {}
|
||||||
: { dailyChallenges: updatedDailyChallenges },
|
: { dailyChallenges: updatedDailyChallenges },
|
||||||
|
...newlyUnlockedZoneIds.size === 0 || state.exploration === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
exploration: {
|
||||||
|
...state.exploration,
|
||||||
|
areas: state.exploration.areas.map((area) => {
|
||||||
|
const areaDefinition = EXPLORATION_AREAS.find((definition) => {
|
||||||
|
return definition.id === area.id;
|
||||||
|
});
|
||||||
|
return areaDefinition !== undefined
|
||||||
|
&& newlyUnlockedZoneIds.has(areaDefinition.zoneId)
|
||||||
|
&& area.status === "locked"
|
||||||
|
? { ...area, status: "available" as const }
|
||||||
|
: area;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
adventurers: updatedAdventurers,
|
adventurers: updatedAdventurers,
|
||||||
bosses: updatedBosses,
|
bosses: updatedBosses,
|
||||||
equipment: updatedEquipmentReference,
|
equipment: updatedEquipmentReference,
|
||||||
|
|||||||
Reference in New Issue
Block a user