feat: comprehensive balance pass (#239)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m9s
CI / Lint, Build & Test (push) Successful in 1m12s

## 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>
This commit was merged in pull request #239.
This commit is contained in:
2026-04-06 18:58:04 -07:00
committed by Naomi Carrigan
parent 1195b657a0
commit e7164257c5
13 changed files with 160 additions and 41 deletions
+3 -3
View File
@@ -247,7 +247,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "☄️", icon: "☄️",
id: "click_deity", id: "click_deity",
name: "Click Deity", name: "Click Deity",
reward: { crystals: 5000 }, reward: { crystals: 15_000 },
unlockedAt: null, unlockedAt: null,
}, },
// Endgame gold milestones // Endgame gold milestones
@@ -405,7 +405,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "💫", icon: "💫",
id: "prestige_master", id: "prestige_master",
name: "Master of Cycles", name: "Master of Cycles",
reward: { crystals: 5000 }, reward: { crystals: 15_000 },
unlockedAt: null, unlockedAt: null,
}, },
{ {
@@ -414,7 +414,7 @@ export const defaultAchievements: Array<Achievement> = [
icon: "🌠", icon: "🌠",
id: "prestige_legend", id: "prestige_legend",
name: "Legend of Eternity", name: "Legend of Eternity",
reward: { crystals: 25_000 }, reward: { crystals: 75_000 },
unlockedAt: null, unlockedAt: null,
}, },
{ {
+14
View File
@@ -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",
+2 -2
View File
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_10", id: "income_10",
multiplier: 200, multiplier: 200,
name: "Eternal Rune I", name: "Eternal Rune I",
runestonesCost: 22_500, runestonesCost: 15_000,
}, },
{ {
category: "income", category: "income",
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
id: "income_11", id: "income_11",
multiplier: 500, multiplier: 500,
name: "Eternal Rune II", name: "Eternal Rune II",
runestonesCost: 60_000, runestonesCost: 35_000,
}, },
// ── Click Power ─────────────────────────────────────────────────────────── // ── Click Power ───────────────────────────────────────────────────────────
{ {
+2
View File
@@ -19,6 +19,7 @@ export const defaultQuests: Array<Quest> = [
prerequisiteIds: [], prerequisiteIds: [],
rewards: [ rewards: [
{ amount: 500, type: "gold" }, { amount: 500, type: "gold" },
{ amount: 5, type: "crystals" },
{ targetId: "militia", type: "adventurer" }, { targetId: "militia", type: "adventurer" },
], ],
status: "available", status: "available",
@@ -33,6 +34,7 @@ export const defaultQuests: Array<Quest> = [
rewards: [ rewards: [
{ amount: 2000, type: "gold" }, { amount: 2000, type: "gold" },
{ amount: 5, type: "essence" }, { amount: 5, type: "essence" },
{ amount: 10, type: "crystals" },
{ targetId: "peasant_1", type: "upgrade" }, { targetId: "peasant_1", type: "upgrade" },
{ targetId: "apprentice_1", type: "upgrade" }, { targetId: "apprentice_1", type: "upgrade" },
{ targetId: "apprentice", type: "adventurer" }, { targetId: "apprentice", type: "adventurer" },
+37
View File
@@ -496,6 +496,43 @@ export const defaultUpgrades: Array<Upgrade> = [
unlocked: false, unlocked: false,
}, },
// ── Purchasable essence/crystal sink upgrades ───────────────────────────── // ── 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, costCrystals: 0,
costEssence: 5_000_000, costEssence: 5_000_000,
+11
View File
@@ -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() },
+16 -3
View File
@@ -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) => {
+1 -1
View File
@@ -15,7 +15,7 @@ import type {
} from "@elysium/types"; } from "@elysium/types";
const basePrestigeGoldThreshold = 1_000_000; const basePrestigeGoldThreshold = 1_000_000;
const runestonesPerPrestigeLevel = 15; const runestonesPerPrestigeLevel = 20;
const milestoneInterval = 5; const milestoneInterval = 5;
const milestoneRunestonesPerInterval = 25; const milestoneRunestonesPerInterval = 25;
+28
View File
@@ -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 });
+18 -5
View File
@@ -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);
}); });
}); });
+7 -7
View File
@@ -102,25 +102,25 @@ describe("isEligibleForPrestige", () => {
describe("calculateRunestones", () => { describe("calculateRunestones", () => {
it("calculates basic runestones formula", () => { 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: [] }); const result = calculateRunestones({ totalGoldEarned: 4_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(15); expect(result).toBe(20);
}); });
it("applies echo runestone multiplier", () => { 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 }); 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", () => { 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"] }); 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", () => { 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: [] }); const result = calculateRunestones({ totalGoldEarned: 9_261_000_000, prestigeCount: 0, purchasedUpgradeIds: [] });
expect(result).toBe(200); expect(result).toBe(200);
}); });
+20 -20
View File
@@ -95,29 +95,29 @@ export const PRESTIGE_COMBAT_BASE = 4;
export const RESOURCE_CAP = 1e300; 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 * On failure the quest resets to "available" with no rewards; the player must wait the
* full duration again on their next attempt. * full duration again on their next attempt.
*/ */
export const zoneFailureChance: Record<string, number> = { export const zoneFailureChance: Record<string, number> = {
abyssal_trench: 0.24, abyssal_trench: 0.09,
astral_void: 0.2, astral_void: 0.08,
celestial_reaches: 0.22, celestial_reaches: 0.08,
cosmic_maelstrom: 0.4, cosmic_maelstrom: 0.15,
crystalline_spire: 0.28, crystalline_spire: 0.11,
eternal_throne: 0.32, eternal_throne: 0.12,
frozen_peaks: 0.14, frozen_peaks: 0.05,
infernal_court: 0.26, infernal_court: 0.1,
infinite_expanse: 0.36, infinite_expanse: 0.14,
primeval_sanctum: 0.4, primeval_sanctum: 0.15,
primordial_chaos: 0.34, primordial_chaos: 0.13,
reality_forge: 0.38, reality_forge: 0.14,
shadow_marshes: 0.16, shadow_marshes: 0.06,
shattered_ruins: 0.12, shattered_ruins: 0.05,
the_absolute: 0.4, the_absolute: 0.15,
verdant_vale: 0.1, verdant_vale: 0.04,
void_sanctum: 0.3, void_sanctum: 0.11,
volcanic_depths: 0.18, volcanic_depths: 0.07,
}; };
/** /**
@@ -451,7 +451,7 @@ export const computePartyCombatPower = (state: GameState): number => {
}; };
const basePrestigeThreshold = 1_000_000; const basePrestigeThreshold = 1_000_000;
const runestonesPerPrestigeLevelClient = 15; const runestonesPerPrestigeLevelClient = 20;
const maxBaseRunestones = 200; const maxBaseRunestones = 200;
/** /**
@@ -8,6 +8,7 @@
type DailyChallengeType = type DailyChallengeType =
| "clicks" | "clicks"
| "bossesDefeated" | "bossesDefeated"
| "crafting"
| "questsCompleted" | "questsCompleted"
| "prestige"; | "prestige";