feat: initial prototype — core game systems (#30)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m1s
CI / Lint, Build & Test (push) Successful in 1m6s

## 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:
2026-03-08 15:53:39 -07:00
committed by Naomi Carrigan
parent c69e155de3
commit 29c817230d
172 changed files with 50706 additions and 0 deletions
+374
View File
@@ -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 };