generated from nhcarrigan/template
feat: initial prototype — core game systems (#30)
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file About route providing API version and release information.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable stylistic/max-len -- URL cannot be shortened */
|
||||
/* eslint-disable require-atomic-updates -- Simple cache; race condition is acceptable */
|
||||
import { Hono } from "hono";
|
||||
import type { AboutResponse, GiteaRelease } from "@elysium/types";
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const apiVersion = process.env.npm_package_version ?? "unknown";
|
||||
|
||||
const giteaReleasesUrl = "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/elysium/releases";
|
||||
const cacheTtlMs = 5 * 60 * 1000;
|
||||
|
||||
interface ReleasesCache {
|
||||
data: Array<GiteaRelease>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
let releasesCache: ReleasesCache = { data: [], timestamp: 0 };
|
||||
|
||||
const fetchReleases = async(): Promise<Array<GiteaRelease>> => {
|
||||
const now = Date.now();
|
||||
if (releasesCache.data.length > 0 && now - releasesCache.timestamp < cacheTtlMs) {
|
||||
return releasesCache.data;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(giteaReleasesUrl);
|
||||
if (!response.ok) {
|
||||
return releasesCache.data;
|
||||
}
|
||||
const rawData: unknown = await response.json();
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- External API response */
|
||||
const data = rawData as Array<GiteaRelease>;
|
||||
releasesCache = { data: data, timestamp: now };
|
||||
return releasesCache.data;
|
||||
} catch {
|
||||
return releasesCache.data;
|
||||
}
|
||||
};
|
||||
|
||||
const aboutRouter = new Hono();
|
||||
|
||||
aboutRouter.get("/", async(context) => {
|
||||
const releases = await fetchReleases();
|
||||
const body: AboutResponse = {
|
||||
apiVersion,
|
||||
releases,
|
||||
};
|
||||
return context.json(body);
|
||||
});
|
||||
|
||||
export { aboutRouter };
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @file Apotheosis route handling the apotheosis reset mechanic.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||
/* eslint-disable stylistic/max-len -- Description string cannot be shortened */
|
||||
import { Hono } from "hono";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import {
|
||||
buildPostApotheosisState,
|
||||
isEligibleForApotheosis,
|
||||
} from "../services/apotheosis.js";
|
||||
import {
|
||||
grantApotheosisRole,
|
||||
postMilestoneWebhook,
|
||||
} from "../services/webhook.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const apotheosisRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
apotheosisRouter.use("*", authMiddleware);
|
||||
|
||||
apotheosisRouter.post("/", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
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 (!isEligibleForApotheosis(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error:
|
||||
"Not eligible for Apotheosis — purchase all Transcendence upgrades first",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Capture current-run stats before the nuclear reset
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 9 -- @preserve */
|
||||
const runBossesDefeated = state.bosses.filter((b) => {
|
||||
return b.status === "defeated";
|
||||
}).length;
|
||||
const runQuestsCompleted = state.quests.filter((q) => {
|
||||
return q.status === "completed";
|
||||
}).length;
|
||||
const runAdventurersRecruited = state.adventurers.reduce((sum, a) => {
|
||||
return sum + a.count;
|
||||
}, 0);
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const runAchievementsUnlocked = state.achievements.filter((a) => {
|
||||
return a.unlockedAt !== null;
|
||||
}).length;
|
||||
|
||||
const { updatedState, updatedApotheosisData } = buildPostApotheosisState(
|
||||
state,
|
||||
state.player.characterName,
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
await prisma.player.update({
|
||||
data: {
|
||||
characterName: state.player.characterName,
|
||||
|
||||
lastSavedAt: now,
|
||||
|
||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||
|
||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||
|
||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||
|
||||
lifetimeClicks: { increment: state.player.totalClicks },
|
||||
|
||||
// Accumulate into lifetime totals
|
||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||
|
||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||
|
||||
totalClicks: 0,
|
||||
// Reset current-run counters
|
||||
totalGoldEarned: 0,
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void grantApotheosisRole(discordId);
|
||||
void postMilestoneWebhook(discordId, "apotheosis", {
|
||||
apotheosis: updatedApotheosisData.count,
|
||||
prestige: updatedState.prestige.count,
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
transcendence: updatedState.transcendence?.count ?? 0,
|
||||
});
|
||||
|
||||
return context.json({ apotheosisCount: updatedApotheosisData.count });
|
||||
});
|
||||
|
||||
export { apotheosisRouter };
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @file Authentication routes for Discord OAuth.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Auth callback requires many steps */
|
||||
/* eslint-disable max-statements -- Auth callback requires many statements */
|
||||
import { Hono } from "hono";
|
||||
import { initialGameState } from "../data/initialState.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import {
|
||||
buildOAuthUrl,
|
||||
exchangeCode,
|
||||
fetchDiscordUser,
|
||||
} from "../services/discord.js";
|
||||
import { signToken } from "../services/jwt.js";
|
||||
import type { Player } from "@elysium/types";
|
||||
|
||||
const authRouter = new Hono();
|
||||
|
||||
authRouter.get("/url", (context) => {
|
||||
try {
|
||||
const url = buildOAuthUrl();
|
||||
return context.json({ url });
|
||||
} catch {
|
||||
return context.json({ error: "Failed to build OAuth URL" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get("/callback", async(context) => {
|
||||
const code = context.req.query("code");
|
||||
|
||||
if (code === undefined || code === "") {
|
||||
return context.json({ error: "Missing code parameter" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenData = await exchangeCode(code);
|
||||
const discordUser = await fetchDiscordUser(tokenData.access_token);
|
||||
|
||||
const existing = await prisma.player.findUnique({
|
||||
where: { discordId: discordUser.id },
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (!existing) {
|
||||
const player = await prisma.player.create({
|
||||
data: {
|
||||
avatar: discordUser.avatar,
|
||||
characterName: discordUser.username,
|
||||
createdAt: now,
|
||||
discordId: discordUser.id,
|
||||
discriminator: discordUser.discriminator,
|
||||
lastSavedAt: now,
|
||||
totalClicks: 0,
|
||||
totalGoldEarned: 0,
|
||||
username: discordUser.username,
|
||||
},
|
||||
});
|
||||
|
||||
const playerShape: Player = {
|
||||
avatar: player.avatar ?? null,
|
||||
characterName: player.characterName,
|
||||
createdAt: player.createdAt,
|
||||
discordId: player.discordId,
|
||||
discriminator: player.discriminator,
|
||||
lastSavedAt: player.lastSavedAt,
|
||||
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
||||
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
||||
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
||||
lifetimeClicks: player.lifetimeClicks,
|
||||
lifetimeGoldEarned: player.lifetimeGoldEarned,
|
||||
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
||||
totalClicks: player.totalClicks,
|
||||
totalGoldEarned: player.totalGoldEarned,
|
||||
username: player.username,
|
||||
};
|
||||
|
||||
const freshState = initialGameState(
|
||||
playerShape,
|
||||
playerShape.characterName,
|
||||
);
|
||||
await prisma.gameState.create({
|
||||
data: {
|
||||
discordId: player.discordId,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires never type */
|
||||
state: freshState as unknown as never,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
const jwtToken = signToken(player.discordId);
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
return context.redirect(
|
||||
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=true`,
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await prisma.player.update({
|
||||
data: {
|
||||
avatar: discordUser.avatar,
|
||||
discriminator: discordUser.discriminator,
|
||||
username: discordUser.username,
|
||||
},
|
||||
where: { discordId: discordUser.id },
|
||||
});
|
||||
|
||||
const jwtToken = signToken(updated.discordId);
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
return context.redirect(
|
||||
`${clientUrl}/auth/callback?token=${jwtToken}&isNew=false`,
|
||||
);
|
||||
} catch {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const clientUrl = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
return context.redirect(`${clientUrl}/auth/callback?error=auth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
export { authRouter };
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* @file Boss challenge route handling combat mechanics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Boss handler requires many steps */
|
||||
/* eslint-disable max-statements -- Boss handler requires many statements */
|
||||
/* eslint-disable complexity -- Boss handler has inherent complexity */
|
||||
/* eslint-disable stylistic/max-len -- Long lines in combat logic */
|
||||
import {
|
||||
computeSetBonuses,
|
||||
getActiveCompanionBonus,
|
||||
type BossChallengeResponse,
|
||||
type GameState,
|
||||
} from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import { defaultBosses } from "../data/bosses.js";
|
||||
import { defaultEquipmentSets } from "../data/equipmentSets.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
|
||||
const bossRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
bossRouter.use("*", authMiddleware);
|
||||
|
||||
const calculatePartyStats = (
|
||||
state: GameState,
|
||||
): { partyDPS: number; partyMaxHp: number } => {
|
||||
let globalMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (upgrade.purchased && upgrade.target === "global") {
|
||||
globalMultiplier = globalMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- prestige count * factor is clear
|
||||
const prestigeMultiplier = 1 + state.prestige.count * 0.1;
|
||||
|
||||
// Apply equipped weapon's combat bonus
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const equipmentCombatMultiplier = state.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped && item.bonus.combatMultiplier !== undefined;
|
||||
}).
|
||||
reduce((mult, item) => {
|
||||
return mult * (item.bonus.combatMultiplier ?? 1);
|
||||
}, 1);
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const equippedItemIds = state.equipment.
|
||||
filter((item) => {
|
||||
return item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return item.id;
|
||||
});
|
||||
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
|
||||
equippedItemIds,
|
||||
defaultEquipmentSets,
|
||||
);
|
||||
|
||||
let partyDPS = 0;
|
||||
let partyMaxHp = 0;
|
||||
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let adventurerMultiplier = 1;
|
||||
for (const upgrade of state.upgrades) {
|
||||
if (
|
||||
upgrade.purchased
|
||||
&& upgrade.target === "adventurer"
|
||||
&& upgrade.adventurerId === adventurer.id
|
||||
) {
|
||||
adventurerMultiplier = adventurerMultiplier * upgrade.multiplier;
|
||||
}
|
||||
}
|
||||
|
||||
const adventurerContribution
|
||||
= adventurer.combatPower
|
||||
* adventurer.count
|
||||
* adventurerMultiplier
|
||||
* globalMultiplier
|
||||
* prestigeMultiplier;
|
||||
partyDPS = partyDPS + adventurerContribution;
|
||||
|
||||
const adventurerHp = adventurer.level * 50 * adventurer.count;
|
||||
partyMaxHp = partyMaxHp + adventurerHp;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 12 -- @preserve */
|
||||
const echoCombatMultiplier = state.transcendence?.echoCombatMultiplier ?? 1;
|
||||
const craftedCombatMultiplier
|
||||
= state.exploration?.craftedCombatMultiplier ?? 1;
|
||||
|
||||
const companionBonus = getActiveCompanionBonus(
|
||||
state.companions?.activeCompanionId ?? null,
|
||||
state.companions?.unlockedCompanionIds ?? [],
|
||||
);
|
||||
const companionCombatMult
|
||||
= companionBonus?.type === "bossDamage"
|
||||
? 1 + companionBonus.value
|
||||
: 1;
|
||||
|
||||
partyDPS = partyDPS
|
||||
* equipmentCombatMultiplier
|
||||
* setCombatMultiplier
|
||||
* echoCombatMultiplier
|
||||
* craftedCombatMultiplier
|
||||
* companionCombatMult;
|
||||
|
||||
return { partyDPS, partyMaxHp };
|
||||
};
|
||||
|
||||
bossRouter.post("/challenge", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<{ bossId: string }>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!body.bossId) {
|
||||
return context.json({ error: "Invalid request body" }, 400);
|
||||
}
|
||||
|
||||
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;
|
||||
const boss = state.bosses.find((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
|
||||
if (!boss) {
|
||||
return context.json({ error: "Boss not found" }, 404);
|
||||
}
|
||||
|
||||
if (boss.status !== "available" && boss.status !== "in_progress") {
|
||||
return context.json({ error: "Boss is not currently available" }, 400);
|
||||
}
|
||||
|
||||
if (boss.prestigeRequirement > state.prestige.count) {
|
||||
return context.json({ error: "Prestige requirement not met" }, 403);
|
||||
}
|
||||
|
||||
const { partyDPS, partyMaxHp } = calculatePartyStats(state);
|
||||
|
||||
if (
|
||||
partyDPS === 0
|
||||
|| partyMaxHp === 0
|
||||
|| !Number.isFinite(partyDPS)
|
||||
|| !Number.isFinite(partyMaxHp)
|
||||
) {
|
||||
return context.json(
|
||||
{ error: "Your party has no adventurers ready to fight" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const bossHpBefore = boss.currentHp;
|
||||
const bossDPS = boss.damagePerSecond;
|
||||
|
||||
const timeToKillBoss = bossHpBefore / partyDPS;
|
||||
const timeToKillParty = partyMaxHp / bossDPS;
|
||||
|
||||
const won = timeToKillBoss <= timeToKillParty;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||
let partyHpRemaining: number;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||
let bossHpAtBattleEnd: number;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned in if/else branches
|
||||
let bossUpdatedHp: number;
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||
let rewards: BossChallengeResponse["rewards"];
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations -- Conditionally assigned based on win/loss
|
||||
let casualties: BossChallengeResponse["casualties"];
|
||||
|
||||
if (won) {
|
||||
bossHpAtBattleEnd = 0;
|
||||
bossUpdatedHp = 0;
|
||||
const bossDamageDealt = bossDPS * timeToKillBoss;
|
||||
partyHpRemaining = Math.max(0, partyMaxHp - bossDamageDealt);
|
||||
|
||||
boss.status = "defeated";
|
||||
boss.currentHp = 0;
|
||||
|
||||
state.resources.gold = state.resources.gold + boss.goldReward;
|
||||
state.resources.essence = state.resources.essence + boss.essenceReward;
|
||||
state.resources.crystals = state.resources.crystals + boss.crystalReward;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + boss.goldReward;
|
||||
|
||||
for (const upgradeId of boss.upgradeRewards) {
|
||||
const upgrade = state.upgrades.find((u) => {
|
||||
return u.id === upgradeId;
|
||||
});
|
||||
if (upgrade) {
|
||||
upgrade.unlocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Grant equipment rewards — auto-equip if the slot is currently empty
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 14 -- @preserve */
|
||||
for (const equipmentId of boss.equipmentRewards) {
|
||||
const equipment = state.equipment.find((item) => {
|
||||
return item.id === equipmentId;
|
||||
});
|
||||
if (equipment) {
|
||||
equipment.owned = true;
|
||||
|
||||
const slotAlreadyEquipped = state.equipment.some((item) => {
|
||||
return item.type === equipment.type && item.equipped;
|
||||
});
|
||||
if (!slotAlreadyEquipped) {
|
||||
equipment.equipped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock next boss in the same zone (zone-based sequential progression)
|
||||
const zoneBosses = state.bosses.filter((b) => {
|
||||
return b.zoneId === boss.zoneId;
|
||||
});
|
||||
const zoneIndex = zoneBosses.findIndex((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
const [ nextZoneBoss ] = zoneBosses.slice(zoneIndex + 1);
|
||||
if (
|
||||
nextZoneBoss
|
||||
&& nextZoneBoss.prestigeRequirement <= state.prestige.count
|
||||
) {
|
||||
const nextBossInState = state.bosses.find((b) => {
|
||||
return b.id === nextZoneBoss.id;
|
||||
});
|
||||
if (nextBossInState) {
|
||||
nextBossInState.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Unlock any zone whose unlock conditions are now both satisfied
|
||||
* (final boss defeated AND final quest completed)
|
||||
*/
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
for (const zone of state.zones) {
|
||||
if (zone.status === "unlocked") {
|
||||
continue;
|
||||
}
|
||||
if (zone.unlockBossId !== body.bossId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Boss condition just became satisfied — check the quest condition too
|
||||
const questSatisfied
|
||||
= zone.unlockQuestId === null
|
||||
|| state.quests.some((q) => {
|
||||
return q.id === zone.unlockQuestId && q.status === "completed";
|
||||
});
|
||||
if (!questSatisfied) {
|
||||
continue;
|
||||
}
|
||||
zone.status = "unlocked";
|
||||
const updatedZoneBosses = state.bosses.filter((b) => {
|
||||
return b.zoneId === zone.id;
|
||||
});
|
||||
const [ firstUpdatedBoss ] = updatedZoneBosses;
|
||||
if (
|
||||
firstUpdatedBoss
|
||||
&& firstUpdatedBoss.prestigeRequirement <= state.prestige.count
|
||||
) {
|
||||
firstUpdatedBoss.status = "available";
|
||||
}
|
||||
}
|
||||
|
||||
// Update daily boss challenge progress
|
||||
if (state.dailyChallenges) {
|
||||
const { crystalsAwarded, updatedChallenges } = updateChallengeProgress(
|
||||
state.dailyChallenges,
|
||||
"bossesDefeated",
|
||||
1,
|
||||
);
|
||||
state.dailyChallenges = updatedChallenges;
|
||||
state.resources.crystals = state.resources.crystals + crystalsAwarded;
|
||||
}
|
||||
|
||||
// First-kill bounty — look up authoritative bounty from static data
|
||||
const staticBoss = defaultBosses.find((b) => {
|
||||
return b.id === body.bossId;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const bountyRunestones = staticBoss?.bountyRunestones ?? 0;
|
||||
state.prestige.runestones = state.prestige.runestones + bountyRunestones;
|
||||
|
||||
rewards = {
|
||||
bountyRunestones: bountyRunestones,
|
||||
crystals: boss.crystalReward,
|
||||
equipmentIds: boss.equipmentRewards,
|
||||
essence: boss.essenceReward,
|
||||
gold: boss.goldReward,
|
||||
upgradeIds: boss.upgradeRewards,
|
||||
};
|
||||
} else {
|
||||
const partyDamageDealt = partyDPS * timeToKillParty;
|
||||
bossHpAtBattleEnd = Math.max(0, bossHpBefore - partyDamageDealt);
|
||||
bossUpdatedHp = boss.maxHp;
|
||||
partyHpRemaining = 0;
|
||||
|
||||
boss.status = "available";
|
||||
boss.currentHp = boss.maxHp;
|
||||
|
||||
// How close was the party to winning? (0 = hopeless, 1 = nearly won)
|
||||
const victoryProgress = Math.min(1, timeToKillParty / timeToKillBoss);
|
||||
// Casualty rate scales from ~0% (nearly won) to 60% (completely outmatched)
|
||||
const casualtyFraction = (1 - victoryProgress) * 0.6;
|
||||
|
||||
casualties = [];
|
||||
for (const adventurer of state.adventurers) {
|
||||
if (adventurer.count === 0) {
|
||||
continue;
|
||||
}
|
||||
const killed = Math.floor(adventurer.count * casualtyFraction);
|
||||
if (killed > 0) {
|
||||
adventurer.count = Math.max(1, adventurer.count - killed);
|
||||
casualties.push({ adventurerId: adventurer.id, killed: killed });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const bossMaxHp = boss.maxHp;
|
||||
const bossNewHp = bossUpdatedHp;
|
||||
const response: BossChallengeResponse = {
|
||||
bossDPS,
|
||||
bossHpAtBattleEnd,
|
||||
bossHpBefore,
|
||||
bossMaxHp,
|
||||
bossNewHp,
|
||||
partyDPS,
|
||||
partyHpRemaining,
|
||||
partyMaxHp,
|
||||
won,
|
||||
};
|
||||
if (rewards !== undefined) {
|
||||
response.rewards = rewards;
|
||||
}
|
||||
if (casualties !== undefined) {
|
||||
response.casualties = casualties;
|
||||
}
|
||||
|
||||
return context.json(response);
|
||||
});
|
||||
|
||||
export { bossRouter };
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @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 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) => {
|
||||
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;
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
const bonusType = recipe.bonus.type;
|
||||
const bonusValue = recipe.bonus.value;
|
||||
const response: CraftRecipeResponse = {
|
||||
bonusType,
|
||||
bonusValue,
|
||||
recipeId,
|
||||
...updatedMultipliers,
|
||||
};
|
||||
return context.json(response);
|
||||
});
|
||||
|
||||
export { craftRouter };
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* @file Exploration routes handling area exploration mechanics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
import { Hono } from "hono";
|
||||
import { defaultExplorations } from "../data/explorations.js";
|
||||
import { initialExploration } from "../data/initialState.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type {
|
||||
ExploreCollectEventResult,
|
||||
ExploreCollectRequest,
|
||||
ExploreCollectResponse,
|
||||
ExploreStartRequest,
|
||||
ExploreStartResponse,
|
||||
GameState,
|
||||
} from "@elysium/types";
|
||||
|
||||
const exploreRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
exploreRouter.use("*", authMiddleware);
|
||||
|
||||
const nothingProbability = 0.2;
|
||||
|
||||
const nothingMessages = [
|
||||
"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.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random "nothing found" message.
|
||||
* V8 ignore next 2 -- @preserve.
|
||||
* @returns A random message string.
|
||||
*/
|
||||
const pickNothingMessage = (): string => {
|
||||
const index = Math.floor(Math.random() * nothingMessages.length);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
return nothingMessages[index] ?? nothingMessages[0] ?? "";
|
||||
};
|
||||
|
||||
exploreRouter.post("/start", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<ExploreStartRequest>();
|
||||
|
||||
const { areaId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultExplorations.find((a) => {
|
||||
return 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 rawState: unknown = record.state;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue; cast to GameState */
|
||||
const state = rawState as GameState;
|
||||
|
||||
// Backfill exploration state for old saves that predate this feature
|
||||
if (!state.exploration) {
|
||||
state.exploration = structuredClone(initialExploration);
|
||||
// Unlock areas for zones already unlocked in this save
|
||||
for (const area of state.exploration.areas) {
|
||||
const areaData = defaultExplorations.find((areaItem) => {
|
||||
return areaItem.id === area.id;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (!areaData) {
|
||||
continue;
|
||||
}
|
||||
const zone = state.zones.find((z) => {
|
||||
return z.id === areaData.zoneId;
|
||||
});
|
||||
if (zone?.status === "unlocked") {
|
||||
area.status = "available";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zone = state.zones.find((z) => {
|
||||
return 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) => {
|
||||
return a.id === areaId;
|
||||
});
|
||||
if (!area) {
|
||||
return context.json({ error: "Exploration area not found in state" }, 404);
|
||||
}
|
||||
|
||||
const anyInProgress = state.exploration.areas.some((a) => {
|
||||
return a.status === "in_progress";
|
||||
});
|
||||
if (anyInProgress) {
|
||||
return context.json(
|
||||
{ error: "An exploration is 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({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
// eslint-disable-next-line stylistic/no-mixed-operators -- duration * 1000 is clear
|
||||
const endsAt = now + explorationArea.durationSeconds * 1000;
|
||||
const response: ExploreStartResponse = {
|
||||
areaId,
|
||||
endsAt,
|
||||
};
|
||||
return context.json(response);
|
||||
});
|
||||
|
||||
exploreRouter.post("/collect", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<ExploreCollectRequest>();
|
||||
|
||||
const { areaId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!areaId) {
|
||||
return context.json({ error: "areaId is required" }, 400);
|
||||
}
|
||||
|
||||
const explorationArea = defaultExplorations.find((a) => {
|
||||
return 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 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);
|
||||
}
|
||||
|
||||
const area = state.exploration.areas.find((a) => {
|
||||
return 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();
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const startedAt = area.startedAt ?? 0;
|
||||
const durationMs = explorationArea.durationSeconds * 1000;
|
||||
const expiresAt = startedAt + durationMs;
|
||||
|
||||
if (now < expiresAt) {
|
||||
return context.json({ error: "Exploration is not yet complete" }, 400);
|
||||
}
|
||||
|
||||
area.status = "available";
|
||||
area.completedOnce = true;
|
||||
|
||||
// 20% chance of finding nothing
|
||||
if (Math.random() < nothingProbability) {
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const response: ExploreCollectResponse = {
|
||||
event: null,
|
||||
foundNothing: true,
|
||||
materialsFound: [],
|
||||
nothingMessage: pickNothingMessage(),
|
||||
};
|
||||
return context.json(response);
|
||||
}
|
||||
|
||||
// Pick a random event
|
||||
const eventIndex = Math.floor(Math.random() * explorationArea.events.length);
|
||||
const event = explorationArea.events[eventIndex];
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
if (!event) {
|
||||
return context.json({ error: "No events available" }, 500);
|
||||
}
|
||||
|
||||
// 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") {
|
||||
// Gold gain — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.gold = state.resources.gold + amount;
|
||||
state.player.totalGoldEarned = state.player.totalGoldEarned + amount;
|
||||
goldChange = amount;
|
||||
} else if (event.effect.type === "gold_loss") {
|
||||
// Gold loss — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = Math.min(state.resources.gold, event.effect.amount ?? 0);
|
||||
state.resources.gold = state.resources.gold - amount;
|
||||
goldChange = -amount;
|
||||
} else if (event.effect.type === "essence_gain") {
|
||||
// Essence gain — amount may be undefined in edge cases
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const amount = event.effect.amount ?? 0;
|
||||
state.resources.essence = state.resources.essence + amount;
|
||||
essenceChange = amount;
|
||||
} else if (event.effect.type === "material_gain") {
|
||||
const { materialId } = event.effect;
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
const quantity = event.effect.quantity ?? 1;
|
||||
if (materialId !== undefined && materialId !== "") {
|
||||
const existing = state.exploration.materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing) {
|
||||
existing.quantity = existing.quantity + quantity;
|
||||
} else {
|
||||
state.exploration.materials.push({ materialId, quantity });
|
||||
}
|
||||
materialGained = { materialId, quantity };
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 13 -- @preserve */
|
||||
}
|
||||
} else if (event.effect.type === "adventurer_loss") { // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- exhausted all other union members above
|
||||
// Adventurer loss — fraction and loop are defensive
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
const fraction = event.effect.fraction ?? 0.05;
|
||||
for (const adventurer of state.adventurers) {
|
||||
const lost = Math.floor(adventurer.count * fraction);
|
||||
if (lost > 0) {
|
||||
adventurer.count = Math.max(0, adventurer.count - lost);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 8 -- @preserve */
|
||||
let adventurerLostCount = 0;
|
||||
if (event.effect.type === "adventurer_loss") {
|
||||
const fraction = event.effect.fraction ?? 0.05;
|
||||
for (const adv of state.adventurers) {
|
||||
const lost = Math.floor(adv.count * fraction);
|
||||
adventurerLostCount = adventurerLostCount + lost;
|
||||
}
|
||||
}
|
||||
|
||||
const eventResult: ExploreCollectEventResult = {
|
||||
adventurerLostCount: adventurerLostCount,
|
||||
essenceChange: essenceChange,
|
||||
goldChange: goldChange,
|
||||
materialGained: materialGained,
|
||||
text: event.text,
|
||||
};
|
||||
|
||||
// Roll for material drops from possibleMaterials (weighted random selection)
|
||||
const materialsFound: Array<{ materialId: string; quantity: number }> = [];
|
||||
|
||||
if (explorationArea.possibleMaterials.length > 0) {
|
||||
let totalWeight = 0;
|
||||
for (const materialDrop of explorationArea.possibleMaterials) {
|
||||
totalWeight = totalWeight + materialDrop.weight;
|
||||
}
|
||||
let roll = Math.random() * totalWeight;
|
||||
|
||||
for (const possible of explorationArea.possibleMaterials) {
|
||||
roll = roll - possible.weight;
|
||||
if (roll <= 0) {
|
||||
const maxMinDiff = possible.maxQuantity - possible.minQuantity;
|
||||
const range = maxMinDiff + 1;
|
||||
const randomOffset = Math.floor(Math.random() * range);
|
||||
const quantity = randomOffset + possible.minQuantity;
|
||||
const { materialId } = possible;
|
||||
|
||||
const existing = state.exploration.materials.find((m) => {
|
||||
return m.materialId === materialId;
|
||||
});
|
||||
if (existing) {
|
||||
existing.quantity = existing.quantity + quantity;
|
||||
} else {
|
||||
state.exploration.materials.push({ materialId, quantity });
|
||||
}
|
||||
|
||||
materialsFound.push({ materialId, quantity });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: state as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const response: ExploreCollectResponse = {
|
||||
event: eventResult,
|
||||
foundNothing: false,
|
||||
materialsFound: materialsFound,
|
||||
};
|
||||
return context.json(response);
|
||||
});
|
||||
|
||||
export { exploreRouter };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* @file Leaderboard routes for retrieving ranked player statistics.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handler requires many steps */
|
||||
/* eslint-disable complexity -- Leaderboard handler has inherent complexity */
|
||||
import { Hono } from "hono";
|
||||
import { gameTitles } from "../data/titles.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { GameState } from "@elysium/types";
|
||||
|
||||
const leaderboardRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
const validCategories = new Set([
|
||||
"totalGold",
|
||||
"bossesDefeated",
|
||||
"questsCompleted",
|
||||
"achievementsUnlocked",
|
||||
"prestigeCount",
|
||||
"transcendenceCount",
|
||||
"apotheosisCount",
|
||||
]);
|
||||
|
||||
const gameStateCategories = new Set([
|
||||
"prestigeCount",
|
||||
"transcendenceCount",
|
||||
"apotheosisCount",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Parses the showOnLeaderboards flag from a player's profile settings blob.
|
||||
* @param raw - The raw profile settings value from the database.
|
||||
* @returns True if the player should appear on leaderboards, false otherwise.
|
||||
*/
|
||||
const parseShowOnLeaderboards = (raw: unknown): boolean => {
|
||||
if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime profile shape */
|
||||
return (raw as Record<string, unknown>).showOnLeaderboards !== false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the display title name for a given title ID.
|
||||
* @param titleId - The player's active title ID.
|
||||
* @returns The human-readable title name, or empty string if no title.
|
||||
*/
|
||||
const resolveTitleName = (titleId: string | null): string => {
|
||||
if (titleId === null || titleId === "") {
|
||||
return "";
|
||||
}
|
||||
return gameTitles.find((title) => {
|
||||
return title.id === titleId;
|
||||
})?.name ?? titleId;
|
||||
};
|
||||
|
||||
leaderboardRouter.get("/", async(context) => {
|
||||
const category = context.req.query("category") ?? "totalGold";
|
||||
const limitRaw = Number(context.req.query("limit") ?? "100");
|
||||
const limit = Math.min(Math.max(1, limitRaw), 100);
|
||||
|
||||
if (!validCategories.has(category)) {
|
||||
return context.json({ error: "Invalid category" }, 400);
|
||||
}
|
||||
|
||||
const [ players, gameStates ] = await Promise.all([
|
||||
prisma.player.findMany(),
|
||||
gameStateCategories.has(category)
|
||||
? prisma.gameState.findMany()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const stateMap = new Map(
|
||||
gameStates.map((gs) => {
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
return [ gs.discordId, gs.state as unknown as GameState ];
|
||||
}),
|
||||
);
|
||||
|
||||
const entries = players.
|
||||
filter((player) => {
|
||||
return parseShowOnLeaderboards(player.profileSettings);
|
||||
}).
|
||||
map((player) => {
|
||||
let value = 0;
|
||||
if (category === "totalGold") {
|
||||
value = player.lifetimeGoldEarned;
|
||||
} else if (category === "bossesDefeated") {
|
||||
value = player.lifetimeBossesDefeated;
|
||||
} else if (category === "questsCompleted") {
|
||||
value = player.lifetimeQuestsCompleted;
|
||||
} else if (category === "achievementsUnlocked") {
|
||||
value = player.lifetimeAchievementsUnlocked;
|
||||
} else {
|
||||
const state = stateMap.get(player.discordId);
|
||||
if (category === "prestigeCount") {
|
||||
value = state?.prestige.count ?? 0;
|
||||
} else if (category === "transcendenceCount") {
|
||||
value = state?.transcendence?.count ?? 0;
|
||||
} else if (category === "apotheosisCount") {
|
||||
value = state?.apotheosis?.count ?? 0;
|
||||
}
|
||||
}
|
||||
return {
|
||||
activeTitle: resolveTitleName(player.activeTitle),
|
||||
avatar: player.avatar ?? null,
|
||||
characterName: player.characterName,
|
||||
discordId: player.discordId,
|
||||
username: player.username,
|
||||
value: value,
|
||||
};
|
||||
}).
|
||||
sort((a, b) => {
|
||||
return b.value - a.value;
|
||||
}).
|
||||
slice(0, limit).
|
||||
map((entry, index) => {
|
||||
return { ...entry, rank: index + 1 };
|
||||
});
|
||||
|
||||
return context.json({ category, entries });
|
||||
});
|
||||
|
||||
export { leaderboardRouter };
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @file Prestige routes handling prestige resets and upgrade purchases.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
import { Hono } from "hono";
|
||||
import { defaultPrestigeUpgrades } from "../data/prestigeUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { updateChallengeProgress } from "../services/dailyChallenges.js";
|
||||
import {
|
||||
buildPostPrestigeState,
|
||||
computeRunestoneMultipliers,
|
||||
isEligibleForPrestige,
|
||||
} from "../services/prestige.js";
|
||||
import { postMilestoneWebhook } from "../services/webhook.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { BuyPrestigeUpgradeRequest, GameState } from "@elysium/types";
|
||||
|
||||
const prestigeRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
prestigeRouter.use("*", authMiddleware);
|
||||
|
||||
prestigeRouter.post("/", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!isEligibleForPrestige(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Not eligible for prestige — collect 1,000,000 total gold first",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Update daily prestige challenge progress before resetting the run
|
||||
let updatedDailyChallenges = state.dailyChallenges;
|
||||
let challengeCrystals = 0;
|
||||
if (updatedDailyChallenges) {
|
||||
const result = updateChallengeProgress(
|
||||
updatedDailyChallenges,
|
||||
"prestige",
|
||||
1,
|
||||
);
|
||||
updatedDailyChallenges = result.updatedChallenges;
|
||||
challengeCrystals = result.crystalsAwarded;
|
||||
}
|
||||
|
||||
const {
|
||||
milestoneRunestones,
|
||||
prestigeData,
|
||||
prestigeState,
|
||||
runestonesEarned,
|
||||
} = buildPostPrestigeState(state, state.player.characterName);
|
||||
|
||||
// Preserve daily challenges across the prestige reset and apply any crystal rewards
|
||||
const finalState: GameState = {
|
||||
...prestigeState,
|
||||
...updatedDailyChallenges === undefined
|
||||
? {}
|
||||
: { dailyChallenges: updatedDailyChallenges },
|
||||
resources: {
|
||||
...prestigeState.resources,
|
||||
crystals: prestigeState.resources.crystals + challengeCrystals,
|
||||
},
|
||||
};
|
||||
|
||||
// Capture current-run stats to accumulate into lifetime totals before resetting
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 10 -- @preserve */
|
||||
const runBossesDefeated = state.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length;
|
||||
const runQuestsCompleted = state.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length;
|
||||
let runAdventurersRecruited = 0;
|
||||
for (const adventurer of state.adventurers) {
|
||||
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length;
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: finalState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
await prisma.player.update({
|
||||
data: {
|
||||
characterName: state.player.characterName,
|
||||
|
||||
lastSavedAt: now,
|
||||
|
||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||
|
||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||
|
||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||
|
||||
lifetimeClicks: { increment: state.player.totalClicks },
|
||||
|
||||
// Accumulate into lifetime totals — never reset
|
||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||
|
||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||
|
||||
totalClicks: 0,
|
||||
// Reset current-run counters
|
||||
totalGoldEarned: 0,
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void postMilestoneWebhook(discordId, "prestige", {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
apotheosis: prestigeState.apotheosis?.count ?? 0,
|
||||
|
||||
prestige: prestigeData.count,
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
transcendence: prestigeState.transcendence?.count ?? 0,
|
||||
});
|
||||
|
||||
return context.json({
|
||||
milestoneRunestones: milestoneRunestones,
|
||||
newPrestigeCount: prestigeData.count, // eslint-disable-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||
runestones: runestonesEarned,
|
||||
});
|
||||
});
|
||||
|
||||
prestigeRouter.post("/buy-upgrade", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<BuyPrestigeUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = defaultPrestigeUpgrades.find((prestigeUpgrade) => {
|
||||
return prestigeUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown prestige upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
const { purchasedUpgradeIds, runestones } = state.prestige;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (runestones < upgrade.runestonesCost) {
|
||||
return context.json({ error: "Not enough runestones" }, 400);
|
||||
}
|
||||
|
||||
const updatedRunestones = runestones - upgrade.runestonesCost;
|
||||
const updatedPurchasedUpgradeIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
prestige: {
|
||||
...state.prestige,
|
||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||
runestones: updatedRunestones,
|
||||
...computeRunestoneMultipliers(updatedPurchasedUpgradeIds),
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
const multipliers = computeRunestoneMultipliers(updatedPurchasedUpgradeIds);
|
||||
|
||||
return context.json({
|
||||
purchasedUpgradeIds: updatedPurchasedUpgradeIds,
|
||||
runestonesRemaining: updatedRunestones,
|
||||
...multipliers,
|
||||
});
|
||||
});
|
||||
|
||||
export { prestigeRouter };
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* @file Profile routes handling player profile retrieval and updates.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable complexity -- Route handlers have inherent complexity */
|
||||
/* eslint-disable stylistic/key-spacing -- ProfileSettings keys exceed max-len when aligned */
|
||||
/* eslint-disable stylistic/max-len -- ProfileSettings key names exceed line length limit */
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Defensive checks for runtime nullable fields */
|
||||
import {
|
||||
DEFAULT_PROFILE_SETTINGS,
|
||||
type GameState,
|
||||
type ProfileSettings,
|
||||
type UpdateProfileRequest,
|
||||
} from "@elysium/types";
|
||||
import { Hono } from "hono";
|
||||
import { gameTitles } from "../data/titles.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import { parseUnlockedTitles } from "../services/titles.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
|
||||
const profileRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
const validNumberFormats = new Set([ "suffix", "scientific", "engineering" ]);
|
||||
|
||||
/**
|
||||
* Parses a raw profile settings blob from the database into a typed ProfileSettings object.
|
||||
* @param raw - The raw value from the database.
|
||||
* @returns A valid ProfileSettings object with defaults for missing fields.
|
||||
*/
|
||||
const parseProfileSettings = (raw: unknown): ProfileSettings => {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ...DEFAULT_PROFILE_SETTINGS };
|
||||
}
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
const rawObject = raw as Record<string, unknown>;
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
const parsedNumberFormat = rawObject.numberFormat as string;
|
||||
const numberFormat = validNumberFormats.has(parsedNumberFormat)
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||
: "suffix";
|
||||
return {
|
||||
enableNotifications: rawObject.enableNotifications === true,
|
||||
enableSounds: rawObject.enableSounds === true,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false,
|
||||
showAdventurersRecruited: rawObject.showAdventurersRecruited !== false,
|
||||
showApotheosis: rawObject.showApotheosis !== false,
|
||||
showBossesDefeated: rawObject.showBossesDefeated !== false,
|
||||
showCurrentClicks: rawObject.showCurrentClicks !== false,
|
||||
showCurrentGold: rawObject.showCurrentGold !== false,
|
||||
showGuildFounded: rawObject.showGuildFounded !== false,
|
||||
showLifetimeAchievementsUnlocked: rawObject.showLifetimeAchievementsUnlocked !== false,
|
||||
showLifetimeAdventurersRecruited: rawObject.showLifetimeAdventurersRecruited !== false,
|
||||
showLifetimeBossesDefeated: rawObject.showLifetimeBossesDefeated !== false,
|
||||
showLifetimeQuestsCompleted: rawObject.showLifetimeQuestsCompleted !== false,
|
||||
showOnLeaderboards: rawObject.showOnLeaderboards !== false,
|
||||
showPrestige: rawObject.showPrestige !== false,
|
||||
showQuestsCompleted: rawObject.showQuestsCompleted !== false,
|
||||
showTotalClicks: rawObject.showTotalClicks !== false,
|
||||
showTotalGold: rawObject.showTotalGold !== false,
|
||||
showTranscendence: rawObject.showTranscendence !== false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a title ID to its display name.
|
||||
* @param id - The title ID to resolve.
|
||||
* @returns An object with id and name fields.
|
||||
*/
|
||||
const resolveTitle = (id: string): { id: string; name: string } => {
|
||||
const title = gameTitles.find((gameTitle) => {
|
||||
return gameTitle.id === id;
|
||||
});
|
||||
return { id: id, name: title?.name ?? id };
|
||||
};
|
||||
|
||||
profileRouter.get("/:discordId", async(context) => {
|
||||
const { discordId } = context.req.param();
|
||||
|
||||
const [ player, gameStateRecord ] = await Promise.all([
|
||||
prisma.player.findUnique({ where: { discordId } }),
|
||||
prisma.gameState.findUnique({ where: { discordId } }),
|
||||
]);
|
||||
|
||||
if (!player) {
|
||||
return context.json({ error: "Player not found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = gameStateRecord?.state as unknown as GameState | undefined;
|
||||
const prestigeCount = state?.prestige.count ?? 0;
|
||||
const transcendenceCount = state?.transcendence?.count ?? 0;
|
||||
const apotheosisCount = state?.apotheosis?.count ?? 0;
|
||||
const profileSettings = parseProfileSettings(player.profileSettings);
|
||||
|
||||
const bossesDefeated
|
||||
= state?.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length ?? 0;
|
||||
const questsCompleted
|
||||
= state?.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length ?? 0;
|
||||
|
||||
let adventurersRecruited = 0;
|
||||
if (state) {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
for (const adventurer of state.adventurers) {
|
||||
adventurersRecruited = adventurersRecruited + adventurer.count;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const achievementsUnlocked = (state?.achievements ?? []).filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length;
|
||||
|
||||
const unlockedTitleIds = parseUnlockedTitles(player.unlockedTitles);
|
||||
const unlockedTitles = unlockedTitleIds.map((id) => {
|
||||
return resolveTitle(id);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 12 -- @preserve */
|
||||
const equippedItems = (state?.equipment ?? []).
|
||||
filter((item) => {
|
||||
return item.owned && item.equipped;
|
||||
}).
|
||||
map((item) => {
|
||||
return {
|
||||
bonus: item.bonus,
|
||||
name: item.name,
|
||||
rarity: item.rarity,
|
||||
type: item.type,
|
||||
};
|
||||
});
|
||||
|
||||
return context.json({
|
||||
achievementsUnlocked: achievementsUnlocked,
|
||||
activeTitle: player.activeTitle,
|
||||
adventurersRecruited: adventurersRecruited,
|
||||
apotheosisCount: apotheosisCount,
|
||||
avatar: player.avatar,
|
||||
bio: player.bio ?? "",
|
||||
bossesDefeated: bossesDefeated,
|
||||
characterClass: player.characterClass,
|
||||
characterName: player.characterName,
|
||||
characterRace: player.characterRace ?? "",
|
||||
createdAt: player.createdAt,
|
||||
currentRunClicks: state?.player.totalClicks ?? 0,
|
||||
currentRunGold: state?.player.totalGoldEarned ?? 0,
|
||||
equippedItems: equippedItems,
|
||||
guildDescription: player.guildDescription,
|
||||
guildName: player.guildName,
|
||||
lifetimeAchievementsUnlocked: player.lifetimeAchievementsUnlocked,
|
||||
lifetimeAdventurersRecruited: player.lifetimeAdventurersRecruited,
|
||||
lifetimeBossesDefeated: player.lifetimeBossesDefeated,
|
||||
lifetimeQuestsCompleted: player.lifetimeQuestsCompleted,
|
||||
prestigeCount: prestigeCount,
|
||||
profileSettings: profileSettings,
|
||||
pronouns: player.pronouns ?? "",
|
||||
questsCompleted: questsCompleted,
|
||||
totalClicks: player.lifetimeClicks,
|
||||
totalGoldEarned: player.lifetimeGoldEarned,
|
||||
transcendenceCount: transcendenceCount,
|
||||
unlockedTitles: unlockedTitles,
|
||||
username: player.username,
|
||||
});
|
||||
});
|
||||
|
||||
profileRouter.put("/", authMiddleware, async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<UpdateProfileRequest>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!body.characterName) {
|
||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||
}
|
||||
|
||||
const characterName = body.characterName.trim().slice(0, 32);
|
||||
|
||||
if (characterName === "") {
|
||||
return context.json({ error: "Character name cannot be empty" }, 400);
|
||||
}
|
||||
|
||||
const pronouns = (body.pronouns ?? "").trim().slice(0, 20);
|
||||
const characterRace = (body.characterRace ?? "").trim().slice(0, 32);
|
||||
const characterClass = (body.characterClass ?? "").trim().slice(0, 32);
|
||||
const bio = (body.bio ?? "").trim().slice(0, 200);
|
||||
const guildName = (body.guildName ?? "").trim().slice(0, 64);
|
||||
const guildDescription = (body.guildDescription ?? "").trim().slice(0, 500);
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 2 -- @preserve */
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
const parsedNumberFormat = (body.profileSettings.numberFormat ?? "") as string;
|
||||
const numberFormat = validNumberFormats.has(parsedNumberFormat)
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check */
|
||||
? (parsedNumberFormat as ProfileSettings["numberFormat"])
|
||||
: "suffix";
|
||||
const profileSettings: ProfileSettings = {
|
||||
enableNotifications: body.profileSettings.enableNotifications ?? false,
|
||||
enableSounds: body.profileSettings.enableSounds ?? false,
|
||||
numberFormat: numberFormat,
|
||||
showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true,
|
||||
showAdventurersRecruited: body.profileSettings.showAdventurersRecruited ?? true,
|
||||
showApotheosis: body.profileSettings.showApotheosis ?? true,
|
||||
showBossesDefeated: body.profileSettings.showBossesDefeated ?? true,
|
||||
showCurrentClicks: body.profileSettings.showCurrentClicks ?? true,
|
||||
showCurrentGold: body.profileSettings.showCurrentGold ?? true,
|
||||
showGuildFounded: body.profileSettings.showGuildFounded ?? true,
|
||||
showLifetimeAchievementsUnlocked: body.profileSettings.showLifetimeAchievementsUnlocked ?? true,
|
||||
showLifetimeAdventurersRecruited: body.profileSettings.showLifetimeAdventurersRecruited ?? true,
|
||||
showLifetimeBossesDefeated: body.profileSettings.showLifetimeBossesDefeated ?? true,
|
||||
showLifetimeQuestsCompleted: body.profileSettings.showLifetimeQuestsCompleted ?? true,
|
||||
showOnLeaderboards: body.profileSettings.showOnLeaderboards ?? true,
|
||||
showPrestige: body.profileSettings.showPrestige ?? true,
|
||||
showQuestsCompleted: body.profileSettings.showQuestsCompleted ?? true,
|
||||
showTotalClicks: body.profileSettings.showTotalClicks ?? true,
|
||||
showTotalGold: body.profileSettings.showTotalGold ?? true,
|
||||
showTranscendence: body.profileSettings.showTranscendence ?? true,
|
||||
};
|
||||
|
||||
const activeTitle
|
||||
= typeof body.activeTitle === "string"
|
||||
? body.activeTitle.slice(0, 64)
|
||||
: undefined;
|
||||
|
||||
const updated = await prisma.player.update({
|
||||
data: {
|
||||
bio: bio,
|
||||
characterClass: characterClass,
|
||||
characterName: characterName,
|
||||
characterRace: characterRace,
|
||||
guildDescription: guildDescription,
|
||||
guildName: guildName,
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
profileSettings: profileSettings as object,
|
||||
pronouns: pronouns,
|
||||
...activeTitle === undefined
|
||||
? {}
|
||||
: { activeTitle },
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
return context.json({
|
||||
activeTitle: updated.activeTitle,
|
||||
bio: updated.bio,
|
||||
characterClass: updated.characterClass,
|
||||
characterName: updated.characterName,
|
||||
characterRace: updated.characterRace,
|
||||
guildDescription: updated.guildDescription,
|
||||
guildName: updated.guildName,
|
||||
profileSettings: profileSettings,
|
||||
pronouns: updated.pronouns,
|
||||
});
|
||||
});
|
||||
|
||||
export { profileRouter };
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @file Transcendence routes handling transcendence resets and echo upgrade purchases.
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
/* eslint-disable max-lines-per-function -- Route handlers require many steps */
|
||||
/* eslint-disable max-statements -- Route handlers require many statements */
|
||||
import { Hono } from "hono";
|
||||
import { defaultTranscendenceUpgrades } from "../data/transcendenceUpgrades.js";
|
||||
import { prisma } from "../db/client.js";
|
||||
import { authMiddleware } from "../middleware/auth.js";
|
||||
import {
|
||||
buildPostTranscendenceState,
|
||||
computeTranscendenceMultipliers,
|
||||
isEligibleForTranscendence,
|
||||
} from "../services/transcendence.js";
|
||||
import { postMilestoneWebhook } from "../services/webhook.js";
|
||||
import type { HonoEnvironment } from "../types/hono.js";
|
||||
import type { BuyEchoUpgradeRequest, GameState } from "@elysium/types";
|
||||
|
||||
const transcendenceRouter = new Hono<HonoEnvironment>();
|
||||
|
||||
transcendenceRouter.use("*", authMiddleware);
|
||||
|
||||
transcendenceRouter.post("/", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!isEligibleForTranscendence(state)) {
|
||||
return context.json(
|
||||
{
|
||||
error: "Not eligible for transcendence — defeat The Absolute One first",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
echoesEarned,
|
||||
transcendenceData,
|
||||
transcendenceState,
|
||||
} = buildPostTranscendenceState(state, state.player.characterName);
|
||||
|
||||
// Capture current-run stats before the nuclear reset
|
||||
const runBossesDefeated = state.bosses.filter((boss) => {
|
||||
return boss.status === "defeated";
|
||||
}).length;
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 7 -- @preserve */
|
||||
const runQuestsCompleted = state.quests.filter((quest) => {
|
||||
return quest.status === "completed";
|
||||
}).length;
|
||||
let runAdventurersRecruited = 0;
|
||||
for (const adventurer of state.adventurers) {
|
||||
runAdventurersRecruited = runAdventurersRecruited + adventurer.count;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next 3 -- @preserve */
|
||||
const runAchievementsUnlocked = state.achievements.filter((achievement) => {
|
||||
return achievement.unlockedAt !== null;
|
||||
}).length;
|
||||
|
||||
const now = Date.now();
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: transcendenceState as object, updatedAt: now },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
await prisma.player.update({
|
||||
data: {
|
||||
characterName: state.player.characterName,
|
||||
|
||||
lastSavedAt: now,
|
||||
|
||||
lifetimeAchievementsUnlocked: { increment: runAchievementsUnlocked },
|
||||
|
||||
lifetimeAdventurersRecruited: { increment: runAdventurersRecruited },
|
||||
|
||||
lifetimeBossesDefeated: { increment: runBossesDefeated },
|
||||
|
||||
lifetimeClicks: { increment: state.player.totalClicks },
|
||||
|
||||
// Accumulate into lifetime totals
|
||||
lifetimeGoldEarned: { increment: state.player.totalGoldEarned },
|
||||
|
||||
lifetimeQuestsCompleted: { increment: runQuestsCompleted },
|
||||
|
||||
totalClicks: 0,
|
||||
// Reset current-run counters (same as prestige)
|
||||
totalGoldEarned: 0,
|
||||
},
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
void postMilestoneWebhook(discordId, "transcendence", {
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
// eslint-disable-next-line capitalized-comments -- v8 ignore
|
||||
/* v8 ignore next -- @preserve */
|
||||
apotheosis: transcendenceState.apotheosis?.count ?? 0,
|
||||
|
||||
prestige: transcendenceState.prestige.count,
|
||||
|
||||
transcendence: transcendenceData.count,
|
||||
});
|
||||
|
||||
return context.json({
|
||||
echoes: echoesEarned,
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix -- API response field name required by client
|
||||
newTranscendenceCount: transcendenceData.count,
|
||||
});
|
||||
});
|
||||
|
||||
transcendenceRouter.post("/buy-upgrade", async(context) => {
|
||||
const discordId = context.get("discordId");
|
||||
const body = await context.req.json<BuyEchoUpgradeRequest>();
|
||||
|
||||
const { upgradeId } = body;
|
||||
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions -- Runtime body validation
|
||||
if (!upgradeId) {
|
||||
return context.json({ error: "upgradeId is required" }, 400);
|
||||
}
|
||||
|
||||
const upgrade = defaultTranscendenceUpgrades.find((transcendenceUpgrade) => {
|
||||
return transcendenceUpgrade.id === upgradeId;
|
||||
});
|
||||
if (!upgrade) {
|
||||
return context.json({ error: "Unknown echo upgrade" }, 404);
|
||||
}
|
||||
|
||||
const record = await prisma.gameState.findUnique({ where: { discordId } });
|
||||
if (!record) {
|
||||
return context.json({ error: "No save found" }, 404);
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma returns JsonValue */
|
||||
const state = record.state as unknown as GameState;
|
||||
|
||||
if (!state.transcendence) {
|
||||
return context.json({ error: "No transcendence data found" }, 400);
|
||||
}
|
||||
|
||||
const { purchasedUpgradeIds, echoes } = state.transcendence;
|
||||
|
||||
if (purchasedUpgradeIds.includes(upgradeId)) {
|
||||
return context.json({ error: "Upgrade already purchased" }, 400);
|
||||
}
|
||||
|
||||
if (echoes < upgrade.cost) {
|
||||
return context.json({ error: "Not enough echoes" }, 400);
|
||||
}
|
||||
|
||||
const updatedEchoes = echoes - upgrade.cost;
|
||||
const updatedPurchasedIds = [ ...purchasedUpgradeIds, upgradeId ];
|
||||
const updatedMultipliers
|
||||
= computeTranscendenceMultipliers(updatedPurchasedIds);
|
||||
|
||||
const updatedState: GameState = {
|
||||
...state,
|
||||
transcendence: {
|
||||
...state.transcendence,
|
||||
echoes: updatedEchoes,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
...updatedMultipliers,
|
||||
},
|
||||
};
|
||||
|
||||
await prisma.gameState.update({
|
||||
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Prisma requires object */
|
||||
data: { state: updatedState as object, updatedAt: Date.now() },
|
||||
where: { discordId },
|
||||
});
|
||||
|
||||
return context.json({
|
||||
echoesRemaining: updatedEchoes,
|
||||
purchasedUpgradeIds: updatedPurchasedIds,
|
||||
...updatedMultipliers,
|
||||
});
|
||||
});
|
||||
|
||||
export { transcendenceRouter };
|
||||
Reference in New Issue
Block a user