feat: add exploration and crafting systems

Adds two new game systems: Exploration (scouts collect materials from
timed area runs) and Crafting (combine materials into permanent
multipliers). Includes 72 exploration areas, 54 materials, 36 recipes,
and 108 new Codex lore entries. Removes unused characterName requirement
from prestige/transcendence/apotheosis reset flows.
This commit is contained in:
2026-03-07 04:14:04 -08:00
committed by Naomi Carrigan
parent 2aa6362ad6
commit 6ddf8e0b43
35 changed files with 4722 additions and 94 deletions
+3 -9
View File
@@ -1,4 +1,4 @@
import type { ApotheosisRequest, GameState } from "@elysium/types";
import type { GameState } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
@@ -14,12 +14,6 @@ apotheosisRouter.use("*", authMiddleware);
apotheosisRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<ApotheosisRequest>();
const characterName = body.characterName?.trim();
if (!characterName) {
return context.json({ error: "characterName is required" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
@@ -41,7 +35,7 @@ apotheosisRouter.post("/", async (context) => {
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => sum + a.count, 0);
const runAchievementsUnlocked = (state.achievements ?? []).filter((a) => a.unlockedAt !== null).length;
const { newState, newApotheosisData } = buildPostApotheosisState(state, characterName);
const { newState, newApotheosisData } = buildPostApotheosisState(state, state.player.characterName);
const now = Date.now();
await prisma.gameState.update({
@@ -52,7 +46,7 @@ apotheosisRouter.post("/", async (context) => {
await prisma.player.update({
where: { discordId },
data: {
characterName,
characterName: state.player.characterName,
// Reset current-run counters
totalGoldEarned: 0,
totalClicks: 0,
+2 -1
View File
@@ -60,7 +60,8 @@ const calculatePartyStats = (
}
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier * echoCombatMultiplier;
const craftedCombatMultiplier = state.exploration?.craftedCombatMultiplier ?? 1;
partyDPS *= equipmentCombatMultiplier * setCombatMultiplier * echoCombatMultiplier * craftedCombatMultiplier;
return { partyDPS, partyMaxHp };
};
+103
View File
@@ -0,0 +1,103 @@
import type { CraftRecipeRequest, CraftRecipeResponse, GameState } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
import { DEFAULT_RECIPES } from "../data/recipes.js";
import { authMiddleware } from "../middleware/auth.js";
export const craftRouter = new Hono<HonoEnv>();
craftRouter.use("*", authMiddleware);
const recomputeCraftedMultipliers = (
craftedRecipeIds: string[],
): {
craftedGoldMultiplier: number;
craftedEssenceMultiplier: number;
craftedClickMultiplier: number;
craftedCombatMultiplier: number;
} => ({
craftedGoldMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "gold_income")
.reduce((mult, r) => mult * r.bonus.value, 1),
craftedEssenceMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "essence_income")
.reduce((mult, r) => mult * r.bonus.value, 1),
craftedClickMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "click_power")
.reduce((mult, r) => mult * r.bonus.value, 1),
craftedCombatMultiplier: DEFAULT_RECIPES
.filter((r) => craftedRecipeIds.includes(r.id) && r.bonus.type === "combat_power")
.reduce((mult, r) => mult * r.bonus.value, 1),
});
craftRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<CraftRecipeRequest>();
const { recipeId } = body;
if (!recipeId) {
return context.json({ error: "recipeId is required" }, 400);
}
const recipe = DEFAULT_RECIPES.find((r) => 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 state = record.state as unknown 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) => 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) => m.materialId === requirement.materialId);
if (material) {
material.quantity -= requirement.quantity;
}
}
// Add recipe and recompute all multipliers from scratch
state.exploration.craftedRecipeIds.push(recipeId);
const newMultipliers = recomputeCraftedMultipliers(state.exploration.craftedRecipeIds);
state.exploration.craftedGoldMultiplier = newMultipliers.craftedGoldMultiplier;
state.exploration.craftedEssenceMultiplier = newMultipliers.craftedEssenceMultiplier;
state.exploration.craftedClickMultiplier = newMultipliers.craftedClickMultiplier;
state.exploration.craftedCombatMultiplier = newMultipliers.craftedCombatMultiplier;
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: Date.now() },
});
const response: CraftRecipeResponse = {
recipeId,
bonusType: recipe.bonus.type,
bonusValue: recipe.bonus.value,
...newMultipliers,
};
return context.json(response);
});
+263
View File
@@ -0,0 +1,263 @@
import type {
ExploreCollectEventResult,
ExploreCollectRequest,
ExploreCollectResponse,
ExploreStartRequest,
ExploreStartResponse,
GameState,
} from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
import { DEFAULT_EXPLORATIONS } from "../data/explorations.js";
import { INITIAL_EXPLORATION } from "../data/initialState.js";
import { authMiddleware } from "../middleware/auth.js";
export const exploreRouter = new Hono<HonoEnv>();
exploreRouter.use("*", authMiddleware);
const NOTHING_PROBABILITY = 0.2;
const NOTHING_MESSAGES = [
"Your scouts searched thoroughly but found nothing of value.",
"The area yielded nothing remarkable this time.",
"Your scouts returned empty-handed.",
"A wasted journey — the area proved barren.",
"Nothing to show for the effort. Perhaps next time.",
];
const pickNothingMessage = (): string =>
NOTHING_MESSAGES[Math.floor(Math.random() * NOTHING_MESSAGES.length)] ?? NOTHING_MESSAGES[0]!;
exploreRouter.post("/start", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<ExploreStartRequest>();
const { areaId } = body;
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = DEFAULT_EXPLORATIONS.find((a) => a.id === areaId);
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
// Backfill exploration state for old saves that predate this feature
if (!state.exploration) {
state.exploration = structuredClone(INITIAL_EXPLORATION);
// Unlock areas for zones already unlocked in this save
for (const area of state.exploration.areas) {
const areaData = DEFAULT_EXPLORATIONS.find((e) => e.id === area.id);
if (!areaData) continue;
const zone = state.zones.find((z) => z.id === areaData.zoneId);
if (zone?.status === "unlocked") {
area.status = "available";
}
}
}
const zone = state.zones.find((z) => z.id === explorationArea.zoneId);
if (!zone || zone.status !== "unlocked") {
return context.json({ error: "Zone is not unlocked" }, 400);
}
const area = state.exploration.areas.find((a) => a.id === areaId);
if (!area) {
return context.json({ error: "Exploration area not found in state" }, 404);
}
if (area.status === "in_progress") {
return context.json({ error: "Exploration already in progress" }, 400);
}
if (area.status === "locked") {
return context.json({ error: "Exploration area is locked" }, 400);
}
const now = Date.now();
area.status = "in_progress";
area.startedAt = now;
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: now },
});
const response: ExploreStartResponse = {
areaId,
endsAt: now + explorationArea.durationSeconds * 1000,
};
return context.json(response);
});
exploreRouter.post("/collect", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<ExploreCollectRequest>();
const { areaId } = body;
if (!areaId) {
return context.json({ error: "areaId is required" }, 400);
}
const explorationArea = DEFAULT_EXPLORATIONS.find((a) => a.id === areaId);
if (!explorationArea) {
return context.json({ error: "Unknown exploration area" }, 404);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
return context.json({ error: "No save found" }, 404);
}
const state = record.state as unknown as GameState;
if (!state.exploration) {
return context.json({ error: "No exploration state found" }, 400);
}
const area = state.exploration.areas.find((a) => a.id === areaId);
if (!area) {
return context.json({ error: "Exploration area not found" }, 404);
}
if (area.status !== "in_progress") {
return context.json({ error: "Exploration is not in progress" }, 400);
}
const now = Date.now();
const startedAt = area.startedAt ?? 0;
const durationMs = explorationArea.durationSeconds * 1000;
if (now < startedAt + durationMs) {
return context.json({ error: "Exploration is not yet complete" }, 400);
}
area.status = "available";
area.completedOnce = true;
// 20% chance of finding nothing
if (Math.random() < NOTHING_PROBABILITY) {
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: now },
});
const response: ExploreCollectResponse = {
foundNothing: true,
nothingMessage: pickNothingMessage(),
materialsFound: [],
event: null,
};
return context.json(response);
}
// Pick a random event
const event = explorationArea.events[Math.floor(Math.random() * explorationArea.events.length)]!;
// Apply event effects and build the result summary
let goldChange = 0;
let essenceChange = 0;
let materialGained: { materialId: string; quantity: number } | null = null;
if (event.effect.type === "gold_gain") {
const amount = event.effect.amount ?? 0;
state.resources.gold += amount;
state.player.totalGoldEarned += amount;
goldChange = amount;
} else if (event.effect.type === "gold_loss") {
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
state.resources.gold -= amount;
goldChange = -amount;
} else if (event.effect.type === "essence_gain") {
const amount = event.effect.amount ?? 0;
state.resources.essence += amount;
essenceChange = amount;
} else if (event.effect.type === "material_gain") {
const materialId = event.effect.materialId;
const quantity = event.effect.quantity ?? 1;
if (materialId) {
const existing = state.exploration.materials.find((m) => m.materialId === materialId);
if (existing) {
existing.quantity += quantity;
} else {
state.exploration.materials.push({ materialId, quantity });
}
materialGained = { materialId, quantity };
}
} else if (event.effect.type === "adventurer_loss") {
const fraction = event.effect.fraction ?? 0.05;
let totalLost = 0;
for (const adventurer of state.adventurers) {
const lost = Math.floor(adventurer.count * fraction);
if (lost > 0) {
adventurer.count = Math.max(0, adventurer.count - lost);
totalLost += lost;
}
}
// adventurerLostCount captured below
}
const adventurerLostCount =
event.effect.type === "adventurer_loss"
? state.adventurers.reduce((sum, a) => {
const fraction = event.effect.fraction ?? 0.05;
return sum + Math.floor(a.count * fraction);
}, 0)
: 0;
const eventResult: ExploreCollectEventResult = {
text: event.text,
goldChange,
essenceChange,
materialGained,
adventurerLostCount,
};
// Roll for material drops from possibleMaterials (weighted random selection)
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
if (explorationArea.possibleMaterials.length > 0) {
const totalWeight = explorationArea.possibleMaterials.reduce((sum, m) => sum + m.weight, 0);
let roll = Math.random() * totalWeight;
for (const possible of explorationArea.possibleMaterials) {
roll -= possible.weight;
if (roll <= 0) {
const quantity =
Math.floor(Math.random() * (possible.maxQuantity - possible.minQuantity + 1)) +
possible.minQuantity;
const existing = state.exploration.materials.find((m) => m.materialId === possible.materialId);
if (existing) {
existing.quantity += quantity;
} else {
state.exploration.materials.push({ materialId: possible.materialId, quantity });
}
materialsFound.push({ materialId: possible.materialId, quantity });
break;
}
}
}
await prisma.gameState.update({
where: { discordId },
data: { state: state as object, updatedAt: now },
});
const response: ExploreCollectResponse = {
foundNothing: false,
materialsFound,
event: eventResult,
};
return context.json(response);
});
+67 -3
View File
@@ -9,6 +9,8 @@ import { DEFAULT_ADVENTURERS } from "../data/adventurers.js";
import { DEFAULT_BOSSES } from "../data/bosses.js";
import { DEFAULT_EQUIPMENT } from "../data/equipment.js";
import { DEFAULT_EQUIPMENT_SETS } from "../data/equipmentSets.js";
import { DEFAULT_EXPLORATIONS } from "../data/explorations.js";
import { INITIAL_EXPLORATION } from "../data/initialState.js";
import { DEFAULT_QUESTS } from "../data/quests.js";
import { authMiddleware } from "../middleware/auth.js";
import { getOrResetDailyChallenges } from "../services/dailyChallenges.js";
@@ -51,6 +53,8 @@ const computeMaxPassiveIncome = (
const setGoldMultiplier = computeSetBonuses(equippedItemIds, DEFAULT_EQUIPMENT_SETS).goldMultiplier;
const runestonesIncome = state.prestige.runestonesIncomeMultiplier ?? 1;
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
const craftedGoldMultiplier = state.exploration?.craftedGoldMultiplier ?? 1;
const craftedEssenceMultiplier = state.exploration?.craftedEssenceMultiplier ?? 1;
let goldPerSecond = 0;
let essencePerSecond = 0;
@@ -76,14 +80,16 @@ const computeMaxPassiveIncome = (
prestige *
runestonesIncome *
equipmentGoldMultiplier *
setGoldMultiplier;
setGoldMultiplier *
craftedGoldMultiplier;
essencePerSecond +=
adventurer.essencePerSecond *
adventurer.count *
upgradeMultiplier *
prestige *
runestonesEssence;
runestonesEssence *
craftedEssenceMultiplier;
}
return { goldPerSecond, essencePerSecond };
@@ -309,7 +315,38 @@ const validateAndSanitize = (incoming: GameState, previous: GameState): GameStat
? { apotheosis: previous.apotheosis }
: {};
return { ...incoming, resources, bosses, quests, achievements, prestige, ...transcendenceSpread, ...apotheosisSpread };
// Exploration: materials and crafted recipes can only be added server-side.
// Cap material quantities and crafted recipe IDs at the previous DB values to block inflation.
// Crafted multipliers are always derived from the previous state (only /craft can change them).
const explorationSpread = (() => {
const prevExploration = previous.exploration;
if (!incoming.exploration) {
return prevExploration ? { exploration: prevExploration } : {};
}
if (!prevExploration) {
return { exploration: incoming.exploration };
}
const materials = incoming.exploration.materials.map((m) => {
const prev = prevExploration.materials.find((p) => p.materialId === m.materialId);
return { ...m, quantity: Math.min(m.quantity, prev?.quantity ?? 0) };
});
const craftedRecipeIds = incoming.exploration.craftedRecipeIds.filter((id) =>
prevExploration.craftedRecipeIds.includes(id),
);
return {
exploration: {
...incoming.exploration,
materials,
craftedRecipeIds,
craftedGoldMultiplier: prevExploration.craftedGoldMultiplier,
craftedEssenceMultiplier: prevExploration.craftedEssenceMultiplier,
craftedClickMultiplier: prevExploration.craftedClickMultiplier,
craftedCombatMultiplier: prevExploration.craftedCombatMultiplier,
},
};
})();
return { ...incoming, resources, bosses, quests, achievements, prestige, ...transcendenceSpread, ...apotheosisSpread, ...explorationSpread };
};
export const gameRouter = new Hono<HonoEnv>();
@@ -559,6 +596,33 @@ gameRouter.get("/load", async (context) => {
}
}
// Backfill exploration state on saves that predate the feature
if (!state.exploration) {
state.exploration = structuredClone(INITIAL_EXPLORATION);
// Unlock areas for zones already unlocked in this save
for (const area of state.exploration.areas) {
const areaData = DEFAULT_EXPLORATIONS.find((e) => e.id === area.id);
if (!areaData) continue;
const zone = state.zones.find((z) => z.id === areaData.zoneId);
if (zone?.status === "unlocked") {
area.status = "available";
}
}
needsBackfill = true;
} else {
// Merge any new exploration areas added since this save was created
for (const defaultArea of DEFAULT_EXPLORATIONS) {
if (!state.exploration.areas.some((a) => a.id === defaultArea.id)) {
const zone = state.zones.find((z) => z.id === defaultArea.zoneId);
state.exploration.areas.push({
id: defaultArea.id,
status: zone?.status === "unlocked" ? "available" : "locked",
});
needsBackfill = true;
}
}
}
const now = Date.now();
const { offlineGold, offlineEssence, offlineSeconds } = calculateOfflineEarnings(state, now);
+3 -9
View File
@@ -1,4 +1,4 @@
import type { BuyPrestigeUpgradeRequest, GameState, PrestigeRequest } from "@elysium/types";
import type { BuyPrestigeUpgradeRequest, GameState } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
@@ -17,12 +17,6 @@ prestigeRouter.use("*", authMiddleware);
prestigeRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<PrestigeRequest>();
const characterName = body.characterName?.trim();
if (!characterName) {
return context.json({ error: "characterName is required" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
@@ -50,7 +44,7 @@ prestigeRouter.post("/", async (context) => {
const { newState, newPrestigeData, runestonesEarned, milestoneRunestones } = buildPostPrestigeState(
state,
characterName,
state.player.characterName,
);
// Preserve daily challenges across the prestige reset and apply any crystal rewards
@@ -78,7 +72,7 @@ prestigeRouter.post("/", async (context) => {
await prisma.player.update({
where: { discordId },
data: {
characterName,
characterName: state.player.characterName,
// Reset current-run counters
totalGoldEarned: 0,
totalClicks: 0,
+3 -9
View File
@@ -1,4 +1,4 @@
import type { BuyEchoUpgradeRequest, GameState, TranscendenceRequest } from "@elysium/types";
import type { BuyEchoUpgradeRequest, GameState } from "@elysium/types";
import { Hono } from "hono";
import type { HonoEnv } from "../types/hono.js";
import { prisma } from "../db/client.js";
@@ -16,12 +16,6 @@ transcendenceRouter.use("*", authMiddleware);
transcendenceRouter.post("/", async (context) => {
const discordId = context.get("discordId") as string;
const body = await context.req.json<TranscendenceRequest>();
const characterName = body.characterName?.trim();
if (!characterName) {
return context.json({ error: "characterName is required" }, 400);
}
const record = await prisma.gameState.findUnique({ where: { discordId } });
if (!record) {
@@ -39,7 +33,7 @@ transcendenceRouter.post("/", async (context) => {
const { newState, newTranscendenceData, echoesEarned } = buildPostTranscendenceState(
state,
characterName,
state.player.characterName,
);
// Capture current-run stats before the nuclear reset
@@ -57,7 +51,7 @@ transcendenceRouter.post("/", async (context) => {
await prisma.player.update({
where: { discordId },
data: {
characterName,
characterName: state.player.characterName,
// Reset current-run counters (same as prestige)
totalGoldEarned: 0,
totalClicks: 0,