Files
elysium/apps/api/src/routes/craft.ts
T
hikari e7164257c5
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m12s
feat: comprehensive balance pass (#239)
## Summary

Working through all 15 open balance tickets in a coordinated multi-pass approach.

### Pass 1 — Quest failure rates (closes #172)
- Capped all zone quest failure chances at 15% (down from up to 40%)
- Proportional scaling preserved (harder zones still fail more than easier ones)

### Pass 2 — Crystal economy (closes #165, #173, #215)
- Added `crystal_pulse` (3,000 crystals), `crystal_surge` (20,000), `crystal_tempest` (150,000) upgrades to fill the dead zone between 600 and 2M crystal sinks
- Bumped `click_deity`, `prestige_master`, and `prestige_legend` achievement crystal rewards (5K→15K, 5K→15K, 25K→75K)
- Added crystal rewards to `first_steps` (+5) and `goblin_camp` (+10) early quests

### Pass 3 — Runestone/prestige loop (closes #166, #170)
- Bumped `runestonesPerPrestigeLevel` from 15 → 20 (~33% yield increase for mid-game runs)
- Reduced `income_10` cost from 22,500 → 15,000 and `income_11` from 60,000 → 35,000
- Kept client/server parity: `runestonesPerPrestigeLevelClient` in tick.ts updated to match

### Pass 4 — Quest content (#175, #178)
- Both already resolved in commit 666a5b2: quests now reach 5e141 CP across reality_forge, cosmic_maelstrom, primeval_sanctum, and the_absolute — fully covering P60–P212

### Pass 5 — Daily challenges (closes #167)
- Added `crafting` as a new `DailyChallengeType`
- Added 3 crafting challenge templates (craft 1/2/3 recipes)
- Changed generation to guarantee: 1 clicks + 1 crafting + 1 from progression pool
- Added crafting challenge tracking in `craft.ts` (awards crystals on recipe craft)
- Stuck players now have 2/3 daily challenges always completable

### Pass 6 — Transcendence costs (#179)
- Already resolved in commit 666a5b2: echo meta costs are 15/45/100 (was 25/75/200)

### Also closed as stale
- #171 (milestone bonus already quadratic)
- #174 (production multiplier already 1.3^n)
- #176 (expanse_sovereign HP already at 3e39)
- #177 (recipe costs already in expected range)
- #178 (post-absolute quests already present)
- #179 (echo meta costs already reduced)

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #239
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-06 18:58:04 -07:00

192 lines
6.4 KiB
TypeScript

/**
* @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<HonoEnvironment>();
craftRouter.use("*", authMiddleware);
const recomputeCraftedMultipliers = (
craftedRecipeIds: Array<string>,
): {
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<CraftRecipeRequest>();
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 };