generated from nhcarrigan/template
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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() },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
+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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
type DailyChallengeType =
|
type DailyChallengeType =
|
||||||
| "clicks"
|
| "clicks"
|
||||||
| "bossesDefeated"
|
| "bossesDefeated"
|
||||||
|
| "crafting"
|
||||||
| "questsCompleted"
|
| "questsCompleted"
|
||||||
| "prestige";
|
| "prestige";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user