generated from nhcarrigan/template
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 commit666a5b2: 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 commit666a5b2: 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:
@@ -247,7 +247,7 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
icon: "☄️",
|
||||
id: "click_deity",
|
||||
name: "Click Deity",
|
||||
reward: { crystals: 5000 },
|
||||
reward: { crystals: 15_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
// Endgame gold milestones
|
||||
@@ -405,7 +405,7 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
icon: "💫",
|
||||
id: "prestige_master",
|
||||
name: "Master of Cycles",
|
||||
reward: { crystals: 5000 },
|
||||
reward: { crystals: 15_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
@@ -414,7 +414,7 @@ export const defaultAchievements: Array<Achievement> = [
|
||||
icon: "🌠",
|
||||
id: "prestige_legend",
|
||||
name: "Legend of Eternity",
|
||||
reward: { crystals: 25_000 },
|
||||
reward: { crystals: 75_000 },
|
||||
unlockedAt: null,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -28,6 +28,20 @@ export const dailyChallengeTemplates: Array<DailyChallengeTemplate> = [
|
||||
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",
|
||||
|
||||
@@ -96,7 +96,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
||||
id: "income_10",
|
||||
multiplier: 200,
|
||||
name: "Eternal Rune I",
|
||||
runestonesCost: 22_500,
|
||||
runestonesCost: 15_000,
|
||||
},
|
||||
{
|
||||
category: "income",
|
||||
@@ -105,7 +105,7 @@ export const defaultPrestigeUpgrades: Array<PrestigeUpgrade> = [
|
||||
id: "income_11",
|
||||
multiplier: 500,
|
||||
name: "Eternal Rune II",
|
||||
runestonesCost: 60_000,
|
||||
runestonesCost: 35_000,
|
||||
},
|
||||
// ── Click Power ───────────────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -19,6 +19,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
prerequisiteIds: [],
|
||||
rewards: [
|
||||
{ amount: 500, type: "gold" },
|
||||
{ amount: 5, type: "crystals" },
|
||||
{ targetId: "militia", type: "adventurer" },
|
||||
],
|
||||
status: "available",
|
||||
@@ -33,6 +34,7 @@ export const defaultQuests: Array<Quest> = [
|
||||
rewards: [
|
||||
{ amount: 2000, type: "gold" },
|
||||
{ amount: 5, type: "essence" },
|
||||
{ amount: 10, type: "crystals" },
|
||||
{ targetId: "peasant_1", type: "upgrade" },
|
||||
{ targetId: "apprentice_1", type: "upgrade" },
|
||||
{ targetId: "apprentice", type: "adventurer" },
|
||||
|
||||
@@ -496,6 +496,43 @@ export const defaultUpgrades: Array<Upgrade> = [
|
||||
unlocked: false,
|
||||
},
|
||||
// ── 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,
|
||||
costEssence: 5_000_000,
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -71,6 +71,10 @@ const shuffleWithSeed = <T>(array: Array<T>, seed: number): Array<T> => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const nonProgressionChallengeTypes: Array<DailyChallengeType> = [
|
||||
"crafting",
|
||||
];
|
||||
|
||||
const progressionChallengeTypes: Array<DailyChallengeType> = [
|
||||
"bossesDefeated",
|
||||
"questsCompleted",
|
||||
@@ -79,8 +83,10 @@ const progressionChallengeTypes: Array<DailyChallengeType> = [
|
||||
|
||||
/**
|
||||
* 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<DailyChallengeType> = [
|
||||
"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) => {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from "@elysium/types";
|
||||
|
||||
const basePrestigeGoldThreshold = 1_000_000;
|
||||
const runestonesPerPrestigeLevel = 15;
|
||||
const runestonesPerPrestigeLevel = 20;
|
||||
const milestoneInterval = 5;
|
||||
const milestoneRunestonesPerInterval = 25;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -102,25 +102,25 @@ describe("isEligibleForPrestige", () => {
|
||||
|
||||
describe("calculateRunestones", () => {
|
||||
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: [] });
|
||||
expect(result).toBe(15);
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
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 });
|
||||
expect(result).toBe(30);
|
||||
expect(result).toBe(40);
|
||||
});
|
||||
|
||||
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"] });
|
||||
expect(result).toBe(18);
|
||||
expect(result).toBe(25);
|
||||
});
|
||||
|
||||
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: [] });
|
||||
expect(result).toBe(200);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user