diff --git a/README.md b/README.md index f6e0558..456a212 100755 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

Mana Loop Logo
- An incremental/idle game about climbing a magical spire, mastering skills, and uncovering ancient secrets. + An incremental/idle game about climbing a magical spire, mastering disciplines, and uncovering ancient secrets.

@@ -15,7 +15,7 @@

- Version + Version License Next.js TypeScript @@ -42,13 +42,13 @@ ## Overview -**Mana Loop** is a browser-based incremental/idle game where players gather mana, master skills, climb a mysterious 100-floor spire, craft enchanted equipment, and summon magical golems. The game features a unique time-loop prestige system (Insight) that provides permanent progression bonuses across playthroughs. +**Mana Loop** is a browser-based incremental/idle game where players gather mana, practice disciplines, climb a mysterious spire, craft enchanted equipment, and summon magical golems. The game features a unique time-loop prestige system (Insight) that provides permanent progression bonuses across playthroughs. ### Core Game Loop 1. **Gather Mana** - Click to collect mana or let it regenerate automatically (14 total mana types) -2. **Study Skills & Spells** - 20+ skills with 5-tier evolution system and milestone upgrades -3. **Climb the Spire** - Battle through 100 procedurally-generated floors, defeat guardians, sign pacts +2. **Practice Disciplines** - Continuously train abilities that drain mana each tick in exchange for growing stat bonuses +3. **Climb the Spire** - Battle through procedurally-generated floors; every 10th floor is a guardian encounter 4. **Craft & Enchant** - 3-stage equipment enchantment system with capacity limits 5. **Summon Golems** - Magical constructs that fight alongside you (4 base + 6 hybrid types) 6. **Prestige (Loop)** - Reset progress for Insight currency, gain permanent bonuses @@ -62,18 +62,19 @@ - Elemental conversion, regeneration mechanics, and meditation bonuses - Mana types: Fire, Water, Air, Earth, Light, Dark, Death (base), Transference (utility), Metal, Sand, Lightning (compound), Crystal, Stellar, Void (exotic) -### πŸ“œ Skill & Spell System -- 20+ skills across multiple categories (mana, study, enchanting, golemancy) -- 5-tier evolution system for each skill -- Milestone upgrades at levels 5 and 10 per tier -- Unique special effects unlocked through skill upgrades +### πŸ“œ Discipline System +- Practice-based progression - no discrete levels, only continuous XP growth +- Disciplines drain mana each tick; stat bonuses grow as a power curve of accumulated XP +- Perks unlock at XP thresholds (once, capped, or infinite stacking) +- Attunement-gated discipline pools (Base / Enchanter / Invoker / Fabricator) +- Concurrent discipline slots unlock as total XP grows (max 4) ### βš”οΈ Combat & Spire -- Cast-speed based combat system +- Cast-speed based combat system with elemental effectiveness - Multi-spell support from equipped weapons -- 100-floor spire with elemental themes -- Floor guardians with unique mechanics and pacts +- Every 10th floor is a guardian: base elements (10–80), compound (90–110), exotic (120–140), then procedural combination bosses (150+) - Golem allies that deal automatic damage each tick +- Enemy modifiers: Armored, Agile, Mage, Shield, Swarm ### πŸ›‘οΈ Equipment & Enchanting - 3-stage enchantment process: Design β†’ Prepare β†’ Apply @@ -86,20 +87,19 @@ - Summon magical constructs (Earth, Steel, Crystal, Sand + 6 hybrid types) - Golem slots unlock every 2 Fabricator levels (max 5 slots at Level 10) - Hybrid golems require Enchanter 5 + Fabricator 5 -- Golem maintenance costs and stat upgrades via skills ### πŸ”„ Prestige (Insight) - Reset progress for permanent Insight currency - Insight upgrades across multiple categories - Signed pacts and attunements persist through prestige -- Three attunement classes: Enchanter (Transference), Invoker (Spells), Fabricator (Golems/Equipment) +- Three attunement classes: Enchanter (Transference), Invoker (Spells/Pacts), Fabricator (Golems/Equipment) --- ## Tech Stack | Technology | Version | Purpose | -|------------|---------|---------| +|------------|---------|---------| | **Next.js** | ^16.1.1 | Full-stack framework (App Router) | | **React** | ^19.0.0 | UI library | | **TypeScript** | ^5 | Type-safe development | @@ -176,50 +176,53 @@ Mana-Loop/ β”œβ”€β”€ src/ # Application source code β”‚ β”œβ”€β”€ app/ # Next.js App Router β”‚ β”‚ β”œβ”€β”€ layout.tsx # Root layout (metadata, fonts, providers) -β”‚ β”‚ β”œβ”€β”€ page.tsx # Main game UI (~583 lines) +β”‚ β”‚ β”œβ”€β”€ page.tsx # Main game UI β”‚ β”‚ β”œβ”€β”€ globals.css # Global styles β”‚ β”‚ └── api/ # API routes (minimal) β”‚ β”œβ”€β”€ components/ # React components β”‚ β”‚ β”œβ”€β”€ ui/ # shadcn/ui components (20+ components) β”‚ β”‚ └── game/ # Game-specific components -β”‚ β”‚ β”œβ”€β”€ tabs/ # Tab components (SpireTab, SkillsTab, etc.) +β”‚ β”‚ β”œβ”€β”€ tabs/ # Tab components (SpireTab, DisciplinesTab, etc.) β”‚ β”‚ β”œβ”€β”€ ManaDisplay.tsx, ActionButtons.tsx, TimeDisplay.tsx β”‚ β”‚ └── crafting/, debug/, shared/, stats/ subdirectories β”‚ β”œβ”€β”€ hooks/ # Custom React hooks (use-mobile, use-toast) -β”‚ β”œβ”€β”€ lib/ # Utility libraries -β”‚ β”‚ β”œβ”€β”€ game/ # Core game logic -β”‚ β”‚ β”‚ β”œβ”€β”€ store.ts # Main Zustand store (~2862 lines) -β”‚ β”‚ β”‚ β”œβ”€β”€ crafting-slice.ts, study-slice.ts, navigation-slice.ts -β”‚ β”‚ β”‚ β”œβ”€β”€ effects.ts, upgrade-effects.ts -β”‚ β”‚ β”‚ β”œβ”€β”€ skill-evolution.ts (~3400 lines) -β”‚ β”‚ β”‚ β”œβ”€β”€ constants/ # Game definitions (elements, spells, skills) -β”‚ β”‚ β”‚ β”œβ”€β”€ data/ # Game data (equipment, golems, recipes) -β”‚ β”‚ β”‚ └── __tests__/ # Test files for game logic -β”‚ β”‚ └── db.ts, utils.ts -β”‚ └── test/ # Test setup +β”‚ └── lib/ # Utility libraries +β”‚ └── game/ # Core game logic +β”‚ β”œβ”€β”€ stores/ # Modular Zustand stores +β”‚ β”‚ β”œβ”€β”€ gameStore.ts # Core state & tick logic +β”‚ β”‚ β”œβ”€β”€ manaStore.ts # Mana gathering & conversion +β”‚ β”‚ β”œβ”€β”€ combatStore.ts # Combat, spells, floor progression +β”‚ β”‚ β”œβ”€β”€ prestigeStore.ts # Prestige/loop & insight +β”‚ β”‚ β”œβ”€β”€ discipline-slice.ts # Discipline activation & XP +β”‚ β”‚ β”œβ”€β”€ attunementStore.ts # Attunement classes +β”‚ β”‚ β”œβ”€β”€ craftingStore.ts # Crafting state +β”‚ β”‚ └── uiStore.ts # UI state & modals +β”‚ β”œβ”€β”€ crafting-actions/ # Modular crafting stage handlers +β”‚ β”œβ”€β”€ constants/ # Elements, spells, rooms, prestige +β”‚ β”œβ”€β”€ data/ # Game data +β”‚ β”‚ β”œβ”€β”€ disciplines/ # Per-attunement discipline definitions +β”‚ β”‚ β”œβ”€β”€ enchantments/ # Enchantment effects by category +β”‚ β”‚ β”œβ”€β”€ equipment/ # Equipment type definitions +β”‚ β”‚ β”œβ”€β”€ golems/ # Golem definitions +β”‚ β”‚ β”œβ”€β”€ guardian-data.ts # Static guardian definitions (floors 10–140) +β”‚ β”‚ └── guardian-encounters.ts # Procedural guardian lookup & combo bosses +β”‚ β”œβ”€β”€ effects/ # Unified stat computation +β”‚ β”‚ └── discipline-effects.ts # Discipline β†’ getUnifiedEffects() +β”‚ β”œβ”€β”€ types/ # TypeScript types (disciplines, elements, etc.) +β”‚ └── utils/ # Combat, floor, enemy, discipline math helpers β”œβ”€β”€ prisma/ # Database schema and migrations -β”‚ └── schema.prisma # SQLite schema -β”œβ”€β”€ public/ # Static assets (logo.svg, robots.txt) +β”œβ”€β”€ public/ # Static assets β”œβ”€β”€ docs/ # Project documentation -β”‚ β”œβ”€β”€ AGENTS.md # Comprehensive architecture guide -β”‚ β”œβ”€β”€ GAME_BRIEFING.md # Game design document -β”‚ └── task/ # Task tracking documentation -β”œβ”€β”€ .next/ # Next.js build output (generated) -β”œβ”€β”€ node_modules/ # Dependencies (generated) -β”œβ”€β”€ Configuration Files: -β”‚ β”œβ”€β”€ package.json # Project metadata and scripts -β”‚ β”œβ”€β”€ tsconfig.json # TypeScript configuration -β”‚ β”œβ”€β”€ next.config.ts # Next.js config (standalone output) -β”‚ β”œβ”€β”€ vitest.config.ts # Vitest test configuration -β”‚ β”œβ”€β”€ eslint.config.mjs # ESLint configuration -β”‚ β”œβ”€β”€ Dockerfile # Docker multi-stage build -β”‚ β”œβ”€β”€ docker-compose.yml # Docker Compose setup -β”‚ β”œβ”€β”€ Caddyfile # Reverse proxy configuration -β”‚ └── .gitea/workflows/ # Gitea Actions CI/CD pipeline -└── README.md # This file +β”‚ β”œβ”€β”€ AGENTS.md # Architecture guide for AI agents +β”‚ └── GAME_BRIEFING.md # Comprehensive game design document +└── Configuration Files: + β”œβ”€β”€ package.json, tsconfig.json, next.config.ts + β”œβ”€β”€ vitest.config.ts, eslint.config.mjs + β”œβ”€β”€ Dockerfile, docker-compose.yml, Caddyfile + └── .gitea/workflows/ # Gitea Actions CI/CD pipeline ``` -For detailed architecture patterns and coding guidelines, see [AGENTS.md](./docs/AGENTS.md). +For detailed architecture patterns and coding guidelines, see [AGENTS.md](./AGENTS.md). --- @@ -232,36 +235,50 @@ The core resource of the game with 14 distinct types organized in a hierarchy: - **Compound (3)**: Metal (Fire+Earth), Sand (Earth+Water), Lightning (Fire+Air) - **Exotic (3)**: Crystal (Sand+Sand+Light), Stellar (Fire+Fire+Light), Void (Dark+Dark+Death) -**Key Files**: `src/lib/game/store.ts`, `src/lib/game/constants/elements.ts` +**Key Files**: `src/lib/game/stores/manaStore.ts`, `src/lib/game/constants/elements.ts` -### Skill Evolution System -Each skill progresses through 5 tiers with upgrades at levels 5 and 10 per tier: -- **Tier 1**: Basic functionality -- **Tier 2-5**: Unlock new mechanics and bonuses -- **Evolution Paths**: Defined in `src/lib/game/skill-evolution.ts` (~3400 lines) +### Discipline System +Disciplines replace the old skill system entirely. There are no discrete levels - disciplines grow **continuously** through practice. The player activates a discipline and it drains mana each tick in exchange for permanent stat growth within the run. + +- **Stat bonus** grows as a power curve of XP: `baseValue Γ— (XP / scalingFactor)^0.65` +- **Mana drain** also increases with XP: `drainBase Γ— (1 + (XP / difficultyFactor)^0.4)` +- **Perks** unlock at XP thresholds (`once`, `capped`, or `infinite`) +- **Concurrent slots** start at 1 and unlock as total XP grows (max 4) + +**Key Files**: `src/lib/game/data/disciplines/`, `src/lib/game/stores/discipline-slice.ts`, `src/lib/game/utils/discipline-math.ts` + +### Guardian & Spire System +Every 10th floor is a guardian encounter. Guardians progress through four tiers of complexity: + +1. **Base Elements (Floors 10–80)**: One guardian per base element + Transference. Static definitions with named guardians (Ignis Prime, Aqua Regia, etc.). Defeating them unlocks their associated mana types. +2. **Compound Elements (Floors 90–110)**: Metal, Sand, and Lightning guardians with procedurally generated names. +3. **Exotic Elements (Floors 120–140)**: Crystal, Stellar, and Void guardians - the most powerful single-element encounters. +4. **Combination Bosses (Floor 150+)**: Fully procedural dual-element guardians. Each one wields two base elements simultaneously (e.g. Fire+Water, Light+Dark) and grows stronger every 10 floors. + +**Key Files**: `src/lib/game/data/guardian-data.ts`, `src/lib/game/data/guardian-encounters.ts` ### Combat System -- Cast-speed based spell casting with DPS calculations -- Elemental damage bonuses and effectiveness -- Multi-spell support from equipped weapons +- Cast-speed based spell casting with elemental effectiveness multipliers +- Enemy modifiers: Armored, Agile, Mage (barrier), Shielded, Swarm - Golem allies deal automatic damage each tick +- Discipline bonuses feed into damage via `getUnifiedEffects()` -**Key Files**: `src/lib/game/store.ts` (combat tick logic), `src/lib/game/constants/spells.ts` +**Key Files**: `src/lib/game/stores/combatStore.ts`, `src/lib/game/utils/combat-utils.ts`, `src/lib/game/utils/enemy-generator.ts` ### Enchanting System 3-stage equipment enchantment process: 1. **Design**: Choose effects for your equipment type -2. **Prepare**: Prepare equipment (ONLY way to disenchant existing enchantments) +2. **Prepare**: Ready equipment (ONLY stage where disenchanting is possible) 3. **Apply**: Apply designed enchantments (cannot re-enchant already enchanted gear) -**Key Files**: `src/lib/game/crafting-slice.ts`, `src/lib/game/data/enchantment-effects.ts` +**Key Files**: `src/lib/game/crafting-actions/`, `src/lib/game/data/enchantments/` ### Golemancy System - **Base Golems**: Earth (Fabricator 2), Steel (Metal), Crystal, Sand - **Hybrid Golems** (Enchanter 5 + Fabricator 5): Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone - **Golem Slots**: 1 slot at Fabricator Level 2, +1 every 2 levels (max 5 at Level 10) -**Key Files**: `src/lib/game/data/golems.ts`, `src/lib/game/store.ts` +**Key Files**: `src/lib/game/data/golems/`, `src/lib/game/stores/gameStore.ts` ### Prestige (Insight) Reset progress to gain Insight currency for permanent upgrades: @@ -274,7 +291,6 @@ Reset progress to gain Insight currency for permanent upgrades: ## Deployment ### Docker Deployment -The project includes Docker configuration for containerized deployment: ```bash # Build and run with Docker Compose @@ -286,7 +302,7 @@ docker run -p 3000:3000 mana-loop ``` ### CI/CD Pipeline -- **Gitea Actions**: `.gitea/workflows/docker-build.yaml` automatically builds and pushes Docker images to `gitea.tailf367e3.ts.net/anexim/mana-loop:latest` on push to `master`/`main` branches +- **Gitea Actions**: `.gitea/workflows/docker-build.yaml` automatically builds and pushes Docker images to `gitea.tailf367e3.ts.net/anexim/mana-loop:latest` on push to `master`/`main` - **Multi-platform**: Builds for linux/amd64 architecture - **Image Tags**: Branch name, commit SHA, "latest" @@ -316,26 +332,24 @@ We welcome contributions! Please follow these guidelines: ### Code Style - TypeScript throughout with strict typing - Use existing shadcn/ui components over custom implementations -- Follow the slice pattern for Zustand store actions -- Keep components focused (extract to separate files when >50 lines) +- Follow the modular store pattern (`src/lib/game/stores/`) +- Keep files under 400 lines (enforced by pre-commit hook) - Use path aliases: `@/*` maps to `./src/*` ### Adding New Features -For detailed patterns on adding new effects, skills, spells, or systems, see the comprehensive [AGENTS.md](./docs/AGENTS.md) guide, which includes: -- Architecture overview -- Coding patterns -- Git workflow (mandatory pull before work, commit & push after) -- Credentials for automation (if applicable) +For detailed patterns on adding new effects, disciplines, spells, or systems, see the comprehensive [AGENTS.md](./AGENTS.md) guide, which includes architecture overview, coding patterns, and git workflow. --- ## Banned Content -The following content has been removed from the game and should not be re-added: +The following content has been removed from the game and must not be re-added: ### Banned Mechanics - **Lifesteal** - Player cannot heal from dealing damage -- **Healing** - Player cannot heal themselves (floors take damage, not player) +- **Healing** - Player cannot heal themselves (floors take damage, not the player) +- **Scroll crafting** - Violates the no-instant-finishing design pillar +- **Ascension skills** - Removed; no replacement ### Banned Mana Types - **Life** - Removed (healing theme conflicts with core design) @@ -345,14 +359,13 @@ The following content has been removed from the game and should not be re-added: - **Force** - Removed ### Banned Systems -- **Familiar System** - Removed in favor of Golemancy and Pact systems +- **Familiar System** - Removed in favour of Golemancy and Pact systems +- **Skill System** (study, tiers T1–T5, milestone upgrades) - Fully replaced by the Discipline System --- ## License -This project is licensed under the MIT License - see the LICENSE section below for details. - ``` MIT License @@ -377,8 +390,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` -**Note**: A `LICENSE` file is not currently present in the project root. It is recommended to create one with the above MIT License text. - --- ## Acknowledgments @@ -393,4 +404,4 @@ SOFTWARE.

