diff --git a/apps/api/src/data/achievements.ts b/apps/api/src/data/achievements.ts index 11f3114..6a6781e 100644 --- a/apps/api/src/data/achievements.ts +++ b/apps/api/src/data/achievements.ts @@ -247,7 +247,7 @@ export const defaultAchievements: Array = [ icon: "☄️", id: "click_deity", name: "Click Deity", - reward: { crystals: 5000 }, + reward: { crystals: 15_000 }, unlockedAt: null, }, // Endgame gold milestones @@ -405,7 +405,7 @@ export const defaultAchievements: Array = [ icon: "💫", id: "prestige_master", name: "Master of Cycles", - reward: { crystals: 5000 }, + reward: { crystals: 15_000 }, unlockedAt: null, }, { @@ -414,7 +414,7 @@ export const defaultAchievements: Array = [ icon: "🌠", id: "prestige_legend", name: "Legend of Eternity", - reward: { crystals: 25_000 }, + reward: { crystals: 75_000 }, unlockedAt: null, }, { diff --git a/apps/api/src/data/dailyChallenges.ts b/apps/api/src/data/dailyChallenges.ts index 1f4bb8f..aa2d4db 100644 --- a/apps/api/src/data/dailyChallenges.ts +++ b/apps/api/src/data/dailyChallenges.ts @@ -28,6 +28,20 @@ export const dailyChallengeTemplates: Array = [ target: 5000, 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 { label: "Defeat 1 boss", diff --git a/apps/api/src/data/prestigeUpgrades.ts b/apps/api/src/data/prestigeUpgrades.ts index 6d33eb0..d314cbc 100644 --- a/apps/api/src/data/prestigeUpgrades.ts +++ b/apps/api/src/data/prestigeUpgrades.ts @@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array = [ id: "income_10", multiplier: 200, name: "Eternal Rune I", - runestonesCost: 22_500, + runestonesCost: 15_000, }, { category: "income", @@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array = [ id: "income_11", multiplier: 500, name: "Eternal Rune II", - runestonesCost: 60_000, + runestonesCost: 35_000, }, // ── Click Power ─────────────────────────────────────────────────────────── { diff --git a/apps/api/src/data/quests.ts b/apps/api/src/data/quests.ts index fac9c44..e103f4f 100644 --- a/apps/api/src/data/quests.ts +++ b/apps/api/src/data/quests.ts @@ -19,6 +19,7 @@ export const defaultQuests: Array = [ prerequisiteIds: [], rewards: [ { amount: 500, type: "gold" }, + { amount: 5, type: "crystals" }, { targetId: "militia", type: "adventurer" }, ], status: "available", @@ -33,6 +34,7 @@ export const defaultQuests: Array = [ rewards: [ { amount: 2000, type: "gold" }, { amount: 5, type: "essence" }, + { amount: 10, type: "crystals" }, { targetId: "peasant_1", type: "upgrade" }, { targetId: "apprentice_1", type: "upgrade" }, { targetId: "apprentice", type: "adventurer" }, diff --git a/apps/api/src/data/upgrades.ts b/apps/api/src/data/upgrades.ts index afbd696..665cd05 100644 --- a/apps/api/src/data/upgrades.ts +++ b/apps/api/src/data/upgrades.ts @@ -496,6 +496,43 @@ export const defaultUpgrades: Array = [ unlocked: false, }, // ── 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, costEssence: 5_000_000, diff --git a/apps/api/src/routes/craft.ts b/apps/api/src/routes/craft.ts index 88f44ae..671fc27 100644 --- a/apps/api/src/routes/craft.ts +++ b/apps/api/src/routes/craft.ts @@ -11,6 +11,7 @@ import { Hono } from "hono"; import { defaultRecipes } from "../data/recipes.js"; import { prisma } from "../db/client.js"; import { authMiddleware } from "../middleware/auth.js"; +import { updateChallengeProgress } from "../services/dailyChallenges.js"; import { logger } from "../services/logger.js"; import type { HonoEnvironment } from "../types/hono.js"; import type { @@ -138,6 +139,16 @@ craftRouter.post("/", async(context) => { state.exploration.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({ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */ data: { state: state as object, updatedAt: Date.now() }, diff --git a/apps/api/src/services/dailyChallenges.ts b/apps/api/src/services/dailyChallenges.ts index 598ebc3..15d43fc 100644 --- a/apps/api/src/services/dailyChallenges.ts +++ b/apps/api/src/services/dailyChallenges.ts @@ -71,6 +71,10 @@ const shuffleWithSeed = (array: Array, seed: number): Array => { return result; }; +const nonProgressionChallengeTypes: Array = [ + "crafting", +]; + const progressionChallengeTypes: Array = [ "bossesDefeated", "questsCompleted", @@ -79,8 +83,10 @@ const progressionChallengeTypes: Array = [ /** * Generates 3 daily challenges for the given date string, deterministically. - * Always includes a "clicks" challenge (always completable regardless of - * progression), then picks 2 more from the remaining types. + * Always includes a "clicks" challenge and a "crafting" challenge (both + * 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. * @returns An array of 3 DailyChallenge objects. */ @@ -90,7 +96,14 @@ const generateDailyChallenges = ( const seed = dateSeed(dateString); const selectedTypes: Array = [ "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) => { diff --git a/apps/api/src/services/prestige.ts b/apps/api/src/services/prestige.ts index 4fd106e..6ec8d2b 100644 --- a/apps/api/src/services/prestige.ts +++ b/apps/api/src/services/prestige.ts @@ -15,7 +15,7 @@ import type { } from "@elysium/types"; const basePrestigeGoldThreshold = 1_000_000; -const runestonesPerPrestigeLevel = 15; +const runestonesPerPrestigeLevel = 20; const milestoneInterval = 5; const milestoneRunestonesPerInterval = 25; diff --git a/apps/api/test/routes/craft.spec.ts b/apps/api/test/routes/craft.spec.ts index 9e2d5f0..c3ae580 100644 --- a/apps/api/test/routes/craft.spec.ts +++ b/apps/api/test/routes/craft.spec.ts @@ -144,6 +144,34 @@ describe("craft route", () => { 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 () => { vi.mocked(prisma.gameState.findUnique).mockRejectedValueOnce(new Error("DB error")); const res = await post({ recipeId: TEST_RECIPE_ID }); diff --git a/apps/api/test/services/dailyChallenges.spec.ts b/apps/api/test/services/dailyChallenges.spec.ts index d0b43a9..0e40b18 100644 --- a/apps/api/test/services/dailyChallenges.spec.ts +++ b/apps/api/test/services/dailyChallenges.spec.ts @@ -55,15 +55,28 @@ describe("generateDailyChallenges", () => { 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); const { generateDailyChallenges } = await import("../../src/services/dailyChallenges.js"); const day1 = generateDailyChallenges("2024-01-15"); const day2 = generateDailyChallenges("2024-01-16"); - // The 2 non-clicks types should vary by seed between dates - const day1NonClicks = day1.filter((c) => c.type !== "clicks").map((c) => c.type); - const day2NonClicks = day2.filter((c) => c.type !== "clicks").map((c) => c.type); - expect(day1NonClicks).not.toEqual(day2NonClicks); + expect(day1.some((c) => c.type === "crafting")).toBe(true); + expect(day2.some((c) => c.type === "crafting")).toBe(true); + }); + + 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); }); }); diff --git a/apps/api/test/services/prestige.spec.ts b/apps/api/test/services/prestige.spec.ts index 90fbddf..95e71bc 100644 --- a/apps/api/test/services/prestige.spec.ts +++ b/apps/api/test/services/prestige.spec.ts @@ -102,25 +102,25 @@ describe("isEligibleForPrestige", () => { describe("calculateRunestones", () => { 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: [] }); - expect(result).toBe(15); + expect(result).toBe(20); }); 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 }); - expect(result).toBe(30); + expect(result).toBe(40); }); 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"] }); - expect(result).toBe(18); + expect(result).toBe(25); }); 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: [] }); expect(result).toBe(200); }); diff --git a/apps/web/src/engine/tick.ts b/apps/web/src/engine/tick.ts index a53cf0c..6596555 100644 --- a/apps/web/src/engine/tick.ts +++ b/apps/web/src/engine/tick.ts @@ -95,29 +95,29 @@ export const PRESTIGE_COMBAT_BASE = 4; 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 * full duration again on their next attempt. */ export const zoneFailureChance: Record = { - abyssal_trench: 0.24, - astral_void: 0.2, - celestial_reaches: 0.22, - cosmic_maelstrom: 0.4, - crystalline_spire: 0.28, - eternal_throne: 0.32, - frozen_peaks: 0.14, - infernal_court: 0.26, - infinite_expanse: 0.36, - primeval_sanctum: 0.4, - primordial_chaos: 0.34, - reality_forge: 0.38, - shadow_marshes: 0.16, - shattered_ruins: 0.12, - the_absolute: 0.4, - verdant_vale: 0.1, - void_sanctum: 0.3, - volcanic_depths: 0.18, + abyssal_trench: 0.09, + astral_void: 0.08, + celestial_reaches: 0.08, + cosmic_maelstrom: 0.15, + crystalline_spire: 0.11, + eternal_throne: 0.12, + frozen_peaks: 0.05, + infernal_court: 0.1, + infinite_expanse: 0.14, + primeval_sanctum: 0.15, + primordial_chaos: 0.13, + reality_forge: 0.14, + shadow_marshes: 0.06, + shattered_ruins: 0.05, + the_absolute: 0.15, + verdant_vale: 0.04, + void_sanctum: 0.11, + volcanic_depths: 0.07, }; /** @@ -451,7 +451,7 @@ export const computePartyCombatPower = (state: GameState): number => { }; const basePrestigeThreshold = 1_000_000; -const runestonesPerPrestigeLevelClient = 15; +const runestonesPerPrestigeLevelClient = 20; const maxBaseRunestones = 200; /** diff --git a/packages/types/src/interfaces/dailyChallenge.ts b/packages/types/src/interfaces/dailyChallenge.ts index 0ea947f..38f1383 100644 --- a/packages/types/src/interfaces/dailyChallenge.ts +++ b/packages/types/src/interfaces/dailyChallenge.ts @@ -8,6 +8,7 @@ type DailyChallengeType = | "clicks" | "bossesDefeated" + | "crafting" | "questsCompleted" | "prestige";