/** * @file Crafting route handling recipe crafting mechanics. * @copyright nhcarrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Route handler requires many steps */ /* eslint-disable max-statements -- Route handler requires many statements */ /* eslint-disable complexity -- Route handler has inherent complexity */ 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 { CraftRecipeRequest, CraftRecipeResponse, GameState, } from "@elysium/types"; const craftRouter = new Hono(); craftRouter.use("*", authMiddleware); const recomputeCraftedMultipliers = ( craftedRecipeIds: Array, ): { craftedGoldMultiplier: number; craftedEssenceMultiplier: number; craftedClickMultiplier: number; craftedCombatMultiplier: number; } => { return { craftedClickMultiplier: defaultRecipes.filter((r) => { return craftedRecipeIds.includes(r.id) && r.bonus.type === "click_power"; }).reduce((mult, r) => { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ return mult * r.bonus.value; }, 1), craftedCombatMultiplier: defaultRecipes.filter((r) => { return craftedRecipeIds.includes(r.id) && r.bonus.type === "combat_power"; }).reduce((mult, r) => { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ return mult * r.bonus.value; }, 1), craftedEssenceMultiplier: defaultRecipes.filter((r) => { return ( craftedRecipeIds.includes(r.id) && r.bonus.type === "essence_income" ); }).reduce((mult, r) => { // eslint-disable-next-line capitalized-comments -- v8 ignore /* v8 ignore next -- @preserve */ return mult * r.bonus.value; }, 1), craftedGoldMultiplier: defaultRecipes.filter((r) => { return craftedRecipeIds.includes(r.id) && r.bonus.type === "gold_income"; }).reduce((mult, r) => { return mult * r.bonus.value; }, 1), }; }; craftRouter.post("/", async(context) => { try { const discordId = context.get("discordId"); const body = await context.req.json(); const { recipeId } = body; // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation if (!recipeId) { return context.json({ error: "recipeId is required" }, 400); } const recipe = defaultRecipes.find((r) => { return r.id === recipeId; }); if (!recipe) { return context.json({ error: "Unknown recipe" }, 404); } const record = await prisma.gameState.findUnique({ where: { discordId } }); if (!record) { return context.json({ error: "No save found" }, 404); } const rawState: unknown = record.state; /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */ const state = rawState as GameState; if (!state.exploration) { return context.json({ error: "No exploration state found" }, 400); } if (state.exploration.craftedRecipeIds.includes(recipeId)) { return context.json({ error: "Recipe already crafted" }, 400); } // Verify the player has all required materials for (const requirement of recipe.requiredMaterials) { const material = state.exploration.materials.find((m) => { return m.materialId === requirement.materialId; }); const quantity = material?.quantity ?? 0; if (quantity < requirement.quantity) { return context.json( { error: `Not enough ${requirement.materialId} (need ${String(requirement.quantity)}, have ${String(quantity)})`, }, 400, ); } } // Deduct materials for (const requirement of recipe.requiredMaterials) { const material = state.exploration.materials.find((m) => { return m.materialId === requirement.materialId; }); if (material) { material.quantity = material.quantity - requirement.quantity; } } // Add recipe and recompute all multipliers from scratch state.exploration.craftedRecipeIds.push(recipeId); const updatedMultipliers = recomputeCraftedMultipliers( state.exploration.craftedRecipeIds, ); state.exploration.craftedGoldMultiplier = updatedMultipliers.craftedGoldMultiplier; state.exploration.craftedEssenceMultiplier = updatedMultipliers.craftedEssenceMultiplier; state.exploration.craftedClickMultiplier = updatedMultipliers.craftedClickMultiplier; 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() }, where: { discordId }, }); void logger.metric("recipe_crafted", 1, { discordId, recipeId }); const bonusType = recipe.bonus.type; const bonusValue = recipe.bonus.value; const { materials } = state.exploration; const { craftedGoldMultiplier, craftedEssenceMultiplier, craftedClickMultiplier, craftedCombatMultiplier, } = updatedMultipliers; const response: CraftRecipeResponse = { bonusType, bonusValue, craftedClickMultiplier, craftedCombatMultiplier, craftedEssenceMultiplier, craftedGoldMultiplier, materials, recipeId, }; return context.json(response); } catch (error) { void logger.error( "craft", error instanceof Error ? error : new Error(String(error)), ); return context.json({ error: "Internal server error" }, 500); } }); export { craftRouter };