Climb the spire. Master the mana. Uncover the loop. -

+

\ No newline at end of file diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 6f04272..48e7bd7 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,8 +1,8 @@ # Circular Dependencies -Generated: 2026-05-25T18:44:15.053Z +Generated: 2026-05-26T08:53:53.586Z Found: 6 circular chain(s) β€” these MUST be fixed before modifying involved files. -1. Processed 135 files (1.7s) (2 warnings) +1. Processed 135 files (1.5s) (2 warnings) 2. 1) utils/floor-utils.ts > utils/room-utils.ts > utils/enemy-utils.ts 3. 2) utils/floor-utils.ts > utils/room-utils.ts 4. 3) stores/gameStore.ts > stores/gameActions.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 5a8bc97..bdd3c02 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-25T18:44:13.132Z", + "generated": "2026-05-26T08:53:51.901Z", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." }, @@ -123,6 +123,7 @@ "stores/craftingStore.types.ts" ], "crafting-apply.ts": [ + "constants.ts", "crafting-utils.ts", "data/attunements.ts", "data/enchantment-effects.ts", @@ -135,6 +136,7 @@ "types.ts" ], "crafting-design.ts": [ + "constants.ts", "data/attunements.ts", "data/enchantment-effects.ts", "data/equipment/index.ts", @@ -143,6 +145,7 @@ "types.ts" ], "crafting-equipment.ts": [ + "constants.ts", "data/crafting-recipes.ts", "data/equipment/index.ts", "types.ts", @@ -153,6 +156,7 @@ "types.ts" ], "crafting-prep.ts": [ + "constants.ts", "crafting-utils.ts", "types.ts" ], diff --git a/scorecard.png b/scorecard.png index e9377de..efe38c7 100644 Binary files a/scorecard.png and b/scorecard.png differ diff --git a/src/components/game/AttunementStatus.tsx b/src/components/game/AttunementStatus.tsx index 327654b..9f825ba 100644 --- a/src/components/game/AttunementStatus.tsx +++ b/src/components/game/AttunementStatus.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMemo } from 'react'; import { useAttunementStore } from '@/lib/game/stores'; import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements'; import { Separator } from '@/components/ui/separator'; @@ -18,13 +19,17 @@ const SLOT_LABELS: Record = { export function AttunementStatus() { const attunements = useAttunementStore((s) => s.attunements); - const activeAttunements = Object.entries(attunements) - .filter(([, state]) => state.active) - .sort(([, a], [, b]) => { - const orderA = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === a.id); - const orderB = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === b.id); - return orderA - orderB; - }); + const attunementOrder = useMemo(() => { + const map = new Map(); + Object.values(ATTUNEMENTS_DEF).forEach((d, i) => map.set(d.id, i)); + return map; + }, []); + + const activeAttunements = useMemo(() => { + return Object.entries(attunements) + .filter(([, state]) => state.active) + .sort(([, a], [, b]) => (attunementOrder.get(a.id) ?? 0) - (attunementOrder.get(b.id) ?? 0)); + }, [attunements, attunementOrder]); const xpForNext = (level: number) => { if (level <= 1) return 0; diff --git a/src/components/game/ManaDisplay.tsx b/src/components/game/ManaDisplay.tsx index cbbb598..47669ef 100755 --- a/src/components/game/ManaDisplay.tsx +++ b/src/components/game/ManaDisplay.tsx @@ -67,7 +67,7 @@ export function ManaDisplay({ style={{ background: 'var(--mana-raw)', border: '1px solid var(--border-accent)', - color: '#0C1020', + color: 'var(--bg-gather-btn)', fontWeight: 600, }} onMouseDown={onGatherStart} diff --git a/src/components/game/tabs/AchievementsTab.tsx b/src/components/game/tabs/AchievementsTab.tsx index 7956ce6..0083de7 100644 --- a/src/components/game/tabs/AchievementsTab.tsx +++ b/src/components/game/tabs/AchievementsTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useEffect } from 'react'; +import { useState, useMemo } from 'react'; import { useCombatStore } from '@/lib/game/stores'; import { ACHIEVEMENTS, @@ -164,14 +164,8 @@ function CategorySection({ export function AchievementsTab() { const achievements = useCombatStore((s) => s.achievements); - const [mounted, setMounted] = useState(false); const [collapsedCategories, setCollapsedCategories] = useState>({}); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); - }, []); - const byCategory = useMemo(() => getAchievementsByCategory(), []); const categories = useMemo( () => Object.keys(byCategory).sort(), @@ -188,14 +182,6 @@ export function AchievementsTab() { })); }; - if (!mounted) { - return ( -
- Loading achievements… -
- ); - } - return (
diff --git a/src/components/game/tabs/AttunementsTab.tsx b/src/components/game/tabs/AttunementsTab.tsx index ca2ab23..34fc545 100644 --- a/src/components/game/tabs/AttunementsTab.tsx +++ b/src/components/game/tabs/AttunementsTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useAttunementStore } from '@/lib/game/stores'; import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements'; import type { AttunementDef, AttunementState } from '@/lib/game/types'; @@ -157,24 +157,10 @@ function AttunementCard({ def, state }: AttunementCardProps) { export function AttunementsTab() { const attunements = useAttunementStore((s) => s.attunements); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); - }, []); const allDefs = Object.values(ATTUNEMENTS_DEF); const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length; - if (!mounted) { - return ( -
- Loading attunements… -
- ); - } - return (
diff --git a/src/components/game/tabs/DisciplinesTab.tsx b/src/components/game/tabs/DisciplinesTab.tsx index 4fd3b96..c924fa1 100644 --- a/src/components/game/tabs/DisciplinesTab.tsx +++ b/src/components/game/tabs/DisciplinesTab.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { useDisciplineStore } from '@/lib/game/stores/discipline-slice'; import type { DisciplineDefinition } from '@/lib/game/types/disciplines'; import type { ManaType } from '@/lib/game/types/elements'; @@ -201,14 +201,8 @@ export const DisciplinesTab: React.FC = () => { const activate = useDisciplineStore((s) => s.activate); const deactivate = useDisciplineStore((s) => s.deactivate); - const [mounted, setMounted] = useState(false); const [activeAttunement, setActiveAttunement] = useState('base'); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); - }, []); - const handleToggle = useCallback((id: string, paused: boolean) => { if (paused) { activate(id); @@ -217,14 +211,6 @@ export const DisciplinesTab: React.FC = () => { } }, [activate, deactivate]); - if (!mounted) { - return ( -
- Loading disciplines… -
- ); - } - const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement); return ( diff --git a/src/components/game/tabs/EquipmentTab.tsx b/src/components/game/tabs/EquipmentTab.tsx index bb70dba..54225a5 100644 --- a/src/components/game/tabs/EquipmentTab.tsx +++ b/src/components/game/tabs/EquipmentTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useCraftingStore } from '@/lib/game/stores/craftingStore'; import type { EquipmentSlot } from '@/lib/game/types'; import { DebugName } from '@/components/game/debug/debug-context'; @@ -9,19 +9,12 @@ import { InventoryList } from './EquipmentTab/InventoryList'; import { EquipmentEffectsSummary } from './EquipmentTab/EquipmentEffectsSummary'; export function EquipmentTab() { - const [mounted, setMounted] = useState(false); - const equippedInstances = useCraftingStore((s) => s.equippedInstances); const equipmentInstances = useCraftingStore((s) => s.equipmentInstances); const storeEquipItem = useCraftingStore((s) => s.equipItem); const storeUnequipItem = useCraftingStore((s) => s.unequipItem); const storeDeleteEquipment = useCraftingStore((s) => s.deleteEquipmentInstance); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); - }, []); - const handleEquip = useCallback( (instanceId: string, slot: EquipmentSlot): boolean => { return storeEquipItem(instanceId, slot); @@ -51,14 +44,6 @@ export function EquipmentTab() { [equipmentInstances, equippedInstances] ); - if (!mounted) { - return ( -
- Loading equipment… -
- ); - } - return (
diff --git a/src/components/game/tabs/GolemancyTab.tsx b/src/components/game/tabs/GolemancyTab.tsx index 3365ee4..613e536 100644 --- a/src/components/game/tabs/GolemancyTab.tsx +++ b/src/components/game/tabs/GolemancyTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { useCombatStore } from '@/lib/game/stores/combatStore'; import { useAttunementStore } from '@/lib/game/stores/attunementStore'; @@ -197,7 +197,6 @@ GolemCard.displayName = 'GolemCard'; // ─── Main Tab ──────────────────────────────────────────────────────────────── export const GolemancyTab: React.FC = () => { - const [mounted, setMounted] = useState(false); const [activeTier, setActiveTier] = useState('base'); const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({ @@ -210,11 +209,6 @@ export const GolemancyTab: React.FC = () => { elements: s.elements, }))); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); - }, []); - // Build attunement lookup for isGolemUnlocked const attunementLookup = useMemo(() => { const lookup: Record = {}; @@ -254,14 +248,6 @@ export const GolemancyTab: React.FC = () => { const golemSlots = getGolemSlots(fabricatorLevel); const enabledCount = golemancy.enabledGolems.length; - if (!mounted) { - return ( -
- Loading golemancy… -
- ); - } - const activeTierGolems = golemsByTier[activeTier] ?? []; return ( diff --git a/src/components/game/tabs/GuardianPactsTab.tsx b/src/components/game/tabs/GuardianPactsTab.tsx index 2115aab..cb50d40 100644 --- a/src/components/game/tabs/GuardianPactsTab.tsx +++ b/src/components/game/tabs/GuardianPactsTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { usePrestigeStore } from '@/lib/game/stores/prestigeStore'; import { useManaStore } from '@/lib/game/stores/manaStore'; @@ -53,7 +53,6 @@ function groupFloorsByTier(floors: number[]): FloorTier[] { // ─── Main Tab ──────────────────────────────────────────────────────────────── export const GuardianPactsTab: React.FC = () => { - const [mounted, setMounted] = useState(false); const [activeTier, setActiveTier] = useState('all'); const { @@ -75,11 +74,6 @@ export const GuardianPactsTab: React.FC = () => { const rawMana = useManaStore(s => s.rawMana); const addLog = useUIStore(s => s.addLog); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); - }, []); - const guardianFloors = useMemo( () => getAllGuardianFloors(), [], @@ -126,14 +120,6 @@ export const GuardianPactsTab: React.FC = () => { return boonMap; }, [signedPacts]); - if (!mounted) { - return ( -
- Loading guardian pacts… -
- ); - } - return (
diff --git a/src/components/game/tabs/PrestigeTab.tsx b/src/components/game/tabs/PrestigeTab.tsx index 60c7626..a7e10da 100644 --- a/src/components/game/tabs/PrestigeTab.tsx +++ b/src/components/game/tabs/PrestigeTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { usePrestigeStore, useGameStore } from '@/lib/game/stores'; import { PRESTIGE_DEF } from '@/lib/game/constants/prestige'; @@ -186,8 +186,6 @@ function ResetLoopSection({ loopInsight, onReset }: { loopInsight: number; onRes // ─── Main Component ─────────────────────────────────────────────────────────── export function PrestigeTab() { - const [mounted, setMounted] = useState(false); - const { insight, totalInsight, @@ -212,11 +210,6 @@ export function PrestigeTab() { const startNewLoop = useGameStore((s) => s.startNewLoop); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); - }, []); - const handlePurchase = useCallback((id: string) => { doPrestige(id); }, [doPrestige]); @@ -225,14 +218,6 @@ export function PrestigeTab() { startNewLoop(); }, [startNewLoop]); - if (!mounted) { - return ( -
- Loading prestige… -
- ); - } - const upgradeEntries = Object.entries(PRESTIGE_DEF); return ( diff --git a/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx b/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx index 104af78..b583392 100644 --- a/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx +++ b/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx @@ -71,7 +71,7 @@ function EnemyRow({ enemy }: { enemy: EnemyState }) { ); } -export function RoomDisplay({ floorState, _floor }: RoomDisplayProps) { +export function RoomDisplay({ floorState }: RoomDisplayProps) { // Guard against null/undefined/stale floorState if (!floorState || !floorState.roomType) { return ( diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx index b66dd58..186ccf7 100644 --- a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx +++ b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx @@ -47,7 +47,6 @@ function useSpireStats(prestigeUpgrades: Record, equippedInstanc // ─── Main Component ─────────────────────────────────────────────────────────── export function SpireCombatPage() { - const [mounted, setMounted] = useState(false); const [roomsCleared, setRoomsCleared] = useState(0); const { @@ -104,8 +103,6 @@ export function SpireCombatPage() { const totalRooms = useMemo(() => getRoomsForFloor(currentFloor), [currentFloor]); useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); setRoomsCleared(0); const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms); setCurrentRoom(newRoom); @@ -166,14 +163,6 @@ export function SpireCombatPage() { addActivityLog('floor_transition', 'πŸšͺ Exited the Spire.'); }; - if (!mounted) { - return ( -
- Loading spire... -
- ); - } - return (
diff --git a/src/components/game/tabs/SpireSummaryTab.tsx b/src/components/game/tabs/SpireSummaryTab.tsx index fec86a4..a35ec2f 100644 --- a/src/components/game/tabs/SpireSummaryTab.tsx +++ b/src/components/game/tabs/SpireSummaryTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores'; import { ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants'; @@ -311,8 +311,6 @@ function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; gu // ─── Main Component ─────────────────────────────────────────────────────────── export function SpireSummaryTab() { - const [mounted, setMounted] = useState(false); - const { maxFloorReached, clearedFloors, @@ -327,11 +325,6 @@ export function SpireSummaryTab() { insight: s.insight, }))); - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); - }, []); - const defeatedGuardians = useMemo(() => { return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]); }, [clearedFloors]); @@ -346,14 +339,6 @@ export function SpireSummaryTab() { return Object.values(clearedFloors).filter(Boolean).length; }, [clearedFloors]); - if (!mounted) { - return ( -
- Loading spire data… -
- ); - } - return (
diff --git a/src/lib/game/crafting-apply.ts b/src/lib/game/crafting-apply.ts index d93011d..4d1ad5c 100644 --- a/src/lib/game/crafting-apply.ts +++ b/src/lib/game/crafting-apply.ts @@ -3,6 +3,7 @@ import type { EquipmentInstance, AppliedEnchantment, EnchantmentDesign, ApplicationProgress } from './types'; import { calculateApplicationTime, calculateApplicationManaPerHour } from './crafting-utils'; +import { HOURS_PER_TICK } from './constants'; import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects'; import type { ComputedEffects } from './effects/upgrade-effects.types'; import type { AttunementState } from './types'; @@ -11,32 +12,16 @@ import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects'; // ─── Application Validation ───────────────────────────────────────────────── -// Check if enchantment application can start export function canApplyEnchantment( instance: EquipmentInstance | undefined, design: EnchantmentDesign | undefined, currentAction: string ): { canApply: boolean; reason?: string } { - if (!instance) { - return { canApply: false, reason: 'Equipment instance not found' }; - } - - if (!design) { - return { canApply: false, reason: 'Enchantment design not found' }; - } - - if (currentAction !== 'meditate') { - return { canApply: false, reason: 'Must be in meditate state' }; - } - - if (!instance.tags?.includes('Ready for Enchantment')) { - return { canApply: false, reason: 'Equipment must be prepared for enchanting' }; - } - - if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) { - return { canApply: false, reason: 'Not enough capacity on equipment' }; - } - + if (!instance) return { canApply: false, reason: 'Equipment instance not found' }; + if (!design) return { canApply: false, reason: 'Enchantment design not found' }; + if (currentAction !== 'meditate') return { canApply: false, reason: 'Must be in meditate state' }; + if (!instance.tags?.includes('Ready for Enchantment')) return { canApply: false, reason: 'Equipment must be prepared for enchanting' }; + if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) return { canApply: false, reason: 'Not enough capacity on equipment' }; return { canApply: true }; } @@ -51,21 +36,18 @@ export interface ApplicationCosts { export function calculateApplicationCosts(design: EnchantmentDesign): ApplicationCosts { const time = calculateApplicationTime(design); const manaPerHour = calculateApplicationManaPerHour(design); - const manaPerTick = manaPerHour * 0.04; // HOURS_PER_TICK - + const manaPerTick = manaPerHour * HOURS_PER_TICK; return { time, manaPerHour, manaPerTick }; } // ─── Application Progress ─────────────────────────────────────────────────── -// Initialize application progress export function initializeApplicationProgress( equipmentInstanceId: string, designId: string, design: EnchantmentDesign ): ApplicationProgress { const costs = calculateApplicationCosts(design); - return { equipmentInstanceId, designId, @@ -77,7 +59,13 @@ export function initializeApplicationProgress( }; } -// Calculate application progress after a tick +// Free enchant chance per special effect +const FREE_ENCHANT_CHANCES: Record = { + [SPECIAL_EFFECTS.ENCHANT_PRESERVATION]: 0.25, + [SPECIAL_EFFECTS.THRIFTY_ENCHANTER]: 0.10, + [SPECIAL_EFFECTS.OPTIMIZED_ENCHANTING]: 0.25, +}; + export interface ApplicationTickResult { progress: number; manaSpent: number; @@ -93,20 +81,14 @@ export function calculateApplicationTick( manaPerTick: number, computedEffects: ComputedEffects ): ApplicationTickResult { - let progress = currentProgress + 0.04; + let progress = currentProgress + HOURS_PER_TICK; let manaSpent = currentManaSpent + manaPerTick; let manaConsumed = manaPerTick; let triggeredFreeEnchant = false; let freeEnchantChance = 0; - if (hasSpecial(computedEffects, SPECIAL_EFFECTS.ENCHANT_PRESERVATION)) { - freeEnchantChance += 0.25; - } - if (hasSpecial(computedEffects, SPECIAL_EFFECTS.THRIFTY_ENCHANTER)) { - freeEnchantChance += 0.10; - } - if (hasSpecial(computedEffects, SPECIAL_EFFECTS.OPTIMIZED_ENCHANTING)) { - freeEnchantChance += 0.25; + for (const [special, chance] of Object.entries(FREE_ENCHANT_CHANCES)) { + if (hasSpecial(computedEffects, special)) freeEnchantChance += chance; } if (freeEnchantChance > 0 && Math.random() < freeEnchantChance) { @@ -116,47 +98,32 @@ export function calculateApplicationTick( triggeredFreeEnchant = true; } - return { - progress, - manaSpent, - manaConsumed, - isComplete: progress >= required, - triggeredFreeEnchant, - }; + return { progress, manaSpent, manaConsumed, isComplete: progress >= required, triggeredFreeEnchant }; } // ─── Enchantment Application ──────────────────────────────────────────────── -// Apply enchantments to equipment instance +const PURE_ESSENCE_STACK_BONUS = 1.25; +const PURE_ESSENCE_COST_CAP = 100; + export function applyEnchantments( instance: EquipmentInstance, design: EnchantmentDesign, computedEffects: ComputedEffects -): { - updatedInstance: EquipmentInstance; - xpGained: number; - logMessage: string; -} { +): { updatedInstance: EquipmentInstance; xpGained: number; logMessage: string } { const isPureEssenceActive = hasSpecial(computedEffects, SPECIAL_EFFECTS.PURE_ESSENCE); const newEnchantments: AppliedEnchantment[] = design.effects.map(eff => { - let stacks = eff.stacks; - let actualCost = eff.capacityCost; - const effectDef = ENCHANTMENT_EFFECTS[eff.effectId]; - if (isPureEssenceActive && effectDef && effectDef.baseCapacityCost < 100) { - stacks = Math.ceil(stacks * 1.25); - } - + const bonusStacks = isPureEssenceActive && effectDef && effectDef.baseCapacityCost < PURE_ESSENCE_COST_CAP; return { effectId: eff.effectId, - stacks, - actualCost, + stacks: bonusStacks ? Math.ceil(eff.stacks * PURE_ESSENCE_STACK_BONUS) : eff.stacks, + actualCost: eff.capacityCost, }; }); const xpGained = calculateEnchantingXP(design.totalCapacityUsed); - const updatedInstance: EquipmentInstance = { ...instance, enchantments: [...instance.enchantments, ...newEnchantments], @@ -176,15 +143,12 @@ export function updateEnchanterAttunement( attunements: Record, xpGained: number ): Record { - if (!attunements?.enchanter?.active || xpGained <= 0) { - return attunements; - } + if (!attunements?.enchanter?.active || xpGained <= 0) return attunements; const enchanterState = attunements.enchanter; let newXP = enchanterState.experience + xpGained; let newLevel = enchanterState.level; - while (newLevel < MAX_ATTUNEMENT_LEVEL) { const xpNeeded = getAttunementXPForLevel(newLevel + 1); if (newXP >= xpNeeded) { @@ -197,11 +161,7 @@ export function updateEnchanterAttunement( return { ...attunements, - enchanter: { - ...enchanterState, - level: newLevel, - experience: newXP, - }, + enchanter: { ...enchanterState, level: newLevel, experience: newXP }, }; } @@ -222,7 +182,7 @@ export function resumeApplication() { // ─── Progress Calculations ────────────────────────────────────────────────── export function getApplicationManaCostForTick(manaPerHour: number): number { - return manaPerHour * 0.04; + return manaPerHour * HOURS_PER_TICK; } export function getApplicationRemainingTime(currentProgress: number, required: number): number { diff --git a/src/lib/game/crafting-design.ts b/src/lib/game/crafting-design.ts index d3dfe15..a507c1c 100644 --- a/src/lib/game/crafting-design.ts +++ b/src/lib/game/crafting-design.ts @@ -6,67 +6,55 @@ import type { ComputedEffects } from './effects/upgrade-effects.types'; import { calculateEnchantingXP } from './data/attunements'; import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects'; import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects'; +import { HOURS_PER_TICK } from './constants'; import { EQUIPMENT_TYPES } from './data/equipment'; +// Progress per tick expressed as a fraction of HOURS_PER_TICK +const DESIGN_PROGRESS_PER_TICK = HOURS_PER_TICK; +const HASTY_ENCHANTER_BONUS_MULTIPLIER = 0.25; + // ─── Design Creation & Calculation ────────────────────────────────────────── -// Validate effects for a design against equipment category export function validateDesignEffects( effects: DesignEffect[], equipmentTypeId: string, enchantingLevel: number ): { valid: boolean; reason?: string } { - if (enchantingLevel < 1) { - return { valid: false, reason: 'Requires enchanting skill level 1' }; - } + if (enchantingLevel < 1) return { valid: false, reason: 'Requires enchanting skill level 1' }; const equipType = EQUIPMENT_TYPES[equipmentTypeId]; - if (!equipType) { - return { valid: false, reason: 'Invalid equipment type' }; - } - const category = equipType.category; - if (!category) { - return { valid: false, reason: 'Invalid equipment category' }; - } + if (!equipType || !equipType.category) return { valid: false, reason: 'Invalid equipment type or category' }; for (const eff of effects) { const effectDef = ENCHANTMENT_EFFECTS[eff.effectId]; - if (!effectDef) { - return { valid: false, reason: `Unknown effect: ${eff.effectId}` }; - } - if (!effectDef.allowedEquipmentCategories.includes(category)) { - return { valid: false, reason: `Effect ${eff.effectId} not allowed on ${category}` }; - } - if (eff.stacks > effectDef.maxStacks) { - return { valid: false, reason: `Stacks exceed maximum for ${eff.effectId}` }; + if (!effectDef) return { valid: false, reason: `Unknown effect: ${eff.effectId}` }; + if (!effectDef.allowedEquipmentCategories.includes(equipType.category)) { + return { valid: false, reason: `Effect ${eff.effectId} not allowed on ${equipType.category}` }; } + if (eff.stacks > effectDef.maxStacks) return { valid: false, reason: `Stacks exceed maximum for ${eff.effectId}` }; } return { valid: true }; } -// Create an enchantment design from validated inputs export function createEnchantmentDesign( name: string, equipmentType: string, effects: DesignEffect[], efficiencyBonus: number = 0 ): EnchantmentDesign { - const totalCapacityUsed = calculateDesignCapacityCost(effects, efficiencyBonus); - const designTime = calculateDesignTime(effects); - return { id: `design_${Date.now()}`, name, equipmentType, effects, - totalCapacityUsed, - designTime, + totalCapacityUsed: calculateDesignCapacityCost(effects, efficiencyBonus), + designTime: calculateDesignTime(effects), created: Date.now(), }; } -// ─── Capacity Cost Calculation ────────────────────────────────────────────── +// ─── Capacity & Time Calculations ─────────────────────────────────────────── export function calculateDesignCapacityCost(effects: DesignEffect[], efficiencyBonus: number = 0): number { return effects.reduce((total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus), 0); @@ -76,6 +64,25 @@ export function calculateTotalCapacityCost(design: EnchantmentDesign): number { return design.totalCapacityUsed; } +export function calculateDesignTime(effects: DesignEffect[]): number { + let time = 1; + for (const eff of effects) { + if (ENCHANTMENT_EFFECTS[eff.effectId]) time += 0.5 * eff.stacks; + } + return time; +} + +export function getDesignTimeWithHaste( + effects: DesignEffect[], + isRepeatDesign: boolean, + computedEffects: ComputedEffects +): number { + const time = calculateDesignTime(effects); + return isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER) + ? time * 0.75 + : time; +} + // ─── XP & Progression ─────────────────────────────────────────────────────── export function calculateEnchantingXpFromDesign(design: EnchantmentDesign): number { @@ -88,37 +95,11 @@ export function calculateXpFromInstanceEnchantments( let totalXp = 0; for (const ench of instance.enchantments) { const effectDef = ENCHANTMENT_EFFECTS[ench.effectId]; - const baseCost = effectDef?.baseCapacityCost || 0; - totalXp += calculateEnchantingXP(baseCost * ench.stacks); + totalXp += calculateEnchantingXP((effectDef?.baseCapacityCost || 0) * ench.stacks); } return totalXp; } -// ─── Design Time Calculations ────────────────────────────────────────────── - -export function calculateDesignTime(effects: DesignEffect[]): number { - let time = 1; - for (const eff of effects) { - const effectDef = ENCHANTMENT_EFFECTS[eff.effectId]; - if (effectDef) { - time += 0.5 * eff.stacks; - } - } - return time; -} - -export function getDesignTimeWithHaste( - effects: DesignEffect[], - isRepeatDesign: boolean, - computedEffects: ComputedEffects -): number { - let time = calculateDesignTime(effects); - if (isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)) { - time *= 0.75; - } - return time; -} - // ─── Progress Calculations ────────────────────────────────────────────────── export interface DesignProgressUpdate { @@ -128,21 +109,23 @@ export interface DesignProgressUpdate { timeBonus: number; } +const INSTANT_DESIGN_CHANCE = 0.10; + export function calculateDesignProgress( currentProgress: number, required: number, computedEffects: ComputedEffects, isRepeatDesign: boolean ): DesignProgressUpdate { - let progress = currentProgress + 0.04; + let progress = currentProgress + DESIGN_PROGRESS_PER_TICK; let timeBonus = 0; if (isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)) { - timeBonus = 0.04 * 0.25; + timeBonus = DESIGN_PROGRESS_PER_TICK * HASTY_ENCHANTER_BONUS_MULTIPLIER; progress += timeBonus; } - if (hasSpecial(computedEffects, SPECIAL_EFFECTS.INSTANT_DESIGNS) && Math.random() < 0.10) { + if (hasSpecial(computedEffects, SPECIAL_EFFECTS.INSTANT_DESIGNS) && Math.random() < INSTANT_DESIGN_CHANCE) { progress = required; } @@ -165,8 +148,7 @@ export function isSecondDesignSlotAvailable( ): boolean { if (!designProgress && !designProgress2) return true; if (!designProgress && designProgress2) return false; - if (designProgress && !designProgress2 && hasEnchantMastery) return true; - return false; + return !!(designProgress && !designProgress2 && hasEnchantMastery); } // ─── Auto-save Completed Design ──────────────────────────────────────────── @@ -181,13 +163,12 @@ export function createCompletedDesignFromProgress( }, efficiencyBonus: number = 0 ): EnchantmentDesign { - const totalCapacityCost = calculateDesignCapacityCost(progressData.effects, efficiencyBonus); return { id: progressData.designId, name: progressData.name, equipmentType: progressData.equipmentType, effects: progressData.effects, - totalCapacityUsed: totalCapacityCost, + totalCapacityUsed: calculateDesignCapacityCost(progressData.effects, efficiencyBonus), designTime: progressData.required, created: Date.now(), }; @@ -206,13 +187,10 @@ export function filterDesignsByEquipment( equipment: { instanceId: string; totalCapacity: number; usedCapacity: number } | null ): DesignWithCapacityInfo[] { if (!equipment) return []; + const availableCapacity = equipment.totalCapacity - equipment.usedCapacity; return designs.map(design => ({ design, - fitsInEquipment: designFitsInEquipment(design, equipment), - availableCapacity: equipment.totalCapacity - equipment.usedCapacity, + fitsInEquipment: (equipment.usedCapacity || 0) + design.totalCapacityUsed <= equipment.totalCapacity, + availableCapacity, })); } - -function designFitsInEquipment(design: EnchantmentDesign, instance: { usedCapacity: number; totalCapacity: number }): boolean { - return (instance.usedCapacity || 0) + design.totalCapacityUsed <= instance.totalCapacity; -} diff --git a/src/lib/game/crafting-equipment.ts b/src/lib/game/crafting-equipment.ts index 00a16b7..860e6f5 100644 --- a/src/lib/game/crafting-equipment.ts +++ b/src/lib/game/crafting-equipment.ts @@ -6,10 +6,12 @@ import { CRAFTING_RECIPES, canCraftRecipe, type CraftingRecipe } from './data/cr import { EQUIPMENT_TYPES } from './data/equipment'; import { ok, fail, ErrorCode } from './utils/result'; import type { Result } from './utils/result'; +import { HOURS_PER_TICK } from './constants'; + +const MANA_REFUND_RATE = 0.5; // ─── Equipment Crafting Validation ────────────────────────────────────────── -// Check if equipment crafting can start export function canStartEquipmentCrafting( blueprintId: string, hasBlueprint: boolean, @@ -17,38 +19,27 @@ export function canStartEquipmentCrafting( currentMana: number, currentAction: string ): { canCraft: boolean; reason?: string; recipe?: CraftingRecipe; missingMaterials?: Record; missingMana?: number } { - if (currentAction !== 'meditate') { - return { canCraft: false, reason: 'Must be in meditate state' }; - } + if (currentAction !== 'meditate') return { canCraft: false, reason: 'Must be in meditate state' }; const recipe = CRAFTING_RECIPES[blueprintId]; - if (!recipe) { - return { canCraft: false, reason: 'Invalid blueprint' }; - } - - if (!hasBlueprint) { - return { canCraft: false, reason: 'Blueprint not acquired' }; - } + if (!recipe) return { canCraft: false, reason: 'Invalid blueprint' }; + if (!hasBlueprint) return { canCraft: false, reason: 'Blueprint not acquired' }; const { canCraft, missingMaterials } = canCraftRecipe(recipe, materials, currentMana); + if (canCraft) return { canCraft: true, recipe }; - if (!canCraft) { - const missingMana = Math.max(0, recipe.manaCost - currentMana); - return { - canCraft: false, - reason: missingMana > 0 ? 'Insufficient mana' : 'Missing materials', - recipe, - missingMaterials, - missingMana: missingMana > 0 ? missingMana : undefined, - }; - } - - return { canCraft: true, recipe }; + const missingManaAmount = Math.max(0, recipe.manaCost - currentMana); + return { + canCraft: false, + reason: missingManaAmount > 0 ? 'Insufficient mana' : 'Missing materials', + recipe, + missingMaterials, + missingMana: missingManaAmount > 0 ? missingManaAmount : undefined, + }; } // ─── Equipment Crafting Execution ─────────────────────────────────────────── -// Deduct crafting costs and initialize progress export interface CraftingInitResult { recipe: CraftingRecipe; newMaterials: Record; @@ -63,108 +54,80 @@ export function initializeEquipmentCrafting( ): CraftingInitResult { const recipe = CRAFTING_RECIPES[blueprintId]; - // Deduct materials const newMaterials = { ...materials }; for (const [matId, amount] of Object.entries(recipe.materials)) { newMaterials[matId] = (newMaterials[matId] || 0) - amount; - if (newMaterials[matId] <= 0) { - delete newMaterials[matId]; - } + if (newMaterials[matId] <= 0) delete newMaterials[matId]; } - // Create progress - const progress: EquipmentCraftingProgress = { - blueprintId, - equipmentTypeId: recipe.equipmentTypeId, - progress: 0, - required: recipe.craftTime, - manaSpent: recipe.manaCost, - }; - return { recipe, newMaterials, manaCost: recipe.manaCost, - progress, + progress: { + blueprintId, + equipmentTypeId: recipe.equipmentTypeId, + progress: 0, + required: recipe.craftTime, + manaSpent: recipe.manaCost, + }, }; } // ─── Crafting Progress ────────────────────────────────────────────────────── -// Calculate crafting progress after a tick export interface CraftingTickResult { progress: number; isComplete: boolean; } export function calculateCraftingTick(currentProgress: number, required: number): CraftingTickResult { - const progress = currentProgress + 0.04; // HOURS_PER_TICK - return { - progress, - isComplete: progress >= required, - }; + const progress = currentProgress + HOURS_PER_TICK; + return { progress, isComplete: progress >= required }; } // ─── Crafting Completion ─────────────────────────────────────────────────── -// Create equipment instance from completed crafting +const BASE_EQUIPMENT_QUALITY = 100; + export function completeEquipmentCrafting( blueprintId: string, recipe: CraftingRecipe -): Result<{ - instanceId: string; - instance: EquipmentInstance; - logMessage: string; -}> { +): Result<{ instanceId: string; instance: EquipmentInstance; logMessage: string }> { const equipType = EQUIPMENT_TYPES[recipe.equipmentTypeId]; - if (!equipType) { - return fail(ErrorCode.INVALID_EQUIPMENT_TYPE, `Invalid equipment type: ${recipe.equipmentTypeId}`); - } + if (!equipType) return fail(ErrorCode.INVALID_EQUIPMENT_TYPE, `Invalid equipment type: ${recipe.equipmentTypeId}`); const instanceId = `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const newInstance: EquipmentInstance = { - instanceId, - typeId: recipe.equipmentTypeId, - name: recipe.name, - enchantments: [], - usedCapacity: 0, - totalCapacity: equipType.baseCapacity, - rarity: recipe.rarity, - quality: 100, - tags: [], - }; - return ok({ instanceId, - instance: newInstance, + instance: { + instanceId, + typeId: recipe.equipmentTypeId, + name: recipe.name, + enchantments: [], + usedCapacity: 0, + totalCapacity: equipType.baseCapacity, + rarity: recipe.rarity, + quality: BASE_EQUIPMENT_QUALITY, + tags: [], + }, logMessage: `πŸ”¨ Crafted ${recipe.name}!`, }); } // ─── Crafting Cancellation ────────────────────────────────────────────────── -// Cancel active crafting and refund partial resources export interface CraftingCancelResult { manaRefund: number; logMessage: string; } -export function cancelEquipmentCrafting(_blueprintId: string, manaSpent: number): CraftingCancelResult { +export function cancelEquipmentCrafting(blueprintId: string, manaSpent: number): CraftingCancelResult { const recipe = CRAFTING_RECIPES[blueprintId]; - if (!recipe) { - return { - manaRefund: 0, - logMessage: 'Invalid crafting recipe.', - }; - } + if (!recipe) return { manaRefund: 0, logMessage: 'Invalid crafting recipe.' }; - // Refund 50% of mana - const manaRefund = Math.floor(manaSpent * 0.5); - - return { - manaRefund, - logMessage: `🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.`, - }; + const manaRefund = Math.floor(manaSpent * MANA_REFUND_RATE); + return { manaRefund, logMessage: `🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.` }; } // ─── Recipe Information ───────────────────────────────────────────────────── @@ -179,23 +142,16 @@ export function getCraftableRecipes( currentMana: number ): CraftingRecipe[] { const craftable: CraftingRecipe[] = []; - for (const blueprintId of blueprints) { const recipe = CRAFTING_RECIPES[blueprintId]; if (!recipe) continue; - - const { canCraft } = canCraftRecipe(recipe, materials, currentMana); - if (canCraft) { - craftable.push(recipe); - } + if (canCraftRecipe(recipe, materials, currentMana).canCraft) craftable.push(recipe); } - return craftable; } // ─── Material Management ──────────────────────────────────────────────────── -// Delete materials from inventory export function deleteMaterials(materialId: string, amount: number, materials: Record): { newMaterials: Record; deleted: number; @@ -204,25 +160,18 @@ export function deleteMaterials(materialId: string, amount: number, materials: R const deleted = Math.min(amount, currentAmount); const remaining = Math.max(0, currentAmount - amount); const newMaterials = { ...materials }; - if (remaining <= 0) { delete newMaterials[materialId]; } else { newMaterials[materialId] = remaining; } - - return { - newMaterials, - deleted, - }; + return { newMaterials, deleted }; } -// Get total material count export function getMaterialCount(materials: Record, materialId: string): number { return materials[materialId] || 0; } -// Add materials to inventory export function addMaterials(materials: Record, materialId: string, amount: number): Record { const newMaterials = { ...materials }; newMaterials[materialId] = (newMaterials[materialId] || 0) + amount; diff --git a/src/lib/game/crafting-prep.ts b/src/lib/game/crafting-prep.ts index cb447ee..b69f2ce 100644 --- a/src/lib/game/crafting-prep.ts +++ b/src/lib/game/crafting-prep.ts @@ -3,26 +3,21 @@ import type { EquipmentInstance, PreparationProgress } from './types'; import { calculatePrepTime, calculatePrepManaCost, calculateManaPerHourForPrep } from './crafting-utils'; +import { HOURS_PER_TICK } from './constants'; // ─── Preparation Validation ───────────────────────────────────────────────── -// Check if an equipment instance can be prepared export function canPrepareEquipment( instance: EquipmentInstance | undefined, currentTags: string[] ): { canPrepare: boolean; reason?: string } { - if (!instance) { - return { canPrepare: false, reason: 'Equipment instance not found' }; - } - - if (currentTags.includes('Ready for Enchantment')) { - return { canPrepare: false, reason: 'Equipment is already prepared for enchanting' }; - } - + if (!instance) return { canPrepare: false, reason: 'Equipment instance not found' }; + if (currentTags.includes('Ready for Enchantment')) return { canPrepare: false, reason: 'Equipment is already prepared for enchanting' }; return { canPrepare: true }; } -// Calculate preparation resource costs +// ─── Preparation Costs ────────────────────────────────────────────────────── + export interface PreparationCosts { time: number; manaTotal: number; @@ -34,30 +29,20 @@ export function calculatePreparationCosts(totalCapacity: number): PreparationCos const time = calculatePrepTime(totalCapacity); const manaTotal = calculatePrepManaCost(totalCapacity); const manaPerHour = calculateManaPerHourForPrep(totalCapacity, time); - const manaPerTick = manaPerHour * 0.04; // HOURS_PER_TICK - - return { time, manaTotal, manaPerHour, manaPerTick }; + return { time, manaTotal, manaPerHour, manaPerTick: manaPerHour * HOURS_PER_TICK }; } -// ─── Preparation Progress ─────────────────────────────────────────────────── - -// Initialize preparation progress export function initializePreparationProgress( equipmentInstanceId: string, totalCapacity: number, manaCostPaid: number = 0 ): PreparationProgress { const costs = calculatePreparationCosts(totalCapacity); - - return { - equipmentInstanceId, - progress: 0, - required: costs.time, - manaCostPaid, - }; + return { equipmentInstanceId, progress: 0, required: costs.time, manaCostPaid }; } -// Calculate updated preparation progress after a tick +// ─── Preparation Tick ─────────────────────────────────────────────────────── + export interface PreparationTickResult { progress: number; manaCostPaid: number; @@ -68,15 +53,14 @@ export interface PreparationTickResult { export function calculatePreparationTick( currentProgress: number, required: number, + currentManaCostPaid: number, manaPerTick: number ): PreparationTickResult { - const progress = currentProgress + 0.04; // HOURS_PER_TICK + const progress = currentProgress + HOURS_PER_TICK; const manaConsumed = manaPerTick; - const manaCostPaid = manaPerTick; // Accumulated - return { progress, - manaCostPaid, + manaCostPaid: currentManaCostPaid + manaConsumed, manaConsumed, isComplete: progress >= required, }; @@ -84,51 +68,40 @@ export function calculatePreparationTick( // ─── Preparation Completion ───────────────────────────────────────────────── -// Apply preparation completion to equipment instance +const BASE_DISENCHANT_RECOVERY_RATE = 0.1; +const DISENCHANT_RECOVERY_PER_LEVEL = 0.2; + export function completePreparation( instance: EquipmentInstance, - _manaSpent: number -): { - updatedInstance: EquipmentInstance; - manaRecovered: number; - logMessage: string; -} { - // Calculate mana recovery from disenchanting (disenchanting skill removed - Bug 13) - const disenchantLevel = 0; - const recoveryRate = 0.1 + disenchantLevel * 0.2; // 10% base + 20% per level - + disenchantLevel: number = 0 +): { updatedInstance: EquipmentInstance; manaRecovered: number; logMessage: string } { + const recoveryRate = BASE_DISENCHANT_RECOVERY_RATE + disenchantLevel * DISENCHANT_RECOVERY_PER_LEVEL; let totalRecovered = 0; for (const ench of instance.enchantments) { totalRecovered += Math.floor(ench.actualCost * recoveryRate); } - const updatedInstance: EquipmentInstance = { - ...instance, - enchantments: [], - usedCapacity: 0, - rarity: 'common', - tags: [...(instance.tags || []), 'Ready for Enchantment'], - }; - return { - updatedInstance, + updatedInstance: { + ...instance, + enchantments: [], + usedCapacity: 0, + rarity: 'common', + tags: [...(instance.tags || []), 'Ready for Enchantment'], + }, manaRecovered: totalRecovered, logMessage: `βœ… Equipment prepared for enchanting! Recovered ${totalRecovered} mana.`, }; } -// Cancel preparation (no resource recovery for preparation itself) export function cancelPreparation() { - return { - logMessage: 'Preparation cancelled.', - }; + return { logMessage: 'Preparation cancelled.' }; } // ─── Preparation State Calculations ───────────────────────────────────────── export function getPreparationManaCostForTick(instance: EquipmentInstance): number { - const costs = calculatePreparationCosts(instance.totalCapacity); - return costs.manaPerTick; + return calculatePreparationCosts(instance.totalCapacity).manaPerTick; } export function getPreparationRemainingTime(currentProgress: number, required: number): number { diff --git a/src/lib/game/effects/dynamic-compute.ts b/src/lib/game/effects/dynamic-compute.ts index cf73b22..ec9eeb3 100644 --- a/src/lib/game/effects/dynamic-compute.ts +++ b/src/lib/game/effects/dynamic-compute.ts @@ -2,8 +2,22 @@ // Dynamic computation functions that depend on special effects import type { ComputedEffects } from './upgrade-effects.types'; + import { SPECIAL_EFFECTS, hasSpecial } from './special-effects'; +// Threshold ratios for mana-dependent effects (currentMana / maxMana) +const MANA_HIGH_THRESHOLD = 0.75; +const MANA_OVERPOWER_THRESHOLD = 0.8; +const MANA_BERSERKER_THRESHOLD = 0.5; +const MANA_LOW_THRESHOLD = 0.25; +const MANA_CRITICAL_THRESHOLD = 0.1; + +// Regen multipliers for mana-dependent thresholds +const MANA_TORRENT_MULTIPLIER = 1.5; +const MANA_CRISIS_MULTIPLIER = 1.5; // Desperate / Despair Wells +const PANIC_RESERVE_MULTIPLIER = 2.0; +const REGEN_BOOST_PER_100_MANA = 0.1; + /** * Compute regen with special effects that depend on dynamic values */ @@ -15,60 +29,51 @@ export function computeDynamicRegen( incursionStrength: number ): number { let regen = baseRegen; - - // Mana Cascade: +0.1 regen per 100 max mana + + // Per-100-max-mana regen bonuses + const manaHundreds = Math.floor(maxMana / 100); if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_CASCADE)) { - regen += Math.floor(maxMana / 100) * 0.1; + regen += manaHundreds * REGEN_BOOST_PER_100_MANA; } - - // Mana Waterfall: +0.25 regen per 100 max mana (upgraded cascade) if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_WATERFALL)) { - regen += Math.floor(maxMana / 100) * 0.25; + regen += manaHundreds * 0.25; } - - // Mana Torrent: +50% regen when above 75% mana - if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TORRENT) && currentMana > maxMana * 0.75) { - regen *= 1.5; - } - - // Desperate Wells / Despair Wells: +50% regen when below 25% mana - if ((hasSpecial(effects, SPECIAL_EFFECTS.DESPERATE_WELLS) || hasSpecial(effects, SPECIAL_EFFECTS.DESPAIR_WELLS)) && currentMana < maxMana * 0.25) { - regen *= 1.5; - } - - // Panic Reserve: +100% regen when below 10% mana - if (hasSpecial(effects, SPECIAL_EFFECTS.PANIC_RESERVE) && currentMana < maxMana * 0.1) { - regen *= 2.0; - } - - // Deep Reserve: +0.5 regen per 100 max mana if (hasSpecial(effects, SPECIAL_EFFECTS.DEEP_RESERVE)) { - regen += Math.floor(maxMana / 100) * 0.5; + regen += manaHundreds * 0.5; } - - // Mana Core: 0.5% of max mana added as regen + + // Fractional max-mana regen if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_CORE)) { regen += maxMana * 0.005; } - - // Mana Tide: Regen pulses Β±50% (sinusoidal based on time) + + // Mana Tide: sinusoidal pulse if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TIDE)) { regen *= (1.0 + 0.5 * Math.sin(Date.now() / 10000)); } - - // Eternal Flow: Regen immune to ALL penalties + + // Mana-ratio-dependent multipliers + const manaRatio = currentMana / maxMana; + if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TORRENT) && manaRatio > MANA_HIGH_THRESHOLD) { + regen *= MANA_TORRENT_MULTIPLIER; + } + if ((hasSpecial(effects, SPECIAL_EFFECTS.DESPERATE_WELLS) || hasSpecial(effects, SPECIAL_EFFECTS.DESPAIR_WELLS)) && manaRatio < MANA_LOW_THRESHOLD) { + regen *= MANA_CRISIS_MULTIPLIER; + } + if (hasSpecial(effects, SPECIAL_EFFECTS.PANIC_RESERVE) && manaRatio < MANA_CRITICAL_THRESHOLD) { + regen *= PANIC_RESERVE_MULTIPLIER; + } + + // Eternal Flow: skip incursion + multiplier below if (hasSpecial(effects, SPECIAL_EFFECTS.ETERNAL_FLOW)) { return regen * effects.regenMultiplier; } - - // Steady Stream: Regen immune to incursion (skip incursion penalty only) - if (hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)) { - // incursion penalty is skipped, but regenMultiplier still applies below - } else { - // Apply incursion penalty + + // Steady Stream: skip incursion only + if (!hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)) { regen *= (1 - incursionStrength); } - + return regen * effects.regenMultiplier; } @@ -79,46 +84,7 @@ export function computeDynamicClickMana( effects: ComputedEffects, baseClickMana: number ): number { - let clickMana = baseClickMana; - - // Mana Echo: 10% chance to gain double mana from clicks - // Note: The chance is handled in the click handler, this just returns the base - // The click handler should check hasSpecial and apply the 10% chance - - // Mana Genesis: Generate 1% of max mana per hour passively - // This is handled in the game loop (store.ts), not here - - // Mana Heart: +10% max mana per loop (permanent) - // This is applied during loop reset in store.ts - - return Math.floor((clickMana + effects.clickManaBonus) * effects.clickManaMultiplier); + return Math.floor((baseClickMana + effects.clickManaBonus) * effects.clickManaMultiplier); } -/** - * Compute damage with special effects - */ -export function computeDynamicDamage( - effects: ComputedEffects, - baseDamage: number, - _floorHPPct: number, - currentMana: number, - maxMana: number -): number { - let damage = baseDamage * effects.baseDamageMultiplier; - - // Overpower: +50% damage when mana above 80% - if (hasSpecial(effects, SPECIAL_EFFECTS.OVERPOWER) && currentMana >= maxMana * 0.8) { - damage *= 1.5; - } - - // Berserker: +50% damage when below 50% mana - if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && currentMana < maxMana * 0.5) { - damage *= 1.5; - } - - // Combo Master: Every 5th attack deals 3x damage - // Note: The hit counter is tracked in game state, this just returns the multiplier - // The combat handler should check hasSpecial and the hit count - - return damage + effects.baseDamageBonus; -} + diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 0c6c965..9677ec6 100644 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -82,277 +82,258 @@ export const useGameStore = create()( if (ctx.ui.gameOver || ctx.ui.paused) return; - // ── Phase 2: Compute β€” derive all updates ─────────────────────────── - const writes: TickWrites = { logs: [] }; - const addLog = (msg: string) => writes.logs.push(msg); - - // Compute equipment and discipline effects - const equipmentEffects = computeEquipmentEffects( - ctx.crafting.equipmentInstances || {}, - ctx.crafting.equippedInstances || {} - ); - const disciplineEffects = computeDisciplineEffects(); - const allSpecials = new Set([ - ...equipmentEffects.specials, - ...disciplineEffects.specials, - ]); - const effects = { specials: allSpecials } as ComputedEffects; - - const maxMana = computeMaxMana( - { skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} }, - undefined, - disciplineEffects, - ); - const baseRegen = computeRegen( - { skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} }, - undefined, - disciplineEffects, - ); - - // Time progression - let hour = ctx.game.hour + HOURS_PER_TICK; - let day = ctx.game.day; - if (hour >= 24) { - hour -= 24; - day += 1; - } - - // Check for loop end - if (day > MAX_DAY) { - const insightGained = calcInsight({ - maxFloorReached: ctx.combat.maxFloorReached, - totalManaGathered: ctx.mana.totalManaGathered, - signedPacts: ctx.prestige.signedPacts, - prestigeUpgrades: ctx.prestige.prestigeUpgrades, - skills: {}, - }, disciplineEffects); - - addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`); - writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false }; - writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; - writes.game = { day, hour }; - applyTickWrites(writes, { + // Shared setters object β€” used by every applyTickWrites call below + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const storeSetters = { setGame: set, - setUI: (w) => useUIStore.setState(w), - setPrestige: (w) => usePrestigeStore.setState(w), - setMana: (w) => useManaStore.setState(w), - setCombat: (w) => useCombatStore.setState(w), - setCrafting: (w) => useCraftingStore.setState(w), - setAttunement: (w) => useAttunementStore.setState(w), - setDiscipline: (w) => useDisciplineStore.setState(w), - addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)), - }); - return; - } + setUI: (w: any) => useUIStore.setState(w), + setPrestige: (w: any) => usePrestigeStore.setState(w), + setMana: (w: any) => useManaStore.setState(w), + setCombat: (w: any) => useCombatStore.setState(w), + setCrafting: (w: any) => useCraftingStore.setState(w), + setAttunement: (w: any) => useAttunementStore.setState(w), + setDiscipline: (w: any) => useDisciplineStore.setState(w), + addLogs: (msgs: string[]) => msgs.forEach((m) => useUIStore.getState().addLog(m)), + }; - // Check for victory - if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) { - const insightGained = calcInsight({ - maxFloorReached: ctx.combat.maxFloorReached, - totalManaGathered: ctx.mana.totalManaGathered, - signedPacts: ctx.prestige.signedPacts, - prestigeUpgrades: ctx.prestige.prestigeUpgrades, - skills: {}, - }, disciplineEffects) * 3; + // ── Phase 2: Compute β€” derive all updates ─────────────────────────── + const writes: TickWrites = { logs: [] }; + const addLog = (msg: string) => writes.logs.push(msg); - addLog(`πŸ† VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`); - writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true }; - writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; - applyTickWrites(writes, { - setGame: set, - setUI: (w) => useUIStore.setState(w), - setPrestige: (w) => usePrestigeStore.setState(w), - setMana: (w) => useManaStore.setState(w), - setCombat: (w) => useCombatStore.setState(w), - setCrafting: (w) => useCraftingStore.setState(w), - setAttunement: (w) => useAttunementStore.setState(w), - setDiscipline: (w) => useDisciplineStore.setState(w), - addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)), - }); - return; - } + // Compute equipment and discipline effects + const equipmentEffects = computeEquipmentEffects( + ctx.crafting.equipmentInstances || {}, + ctx.crafting.equippedInstances || {} + ); + const disciplineEffects = computeDisciplineEffects(); + const allSpecials = new Set([ + ...equipmentEffects.specials, + ...disciplineEffects.specials, + ]); + const effects = { specials: allSpecials } as ComputedEffects; - // Incursion - const incursionStrength = getIncursionStrength(day, hour); - - // Meditation bonus tracking - let meditateTicks = ctx.mana.meditateTicks; - let meditationMultiplier = 1; - - if (ctx.combat.currentAction === 'meditate') { - meditateTicks++; - meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1); - } else { - meditateTicks = 0; - } - - // Calculate total attunement conversion per tick - let totalConversionPerTick = 0; - Object.entries(ctx.attunement.attunements).forEach(([id, state]) => { - if (!state.active) return; - const def = ATTUNEMENTS_DEF[id]; - if (!def || def.conversionRate <= 0 || !def.primaryManaType) return; - const scaledRate = getAttunementConversionRate(id, state.level || 1); - totalConversionPerTick += scaledRate * HOURS_PER_TICK; - }); - - // Calculate effective regen - const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick); - - // Mana regeneration - let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana); - let elements = { ...ctx.mana.elements }; - - // Apply attunement conversion - Object.entries(ctx.attunement.attunements).forEach(([id, state]) => { - if (!state.active) return; - const def = ATTUNEMENTS_DEF[id]; - if (!def || def.conversionRate <= 0 || !def.primaryManaType) return; - const scaledRate = getAttunementConversionRate(id, state.level || 1); - const conversionThisTick = scaledRate * HOURS_PER_TICK; - if (elements[def.primaryManaType]) { - elements[def.primaryManaType].current = Math.min( - elements[def.primaryManaType].max, - elements[def.primaryManaType].current + conversionThisTick - ); - } - }); - let totalManaGathered = ctx.mana.totalManaGathered; - - // Convert action β€” delegate to manaStore - if (ctx.combat.currentAction === 'convert') { - const convertResult = useManaStore.getState().processConvertAction(rawMana); - if (convertResult) { - rawMana = convertResult.rawMana; - elements = convertResult.elements; - } - } - - // Pact ritual progress - if (ctx.prestige.pactRitualFloor !== null) { - const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor); - if (guardian) { - const pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1; - const requiredTime = guardian.pactTime * pactAffinityBonus; - const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK; - - if (newProgress >= requiredTime) { - addLog(`πŸ“œ Pact signed with ${guardian.name}! You have gained their boons.`); - writes.prestige = { - ...(writes.prestige || {}), - signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor], - defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor), - pactRitualFloor: null, - pactRitualProgress: 0, - }; - } else { - writes.prestige = { - ...(writes.prestige || {}), - pactRitualProgress: newProgress, - }; - } - } - } - - // Discipline tick β€” process active disciplines (XP accrual + mana drain) - const disciplineResult = useDisciplineStore.getState().processTick({ - rawMana, - elements, - }); - rawMana = disciplineResult.rawMana; - elements = disciplineResult.elements; - - // Apply per-element regen from discipline effects (regen_{element}) - for (const [key, value] of Object.entries(disciplineEffects.bonuses)) { - if (key.startsWith('regen_') && key !== 'regenBonus') { - const element = key.replace('regen_', ''); - if (elements[element]) { - elements[element] = { - ...elements[element], - current: Math.min( - elements[element].max, - elements[element].current + value * HOURS_PER_TICK, - ), - }; - } - } - } - - // Unlock enchantment effects from newly unlocked discipline perks - if (disciplineResult.unlockedEffects.length > 0) { - useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects); - for (const effectId of disciplineResult.unlockedEffects) { - addLog(`✨ Discipline insight unlocked: ${effectId}`); - } - } - - // Combat β€” delegate to combatStore - if (ctx.combat.currentAction === 'climb') { - const combatResult = useCombatStore.getState().processCombatTick( - rawMana, - elements, - maxMana, - 1, - (floor, wasGuardian) => { - if (wasGuardian) { - const defeatedGuardian = getGuardianForFloor(floor); - addLog(`\u2694\ufe0f ${defeatedGuardian?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`); - } else if (floor % 5 === 0) { - addLog(`🏰 Floor ${floor} cleared!`); - } - }, - (damage) => { - let dmg = damage; - if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) { - dmg *= 2; - } - if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { - dmg *= 1.5; - } - return { rawMana, elements, modifiedDamage: dmg }; - }, - ctx.prestige.signedPacts, + const maxMana = computeMaxMana( + { skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} }, + undefined, + disciplineEffects, + ); + const baseRegen = computeRegen( + { skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} }, + undefined, + disciplineEffects, ); - rawMana = combatResult.rawMana; - elements = combatResult.elements; - totalManaGathered += combatResult.totalManaGathered || 0; - - if (combatResult.logMessages) { - combatResult.logMessages.forEach(msg => addLog(msg)); + // Time progression + let hour = ctx.game.hour + HOURS_PER_TICK; + let day = ctx.game.day; + if (hour >= 24) { + hour -= 24; + day += 1; } - writes.combat = { - ...(writes.combat || {}), - currentFloor: combatResult.currentFloor, - floorHP: combatResult.floorHP, - floorMaxHP: combatResult.floorMaxHP, - maxFloorReached: combatResult.maxFloorReached, - castProgress: combatResult.castProgress, - equipmentSpellStates: combatResult.equipmentSpellStates, + // Shared insight params β€” reused for both loop-end and victory + const insightParams = { + maxFloorReached: ctx.combat.maxFloorReached, + totalManaGathered: ctx.mana.totalManaGathered, + signedPacts: ctx.prestige.signedPacts, + prestigeUpgrades: ctx.prestige.prestigeUpgrades, + skills: {} as Record, }; - } - // ── Phase 3: Write β€” batch all state updates ───────────────────────── - writes.game = { day, hour, incursionStrength }; - writes.mana = { - rawMana, - meditateTicks, - totalManaGathered, - elements, - }; + // Check for loop end + if (day > MAX_DAY) { + const insightGained = calcInsight(insightParams, disciplineEffects); - applyTickWrites(writes, { - setGame: set, - setUI: (w) => useUIStore.setState(w), - setPrestige: (w) => usePrestigeStore.setState(w), - setMana: (w) => useManaStore.setState(w), - setCombat: (w) => useCombatStore.setState(w), - setCrafting: (w) => useCraftingStore.setState(w), - setAttunement: (w) => useAttunementStore.setState(w), - setDiscipline: (w) => useDisciplineStore.setState(w), - addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)), - }); + addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`); + writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false }; + writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; + writes.game = { day, hour }; + applyTickWrites(writes, storeSetters); + return; + } + + // Check for victory (3Γ— insight multiplier) + if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) { + const insightGained = calcInsight(insightParams, disciplineEffects) * 3; + + addLog(`πŸ† VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`); + writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true }; + writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; + applyTickWrites(writes, storeSetters); + return; + } + + // Incursion + const incursionStrength = getIncursionStrength(day, hour); + + // Meditation bonus tracking + let meditateTicks = ctx.mana.meditateTicks; + let meditationMultiplier = 1; + + if (ctx.combat.currentAction === 'meditate') { + meditateTicks++; + meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1); + } else { + meditateTicks = 0; + } + + // Calculate total attunement conversion per tick + let totalConversionPerTick = 0; + Object.entries(ctx.attunement.attunements).forEach(([id, state]) => { + if (!state.active) return; + const def = ATTUNEMENTS_DEF[id]; + if (!def || def.conversionRate <= 0 || !def.primaryManaType) return; + const scaledRate = getAttunementConversionRate(id, state.level || 1); + totalConversionPerTick += scaledRate * HOURS_PER_TICK; + }); + + // Calculate effective regen + const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick); + + // Mana regeneration + let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana); + let elements = { ...ctx.mana.elements }; + + // Apply attunement conversion + Object.entries(ctx.attunement.attunements).forEach(([id, state]) => { + if (!state.active) return; + const def = ATTUNEMENTS_DEF[id]; + if (!def || def.conversionRate <= 0 || !def.primaryManaType) return; + const scaledRate = getAttunementConversionRate(id, state.level || 1); + const conversionThisTick = scaledRate * HOURS_PER_TICK; + if (elements[def.primaryManaType]) { + elements[def.primaryManaType].current = Math.min( + elements[def.primaryManaType].max, + elements[def.primaryManaType].current + conversionThisTick + ); + } + }); + let totalManaGathered = ctx.mana.totalManaGathered; + + // Convert action β€” delegate to manaStore + if (ctx.combat.currentAction === 'convert') { + const convertResult = useManaStore.getState().processConvertAction(rawMana); + if (convertResult) { + rawMana = convertResult.rawMana; + elements = convertResult.elements; + } + } + + // Pact ritual progress + if (ctx.prestige.pactRitualFloor !== null) { + const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor); + if (guardian) { + const pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1; + const requiredTime = guardian.pactTime * pactAffinityBonus; + const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK; + + if (newProgress >= requiredTime) { + addLog(`πŸ“œ Pact signed with ${guardian.name}! You have gained their boons.`); + writes.prestige = { + ...(writes.prestige || {}), + signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor], + defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor), + pactRitualFloor: null, + pactRitualProgress: 0, + }; + } else { + writes.prestige = { + ...(writes.prestige || {}), + pactRitualProgress: newProgress, + }; + } + } + } + + // Discipline tick β€” process active disciplines (XP accrual + mana drain) + const disciplineResult = useDisciplineStore.getState().processTick({ + rawMana, + elements, + }); + rawMana = disciplineResult.rawMana; + elements = disciplineResult.elements; + + // Apply per-element regen from discipline effects (regen_{element}) + for (const [key, value] of Object.entries(disciplineEffects.bonuses)) { + if (key.startsWith('regen_') && key !== 'regenBonus') { + const element = key.replace('regen_', ''); + if (elements[element]) { + elements[element] = { + ...elements[element], + current: Math.min( + elements[element].max, + elements[element].current + value * HOURS_PER_TICK, + ), + }; + } + } + } + + // Unlock enchantment effects from newly unlocked discipline perks + if (disciplineResult.unlockedEffects.length > 0) { + useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects); + for (const effectId of disciplineResult.unlockedEffects) { + addLog(`✨ Discipline insight unlocked: ${effectId}`); + } + } + + // Combat β€” delegate to combatStore + if (ctx.combat.currentAction === 'climb') { + const combatResult = useCombatStore.getState().processCombatTick( + rawMana, + elements, + maxMana, + 1, + (floor, wasGuardian) => { + if (wasGuardian) { + const defeatedGuardian = getGuardianForFloor(floor); + addLog(`\u2694\ufe0f ${defeatedGuardian?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`); + } else if (floor % 5 === 0) { + addLog(`🏰 Floor ${floor} cleared!`); + } + }, + (damage) => { + let dmg = damage; + if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) { + dmg *= 2; + } + if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) { + dmg *= 1.5; + } + return { rawMana, elements, modifiedDamage: dmg }; + }, + ctx.prestige.signedPacts, + ); + + rawMana = combatResult.rawMana; + elements = combatResult.elements; + totalManaGathered += combatResult.totalManaGathered || 0; + + if (combatResult.logMessages) { + combatResult.logMessages.forEach(msg => addLog(msg)); + } + + writes.combat = { + ...(writes.combat || {}), + currentFloor: combatResult.currentFloor, + floorHP: combatResult.floorHP, + floorMaxHP: combatResult.floorMaxHP, + maxFloorReached: combatResult.maxFloorReached, + castProgress: combatResult.castProgress, + equipmentSpellStates: combatResult.equipmentSpellStates, + }; + } + + // ── Phase 3: Write β€” batch all state updates ───────────────────────── + writes.game = { day, hour, incursionStrength }; + writes.mana = { + rawMana, + meditateTicks, + totalManaGathered, + elements, + }; + + applyTickWrites(writes, storeSetters); } catch (error: unknown) { // Log error to UI store if available, otherwise console error try { diff --git a/src/lib/game/stores/manaStore.ts b/src/lib/game/stores/manaStore.ts index 4911b34..c5ecca6 100755 --- a/src/lib/game/stores/manaStore.ts +++ b/src/lib/game/stores/manaStore.ts @@ -86,7 +86,6 @@ export const useManaStore = create()( spendRawMana: (amount: number) => { const state = get(); if (state.rawMana < amount) return false; - set({ rawMana: state.rawMana - amount }); return true; }, @@ -98,71 +97,35 @@ export const useManaStore = create()( })); }, - setMeditateTicks: (ticks: number) => { - set({ meditateTicks: ticks }); - }, - - incrementMeditateTicks: () => { - set((state) => ({ meditateTicks: state.meditateTicks + 1 })); - }, - - resetMeditateTicks: () => { - set({ meditateTicks: 0 }); - }, + setMeditateTicks: (ticks: number) => set({ meditateTicks: ticks }), + incrementMeditateTicks: () => set((s) => ({ meditateTicks: s.meditateTicks + 1 })), + resetMeditateTicks: () => set({ meditateTicks: 0 }), convertMana: (element: string, amount: number) => { const state = get(); const elem = state.elements[element]; - if (!elem?.unlocked) { - return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`); - } + if (!elem?.unlocked) return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`); const cost = MANA_PER_ELEMENT * amount; - if (state.rawMana < cost) { - return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`); - } - if (elem.current >= elem.max) { - return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`); - } + if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`); + if (elem.current >= elem.max) return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`); - const canConvert = Math.min( - amount, - Math.floor(state.rawMana / MANA_PER_ELEMENT), - elem.max - elem.current - ); - - if (canConvert <= 0) { - return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount'); - } + const canConvert = Math.min(amount, Math.floor(state.rawMana / MANA_PER_ELEMENT), elem.max - elem.current); + if (canConvert <= 0) return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount'); set({ rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT, - elements: { - ...state.elements, - [element]: { ...elem, current: elem.current + canConvert }, - }, + elements: { ...state.elements, [element]: { ...elem, current: elem.current + canConvert } }, }); - return ok({ converted: canConvert }); }, unlockElement: (element: string, cost: number) => { const state = get(); - if (state.elements[element]?.unlocked) { - return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`); - } - if (state.rawMana < cost) { - return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`); - } - - set({ - rawMana: state.rawMana - cost, - elements: { - ...state.elements, - [element]: { ...state.elements[element], unlocked: true }, - }, - }); + if (state.elements[element]?.unlocked) return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`); + if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`); + set({ rawMana: state.rawMana - cost, elements: { ...state.elements, [element]: { ...state.elements[element], unlocked: true } } }); return okVoid(); }, @@ -170,15 +133,8 @@ export const useManaStore = create()( set((state) => { const elem = state.elements[element]; if (!elem) return state; - return { - elements: { - ...state.elements, - [element]: { - ...elem, - current: Math.min(elem.current + amount, max), - }, - }, + elements: { ...state.elements, [element]: { ...elem, current: Math.min(elem.current + amount, max) } }, }; }); }, @@ -186,64 +142,35 @@ export const useManaStore = create()( spendElementMana: (element: string, amount: number) => { const state = get(); const elem = state.elements[element]; - if (!elem) { - return fail(ErrorCode.INVALID_ELEMENT, `Element ${element} does not exist`); - } - if (elem.current < amount) { - return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amount} ${element} mana, have ${elem.current}`); - } - - set({ - elements: { - ...state.elements, - [element]: { ...elem, current: elem.current - amount }, - }, - }); + if (!elem) return fail(ErrorCode.INVALID_ELEMENT, `Element ${element} does not exist`); + if (elem.current < amount) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amount} ${element} mana, have ${elem.current}`); + set({ elements: { ...state.elements, [element]: { ...elem, current: elem.current - amount } } }); return okVoid(); }, setElementMax: (max: number) => { set((state) => ({ - elements: Object.fromEntries( - Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }]) - ) as Record, + elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])) as Record, })); }, craftComposite: (target: string, recipe: string[]) => { const state = get(); - - // Count required ingredients const costs: Record = {}; - recipe.forEach(r => { - costs[r] = (costs[r] || 0) + 1; - }); + recipe.forEach(r => { costs[r] = (costs[r] || 0) + 1; }); - // Check if we have all ingredients for (const [r, amt] of Object.entries(costs)) { - if ((state.elements[r]?.current || 0) < amt) { - return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`); - } + if ((state.elements[r]?.current || 0) < amt) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`); } - // Deduct ingredients const newElems = { ...state.elements }; for (const [r, amt] of Object.entries(costs)) { - newElems[r] = { - ...newElems[r], - current: newElems[r].current - amt, - }; + newElems[r] = { ...newElems[r], current: newElems[r].current - amt }; } - // Add crafted element const targetElem = newElems[target]; - newElems[target] = { - ...(targetElem || { current: 0, max: 10, unlocked: false }), - current: (targetElem?.current || 0) + 1, - unlocked: true, - }; - + newElems[target] = { ...(targetElem || { current: 0, max: 10, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true }; set({ elements: newElems }); return okVoid(); }, @@ -252,27 +179,16 @@ export const useManaStore = create()( const state = get(); const elements = { ...state.elements }; - const unlockedElements = Object.entries(elements) - .filter(([, e]) => e.unlocked && e.current < e.max); - - if (unlockedElements.length === 0 || rawMana < 100) return null; + const unlockedElements = Object.entries(elements).filter(([, e]) => e.unlocked && e.current < e.max); + if (unlockedElements.length === 0 || rawMana < MANA_PER_ELEMENT) return null; unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current)); const [targetId, targetState] = unlockedElements[0]; - const canConvert = Math.min( - Math.floor(rawMana / 100), - targetState.max - targetState.current - ); - + const canConvert = Math.min(Math.floor(rawMana / MANA_PER_ELEMENT), targetState.max - targetState.current); if (canConvert <= 0) return null; - rawMana -= canConvert * 100; - const updatedElements = { - ...elements, - [targetId]: { ...targetState, current: targetState.current + canConvert } - }; - - return { rawMana, elements: updatedElements }; + rawMana -= canConvert * MANA_PER_ELEMENT; + return { rawMana, elements: { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } } }; }, resetMana: ( @@ -283,24 +199,13 @@ export const useManaStore = create()( ) => { const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5; const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10; - const elements = makeInitialElements(elementMax, prestigeUpgrades); - - set({ - rawMana: startingMana, - meditateTicks: 0, - totalManaGathered: 0, - elements, - }); + set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) }); }, }), { storage: createSafeStorage(), name: 'mana-loop-mana', - partialize: (state) => ({ - rawMana: state.rawMana, - totalManaGathered: state.totalManaGathered, - elements: state.elements, - }), + partialize: (state) => ({ rawMana: state.rawMana, totalManaGathered: state.totalManaGathered, elements: state.elements }), } ) ); @@ -311,16 +216,10 @@ export function makeInitialElements( prestigeUpgrades: Record = {} ): Record { const elemStart = (prestigeUpgrades.elemStart || 0) * 5; - const elements: Record = {}; - Object.keys(ELEMENTS).forEach(k => { + for (const k of Object.keys(ELEMENTS)) { const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k); - elements[k] = { - current: isUnlocked ? elemStart : 0, - max: elementMax, - unlocked: isUnlocked, - }; - }); - + elements[k] = { current: isUnlocked ? elemStart : 0, max: elementMax, unlocked: isUnlocked }; + } return elements; }