generated from nhcarrigan/template
Compare commits
5 Commits
bd8ae930a5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb1d01d2b | |||
|
e341db56af
|
|||
| 2bc47b79aa | |||
| 3afe64e48a | |||
| e7164257c5 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/api",
|
"name": "@elysium/api",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
icon: "☄️",
|
icon: "☄️",
|
||||||
id: "click_deity",
|
id: "click_deity",
|
||||||
name: "Click Deity",
|
name: "Click Deity",
|
||||||
reward: { crystals: 5000 },
|
reward: { crystals: 15_000 },
|
||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
// Endgame gold milestones
|
// Endgame gold milestones
|
||||||
@@ -405,7 +405,7 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
icon: "💫",
|
icon: "💫",
|
||||||
id: "prestige_master",
|
id: "prestige_master",
|
||||||
name: "Master of Cycles",
|
name: "Master of Cycles",
|
||||||
reward: { crystals: 5000 },
|
reward: { crystals: 15_000 },
|
||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -414,7 +414,7 @@ export const defaultAchievements: Array<Achievement> = [
|
|||||||
icon: "🌠",
|
icon: "🌠",
|
||||||
id: "prestige_legend",
|
id: "prestige_legend",
|
||||||
name: "Legend of Eternity",
|
name: "Legend of Eternity",
|
||||||
reward: { crystals: 25_000 },
|
reward: { crystals: 75_000 },
|
||||||
unlockedAt: null,
|
unlockedAt: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const defaultAdventurers: Array<Adventurer> = [
|
|||||||
unlocked: true,
|
unlocked: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
baseCost: 100,
|
baseCost: 65,
|
||||||
class: "warrior",
|
class: "warrior",
|
||||||
combatPower: 3,
|
combatPower: 3,
|
||||||
count: 0,
|
count: 0,
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
|
|||||||
target: 5000,
|
target: 5000,
|
||||||
type: "clicks",
|
type: "clicks",
|
||||||
},
|
},
|
||||||
|
// Crafting — requires materials but no zone/boss progression
|
||||||
|
{ label: "Craft 1 recipe", rewardCrystals: 75, target: 1, type: "crafting" },
|
||||||
|
{
|
||||||
|
label: "Craft 2 recipes",
|
||||||
|
rewardCrystals: 175,
|
||||||
|
target: 2,
|
||||||
|
type: "crafting",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Craft 3 recipes",
|
||||||
|
rewardCrystals: 350,
|
||||||
|
target: 3,
|
||||||
|
type: "crafting",
|
||||||
|
},
|
||||||
// Boss defeats — requires active combat
|
// Boss defeats — requires active combat
|
||||||
{
|
{
|
||||||
label: "Defeat 1 boss",
|
label: "Defeat 1 boss",
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ export const defaultEquipment: Array<Equipment> = [
|
|||||||
type: "trinket",
|
type: "trinket",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { clickMultiplier: 1.65, goldMultiplier: 1.2 },
|
bonus: { clickMultiplier: 1.9, goldMultiplier: 1.3 },
|
||||||
description:
|
description:
|
||||||
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
|
"A fragment of the Mud Kraken's crystallised essence. Focuses raw power into devastating strikes.",
|
||||||
equipped: false,
|
equipped: false,
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
|||||||
id: "income_10",
|
id: "income_10",
|
||||||
multiplier: 200,
|
multiplier: 200,
|
||||||
name: "Eternal Rune I",
|
name: "Eternal Rune I",
|
||||||
runestonesCost: 22_500,
|
runestonesCost: 15_000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: "income",
|
category: "income",
|
||||||
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
|||||||
id: "income_11",
|
id: "income_11",
|
||||||
multiplier: 500,
|
multiplier: 500,
|
||||||
name: "Eternal Rune II",
|
name: "Eternal Rune II",
|
||||||
runestonesCost: 60_000,
|
runestonesCost: 35_000,
|
||||||
},
|
},
|
||||||
// ── Click Power ───────────────────────────────────────────────────────────
|
// ── Click Power ───────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
prerequisiteIds: [],
|
prerequisiteIds: [],
|
||||||
rewards: [
|
rewards: [
|
||||||
{ amount: 500, type: "gold" },
|
{ amount: 500, type: "gold" },
|
||||||
|
{ amount: 5, type: "crystals" },
|
||||||
{ targetId: "militia", type: "adventurer" },
|
{ targetId: "militia", type: "adventurer" },
|
||||||
],
|
],
|
||||||
status: "available",
|
status: "available",
|
||||||
@@ -33,6 +34,7 @@ export const defaultQuests: Array<Quest> = [
|
|||||||
rewards: [
|
rewards: [
|
||||||
{ amount: 2000, type: "gold" },
|
{ amount: 2000, type: "gold" },
|
||||||
{ amount: 5, type: "essence" },
|
{ amount: 5, type: "essence" },
|
||||||
|
{ amount: 10, type: "crystals" },
|
||||||
{ targetId: "peasant_1", type: "upgrade" },
|
{ targetId: "peasant_1", type: "upgrade" },
|
||||||
{ targetId: "apprentice_1", type: "upgrade" },
|
{ targetId: "apprentice_1", type: "upgrade" },
|
||||||
{ targetId: "apprentice", type: "adventurer" },
|
{ targetId: "apprentice", type: "adventurer" },
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 13: primordial_chaos
|
// Zone 13: primordial_chaos
|
||||||
{
|
{
|
||||||
bonus: { type: "click_power", value: 1.2 },
|
bonus: { type: "click_power", value: 1.22 },
|
||||||
description:
|
description:
|
||||||
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
|
"Chaos fragments and creation shards arranged into a lens that hasn't decided what it wants to focus on yet, which somehow makes every click land harder than it should.",
|
||||||
id: "chaos_lens",
|
id: "chaos_lens",
|
||||||
@@ -387,7 +387,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "reality_forge",
|
zoneId: "reality_forge",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "click_power", value: 1.22 },
|
bonus: { type: "click_power", value: 1.25 },
|
||||||
description:
|
description:
|
||||||
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
|
"A reality shard carefully shaped with creation tools into something that could, theoretically, become a universe. Instead it makes your clicks unreasonably effective.",
|
||||||
id: "universe_seed",
|
id: "universe_seed",
|
||||||
@@ -439,7 +439,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
zoneId: "primeval_sanctum",
|
zoneId: "primeval_sanctum",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
bonus: { type: "click_power", value: 1.25 },
|
bonus: { type: "click_power", value: 1.28 },
|
||||||
description:
|
description:
|
||||||
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
|
"The primeval relic, set into a memory shard framework. What function it originally served is unknowable. In your guild's hands, it makes every action more deliberate and more powerful.",
|
||||||
id: "first_artefact",
|
id: "first_artefact",
|
||||||
@@ -522,7 +522,7 @@ export const defaultRecipes: Array<CraftingRecipe> = [
|
|||||||
|
|
||||||
// Zone 18: the_absolute
|
// Zone 18: the_absolute
|
||||||
{
|
{
|
||||||
bonus: { type: "click_power", value: 1.28 },
|
bonus: { type: "click_power", value: 1.3 },
|
||||||
description:
|
description:
|
||||||
"Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.",
|
"Absolute fragments ground and set in an omega crystal lattice — an instrument of pure finality. Every action your guild takes through it carries the weight of an ending. It does not miss.",
|
||||||
id: "absolute_focus",
|
id: "absolute_focus",
|
||||||
|
|||||||
@@ -496,6 +496,43 @@ export const defaultUpgrades: Array<Upgrade> = [
|
|||||||
unlocked: false,
|
unlocked: false,
|
||||||
},
|
},
|
||||||
// ── Purchasable essence/crystal sink upgrades ─────────────────────────────
|
// ── Purchasable essence/crystal sink upgrades ─────────────────────────────
|
||||||
|
{
|
||||||
|
costCrystals: 3000,
|
||||||
|
costEssence: 0,
|
||||||
|
costGold: 0,
|
||||||
|
description: "Crystalline energy pulses through your guild's operations. All income +50%.",
|
||||||
|
id: "crystal_pulse",
|
||||||
|
multiplier: 1.5,
|
||||||
|
name: "Crystal Pulse",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 20_000,
|
||||||
|
costEssence: 0,
|
||||||
|
costGold: 0,
|
||||||
|
description:
|
||||||
|
"Crystal resonance surges into every process your guild undertakes. All income doubled.",
|
||||||
|
id: "crystal_surge",
|
||||||
|
multiplier: 2,
|
||||||
|
name: "Crystal Surge",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
costCrystals: 150_000,
|
||||||
|
costEssence: 0,
|
||||||
|
costGold: 0,
|
||||||
|
description: "Your guild's operations are saturated with crystalline power. All income x3.",
|
||||||
|
id: "crystal_tempest",
|
||||||
|
multiplier: 3,
|
||||||
|
name: "Crystal Tempest",
|
||||||
|
purchased: false,
|
||||||
|
target: "global",
|
||||||
|
unlocked: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
costCrystals: 0,
|
costCrystals: 0,
|
||||||
costEssence: 5_000_000,
|
costEssence: 5_000_000,
|
||||||
|
|||||||
@@ -35,12 +35,16 @@ export const authMiddleware: MiddlewareHandler<HonoEnvironment> = async(
|
|||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
context.set("discordId", payload.discordId);
|
context.set("discordId", payload.discordId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const isExpiredToken
|
||||||
|
= error instanceof Error && error.message === "Token has expired";
|
||||||
|
if (!isExpiredToken) {
|
||||||
void logger.error(
|
void logger.error(
|
||||||
"auth_middleware",
|
"auth_middleware",
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error
|
? error
|
||||||
: new Error(String(error)),
|
: new Error(String(error)),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return context.json({ error: "Invalid or expired token" }, 401);
|
return context.json({ error: "Invalid or expired token" }, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||||
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
||||||
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
|
/* eslint-disable max-lines -- Boss route with full combat logic and helpers exceeds line limit */
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
computeSetBonuses,
|
computeSetBonuses,
|
||||||
getActiveCompanionBonus,
|
getActiveCompanionBonus,
|
||||||
@@ -25,6 +26,17 @@ import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
|||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the HMAC-SHA256 of data using the given secret.
|
||||||
|
* @param data - The data string to sign.
|
||||||
|
* @param secret - The HMAC secret key.
|
||||||
|
* @returns The hex-encoded HMAC digest.
|
||||||
|
*/
|
||||||
|
const computeHmac = (data: string, secret: string): string => {
|
||||||
|
return createHmac("sha256", secret).update(data).
|
||||||
|
digest("hex");
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
* Exponential base for the prestige combat multiplier: Math.pow(base, prestigeCount).
|
||||||
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
* Replaces the former linear formula (1 + count * 0.1) to enable late-game zone progression.
|
||||||
@@ -379,6 +391,11 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
where: { discordId },
|
where: { discordId },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const secret = process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const updatedSignature = secret === undefined
|
||||||
|
? undefined
|
||||||
|
: computeHmac(JSON.stringify(state), secret);
|
||||||
|
|
||||||
const { bossId } = body;
|
const { bossId } = body;
|
||||||
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
|
void logger.metric("boss_challenge", 1, { bossId, discordId, won });
|
||||||
|
|
||||||
@@ -401,6 +418,9 @@ bossRouter.post("/challenge", async(context) => {
|
|||||||
if (casualties !== undefined) {
|
if (casualties !== undefined) {
|
||||||
response.casualties = casualties;
|
response.casualties = casualties;
|
||||||
}
|
}
|
||||||
|
if (updatedSignature !== undefined) {
|
||||||
|
response.signature = updatedSignature;
|
||||||
|
}
|
||||||
|
|
||||||
return context.json(response);
|
return context.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Hono } from "hono";
|
|||||||
import { defaultRecipes } from "../data/recipes.js";
|
import { defaultRecipes } from "../data/recipes.js";
|
||||||
import { prisma } from "../db/client.js";
|
import { prisma } from "../db/client.js";
|
||||||
import { authMiddleware } from "../middleware/auth.js";
|
import { authMiddleware } from "../middleware/auth.js";
|
||||||
|
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||||
import { logger } from "../services/logger.js";
|
import { logger } from "../services/logger.js";
|
||||||
import type { HonoEnvironment } from "../types/hono.js";
|
import type { HonoEnvironment } from "../types/hono.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -138,6 +139,16 @@ craftRouter.post("/", async(context) => {
|
|||||||
state.exploration.craftedCombatMultiplier
|
state.exploration.craftedCombatMultiplier
|
||||||
= updatedMultipliers.craftedCombatMultiplier;
|
= updatedMultipliers.craftedCombatMultiplier;
|
||||||
|
|
||||||
|
if (state.dailyChallenges !== undefined) {
|
||||||
|
const { updatedChallenges, crystalsAwarded } = updateChallengeProgress(
|
||||||
|
state.dailyChallenges,
|
||||||
|
"crafting",
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
state.dailyChallenges = updatedChallenges;
|
||||||
|
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.gameState.update({
|
await prisma.gameState.update({
|
||||||
/* 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: state as object, updatedAt: Date.now() },
|
data: { state: state as object, updatedAt: Date.now() },
|
||||||
|
|||||||
@@ -681,6 +681,45 @@ const validateAndSanitize = (
|
|||||||
storySpread = { story: previous.story };
|
storySpread = { story: previous.story };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Merge daily challenge progress: take the maximum progress for each
|
||||||
|
* challenge so a stale auto-save arriving after a craft/boss/etc. update
|
||||||
|
* cannot silently roll back server-side challenge completions.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||||
|
/* v8 ignore next 35 -- @preserve */
|
||||||
|
let dailyChallengesSpread: object = {};
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
|
||||||
|
if (incoming.dailyChallenges !== undefined && previous.dailyChallenges !== undefined) {
|
||||||
|
const previousChallengeMap = new Map(
|
||||||
|
previous.dailyChallenges.challenges.map((challenge) => {
|
||||||
|
return [ challenge.id, challenge ];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long chain; splitting would reduce readability
|
||||||
|
const mergedChallenges = incoming.dailyChallenges.challenges.map((challenge) => {
|
||||||
|
const serverChallenge = previousChallengeMap.get(challenge.id);
|
||||||
|
if (serverChallenge === undefined) {
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
|
||||||
|
const bestProgress = Math.max(challenge.progress, serverChallenge.progress);
|
||||||
|
return {
|
||||||
|
...challenge,
|
||||||
|
completed: bestProgress >= challenge.target,
|
||||||
|
progress: bestProgress,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
dailyChallengesSpread = {
|
||||||
|
dailyChallenges: {
|
||||||
|
...incoming.dailyChallenges,
|
||||||
|
challenges: mergedChallenges,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (previous.dailyChallenges !== undefined) {
|
||||||
|
dailyChallengesSpread = { dailyChallenges: previous.dailyChallenges };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...incoming,
|
...incoming,
|
||||||
achievements,
|
achievements,
|
||||||
@@ -693,6 +732,7 @@ const validateAndSanitize = (
|
|||||||
...apotheosisSpread,
|
...apotheosisSpread,
|
||||||
...explorationSpread,
|
...explorationSpread,
|
||||||
...storySpread,
|
...storySpread,
|
||||||
|
...dailyChallengesSpread,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1024,7 +1064,8 @@ gameRouter.post("/save", async(context) => {
|
|||||||
const companionUnlocks = computeUnlockedCompanionIds({
|
const companionUnlocks = computeUnlockedCompanionIds({
|
||||||
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
apotheosisCount: stateToSave.apotheosis?.count ?? 0,
|
||||||
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
lifetimeBossesDefeated: playerRecord?.lifetimeBossesDefeated ?? 0,
|
||||||
lifetimeGoldEarned: playerRecord?.lifetimeGoldEarned ?? 0,
|
// eslint-disable-next-line stylistic/max-len -- Long property; splitting would reduce readability
|
||||||
|
lifetimeGoldEarned: (playerRecord?.lifetimeGoldEarned ?? 0) + stateToSave.player.totalGoldEarned,
|
||||||
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
lifetimeQuestsCompleted: playerRecord?.lifetimeQuestsCompleted ?? 0,
|
||||||
prestigeCount: stateToSave.prestige.count,
|
prestigeCount: stateToSave.prestige.count,
|
||||||
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
transcendenceCount: stateToSave.transcendence?.count ?? 0,
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nonProgressionChallengeTypes: Array<DailyChallengeType> = [
|
||||||
|
"crafting",
|
||||||
|
];
|
||||||
|
|
||||||
const progressionChallengeTypes: Array<DailyChallengeType> = [
|
const progressionChallengeTypes: Array<DailyChallengeType> = [
|
||||||
"bossesDefeated",
|
"bossesDefeated",
|
||||||
"questsCompleted",
|
"questsCompleted",
|
||||||
@@ -79,8 +83,10 @@ const progressionChallengeTypes: Array<DailyChallengeType> = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates 3 daily challenges for the given date string, deterministically.
|
* Generates 3 daily challenges for the given date string, deterministically.
|
||||||
* Always includes a "clicks" challenge (always completable regardless of
|
* Always includes a "clicks" challenge and a "crafting" challenge (both
|
||||||
* progression), then picks 2 more from the remaining types.
|
* completable regardless of zone/boss progression), then picks 1 more from
|
||||||
|
* the progression types. This ensures stuck players always have 2 completable
|
||||||
|
* challenges available.
|
||||||
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
|
* @param dateString - The date string (YYYY-MM-DD) to generate challenges for.
|
||||||
* @returns An array of 3 DailyChallenge objects.
|
* @returns An array of 3 DailyChallenge objects.
|
||||||
*/
|
*/
|
||||||
@@ -90,7 +96,14 @@ const generateDailyChallenges = (
|
|||||||
const seed = dateSeed(dateString);
|
const seed = dateSeed(dateString);
|
||||||
const selectedTypes: Array<DailyChallengeType> = [
|
const selectedTypes: Array<DailyChallengeType> = [
|
||||||
"clicks",
|
"clicks",
|
||||||
...shuffleWithSeed([ ...progressionChallengeTypes ], seed).slice(0, 2),
|
...shuffleWithSeed(
|
||||||
|
[ ...nonProgressionChallengeTypes ],
|
||||||
|
seed + 500,
|
||||||
|
).slice(0, 1),
|
||||||
|
...shuffleWithSeed(
|
||||||
|
[ ...progressionChallengeTypes ],
|
||||||
|
seed,
|
||||||
|
).slice(0, 1),
|
||||||
];
|
];
|
||||||
|
|
||||||
return selectedTypes.map((type, index) => {
|
return selectedTypes.map((type, index) => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type {
|
|||||||
} from "@elysium/types";
|
} from "@elysium/types";
|
||||||
|
|
||||||
const basePrestigeGoldThreshold = 1_000_000;
|
const basePrestigeGoldThreshold = 1_000_000;
|
||||||
const runestonesPerPrestigeLevel = 15;
|
const runestonesPerPrestigeLevel = 20;
|
||||||
const milestoneInterval = 5;
|
const milestoneInterval = 5;
|
||||||
const milestoneRunestonesPerInterval = 25;
|
const milestoneRunestonesPerInterval = 25;
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,26 @@ vi.mock("../../src/services/jwt.js", () => ({
|
|||||||
verifyToken: vi.fn(),
|
verifyToken: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../src/services/logger.js", () => ({
|
||||||
|
logger: {
|
||||||
|
error: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe("authMiddleware", () => {
|
describe("authMiddleware", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeApp = async () => {
|
const makeApp = async () => {
|
||||||
const { authMiddleware } = await import("../../src/middleware/auth.js");
|
const { authMiddleware } = await import("../../src/middleware/auth.js");
|
||||||
const { verifyToken } = await import("../../src/services/jwt.js");
|
const { verifyToken } = await import("../../src/services/jwt.js");
|
||||||
|
const { logger } = await import("../../src/services/logger.js");
|
||||||
const app = new Hono<{ Variables: { discordId: string } }>();
|
const app = new Hono<{ Variables: { discordId: string } }>();
|
||||||
app.use("*", authMiddleware);
|
app.use("*", authMiddleware);
|
||||||
app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
|
app.get("/test", (c) => c.json({ discordId: c.get("discordId") }));
|
||||||
return { app, verifyToken };
|
return { app, logger, verifyToken };
|
||||||
};
|
};
|
||||||
|
|
||||||
it("returns 401 when Authorization header is missing", async () => {
|
it("returns 401 when Authorization header is missing", async () => {
|
||||||
@@ -45,8 +53,8 @@ describe("authMiddleware", () => {
|
|||||||
expect(body.discordId).toBe("user_123");
|
expect(body.discordId).toBe("user_123");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when verifyToken throws", async () => {
|
it("returns 401 and logs when verifyToken throws a non-expiry error", async () => {
|
||||||
const { app, verifyToken } = await makeApp();
|
const { app, logger, verifyToken } = await makeApp();
|
||||||
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
throw new Error("Invalid token");
|
throw new Error("Invalid token");
|
||||||
});
|
});
|
||||||
@@ -54,10 +62,15 @@ describe("authMiddleware", () => {
|
|||||||
headers: { Authorization: "Bearer bad_token" },
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
}));
|
}));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
|
||||||
|
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||||
|
"auth_middleware",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when verifyToken throws a non-Error value", async () => {
|
it("returns 401 and logs when verifyToken throws a non-Error value", async () => {
|
||||||
const { app, verifyToken } = await makeApp();
|
const { app, logger, verifyToken } = await makeApp();
|
||||||
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
throw "raw string error";
|
throw "raw string error";
|
||||||
});
|
});
|
||||||
@@ -65,5 +78,23 @@ describe("authMiddleware", () => {
|
|||||||
headers: { Authorization: "Bearer bad_token" },
|
headers: { Authorization: "Bearer bad_token" },
|
||||||
}));
|
}));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
|
||||||
|
expect((logger.error as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
|
||||||
|
"auth_middleware",
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 without logging when token has expired", async () => {
|
||||||
|
const { app, logger, verifyToken } = await makeApp();
|
||||||
|
vi.mocked(verifyToken).mockImplementationOnce(() => {
|
||||||
|
throw new Error("Token has expired");
|
||||||
|
});
|
||||||
|
const res = await app.fetch(new Request("http://localhost/test", {
|
||||||
|
headers: { Authorization: "Bearer expired_token" },
|
||||||
|
}));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- logger mock requires cast */
|
||||||
|
expect((logger.error as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -340,6 +340,37 @@ describe("boss route", () => {
|
|||||||
expect(area?.status).toBe("locked");
|
expect(area?.status).toBe("locked");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes HMAC signature in response when ANTI_CHEAT_SECRET is set", async () => {
|
||||||
|
process.env.ANTI_CHEAT_SECRET = "test_secret";
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss()] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { signature: string | undefined };
|
||||||
|
expect(body.signature).toBeDefined();
|
||||||
|
delete process.env.ANTI_CHEAT_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits signature in response when ANTI_CHEAT_SECRET is not set", async () => {
|
||||||
|
delete process.env.ANTI_CHEAT_SECRET;
|
||||||
|
const state = makeState({
|
||||||
|
bosses: [makeBoss()] as GameState["bosses"],
|
||||||
|
adventurers: [makeAdventurer()] as GameState["adventurers"],
|
||||||
|
zones: [],
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { signature: string | undefined };
|
||||||
|
expect(body.signature).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 500 when the database throws", async () => {
|
it("returns 500 when the database throws", async () => {
|
||||||
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error"));
|
||||||
const res = await challenge({ bossId: "test_boss" });
|
const res = await challenge({ bossId: "test_boss" });
|
||||||
|
|||||||
@@ -144,6 +144,34 @@ describe("craft route", () => {
|
|||||||
expect(body.bonusType).toBe("gold_income");
|
expect(body.bonusType).toBe("gold_income");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates crafting challenge progress and awards crystals when dailyChallenges is defined", async () => {
|
||||||
|
const state = makeState({
|
||||||
|
dailyChallenges: {
|
||||||
|
date: "2024-01-15",
|
||||||
|
challenges: [
|
||||||
|
{
|
||||||
|
completed: false,
|
||||||
|
id: "2024-01-15_crafting",
|
||||||
|
label: "Craft 1 recipe",
|
||||||
|
progress: 0,
|
||||||
|
rewardCrystals: 75,
|
||||||
|
target: 1,
|
||||||
|
type: "crafting",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const updateArg = vi.mocked(prisma.gameState.update).mock.calls[0]![0] as {
|
||||||
|
data: { state: GameState };
|
||||||
|
};
|
||||||
|
expect(updateArg.data.state.dailyChallenges?.challenges[0]?.completed).toBe(true);
|
||||||
|
expect(updateArg.data.state.resources.crystals).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 500 when the database throws", async () => {
|
it("returns 500 when the database throws", 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({ recipeId: TEST_RECIPE_ID });
|
const res = await post({ recipeId: TEST_RECIPE_ID });
|
||||||
|
|||||||
@@ -597,7 +597,7 @@ describe("debug route", () => {
|
|||||||
|
|
||||||
it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => {
|
it("patches adventurer stats when only name has changed (exercises all earlier OR conditions)", async () => {
|
||||||
const state = makeState({
|
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"],
|
adventurers: [{ id: "militia", count: 5, unlocked: true, baseCost: 65, 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.findUnique).mockResolvedValueOnce({ state } as never);
|
||||||
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
vi.mocked(prisma.gameState.update).mockResolvedValueOnce({} as never);
|
||||||
|
|||||||
@@ -55,15 +55,28 @@ describe("generateDailyChallenges", () => {
|
|||||||
expect(day2.some((c) => c.type === "clicks")).toBe(true);
|
expect(day2.some((c) => c.type === "clicks")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates different challenges for different dates", async () => {
|
it("always includes a crafting challenge regardless of date", async () => {
|
||||||
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||||
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||||
const day1 = generateDailyChallenges("2024-01-15");
|
const day1 = generateDailyChallenges("2024-01-15");
|
||||||
const day2 = generateDailyChallenges("2024-01-16");
|
const day2 = generateDailyChallenges("2024-01-16");
|
||||||
// The 2 non-clicks types should vary by seed between dates
|
expect(day1.some((c) => c.type === "crafting")).toBe(true);
|
||||||
const day1NonClicks = day1.filter((c) => c.type !== "clicks").map((c) => c.type);
|
expect(day2.some((c) => c.type === "crafting")).toBe(true);
|
||||||
const day2NonClicks = day2.filter((c) => c.type !== "clicks").map((c) => c.type);
|
});
|
||||||
expect(day1NonClicks).not.toEqual(day2NonClicks);
|
|
||||||
|
it("progression challenge slot varies across different dates", async () => {
|
||||||
|
vi.setSystemTime(LA_MIDNIGHT_2024_01_15);
|
||||||
|
const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js");
|
||||||
|
// 2024-01-01 picks bossesDefeated, 2024-01-02 picks prestige (verified by seed)
|
||||||
|
const day1 = generateDailyChallenges("2024-01-01");
|
||||||
|
const day2 = generateDailyChallenges("2024-01-02");
|
||||||
|
const day1ProgressionType = day1.find((c) => {
|
||||||
|
return c.type !== "clicks" && c.type !== "crafting";
|
||||||
|
})?.type;
|
||||||
|
const day2ProgressionType = day2.find((c) => {
|
||||||
|
return c.type !== "clicks" && c.type !== "crafting";
|
||||||
|
})?.type;
|
||||||
|
expect(day1ProgressionType).not.toBe(day2ProgressionType);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -102,25 +102,25 @@ describe("isEligibleForPrestige", () => {
|
|||||||
|
|
||||||
describe("calculateRunestones", () => {
|
describe("calculateRunestones", () => {
|
||||||
it("calculates basic runestones formula", () => {
|
it("calculates basic runestones formula", () => {
|
||||||
// floor(cbrt(4_000_000 / 1_000_000)) × 15 = floor(cbrt(4)) × 15 = 1 × 15 = 15
|
// floor(cbrt(4_000_000 / 1_000_000)) × 20 = floor(cbrt(4)) × 20 = 1 × 20 = 20
|
||||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||||
expect(result).toBe(15);
|
expect(result).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies echo runestone multiplier", () => {
|
it("applies echo runestone multiplier", () => {
|
||||||
// floor(cbrt(4)) × 15 = 15; × 2 = 30
|
// floor(cbrt(4)) × 20 = 20; × 2 = 40
|
||||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [], echoRunestoneMultiplier: 2 });
|
||||||
expect(result).toBe(30);
|
expect(result).toBe(40);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies purchased runestone upgrade multiplier", () => {
|
it("applies purchased runestone upgrade multiplier", () => {
|
||||||
// With "runestone_gain_1" purchased (multiplier 1.25): floor(15 × 1.25) = 18
|
// With "runestone_gain_1" purchased (multiplier 1.25): floor(20 × 1.25) = 25
|
||||||
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: ["runestone_gain_1"] });
|
||||||
expect(result).toBe(18);
|
expect(result).toBe(25);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps base runestones before multipliers", () => {
|
it("caps base runestones before multipliers", () => {
|
||||||
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 10 = 210, capped at 200
|
// cbrt(9_261_000_000 / 1_000_000) = cbrt(9261) = 21 → 21 × 20 = 420, capped at 200
|
||||||
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
|
||||||
expect(result).toBe(200);
|
expect(result).toBe(200);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/web",
|
"name": "@elysium/web",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ const fetchJson = async <T>(
|
|||||||
= typeof errorBody.error === "string"
|
= typeof errorBody.error === "string"
|
||||||
? errorBody.error
|
? errorBody.error
|
||||||
: "Unknown error";
|
: "Unknown error";
|
||||||
|
if (response.status === 401) {
|
||||||
|
globalThis.localStorage.removeItem("elysium_token");
|
||||||
|
globalThis.localStorage.removeItem("elysium_save_signature");
|
||||||
|
globalThis.location.href = "/";
|
||||||
|
}
|
||||||
if (response.status >= 400 && response.status < 500) {
|
if (response.status >= 400 && response.status < 500) {
|
||||||
throw new ValidationError(message, response.status);
|
throw new ValidationError(message, response.status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,8 @@ const CompanionPanel = (): JSX.Element => {
|
|||||||
const progressByUnlockType: Record<string, number> = {
|
const progressByUnlockType: Record<string, number> = {
|
||||||
apotheosis: state.apotheosis?.count ?? 0,
|
apotheosis: state.apotheosis?.count ?? 0,
|
||||||
lifetimeBosses: state.player.lifetimeBossesDefeated,
|
lifetimeBosses: state.player.lifetimeBossesDefeated,
|
||||||
lifetimeGold: state.player.lifetimeGoldEarned,
|
// eslint-disable-next-line stylistic/max-len -- Long expression; splitting would reduce readability
|
||||||
|
lifetimeGold: state.player.lifetimeGoldEarned + state.player.totalGoldEarned,
|
||||||
lifetimeQuests: state.player.lifetimeQuestsCompleted,
|
lifetimeQuests: state.player.lifetimeQuestsCompleted,
|
||||||
prestige: state.prestige.count,
|
prestige: state.prestige.count,
|
||||||
transcendence: state.transcendence?.count ?? 0,
|
transcendence: state.transcendence?.count ?? 0,
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ const QuestCard = ({
|
|||||||
}
|
}
|
||||||
<div className="quest-rewards">
|
<div className="quest-rewards">
|
||||||
{quest.rewards.map((reward, rewardIndex) => {
|
{quest.rewards.map((reward, rewardIndex) => {
|
||||||
|
if (reward.type === "crystals" && (reward.amount ?? 0) === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
|
<span className="reward-tag" key={`${reward.type}-${reward.targetId ?? String(reward.amount ?? rewardIndex)}`}>
|
||||||
{reward.type === "gold"
|
{reward.type === "gold"
|
||||||
@@ -121,7 +124,6 @@ const QuestCard = ({
|
|||||||
{reward.type === "essence"
|
{reward.type === "essence"
|
||||||
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
&& `✨ ${formatNumber(reward.amount ?? 0)}`}
|
||||||
{reward.type === "crystals"
|
{reward.type === "crystals"
|
||||||
&& (reward.amount ?? 0) > 0
|
|
||||||
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
&& `💎 ${formatNumber(reward.amount ?? 0)}`}
|
||||||
{reward.type === "upgrade" && "🔓 Upgrade"}
|
{reward.type === "upgrade" && "🔓 Upgrade"}
|
||||||
{reward.type === "adventurer" && "👥 New Adventurer"}
|
{reward.type === "adventurer" && "👥 New Adventurer"}
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ const ResourceBar = ({
|
|||||||
: ""}`}>
|
: ""}`}>
|
||||||
<span className="resource-icon">{"💎"}</span>
|
<span className="resource-icon">{"💎"}</span>
|
||||||
<span className="resource-value">
|
<span className="resource-value">
|
||||||
{formatInteger(crystals)}
|
{formatNumber(crystals)}
|
||||||
</span>
|
</span>
|
||||||
<span className="resource-label">{"Crystals"}</span>
|
<span className="resource-label">{"Crystals"}</span>
|
||||||
{crystalsFull
|
{crystalsFull
|
||||||
|
|||||||
@@ -1356,10 +1356,11 @@ export const GameProvider = ({
|
|||||||
newlyFailedQuestsReference.current = [];
|
newlyFailedQuestsReference.current = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-save every 30 seconds (skip if a force sync is in-flight to avoid signature collisions)
|
// Auto-save every 30 seconds (skip if a force sync or auto-prestige is in-flight to avoid signature collisions)
|
||||||
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
|
if (Date.now() - lastSaveReference.current >= autoSaveIntervalMs) {
|
||||||
lastSaveReference.current = Date.now();
|
lastSaveReference.current = Date.now();
|
||||||
if (stateReference.current !== null && !isSyncingReference.current) {
|
// eslint-disable-next-line stylistic/max-len -- Long condition; splitting would reduce readability
|
||||||
|
if (stateReference.current !== null && !isSyncingReference.current && !isAutoPrestigingReference.current) {
|
||||||
void saveGame({
|
void saveGame({
|
||||||
state: stateReference.current,
|
state: stateReference.current,
|
||||||
...signatureReference.current === null
|
...signatureReference.current === null
|
||||||
@@ -1496,11 +1497,20 @@ export const GameProvider = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Boss fight modifies server state; clear stale signature so
|
* Boss fight modifies server state; update signature chain so
|
||||||
* the next pre-save or auto-save does not send a mismatched one.
|
* the next pre-save or auto-save sends the correct token.
|
||||||
*/
|
*/
|
||||||
|
if (result.signature === undefined) {
|
||||||
signatureReference.current = null;
|
signatureReference.current = null;
|
||||||
localStorage.removeItem("elysium_save_signature");
|
localStorage.removeItem("elysium_save_signature");
|
||||||
|
} else {
|
||||||
|
signatureReference.current = result.signature;
|
||||||
|
localStorage.setItem(
|
||||||
|
"elysium_save_signature",
|
||||||
|
result.signature,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lastSaveReference.current = Date.now();
|
||||||
setAutoBossLastResult({
|
setAutoBossLastResult({
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
bossName: bossName,
|
bossName: bossName,
|
||||||
@@ -1847,6 +1857,13 @@ export const GameProvider = ({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Buying a prestige upgrade mutates DB state; clear the cached signature
|
||||||
|
* so the next auto-save doesn't collide with a stale one.
|
||||||
|
*/
|
||||||
|
signatureReference.current = null;
|
||||||
|
localStorage.removeItem("elysium_save_signature");
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
logError("buy_prestige_upgrade", error_);
|
logError("buy_prestige_upgrade", error_);
|
||||||
// Silently ignore — server errors shouldn't crash the UI
|
// Silently ignore — server errors shouldn't crash the UI
|
||||||
@@ -2177,6 +2194,14 @@ export const GameProvider = ({
|
|||||||
}
|
}
|
||||||
return applyBossResult(previous, bossId, result);
|
return applyBossResult(previous, bossId, result);
|
||||||
});
|
});
|
||||||
|
if (result.signature === undefined) {
|
||||||
|
signatureReference.current = null;
|
||||||
|
localStorage.removeItem("elysium_save_signature");
|
||||||
|
} else {
|
||||||
|
signatureReference.current = result.signature;
|
||||||
|
localStorage.setItem("elysium_save_signature", result.signature);
|
||||||
|
}
|
||||||
|
lastSaveReference.current = Date.now();
|
||||||
setBattleResult({ bossName: boss.name, result: result });
|
setBattleResult({ bossName: boss.name, result: result });
|
||||||
} catch (error_: unknown) {
|
} catch (error_: unknown) {
|
||||||
const bossErrorMessage
|
const bossErrorMessage
|
||||||
|
|||||||
+20
-20
@@ -95,29 +95,29 @@ export const PRESTIGE_COMBAT_BASE = 4;
|
|||||||
export const RESOURCE_CAP = 1e300;
|
export const RESOURCE_CAP = 1e300;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probability of quest failure per zone — scales from 10% (early game) to 40% (end game).
|
* Probability of quest failure per zone — scales from 4% (early game) to 15% (end game).
|
||||||
* On failure the quest resets to "available" with no rewards; the player must wait the
|
* On failure the quest resets to "available" with no rewards; the player must wait the
|
||||||
* full duration again on their next attempt.
|
* full duration again on their next attempt.
|
||||||
*/
|
*/
|
||||||
export const zoneFailureChance: Record<string, number> = {
|
export const zoneFailureChance: Record<string, number> = {
|
||||||
abyssal_trench: 0.24,
|
abyssal_trench: 0.09,
|
||||||
astral_void: 0.2,
|
astral_void: 0.08,
|
||||||
celestial_reaches: 0.22,
|
celestial_reaches: 0.08,
|
||||||
cosmic_maelstrom: 0.4,
|
cosmic_maelstrom: 0.15,
|
||||||
crystalline_spire: 0.28,
|
crystalline_spire: 0.11,
|
||||||
eternal_throne: 0.32,
|
eternal_throne: 0.12,
|
||||||
frozen_peaks: 0.14,
|
frozen_peaks: 0.05,
|
||||||
infernal_court: 0.26,
|
infernal_court: 0.1,
|
||||||
infinite_expanse: 0.36,
|
infinite_expanse: 0.14,
|
||||||
primeval_sanctum: 0.4,
|
primeval_sanctum: 0.15,
|
||||||
primordial_chaos: 0.34,
|
primordial_chaos: 0.13,
|
||||||
reality_forge: 0.38,
|
reality_forge: 0.14,
|
||||||
shadow_marshes: 0.16,
|
shadow_marshes: 0.06,
|
||||||
shattered_ruins: 0.12,
|
shattered_ruins: 0.05,
|
||||||
the_absolute: 0.4,
|
the_absolute: 0.15,
|
||||||
verdant_vale: 0.1,
|
verdant_vale: 0.04,
|
||||||
void_sanctum: 0.3,
|
void_sanctum: 0.11,
|
||||||
volcanic_depths: 0.18,
|
volcanic_depths: 0.07,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -451,7 +451,7 @@ export const computePartyCombatPower = (state: GameState): number => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const basePrestigeThreshold = 1_000_000;
|
const basePrestigeThreshold = 1_000_000;
|
||||||
const runestonesPerPrestigeLevelClient = 15;
|
const runestonesPerPrestigeLevelClient = 20;
|
||||||
const maxBaseRunestones = 200;
|
const maxBaseRunestones = 200;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "elysium",
|
"name": "elysium",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@elysium/types",
|
"name": "@elysium/types",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./prod/src/index.js",
|
"main": "./prod/src/index.js",
|
||||||
|
|||||||
@@ -170,6 +170,11 @@ interface BossChallengeResponse {
|
|||||||
adventurerId: string;
|
adventurerId: string;
|
||||||
killed: number;
|
killed: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HMAC-SHA256 signature of the updated state for anti-cheat chain continuity.
|
||||||
|
*/
|
||||||
|
signature?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PrestigeRequest = Record<string, never>;
|
type PrestigeRequest = Record<string, never>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
type DailyChallengeType =
|
type DailyChallengeType =
|
||||||
| "clicks"
|
| "clicks"
|
||||||
| "bossesDefeated"
|
| "bossesDefeated"
|
||||||
|
| "crafting"
|
||||||
| "questsCompleted"
|
| "questsCompleted"
|
||||||
| "prestige";
|
| "prestige";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user