generated from nhcarrigan/template
balance: add crafting daily challenge type to unblock progression-stuck players (closes #167)
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
type DailyChallengeType =
|
type DailyChallengeType =
|
||||||
| "clicks"
|
| "clicks"
|
||||||
| "bossesDefeated"
|
| "bossesDefeated"
|
||||||
|
| "crafting"
|
||||||
| "questsCompleted"
|
| "questsCompleted"
|
||||||
| "prestige";
|
| "prestige";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user