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/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/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/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";