1 Commits

Author SHA1 Message Date
hikari eec93e442b chore: add vampire expansion implementation TODO 2026-03-24 19:51:59 -07:00
7 changed files with 223 additions and 315 deletions
+135
View File
@@ -0,0 +1,135 @@
# Vampire Expansion — Implementation TODO
Branch: `feat/expansions`
Thematic currency names:
- Gold → **Blood**
- Essence → **Ichor**
- Crystals → **Soul Shards**
- Runestones → **Bloodstones**
- Echoes → **Whispers**
- Click action → **Hunt**
- Adventurers → **Thralls**
- Prestige → **Siring** (working name)
- Transcendence → **The Awakening** (working name)
- Apotheosis → **Eternal Sovereignty** (role ID: 1486144657023959180)
CDN prefix for all vampire art: `https://cdn.nhcarrigan.com/elysium/vampire/<folder>/<id>.jpg`
Local scratch dir (delete before committing): `img/vampire/`
---
## Phase 1 — Types
- [ ] Add `VampireExpansionState` interface to `packages/types/src/interfaces/` mirroring full `GameState` structure (zones, bosses, quests, adventurers, upgrades, equipment, achievements, prestige, transcendence, apotheosis, exploration, resources, baseClickPower, lastTickAt, dailyChallenges, codex, autoQuest, autoBoss, autoAdventurer, companions, story)
- [ ] Add `ExpansionsState` interface: `{ vampire?: VampireExpansionState }`
- [ ] Add `expansions?: ExpansionsState` field to `GameState`
- [ ] Export new types from `packages/types/src/index.ts`
---
## Phase 2 — Data files (vampire content)
All data files go in `apps/api/src/data/vampire/`.
Same content scale as base game; use vampire theming throughout.
- [ ] `zones.ts` — 18 vampire-themed zones (crypts, blood forests, cursed castles, etc.)
- [ ] `bosses.ts` — 72 vampire-themed bosses (4 per zone)
- [ ] `quests.ts` — match base game quest count (~95); vampire-themed names/descriptions
- [ ] `adventurers.ts` — 32 thrall tiers with progressive stats
- [ ] `upgrades.ts` — match base game upgrade count (~57); vampire-themed
- [ ] `equipment.ts` — match base game equipment count (~53); vampire-themed sets
- [ ] `equipmentSets.ts` — vampire equipment sets
- [ ] `achievements.ts` — match base game count (~40); vampire-themed conditions
- [ ] `explorations.ts` — 72 areas across 18 vampire lore zones
- [ ] `materials.ts` — match base game material count (~54); vampire-themed
- [ ] `recipes.ts` — match base game recipe count (~36); vampire-themed
- [ ] `prestigeUpgrades.ts` — 25 "Siring" upgrades
- [ ] `transcendenceUpgrades.ts` — 15 "Awakening" upgrades
- [ ] `dailyChallenges.ts` — 10 vampire daily challenges
- [ ] `initialState.ts``initialVampireState()` function mirroring `initialGameState` structure
---
## Phase 3 — Art generation & CDN upload
For each category below, generate images via Gemini API (`gemini-3-pro-image-preview`),
save locally to `img/vampire/<folder>/`, upload to R2, then delete local files.
Use soft-shaded anime style; vampire/gothic aesthetic; crimson/black/dark purple palette.
- [ ] Zone banners (18) → `img/vampire/zones/` → CDN `vampire/zones/`
- [ ] Boss portraits (72) → `img/vampire/bosses/` → CDN `vampire/bosses/`
- [ ] Quest banners (match count) → `img/vampire/quests/` → CDN `vampire/quests/`
- [ ] Adventurer/thrall portraits (32) → `img/vampire/adventurers/` → CDN `vampire/adventurers/`
- [ ] Equipment icons (match count) → `img/vampire/equipment/` → CDN `vampire/equipment/`
- [ ] Achievement icons (match count) → `img/vampire/achievements/` → CDN `vampire/achievements/`
- [ ] Exploration area art (72) → `img/vampire/explorations/` → CDN `vampire/explorations/`
- [ ] Material icons (match count) → `img/vampire/materials/` → CDN `vampire/materials/`
- [ ] Story chapter banners (match count) → `img/vampire/story-chapters/` → CDN `vampire/story-chapters/`
---
## Phase 4 — API changes
- [ ] Add `inGuild` to Prisma `Player` model → update `initialGameState` if needed (already done in #134 — verify migration)
- [ ] Update Prisma schema: no DB changes needed (expansion state is inside the `GameState` JSON blob)
- [ ] Update `initialState.ts` to include `expansions: {}` in `initialGameState`
- [ ] Update `sync-new-content` debug route to inject/patch vampire expansion content when expansion is unlocked
- [ ] Add vampire-specific unlock trigger: when base-game apotheosis count ≥ 1, set `expansions.vampire` to `initialVampireState()` and `unlocked: true`
- [ ] Update the load endpoint to pass expansion state through to the client
- [ ] Ensure prestige/transcendence/apotheosis routes only reset state for their own expansion (base game routes must NOT touch `expansions.*`)
- [ ] Add vampire prestige, transcendence, and apotheosis routes (mirrors of base game routes, scoped to `expansions.vampire`)
- [ ] Grant `Eternal Sovereignty` role (ID: `1486144657023959180`) on vampire apotheosis
---
## Phase 5 — Frontend changes
### Expansion switcher
- [ ] Add expansion toggle buttons below the Early Access warning in the sidebar
- [ ] Always render all expansion buttons; disable any where `unlocked !== true`
- [ ] Active expansion stored in React state (not game state); defaults to `"base"`
- [ ] Switching expansion updates which data the UI panels display
### Resource bar
- [ ] Show ALL currencies from ALL expansions as separate labelled lines
- [ ] Vampire currencies use distinct icons/colours (crimson tint for blood, etc.)
- [ ] The "expand" button label shows the gold-equivalent currency of the active expansion
### Thematic UI
- [ ] When vampire expansion is active, swap labels: gold → Blood, essence → Ichor, etc.
- [ ] Apply `.vampire-mode` CSS class to game container when vampire is active
- [ ] Vampire colour palette: deep crimsons (`#5C0A1A`), rich crimson (`#C41E3A`), blacks, desaturated purples
### Tick engine
- [ ] Update `apps/web/src/engine/tick.ts` to compute passive income for all unlocked expansions every tick (not just base game)
- [ ] Offline income calculation must also cover all expansions
### Profile
- [ ] Profile panel: tab stats by expansion (base game tab + one tab per unlocked expansion)
- [ ] Show correct thematic prestige/transcendence/apotheosis badge names per expansion
- [ ] Lifetime stats (gold earned, clicks, etc.) tracked separately per expansion
### About / How to Play
- [ ] Update `aboutPanel.tsx` `HOW_TO_PLAY` array to document the expansion system
---
## Phase 6 — Tests & CI
- [ ] Unit tests for all new data files (at minimum, validate structure/required fields)
- [ ] Unit tests for `initialVampireState()`
- [ ] Tests for vampire unlock trigger route
- [ ] Tests for vampire prestige/transcendence/apotheosis routes
- [ ] Tests for updated tick engine (expansion income)
- [ ] Maintain 100% coverage on `apps/api` and `packages/types`
- [ ] Full pipeline: lint → build → test passing before PR
---
## Phase 7 — Final
- [ ] Delete `img/vampire/` directory before committing
- [ ] Update `MEMORY.md` with new content counts
- [ ] Open PR → request review
-85
View File
@@ -642,14 +642,6 @@ const patchAdventurerStats = (state: GameState): number => {
if (defaultAdventurer === undefined) { if (defaultAdventurer === undefined) {
continue; continue;
} }
const hasChanged
= savedAdventurer.baseCost !== defaultAdventurer.baseCost
|| savedAdventurer.class !== defaultAdventurer.class
|| savedAdventurer.combatPower !== defaultAdventurer.combatPower
|| savedAdventurer.essencePerSecond !== defaultAdventurer.essencePerSecond
|| savedAdventurer.goldPerSecond !== defaultAdventurer.goldPerSecond
|| savedAdventurer.level !== defaultAdventurer.level
|| savedAdventurer.name !== defaultAdventurer.name;
savedAdventurer.baseCost = defaultAdventurer.baseCost; savedAdventurer.baseCost = defaultAdventurer.baseCost;
savedAdventurer.class = defaultAdventurer.class; savedAdventurer.class = defaultAdventurer.class;
savedAdventurer.combatPower = defaultAdventurer.combatPower; savedAdventurer.combatPower = defaultAdventurer.combatPower;
@@ -657,10 +649,8 @@ const patchAdventurerStats = (state: GameState): number => {
savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond; savedAdventurer.goldPerSecond = defaultAdventurer.goldPerSecond;
savedAdventurer.level = defaultAdventurer.level; savedAdventurer.level = defaultAdventurer.level;
savedAdventurer.name = defaultAdventurer.name; savedAdventurer.name = defaultAdventurer.name;
if (hasChanged) {
patched = patched + 1; patched = patched + 1;
} }
}
return patched; return patched;
}; };
@@ -680,15 +670,6 @@ const patchQuestStats = (state: GameState): number => {
if (defaultQuest === undefined) { if (defaultQuest === undefined) {
continue; continue;
} }
const savedPrereqs = JSON.stringify(savedQuest.prerequisiteIds);
const defaultPrereqs = JSON.stringify(defaultQuest.prerequisiteIds);
const hasChanged
= savedQuest.name !== defaultQuest.name
|| savedQuest.description !== defaultQuest.description
|| savedQuest.durationSeconds !== defaultQuest.durationSeconds
|| savedPrereqs !== defaultPrereqs
|| savedQuest.zoneId !== defaultQuest.zoneId
|| savedQuest.combatPowerRequired !== defaultQuest.combatPowerRequired;
savedQuest.name = defaultQuest.name; savedQuest.name = defaultQuest.name;
savedQuest.description = defaultQuest.description; savedQuest.description = defaultQuest.description;
savedQuest.durationSeconds = defaultQuest.durationSeconds; savedQuest.durationSeconds = defaultQuest.durationSeconds;
@@ -697,10 +678,8 @@ const patchQuestStats = (state: GameState): number => {
if (defaultQuest.combatPowerRequired !== undefined) { if (defaultQuest.combatPowerRequired !== undefined) {
savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired; savedQuest.combatPowerRequired = defaultQuest.combatPowerRequired;
} }
if (hasChanged) {
patched = patched + 1; patched = patched + 1;
} }
}
return patched; return patched;
}; };
@@ -710,7 +689,6 @@ const patchQuestStats = (state: GameState): number => {
* @param state - The player's current game state (mutated in place). * @param state - The player's current game state (mutated in place).
* @returns The number of boss entries whose stats were updated. * @returns The number of boss entries whose stats were updated.
*/ */
/* eslint-disable-next-line complexity, max-statements -- Comparing many boss stat fields for change detection */
const patchBossStats = (state: GameState): number => { const patchBossStats = (state: GameState): number => {
const defaultBossMap = new Map(defaultBosses.map((boss) => { const defaultBossMap = new Map(defaultBosses.map((boss) => {
return [ boss.id, boss ] as const; return [ boss.id, boss ] as const;
@@ -721,20 +699,6 @@ const patchBossStats = (state: GameState): number => {
if (defaultBoss === undefined) { if (defaultBoss === undefined) {
continue; continue;
} }
const savedRewards = JSON.stringify(savedBoss.equipmentRewards);
const defaultRewards = JSON.stringify(defaultBoss.equipmentRewards);
const hasChanged
= savedBoss.name !== defaultBoss.name
|| savedBoss.description !== defaultBoss.description
|| savedBoss.maxHp !== defaultBoss.maxHp
|| savedBoss.damagePerSecond !== defaultBoss.damagePerSecond
|| savedBoss.goldReward !== defaultBoss.goldReward
|| savedBoss.essenceReward !== defaultBoss.essenceReward
|| savedBoss.crystalReward !== defaultBoss.crystalReward
|| savedRewards !== defaultRewards
|| savedBoss.prestigeRequirement !== defaultBoss.prestigeRequirement
|| savedBoss.zoneId !== defaultBoss.zoneId
|| savedBoss.bountyRunestones !== defaultBoss.bountyRunestones;
savedBoss.name = defaultBoss.name; savedBoss.name = defaultBoss.name;
savedBoss.description = defaultBoss.description; savedBoss.description = defaultBoss.description;
savedBoss.maxHp = defaultBoss.maxHp; savedBoss.maxHp = defaultBoss.maxHp;
@@ -746,10 +710,8 @@ const patchBossStats = (state: GameState): number => {
savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement; savedBoss.prestigeRequirement = defaultBoss.prestigeRequirement;
savedBoss.zoneId = defaultBoss.zoneId; savedBoss.zoneId = defaultBoss.zoneId;
savedBoss.bountyRunestones = defaultBoss.bountyRunestones; savedBoss.bountyRunestones = defaultBoss.bountyRunestones;
if (hasChanged) {
patched = patched + 1; patched = patched + 1;
} }
}
return patched; return patched;
}; };
@@ -769,21 +731,13 @@ const patchZoneStats = (state: GameState): number => {
if (defaultZone === undefined) { if (defaultZone === undefined) {
continue; continue;
} }
const hasChanged
= savedZone.name !== defaultZone.name
|| savedZone.description !== defaultZone.description
|| savedZone.emoji !== defaultZone.emoji
|| savedZone.unlockBossId !== defaultZone.unlockBossId
|| savedZone.unlockQuestId !== defaultZone.unlockQuestId;
savedZone.name = defaultZone.name; savedZone.name = defaultZone.name;
savedZone.description = defaultZone.description; savedZone.description = defaultZone.description;
savedZone.emoji = defaultZone.emoji; savedZone.emoji = defaultZone.emoji;
savedZone.unlockBossId = defaultZone.unlockBossId; savedZone.unlockBossId = defaultZone.unlockBossId;
savedZone.unlockQuestId = defaultZone.unlockQuestId; savedZone.unlockQuestId = defaultZone.unlockQuestId;
if (hasChanged) {
patched = patched + 1; patched = patched + 1;
} }
}
return patched; return patched;
}; };
@@ -793,7 +747,6 @@ const patchZoneStats = (state: GameState): number => {
* @param state - The player's current game state (mutated in place). * @param state - The player's current game state (mutated in place).
* @returns The number of upgrade entries whose stats were updated. * @returns The number of upgrade entries whose stats were updated.
*/ */
/* eslint-disable-next-line complexity -- Comparing many upgrade stat fields for change detection */
const patchUpgradeStats = (state: GameState): number => { const patchUpgradeStats = (state: GameState): number => {
const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => { const defaultUpgradeMap = new Map(defaultUpgrades.map((upgrade) => {
return [ upgrade.id, upgrade ] as const; return [ upgrade.id, upgrade ] as const;
@@ -804,15 +757,6 @@ const patchUpgradeStats = (state: GameState): number => {
if (defaultUpgrade === undefined) { if (defaultUpgrade === undefined) {
continue; continue;
} }
const hasChanged
= savedUpgrade.name !== defaultUpgrade.name
|| savedUpgrade.description !== defaultUpgrade.description
|| savedUpgrade.target !== defaultUpgrade.target
|| savedUpgrade.adventurerId !== defaultUpgrade.adventurerId
|| savedUpgrade.multiplier !== defaultUpgrade.multiplier
|| savedUpgrade.costGold !== defaultUpgrade.costGold
|| savedUpgrade.costEssence !== defaultUpgrade.costEssence
|| savedUpgrade.costCrystals !== defaultUpgrade.costCrystals;
savedUpgrade.name = defaultUpgrade.name; savedUpgrade.name = defaultUpgrade.name;
savedUpgrade.description = defaultUpgrade.description; savedUpgrade.description = defaultUpgrade.description;
savedUpgrade.target = defaultUpgrade.target; savedUpgrade.target = defaultUpgrade.target;
@@ -823,10 +767,8 @@ const patchUpgradeStats = (state: GameState): number => {
savedUpgrade.costGold = defaultUpgrade.costGold; savedUpgrade.costGold = defaultUpgrade.costGold;
savedUpgrade.costEssence = defaultUpgrade.costEssence; savedUpgrade.costEssence = defaultUpgrade.costEssence;
savedUpgrade.costCrystals = defaultUpgrade.costCrystals; savedUpgrade.costCrystals = defaultUpgrade.costCrystals;
if (hasChanged) {
patched = patched + 1; patched = patched + 1;
} }
}
return patched; return patched;
}; };
@@ -836,7 +778,6 @@ const patchUpgradeStats = (state: GameState): number => {
* @param state - The player's current game state (mutated in place). * @param state - The player's current game state (mutated in place).
* @returns The number of equipment entries whose stats were updated. * @returns The number of equipment entries whose stats were updated.
*/ */
/* eslint-disable-next-line complexity, max-statements -- Comparing many equipment stat fields for change detection */
const patchEquipmentStats = (state: GameState): number => { const patchEquipmentStats = (state: GameState): number => {
const defaultEquipmentMap = new Map(defaultEquipment.map((item) => { const defaultEquipmentMap = new Map(defaultEquipment.map((item) => {
return [ item.id, item ] as const; return [ item.id, item ] as const;
@@ -847,18 +788,6 @@ const patchEquipmentStats = (state: GameState): number => {
if (defaultItem === undefined) { if (defaultItem === undefined) {
continue; continue;
} }
const savedBonus = JSON.stringify(savedItem.bonus);
const defaultBonus = JSON.stringify(defaultItem.bonus);
const savedCost = JSON.stringify(savedItem.cost);
const defaultCost = JSON.stringify(defaultItem.cost);
const hasChanged
= savedItem.name !== defaultItem.name
|| savedItem.description !== defaultItem.description
|| savedItem.type !== defaultItem.type
|| savedItem.rarity !== defaultItem.rarity
|| savedBonus !== defaultBonus
|| savedCost !== defaultCost
|| savedItem.setId !== defaultItem.setId;
savedItem.name = defaultItem.name; savedItem.name = defaultItem.name;
savedItem.description = defaultItem.description; savedItem.description = defaultItem.description;
savedItem.type = defaultItem.type; savedItem.type = defaultItem.type;
@@ -870,10 +799,8 @@ const patchEquipmentStats = (state: GameState): number => {
if (defaultItem.setId !== undefined) { if (defaultItem.setId !== undefined) {
savedItem.setId = defaultItem.setId; savedItem.setId = defaultItem.setId;
} }
if (hasChanged) {
patched = patched + 1; patched = patched + 1;
} }
}
return patched; return patched;
}; };
@@ -893,16 +820,6 @@ const patchAchievementStats = (state: GameState): number => {
if (defaultAchievement === undefined) { if (defaultAchievement === undefined) {
continue; continue;
} }
const savedCondition = JSON.stringify(savedAchievement.condition);
const defaultCondition = JSON.stringify(defaultAchievement.condition);
const savedReward = JSON.stringify(savedAchievement.reward);
const defaultReward = JSON.stringify(defaultAchievement.reward);
const hasChanged
= savedAchievement.name !== defaultAchievement.name
|| savedAchievement.description !== defaultAchievement.description
|| savedAchievement.icon !== defaultAchievement.icon
|| savedCondition !== defaultCondition
|| savedReward !== defaultReward;
savedAchievement.name = defaultAchievement.name; savedAchievement.name = defaultAchievement.name;
savedAchievement.description = defaultAchievement.description; savedAchievement.description = defaultAchievement.description;
savedAchievement.icon = defaultAchievement.icon; savedAchievement.icon = defaultAchievement.icon;
@@ -910,10 +827,8 @@ const patchAchievementStats = (state: GameState): number => {
if (defaultAchievement.reward !== undefined) { if (defaultAchievement.reward !== undefined) {
savedAchievement.reward = { ...defaultAchievement.reward }; savedAchievement.reward = { ...defaultAchievement.reward };
} }
if (hasChanged) {
patched = patched + 1; patched = patched + 1;
} }
}
return patched; return patched;
}; };
+69 -16
View File
@@ -11,11 +11,10 @@
/* eslint-disable max-lines -- Boss panel with sub-component and helper function */ /* eslint-disable max-lines -- Boss panel with sub-component and helper function */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { computePartyCombatPower } from "../../engine/tick.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
import { LockToggle } from "../ui/lockToggle.js"; import { LockToggle } from "../ui/lockToggle.js";
import { ZoneSelector } from "./zoneSelector.js"; import { ZoneSelector } from "./zoneSelector.js";
import type { Boss } from "@elysium/types"; import type { Boss, GameState } from "@elysium/types";
interface BossCardProperties { interface BossCardProperties {
readonly boss: Boss; readonly boss: Boss;
@@ -158,6 +157,72 @@ const BossCard = ({
); );
}; };
/**
* Computes party DPS and HP from the current game state.
* @param state - The full game state.
* @returns The computed party DPS and HP values.
*/
const computePartyStats = (
state: GameState,
): {
partyDps: number;
partyHp: number;
} => {
const { upgrades, adventurers, equipment, prestige } = state;
let globalMultiplier = 1;
for (const upgrade of upgrades) {
const { purchased, target, multiplier } = upgrade;
if (purchased && target === "global") {
globalMultiplier = globalMultiplier * multiplier;
}
}
const prestigeBonus = prestige.count * 0.1;
const prestigeMultiplier = 1 + prestigeBonus;
const equipmentCombatMultiplier = equipment.
filter((item) => {
return item.equipped && item.bonus.combatMultiplier !== undefined;
}).
reduce((multiplier, item) => {
return multiplier * (item.bonus.combatMultiplier ?? 1);
}, 1);
let partyDps = 0;
let partyHp = 0;
for (const adventurer of adventurers) {
const { count, id: adventurerId, combatPower, level } = adventurer;
if (count === 0) {
continue;
}
let adventurerMultiplier = 1;
for (const upgrade of upgrades) {
const {
purchased,
target,
multiplier,
adventurerId: upgradeAdventurerId,
} = upgrade;
if (
purchased
&& target === "adventurer"
&& upgradeAdventurerId === adventurerId
) {
adventurerMultiplier = adventurerMultiplier * multiplier;
}
}
const dps
= combatPower
* count
* adventurerMultiplier
* globalMultiplier
* prestigeMultiplier;
partyDps = partyDps + dps;
const hp = level * 50 * count;
partyHp = partyHp + hp;
}
partyDps = partyDps * equipmentCombatMultiplier;
return { partyDps, partyHp };
};
/** /**
* Renders the boss panel with zone selection and boss list. * Renders the boss panel with zone selection and boss list.
* @returns The JSX element. * @returns The JSX element.
@@ -201,14 +266,7 @@ const BossPanel = (): JSX.Element => {
void handleChallenge(bossId); void handleChallenge(bossId);
} }
const { const { zones, bosses, quests, autoBoss, prestige: playerPrestige } = state;
adventurers,
autoBoss,
bosses,
prestige: playerPrestige,
quests,
zones,
} = state;
const activeZone = zones.find((zone) => { const activeZone = zones.find((zone) => {
return zone.id === activeZoneId; return zone.id === activeZoneId;
@@ -291,12 +349,7 @@ const BossPanel = (): JSX.Element => {
} }
const autoBossOn = autoBoss === true; const autoBossOn = autoBoss === true;
const partyDps = computePartyCombatPower(state); const { partyDps, partyHp } = computePartyStats(state);
let partyHp = 0;
for (const { level, count } of adventurers) {
// eslint-disable-next-line stylistic/no-mixed-operators -- level * 50 * count is clear
partyHp = partyHp + level * 50 * count;
}
const { count: prestigeCount } = playerPrestige; const { count: prestigeCount } = playerPrestige;
return ( return (
@@ -7,8 +7,6 @@
/* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */ /* eslint-disable react/no-multi-comp -- Sub-component is tightly coupled to the panel */
/* eslint-disable max-lines-per-function -- Complex component with many render paths */ /* eslint-disable max-lines-per-function -- Complex component with many render paths */
/* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */ /* eslint-disable complexity -- UpgradeCard has many conditional render paths for states */
/* eslint-disable max-statements -- UpgradePanel builds hints from three sources */
/* eslint-disable max-lines -- Upgrade panel with sub-component exceeds line limit */
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { cdnImage } from "../../utils/cdn.js"; import { cdnImage } from "../../utils/cdn.js";
@@ -240,22 +238,6 @@ const UpgradePanel = (): JSX.Element => {
} }
} }
} }
for (const upgrade of locked) {
if (
!upgradeUnlockHints.has(upgrade.id)
&& upgrade.adventurerId !== undefined
) {
const adventurerForHint = adventurers.find((a) => {
return a.id === upgrade.adventurerId;
});
if (adventurerForHint !== undefined) {
upgradeUnlockHints.set(
upgrade.id,
`🗡️ Recruit: ${adventurerForHint.name}`,
);
}
}
}
function handleToggle(): void { function handleToggle(): void {
setShowLocked((current) => { setShowLocked((current) => {
+5 -16
View File
@@ -10,12 +10,7 @@
/* eslint-disable complexity -- Many conditional resource and badge render paths */ /* eslint-disable complexity -- Many conditional resource and badge render paths */
import { useState, type FocusEvent, type JSX } from "react"; import { useState, type FocusEvent, type JSX } from "react";
import { useGame } from "../../context/gameContext.js"; import { useGame } from "../../context/gameContext.js";
import { import { RESOURCE_CAP, computeGoldPerSecond } from "../../engine/tick.js";
RESOURCE_CAP,
computeEssencePerSecond,
computeGoldPerSecond,
computePartyCombatPower,
} from "../../engine/tick.js";
import type { Resource } from "@elysium/types"; import type { Resource } from "@elysium/types";
interface ResourceBarProperties { interface ResourceBarProperties {
@@ -88,11 +83,12 @@ const ResourceBar = ({
const { gold, essence, crystals } = resources; const { gold, essence, crystals } = resources;
let partyCombatPower = 0; let partyCombatPower = 0;
let goldPerSecond = 0; let goldPerSecond = 0;
let essencePerSecond = 0;
if (state !== null) { if (state !== null) {
partyCombatPower = computePartyCombatPower(state); for (const adventurer of state.adventurers) {
const contribution = adventurer.combatPower * adventurer.count;
partyCombatPower = partyCombatPower + contribution;
}
goldPerSecond = computeGoldPerSecond(state); goldPerSecond = computeGoldPerSecond(state);
essencePerSecond = computeEssencePerSecond(state);
} }
let avatarUrl: string | null = null; let avatarUrl: string | null = null;
@@ -186,13 +182,6 @@ const ResourceBar = ({
</span> </span>
<span className="resource-label">{"Gold/s"}</span> <span className="resource-label">{"Gold/s"}</span>
</div> </div>
<div className="resource">
<span className="resource-icon">{"⚡"}</span>
<span className="resource-value">
{formatNumber(essencePerSecond)}
</span>
<span className="resource-label">{"Essence/s"}</span>
</div>
<div className={`resource${essenceFull <div className={`resource${essenceFull
? " resource-full" ? " resource-full"
: ""}`}> : ""}`}>
+7 -28
View File
@@ -58,7 +58,6 @@ import {
RESOURCE_CAP, RESOURCE_CAP,
applyTick, applyTick,
calculateClickPower, calculateClickPower,
computePartyCombatPower,
} from "../engine/tick.js"; } from "../engine/tick.js";
import { updateChallengeProgress } from "../utils/dailyChallenges.js"; import { updateChallengeProgress } from "../utils/dailyChallenges.js";
import { formatNumber as formatNumberUtil } from "../utils/format.js"; import { formatNumber as formatNumberUtil } from "../utils/format.js";
@@ -1079,7 +1078,11 @@ export const GameProvider = ({
return q.status === "active"; return q.status === "active";
}); });
if (!hasActiveQuest) { if (!hasActiveQuest) {
const partyCombatPower = computePartyCombatPower(next); // eslint-disable-next-line unicorn/no-array-reduce -- Need the total!
const partyCombatPower = next.adventurers.reduce((total, a) => {
const power = total + a.combatPower;
return power * a.count;
}, 0);
const zoneOrder = new Map( const zoneOrder = new Map(
next.zones.map((z, index) => { next.zones.map((z, index) => {
return [ z.id, index ]; return [ z.id, index ];
@@ -1117,31 +1120,14 @@ export const GameProvider = ({
next.autoAdventurer === true next.autoAdventurer === true
&& next.prestige.purchasedUpgradeIds.includes("auto_adventurer") && next.prestige.purchasedUpgradeIds.includes("auto_adventurer")
) { ) {
const maxAdventurerLevel = Math.max(
...next.adventurers.
filter((a) => {
return a.unlocked;
}).
map((a) => {
return a.level;
}),
);
const autoBuyCap = 100;
const [ bestAdventurer ] = next.adventurers. const [ bestAdventurer ] = next.adventurers.
filter((adventurer) => { filter((adventurer) => {
const cost const cost
= adventurer.baseCost * Math.pow(1.15, adventurer.count); = adventurer.baseCost * Math.pow(1.15, adventurer.count);
const isMaxTier = adventurer.level === maxAdventurerLevel; return adventurer.unlocked && next.resources.gold >= cost;
const withinCap
= isMaxTier || adventurer.count < autoBuyCap;
return (
adventurer.unlocked
&& next.resources.gold >= cost
&& withinCap
);
}). }).
sort((adventurerA, adventurerB) => { sort((adventurerA, adventurerB) => {
return adventurerB.level - adventurerA.level; return adventurerB.combatPower - adventurerA.combatPower;
}); });
if (bestAdventurer !== undefined) { if (bestAdventurer !== undefined) {
const purchaseCost const purchaseCost
@@ -1360,13 +1346,6 @@ export const GameProvider = ({
} }
return afterBoss; return afterBoss;
}); });
/*
* Boss fight modifies server state; clear stale signature so
* the next pre-save or auto-save does not send a mismatched one.
*/
signatureReference.current = null;
localStorage.removeItem("elysium_save_signature");
setAutoBossLastResult({ setAutoBossLastResult({
at: Date.now(), at: Date.now(),
bossName: bossName, bossName: bossName,
-145
View File
@@ -195,138 +195,6 @@ export const computeGoldPerSecond = (state: GameState): number => {
return goldPerSecond; return goldPerSecond;
}; };
/**
* Computes the current essence per second for the given game state,
* applying all relevant multipliers (upgrades, prestige, echo, crafted, companion).
* @param state - The current game state.
* @returns The total essence per second.
*/
export const computeEssencePerSecond = (state: GameState): number => {
const runestonesEssence = state.prestige.runestonesEssenceMultiplier ?? 1;
const craftedEssenceMultiplier
= state.exploration?.craftedEssenceMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionEssenceMult
= companionBonus?.type === "essenceIncome"
? 1 + companionBonus.value
: 1;
let essencePerSecond = 0;
for (const adventurer of state.adventurers) {
if (!adventurer.unlocked || adventurer.count === 0) {
continue;
}
const upgradeMultiplier = state.upgrades.
filter((upgrade) => {
const isGlobal = upgrade.target === "global";
const isThisAdventurer
= upgrade.target === "adventurer"
&& upgrade.adventurerId === adventurer.id;
return upgrade.purchased && (isGlobal || isThisAdventurer);
}).
reduce((mult, upgrade) => {
return mult * upgrade.multiplier;
}, 1);
const contribution
= adventurer.essencePerSecond
* adventurer.count
* upgradeMultiplier
* state.prestige.productionMultiplier
* runestonesEssence
* craftedEssenceMultiplier
* companionEssenceMult;
essencePerSecond = essencePerSecond + contribution;
}
return essencePerSecond;
};
/**
* Computes the party's total combat power, applying all active multipliers
* (upgrades, prestige, equipment, set bonuses, echo, crafted, companion).
* This mirrors the server-side calculatePartyStats in boss.ts.
* @param state - The current game state.
* @returns The total party combat power.
*/
export const computePartyCombatPower = (state: GameState): 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;
const equipmentCombatMultiplier = state.equipment.
filter((item) => {
return item.equipped && item.bonus.combatMultiplier !== undefined;
}).
reduce((mult, item) => {
return mult * (item.bonus.combatMultiplier ?? 1);
}, 1);
const equippedItemIds = state.equipment.
filter((item) => {
return item.equipped;
}).
map((item) => {
return item.id;
});
const { combatMultiplier: setCombatMultiplier } = computeSetBonuses(
equippedItemIds,
EQUIPMENT_SETS,
);
const echoCombatMultiplier
= state.transcendence?.echoCombatMultiplier ?? 1;
const craftedCombatMultiplier
= state.exploration?.craftedCombatMultiplier ?? 1;
const companionBonus = getActiveCompanionBonus(
state.companions?.activeCompanionId,
state.companions?.unlockedCompanionIds ?? [],
);
const companionCombatMult
= companionBonus?.type === "bossDamage"
? 1 + companionBonus.value
: 1;
let partyCombatPower = 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 contribution
= adventurer.combatPower
* adventurer.count
* adventurerMultiplier
* globalMultiplier
* prestigeMultiplier;
partyCombatPower = partyCombatPower + contribution;
}
return partyCombatPower
* equipmentCombatMultiplier
* setCombatMultiplier
* echoCombatMultiplier
* craftedCombatMultiplier
* companionCombatMult;
};
/** /**
* Pure function — applies one game tick to the state. * Pure function — applies one game tick to the state.
* DeltaSeconds: time elapsed since last tick. * DeltaSeconds: time elapsed since last tick.
@@ -601,19 +469,6 @@ export const applyTick = (
challengeCrystals = result.crystalsAwarded; challengeCrystals = result.crystalsAwarded;
} }
// Auto-unlock adventurer-specific upgrades when their adventurer is recruited
updatedUpgrades = updatedUpgrades.map((upgrade) => {
if (upgrade.unlocked || upgrade.adventurerId === undefined) {
return upgrade;
}
const adventurer = updatedAdventurers.find((a) => {
return a.id === upgrade.adventurerId;
});
return adventurer !== undefined && adventurer.count > 0
? { ...upgrade, unlocked: true }
: upgrade;
});
const goldValue = capResource(state.resources.gold + goldGained + questGold); const goldValue = capResource(state.resources.gold + goldGained + questGold);
const essenceValue = capResource( const essenceValue = capResource(
state.resources.essence + essenceGained + questEssence, state.resources.essence + essenceGained + questEssence,