refactor: cleanup codebase — remove hydration guards, extract constants, fix bugs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s

This commit is contained in:
2026-05-26 11:20:36 +02:00
parent 5c64bb00fa
commit b402b8f56e
23 changed files with 579 additions and 979 deletions
+87 -76
View File
@@ -3,7 +3,7 @@
<p align="center"> <p align="center">
<img src="public/logo.svg" alt="Mana Loop Logo" width="200" /> <img src="public/logo.svg" alt="Mana Loop Logo" width="200" />
<br /> <br />
<em>An incremental/idle game about climbing a magical spire, mastering skills, and uncovering ancient secrets.</em> <em>An incremental/idle game about climbing a magical spire, mastering disciplines, and uncovering ancient secrets.</em>
</p> </p>
<p align="center"> <p align="center">
@@ -15,7 +15,7 @@
</p> </p>
<p align="center"> <p align="center">
<img src="https://img.shields.io/badge/version-0.2.0-blue" alt="Version" /> <img src="https://img.shields.io/badge/version-0.3.0-blue" alt="Version" />
<img src="https://img.shields.io/badge/license-MIT-green" alt="License" /> <img src="https://img.shields.io/badge/license-MIT-green" alt="License" />
<img src="https://img.shields.io/badge/Next.js-16.1.1-black" alt="Next.js" /> <img src="https://img.shields.io/badge/Next.js-16.1.1-black" alt="Next.js" />
<img src="https://img.shields.io/badge/TypeScript-5-blue" alt="TypeScript" /> <img src="https://img.shields.io/badge/TypeScript-5-blue" alt="TypeScript" />
@@ -42,13 +42,13 @@
## Overview ## 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 ### Core Game Loop
1. **Gather Mana** - Click to collect mana or let it regenerate automatically (14 total mana types) 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 2. **Practice Disciplines** - Continuously train abilities that drain mana each tick in exchange for growing stat bonuses
3. **Climb the Spire** - Battle through 100 procedurally-generated floors, defeat guardians, sign pacts 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 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) 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 6. **Prestige (Loop)** - Reset progress for Insight currency, gain permanent bonuses
@@ -62,18 +62,19 @@
- Elemental conversion, regeneration mechanics, and meditation bonuses - 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) - Mana types: Fire, Water, Air, Earth, Light, Dark, Death (base), Transference (utility), Metal, Sand, Lightning (compound), Crystal, Stellar, Void (exotic)
### 📜 Skill & Spell System ### 📜 Discipline System
- 20+ skills across multiple categories (mana, study, enchanting, golemancy) - Practice-based progression - no discrete levels, only continuous XP growth
- 5-tier evolution system for each skill - Disciplines drain mana each tick; stat bonuses grow as a power curve of accumulated XP
- Milestone upgrades at levels 5 and 10 per tier - Perks unlock at XP thresholds (once, capped, or infinite stacking)
- Unique special effects unlocked through skill upgrades - Attunement-gated discipline pools (Base / Enchanter / Invoker / Fabricator)
- Concurrent discipline slots unlock as total XP grows (max 4)
### ⚔️ Combat & Spire ### ⚔️ Combat & Spire
- Cast-speed based combat system - Cast-speed based combat system with elemental effectiveness
- Multi-spell support from equipped weapons - Multi-spell support from equipped weapons
- 100-floor spire with elemental themes - Every 10th floor is a guardian: base elements (1080), compound (90110), exotic (120140), then procedural combination bosses (150+)
- Floor guardians with unique mechanics and pacts
- Golem allies that deal automatic damage each tick - Golem allies that deal automatic damage each tick
- Enemy modifiers: Armored, Agile, Mage, Shield, Swarm
### 🛡️ Equipment & Enchanting ### 🛡️ Equipment & Enchanting
- 3-stage enchantment process: Design → Prepare → Apply - 3-stage enchantment process: Design → Prepare → Apply
@@ -86,13 +87,12 @@
- Summon magical constructs (Earth, Steel, Crystal, Sand + 6 hybrid types) - Summon magical constructs (Earth, Steel, Crystal, Sand + 6 hybrid types)
- Golem slots unlock every 2 Fabricator levels (max 5 slots at Level 10) - Golem slots unlock every 2 Fabricator levels (max 5 slots at Level 10)
- Hybrid golems require Enchanter 5 + Fabricator 5 - Hybrid golems require Enchanter 5 + Fabricator 5
- Golem maintenance costs and stat upgrades via skills
### 🔄 Prestige (Insight) ### 🔄 Prestige (Insight)
- Reset progress for permanent Insight currency - Reset progress for permanent Insight currency
- Insight upgrades across multiple categories - Insight upgrades across multiple categories
- Signed pacts and attunements persist through prestige - 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)
--- ---
@@ -176,50 +176,53 @@ Mana-Loop/
├── src/ # Application source code ├── src/ # Application source code
│ ├── app/ # Next.js App Router │ ├── app/ # Next.js App Router
│ │ ├── layout.tsx # Root layout (metadata, fonts, providers) │ │ ├── layout.tsx # Root layout (metadata, fonts, providers)
│ │ ├── page.tsx # Main game UI (~583 lines) │ │ ├── page.tsx # Main game UI
│ │ ├── globals.css # Global styles │ │ ├── globals.css # Global styles
│ │ └── api/ # API routes (minimal) │ │ └── api/ # API routes (minimal)
│ ├── components/ # React components │ ├── components/ # React components
│ │ ├── ui/ # shadcn/ui components (20+ components) │ │ ├── ui/ # shadcn/ui components (20+ components)
│ │ └── game/ # Game-specific components │ │ └── game/ # Game-specific components
│ │ ├── tabs/ # Tab components (SpireTab, SkillsTab, etc.) │ │ ├── tabs/ # Tab components (SpireTab, DisciplinesTab, etc.)
│ │ ├── ManaDisplay.tsx, ActionButtons.tsx, TimeDisplay.tsx │ │ ├── ManaDisplay.tsx, ActionButtons.tsx, TimeDisplay.tsx
│ │ └── crafting/, debug/, shared/, stats/ subdirectories │ │ └── crafting/, debug/, shared/, stats/ subdirectories
│ ├── hooks/ # Custom React hooks (use-mobile, use-toast) │ ├── hooks/ # Custom React hooks (use-mobile, use-toast)
── lib/ # Utility libraries ── lib/ # Utility libraries
── game/ # Core game logic ── game/ # Core game logic
├── store.ts # Main Zustand store (~2862 lines) ├── stores/ # Modular Zustand stores
│ ├── crafting-slice.ts, study-slice.ts, navigation-slice.ts │ ├── gameStore.ts # Core state & tick logic
│ ├── effects.ts, upgrade-effects.ts │ ├── manaStore.ts # Mana gathering & conversion
│ ├── skill-evolution.ts (~3400 lines) │ ├── combatStore.ts # Combat, spells, floor progression
│ ├── constants/ # Game definitions (elements, spells, skills) │ ├── prestigeStore.ts # Prestige/loop & insight
│ ├── data/ # Game data (equipment, golems, recipes) │ ├── discipline-slice.ts # Discipline activation & XP
── __tests__/ # Test files for game logic ── attunementStore.ts # Attunement classes
│ │ ── db.ts, utils.ts ── craftingStore.ts # Crafting state
└── test/ # Test setup └── 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 10140)
│ │ └── 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 ├── prisma/ # Database schema and migrations
│ └── schema.prisma # SQLite schema ├── public/ # Static assets
├── public/ # Static assets (logo.svg, robots.txt)
├── docs/ # Project documentation ├── docs/ # Project documentation
│ ├── AGENTS.md # Comprehensive architecture guide │ ├── AGENTS.md # Architecture guide for AI agents
── GAME_BRIEFING.md # Game design document ── GAME_BRIEFING.md # Comprehensive game design document
│ └── task/ # Task tracking documentation └── Configuration Files:
├── .next/ # Next.js build output (generated) ├── package.json, tsconfig.json, next.config.ts
├── node_modules/ # Dependencies (generated) ├── vitest.config.ts, eslint.config.mjs
├── Configuration Files: ├── Dockerfile, docker-compose.yml, Caddyfile
── package.json # Project metadata and scripts ── .gitea/workflows/ # Gitea Actions CI/CD pipeline
│ ├── 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
``` ```
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) - **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) - **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 ### Discipline System
Each skill progresses through 5 tiers with upgrades at levels 5 and 10 per tier: 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.
- **Tier 1**: Basic functionality
- **Tier 2-5**: Unlock new mechanics and bonuses - **Stat bonus** grows as a power curve of XP: `baseValue × (XP / scalingFactor)^0.65`
- **Evolution Paths**: Defined in `src/lib/game/skill-evolution.ts` (~3400 lines) - **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 1080)**: 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 90110)**: Metal, Sand, and Lightning guardians with procedurally generated names.
3. **Exotic Elements (Floors 120140)**: 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 ### Combat System
- Cast-speed based spell casting with DPS calculations - Cast-speed based spell casting with elemental effectiveness multipliers
- Elemental damage bonuses and effectiveness - Enemy modifiers: Armored, Agile, Mage (barrier), Shielded, Swarm
- Multi-spell support from equipped weapons
- Golem allies deal automatic damage each tick - 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 ### Enchanting System
3-stage equipment enchantment process: 3-stage equipment enchantment process:
1. **Design**: Choose effects for your equipment type 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) 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 ### Golemancy System
- **Base Golems**: Earth (Fabricator 2), Steel (Metal), Crystal, Sand - **Base Golems**: Earth (Fabricator 2), Steel (Metal), Crystal, Sand
- **Hybrid Golems** (Enchanter 5 + Fabricator 5): Lava, Galvanic, Obsidian, Prism, Quicksilver, Voidstone - **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) - **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) ### Prestige (Insight)
Reset progress to gain Insight currency for permanent upgrades: Reset progress to gain Insight currency for permanent upgrades:
@@ -274,7 +291,6 @@ Reset progress to gain Insight currency for permanent upgrades:
## Deployment ## Deployment
### Docker Deployment ### Docker Deployment
The project includes Docker configuration for containerized deployment:
```bash ```bash
# Build and run with Docker Compose # Build and run with Docker Compose
@@ -286,7 +302,7 @@ docker run -p 3000:3000 mana-loop
``` ```
### CI/CD Pipeline ### 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 - **Multi-platform**: Builds for linux/amd64 architecture
- **Image Tags**: Branch name, commit SHA, "latest" - **Image Tags**: Branch name, commit SHA, "latest"
@@ -316,26 +332,24 @@ We welcome contributions! Please follow these guidelines:
### Code Style ### Code Style
- TypeScript throughout with strict typing - TypeScript throughout with strict typing
- Use existing shadcn/ui components over custom implementations - Use existing shadcn/ui components over custom implementations
- Follow the slice pattern for Zustand store actions - Follow the modular store pattern (`src/lib/game/stores/`)
- Keep components focused (extract to separate files when >50 lines) - Keep files under 400 lines (enforced by pre-commit hook)
- Use path aliases: `@/*` maps to `./src/*` - Use path aliases: `@/*` maps to `./src/*`
### Adding New Features ### 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: 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.
- Architecture overview
- Coding patterns
- Git workflow (mandatory pull before work, commit & push after)
- Credentials for automation (if applicable)
--- ---
## Banned Content ## 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 ### Banned Mechanics
- **Lifesteal** - Player cannot heal from dealing damage - **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 ### Banned Mana Types
- **Life** - Removed (healing theme conflicts with core design) - **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 - **Force** - Removed
### Banned Systems ### 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 T1T5, milestone upgrades) - Fully replaced by the Discipline System
--- ---
## License ## License
This project is licensed under the MIT License - see the LICENSE section below for details.
``` ```
MIT License MIT License
@@ -377,8 +390,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. 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 ## Acknowledgments
+2 -2
View File
@@ -1,8 +1,8 @@
# Circular Dependencies # 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. 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 2. 1) utils/floor-utils.ts > utils/room-utils.ts > utils/enemy-utils.ts
3. 2) utils/floor-utils.ts > utils/room-utils.ts 3. 2) utils/floor-utils.ts > utils/room-utils.ts
4. 3) stores/gameStore.ts > stores/gameActions.ts 4. 3) stores/gameStore.ts > stores/gameActions.ts
+5 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_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.", "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." "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" "stores/craftingStore.types.ts"
], ],
"crafting-apply.ts": [ "crafting-apply.ts": [
"constants.ts",
"crafting-utils.ts", "crafting-utils.ts",
"data/attunements.ts", "data/attunements.ts",
"data/enchantment-effects.ts", "data/enchantment-effects.ts",
@@ -135,6 +136,7 @@
"types.ts" "types.ts"
], ],
"crafting-design.ts": [ "crafting-design.ts": [
"constants.ts",
"data/attunements.ts", "data/attunements.ts",
"data/enchantment-effects.ts", "data/enchantment-effects.ts",
"data/equipment/index.ts", "data/equipment/index.ts",
@@ -143,6 +145,7 @@
"types.ts" "types.ts"
], ],
"crafting-equipment.ts": [ "crafting-equipment.ts": [
"constants.ts",
"data/crafting-recipes.ts", "data/crafting-recipes.ts",
"data/equipment/index.ts", "data/equipment/index.ts",
"types.ts", "types.ts",
@@ -153,6 +156,7 @@
"types.ts" "types.ts"
], ],
"crafting-prep.ts": [ "crafting-prep.ts": [
"constants.ts",
"crafting-utils.ts", "crafting-utils.ts",
"types.ts" "types.ts"
], ],
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 37 KiB

+11 -6
View File
@@ -1,5 +1,6 @@
'use client'; 'use client';
import { useMemo } from 'react';
import { useAttunementStore } from '@/lib/game/stores'; import { useAttunementStore } from '@/lib/game/stores';
import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements'; import { ATTUNEMENTS_DEF } from '@/lib/game/data/attunements';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
@@ -18,13 +19,17 @@ const SLOT_LABELS: Record<string, string> = {
export function AttunementStatus() { export function AttunementStatus() {
const attunements = useAttunementStore((s) => s.attunements); const attunements = useAttunementStore((s) => s.attunements);
const activeAttunements = Object.entries(attunements) const attunementOrder = useMemo(() => {
const map = new Map<string, number>();
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) .filter(([, state]) => state.active)
.sort(([, a], [, b]) => { .sort(([, a], [, b]) => (attunementOrder.get(a.id) ?? 0) - (attunementOrder.get(b.id) ?? 0));
const orderA = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === a.id); }, [attunements, attunementOrder]);
const orderB = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === b.id);
return orderA - orderB;
});
const xpForNext = (level: number) => { const xpForNext = (level: number) => {
if (level <= 1) return 0; if (level <= 1) return 0;
+1 -1
View File
@@ -67,7 +67,7 @@ export function ManaDisplay({
style={{ style={{
background: 'var(--mana-raw)', background: 'var(--mana-raw)',
border: '1px solid var(--border-accent)', border: '1px solid var(--border-accent)',
color: '#0C1020', color: 'var(--bg-gather-btn)',
fontWeight: 600, fontWeight: 600,
}} }}
onMouseDown={onGatherStart} onMouseDown={onGatherStart}
+1 -15
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo } from 'react';
import { useCombatStore } from '@/lib/game/stores'; import { useCombatStore } from '@/lib/game/stores';
import { import {
ACHIEVEMENTS, ACHIEVEMENTS,
@@ -164,14 +164,8 @@ function CategorySection({
export function AchievementsTab() { export function AchievementsTab() {
const achievements = useCombatStore((s) => s.achievements); const achievements = useCombatStore((s) => s.achievements);
const [mounted, setMounted] = useState(false);
const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({}); const [collapsedCategories, setCollapsedCategories] = useState<Record<string, boolean>>({});
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const byCategory = useMemo(() => getAchievementsByCategory(), []); const byCategory = useMemo(() => getAchievementsByCategory(), []);
const categories = useMemo( const categories = useMemo(
() => Object.keys(byCategory).sort(), () => Object.keys(byCategory).sort(),
@@ -188,14 +182,6 @@ export function AchievementsTab() {
})); }));
}; };
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading achievements
</div>
);
}
return ( return (
<DebugName name="AchievementsTab"> <DebugName name="AchievementsTab">
<div className="space-y-4"> <div className="space-y-4">
+1 -15
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { useAttunementStore } from '@/lib/game/stores'; import { useAttunementStore } from '@/lib/game/stores';
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements'; import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements';
import type { AttunementDef, AttunementState } from '@/lib/game/types'; import type { AttunementDef, AttunementState } from '@/lib/game/types';
@@ -157,24 +157,10 @@ function AttunementCard({ def, state }: AttunementCardProps) {
export function AttunementsTab() { export function AttunementsTab() {
const attunements = useAttunementStore((s) => s.attunements); 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 allDefs = Object.values(ATTUNEMENTS_DEF);
const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length; const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length;
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading attunements
</div>
);
}
return ( return (
<DebugName name="AttunementsTab"> <DebugName name="AttunementsTab">
<div className="space-y-4"> <div className="space-y-4">
+1 -15
View File
@@ -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 { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import type { DisciplineDefinition } from '@/lib/game/types/disciplines'; import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
import type { ManaType } from '@/lib/game/types/elements'; import type { ManaType } from '@/lib/game/types/elements';
@@ -201,14 +201,8 @@ export const DisciplinesTab: React.FC = () => {
const activate = useDisciplineStore((s) => s.activate); const activate = useDisciplineStore((s) => s.activate);
const deactivate = useDisciplineStore((s) => s.deactivate); const deactivate = useDisciplineStore((s) => s.deactivate);
const [mounted, setMounted] = useState(false);
const [activeAttunement, setActiveAttunement] = useState<string>('base'); const [activeAttunement, setActiveAttunement] = useState<string>('base');
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const handleToggle = useCallback((id: string, paused: boolean) => { const handleToggle = useCallback((id: string, paused: boolean) => {
if (paused) { if (paused) {
activate(id); activate(id);
@@ -217,14 +211,6 @@ export const DisciplinesTab: React.FC = () => {
} }
}, [activate, deactivate]); }, [activate, deactivate]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading disciplines
</div>
);
}
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement); const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
return ( return (
+1 -16
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useCraftingStore } from '@/lib/game/stores/craftingStore'; import { useCraftingStore } from '@/lib/game/stores/craftingStore';
import type { EquipmentSlot } from '@/lib/game/types'; import type { EquipmentSlot } from '@/lib/game/types';
import { DebugName } from '@/components/game/debug/debug-context'; import { DebugName } from '@/components/game/debug/debug-context';
@@ -9,19 +9,12 @@ import { InventoryList } from './EquipmentTab/InventoryList';
import { EquipmentEffectsSummary } from './EquipmentTab/EquipmentEffectsSummary'; import { EquipmentEffectsSummary } from './EquipmentTab/EquipmentEffectsSummary';
export function EquipmentTab() { export function EquipmentTab() {
const [mounted, setMounted] = useState(false);
const equippedInstances = useCraftingStore((s) => s.equippedInstances); const equippedInstances = useCraftingStore((s) => s.equippedInstances);
const equipmentInstances = useCraftingStore((s) => s.equipmentInstances); const equipmentInstances = useCraftingStore((s) => s.equipmentInstances);
const storeEquipItem = useCraftingStore((s) => s.equipItem); const storeEquipItem = useCraftingStore((s) => s.equipItem);
const storeUnequipItem = useCraftingStore((s) => s.unequipItem); const storeUnequipItem = useCraftingStore((s) => s.unequipItem);
const storeDeleteEquipment = useCraftingStore((s) => s.deleteEquipmentInstance); const storeDeleteEquipment = useCraftingStore((s) => s.deleteEquipmentInstance);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const handleEquip = useCallback( const handleEquip = useCallback(
(instanceId: string, slot: EquipmentSlot): boolean => { (instanceId: string, slot: EquipmentSlot): boolean => {
return storeEquipItem(instanceId, slot); return storeEquipItem(instanceId, slot);
@@ -51,14 +44,6 @@ export function EquipmentTab() {
[equipmentInstances, equippedInstances] [equipmentInstances, equippedInstances]
); );
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-[var(--text-muted)]">
Loading equipment
</div>
);
}
return ( return (
<DebugName name="EquipmentTab"> <DebugName name="EquipmentTab">
<div className="space-y-6"> <div className="space-y-6">
+1 -15
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useCombatStore } from '@/lib/game/stores/combatStore'; import { useCombatStore } from '@/lib/game/stores/combatStore';
import { useAttunementStore } from '@/lib/game/stores/attunementStore'; import { useAttunementStore } from '@/lib/game/stores/attunementStore';
@@ -197,7 +197,6 @@ GolemCard.displayName = 'GolemCard';
// ─── Main Tab ──────────────────────────────────────────────────────────────── // ─── Main Tab ────────────────────────────────────────────────────────────────
export const GolemancyTab: React.FC = () => { export const GolemancyTab: React.FC = () => {
const [mounted, setMounted] = useState(false);
const [activeTier, setActiveTier] = useState<string>('base'); const [activeTier, setActiveTier] = useState<string>('base');
const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({ const { golemancy, toggleGolem } = useCombatStore(useShallow(s => ({
@@ -210,11 +209,6 @@ export const GolemancyTab: React.FC = () => {
elements: s.elements, elements: s.elements,
}))); })));
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
// Build attunement lookup for isGolemUnlocked // Build attunement lookup for isGolemUnlocked
const attunementLookup = useMemo(() => { const attunementLookup = useMemo(() => {
const lookup: Record<string, { active: boolean; level: number }> = {}; const lookup: Record<string, { active: boolean; level: number }> = {};
@@ -254,14 +248,6 @@ export const GolemancyTab: React.FC = () => {
const golemSlots = getGolemSlots(fabricatorLevel); const golemSlots = getGolemSlots(fabricatorLevel);
const enabledCount = golemancy.enabledGolems.length; const enabledCount = golemancy.enabledGolems.length;
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading golemancy
</div>
);
}
const activeTierGolems = golemsByTier[activeTier] ?? []; const activeTierGolems = golemsByTier[activeTier] ?? [];
return ( return (
+1 -15
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { usePrestigeStore } from '@/lib/game/stores/prestigeStore'; import { usePrestigeStore } from '@/lib/game/stores/prestigeStore';
import { useManaStore } from '@/lib/game/stores/manaStore'; import { useManaStore } from '@/lib/game/stores/manaStore';
@@ -53,7 +53,6 @@ function groupFloorsByTier(floors: number[]): FloorTier[] {
// ─── Main Tab ──────────────────────────────────────────────────────────────── // ─── Main Tab ────────────────────────────────────────────────────────────────
export const GuardianPactsTab: React.FC = () => { export const GuardianPactsTab: React.FC = () => {
const [mounted, setMounted] = useState(false);
const [activeTier, setActiveTier] = useState<string>('all'); const [activeTier, setActiveTier] = useState<string>('all');
const { const {
@@ -75,11 +74,6 @@ export const GuardianPactsTab: React.FC = () => {
const rawMana = useManaStore(s => s.rawMana); const rawMana = useManaStore(s => s.rawMana);
const addLog = useUIStore(s => s.addLog); const addLog = useUIStore(s => s.addLog);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const guardianFloors = useMemo( const guardianFloors = useMemo(
() => getAllGuardianFloors(), () => getAllGuardianFloors(),
[], [],
@@ -126,14 +120,6 @@ export const GuardianPactsTab: React.FC = () => {
return boonMap; return boonMap;
}, [signedPacts]); }, [signedPacts]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading guardian pacts
</div>
);
}
return ( return (
<DebugName name="GuardianPactsTab"> <DebugName name="GuardianPactsTab">
<div className="space-y-4"> <div className="space-y-4">
+1 -16
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { usePrestigeStore, useGameStore } from '@/lib/game/stores'; import { usePrestigeStore, useGameStore } from '@/lib/game/stores';
import { PRESTIGE_DEF } from '@/lib/game/constants/prestige'; import { PRESTIGE_DEF } from '@/lib/game/constants/prestige';
@@ -186,8 +186,6 @@ function ResetLoopSection({ loopInsight, onReset }: { loopInsight: number; onRes
// ─── Main Component ─────────────────────────────────────────────────────────── // ─── Main Component ───────────────────────────────────────────────────────────
export function PrestigeTab() { export function PrestigeTab() {
const [mounted, setMounted] = useState(false);
const { const {
insight, insight,
totalInsight, totalInsight,
@@ -212,11 +210,6 @@ export function PrestigeTab() {
const startNewLoop = useGameStore((s) => s.startNewLoop); const startNewLoop = useGameStore((s) => s.startNewLoop);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const handlePurchase = useCallback((id: string) => { const handlePurchase = useCallback((id: string) => {
doPrestige(id); doPrestige(id);
}, [doPrestige]); }, [doPrestige]);
@@ -225,14 +218,6 @@ export function PrestigeTab() {
startNewLoop(); startNewLoop();
}, [startNewLoop]); }, [startNewLoop]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading prestige
</div>
);
}
const upgradeEntries = Object.entries(PRESTIGE_DEF); const upgradeEntries = Object.entries(PRESTIGE_DEF);
return ( return (
@@ -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 // Guard against null/undefined/stale floorState
if (!floorState || !floorState.roomType) { if (!floorState || !floorState.roomType) {
return ( return (
@@ -47,7 +47,6 @@ function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstanc
// ─── Main Component ─────────────────────────────────────────────────────────── // ─── Main Component ───────────────────────────────────────────────────────────
export function SpireCombatPage() { export function SpireCombatPage() {
const [mounted, setMounted] = useState(false);
const [roomsCleared, setRoomsCleared] = useState(0); const [roomsCleared, setRoomsCleared] = useState(0);
const { const {
@@ -104,8 +103,6 @@ export function SpireCombatPage() {
const totalRooms = useMemo(() => getRoomsForFloor(currentFloor), [currentFloor]); const totalRooms = useMemo(() => getRoomsForFloor(currentFloor), [currentFloor]);
useEffect(() => { useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
setRoomsCleared(0); setRoomsCleared(0);
const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms); const newRoom = generateSpireFloorState(currentFloor, 0, totalRooms);
setCurrentRoom(newRoom); setCurrentRoom(newRoom);
@@ -166,14 +163,6 @@ export function SpireCombatPage() {
addActivityLog('floor_transition', '🚪 Exited the Spire.'); addActivityLog('floor_transition', '🚪 Exited the Spire.');
}; };
if (!mounted) {
return (
<div className="flex items-center justify-center min-h-screen text-gray-500">
Loading spire...
</div>
);
}
return ( return (
<div className="min-h-screen bg-gray-950 flex flex-col"> <div className="min-h-screen bg-gray-950 flex flex-col">
<header className="sticky top-0 z-50 bg-gray-900/95 border-b border-gray-800 px-4 py-2"> <header className="sticky top-0 z-50 bg-gray-900/95 border-b border-gray-800 px-4 py-2">
+1 -16
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores'; import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
import { ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants'; import { ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
@@ -311,8 +311,6 @@ function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; gu
// ─── Main Component ─────────────────────────────────────────────────────────── // ─── Main Component ───────────────────────────────────────────────────────────
export function SpireSummaryTab() { export function SpireSummaryTab() {
const [mounted, setMounted] = useState(false);
const { const {
maxFloorReached, maxFloorReached,
clearedFloors, clearedFloors,
@@ -327,11 +325,6 @@ export function SpireSummaryTab() {
insight: s.insight, insight: s.insight,
}))); })));
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true);
}, []);
const defeatedGuardians = useMemo(() => { const defeatedGuardians = useMemo(() => {
return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]); return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]);
}, [clearedFloors]); }, [clearedFloors]);
@@ -346,14 +339,6 @@ export function SpireSummaryTab() {
return Object.values(clearedFloors).filter(Boolean).length; return Object.values(clearedFloors).filter(Boolean).length;
}, [clearedFloors]); }, [clearedFloors]);
if (!mounted) {
return (
<div className="flex items-center justify-center p-8 text-gray-500">
Loading spire data
</div>
);
}
return ( return (
<DebugName name="SpireSummaryTab"> <DebugName name="SpireSummaryTab">
<div className="space-y-4"> <div className="space-y-4">
+28 -68
View File
@@ -3,6 +3,7 @@
import type { EquipmentInstance, AppliedEnchantment, EnchantmentDesign, ApplicationProgress } from './types'; import type { EquipmentInstance, AppliedEnchantment, EnchantmentDesign, ApplicationProgress } from './types';
import { calculateApplicationTime, calculateApplicationManaPerHour } from './crafting-utils'; import { calculateApplicationTime, calculateApplicationManaPerHour } from './crafting-utils';
import { HOURS_PER_TICK } from './constants';
import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects'; import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects';
import type { ComputedEffects } from './effects/upgrade-effects.types'; import type { ComputedEffects } from './effects/upgrade-effects.types';
import type { AttunementState } from './types'; import type { AttunementState } from './types';
@@ -11,32 +12,16 @@ import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects';
// ─── Application Validation ───────────────────────────────────────────────── // ─── Application Validation ─────────────────────────────────────────────────
// Check if enchantment application can start
export function canApplyEnchantment( export function canApplyEnchantment(
instance: EquipmentInstance | undefined, instance: EquipmentInstance | undefined,
design: EnchantmentDesign | undefined, design: EnchantmentDesign | undefined,
currentAction: string currentAction: string
): { canApply: boolean; reason?: string } { ): { canApply: boolean; reason?: string } {
if (!instance) { if (!instance) return { canApply: false, reason: 'Equipment instance not found' };
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 (!design) { if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) return { canApply: false, reason: 'Not enough capacity on equipment' };
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 }; return { canApply: true };
} }
@@ -51,21 +36,18 @@ export interface ApplicationCosts {
export function calculateApplicationCosts(design: EnchantmentDesign): ApplicationCosts { export function calculateApplicationCosts(design: EnchantmentDesign): ApplicationCosts {
const time = calculateApplicationTime(design); const time = calculateApplicationTime(design);
const manaPerHour = calculateApplicationManaPerHour(design); const manaPerHour = calculateApplicationManaPerHour(design);
const manaPerTick = manaPerHour * 0.04; // HOURS_PER_TICK const manaPerTick = manaPerHour * HOURS_PER_TICK;
return { time, manaPerHour, manaPerTick }; return { time, manaPerHour, manaPerTick };
} }
// ─── Application Progress ─────────────────────────────────────────────────── // ─── Application Progress ───────────────────────────────────────────────────
// Initialize application progress
export function initializeApplicationProgress( export function initializeApplicationProgress(
equipmentInstanceId: string, equipmentInstanceId: string,
designId: string, designId: string,
design: EnchantmentDesign design: EnchantmentDesign
): ApplicationProgress { ): ApplicationProgress {
const costs = calculateApplicationCosts(design); const costs = calculateApplicationCosts(design);
return { return {
equipmentInstanceId, equipmentInstanceId,
designId, 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<string, number> = {
[SPECIAL_EFFECTS.ENCHANT_PRESERVATION]: 0.25,
[SPECIAL_EFFECTS.THRIFTY_ENCHANTER]: 0.10,
[SPECIAL_EFFECTS.OPTIMIZED_ENCHANTING]: 0.25,
};
export interface ApplicationTickResult { export interface ApplicationTickResult {
progress: number; progress: number;
manaSpent: number; manaSpent: number;
@@ -93,20 +81,14 @@ export function calculateApplicationTick(
manaPerTick: number, manaPerTick: number,
computedEffects: ComputedEffects computedEffects: ComputedEffects
): ApplicationTickResult { ): ApplicationTickResult {
let progress = currentProgress + 0.04; let progress = currentProgress + HOURS_PER_TICK;
let manaSpent = currentManaSpent + manaPerTick; let manaSpent = currentManaSpent + manaPerTick;
let manaConsumed = manaPerTick; let manaConsumed = manaPerTick;
let triggeredFreeEnchant = false; let triggeredFreeEnchant = false;
let freeEnchantChance = 0; let freeEnchantChance = 0;
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.ENCHANT_PRESERVATION)) { for (const [special, chance] of Object.entries(FREE_ENCHANT_CHANCES)) {
freeEnchantChance += 0.25; if (hasSpecial(computedEffects, special)) freeEnchantChance += chance;
}
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.THRIFTY_ENCHANTER)) {
freeEnchantChance += 0.10;
}
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.OPTIMIZED_ENCHANTING)) {
freeEnchantChance += 0.25;
} }
if (freeEnchantChance > 0 && Math.random() < freeEnchantChance) { if (freeEnchantChance > 0 && Math.random() < freeEnchantChance) {
@@ -116,47 +98,32 @@ export function calculateApplicationTick(
triggeredFreeEnchant = true; triggeredFreeEnchant = true;
} }
return { return { progress, manaSpent, manaConsumed, isComplete: progress >= required, triggeredFreeEnchant };
progress,
manaSpent,
manaConsumed,
isComplete: progress >= required,
triggeredFreeEnchant,
};
} }
// ─── Enchantment Application ──────────────────────────────────────────────── // ─── Enchantment Application ────────────────────────────────────────────────
// Apply enchantments to equipment instance const PURE_ESSENCE_STACK_BONUS = 1.25;
const PURE_ESSENCE_COST_CAP = 100;
export function applyEnchantments( export function applyEnchantments(
instance: EquipmentInstance, instance: EquipmentInstance,
design: EnchantmentDesign, design: EnchantmentDesign,
computedEffects: ComputedEffects computedEffects: ComputedEffects
): { ): { updatedInstance: EquipmentInstance; xpGained: number; logMessage: string } {
updatedInstance: EquipmentInstance;
xpGained: number;
logMessage: string;
} {
const isPureEssenceActive = hasSpecial(computedEffects, SPECIAL_EFFECTS.PURE_ESSENCE); const isPureEssenceActive = hasSpecial(computedEffects, SPECIAL_EFFECTS.PURE_ESSENCE);
const newEnchantments: AppliedEnchantment[] = design.effects.map(eff => { const newEnchantments: AppliedEnchantment[] = design.effects.map(eff => {
let stacks = eff.stacks;
let actualCost = eff.capacityCost;
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId]; const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (isPureEssenceActive && effectDef && effectDef.baseCapacityCost < 100) { const bonusStacks = isPureEssenceActive && effectDef && effectDef.baseCapacityCost < PURE_ESSENCE_COST_CAP;
stacks = Math.ceil(stacks * 1.25);
}
return { return {
effectId: eff.effectId, effectId: eff.effectId,
stacks, stacks: bonusStacks ? Math.ceil(eff.stacks * PURE_ESSENCE_STACK_BONUS) : eff.stacks,
actualCost, actualCost: eff.capacityCost,
}; };
}); });
const xpGained = calculateEnchantingXP(design.totalCapacityUsed); const xpGained = calculateEnchantingXP(design.totalCapacityUsed);
const updatedInstance: EquipmentInstance = { const updatedInstance: EquipmentInstance = {
...instance, ...instance,
enchantments: [...instance.enchantments, ...newEnchantments], enchantments: [...instance.enchantments, ...newEnchantments],
@@ -176,15 +143,12 @@ export function updateEnchanterAttunement(
attunements: Record<string, AttunementState>, attunements: Record<string, AttunementState>,
xpGained: number xpGained: number
): Record<string, AttunementState> { ): Record<string, AttunementState> {
if (!attunements?.enchanter?.active || xpGained <= 0) { if (!attunements?.enchanter?.active || xpGained <= 0) return attunements;
return attunements;
}
const enchanterState = attunements.enchanter; const enchanterState = attunements.enchanter;
let newXP = enchanterState.experience + xpGained; let newXP = enchanterState.experience + xpGained;
let newLevel = enchanterState.level; let newLevel = enchanterState.level;
while (newLevel < MAX_ATTUNEMENT_LEVEL) { while (newLevel < MAX_ATTUNEMENT_LEVEL) {
const xpNeeded = getAttunementXPForLevel(newLevel + 1); const xpNeeded = getAttunementXPForLevel(newLevel + 1);
if (newXP >= xpNeeded) { if (newXP >= xpNeeded) {
@@ -197,11 +161,7 @@ export function updateEnchanterAttunement(
return { return {
...attunements, ...attunements,
enchanter: { enchanter: { ...enchanterState, level: newLevel, experience: newXP },
...enchanterState,
level: newLevel,
experience: newXP,
},
}; };
} }
@@ -222,7 +182,7 @@ export function resumeApplication() {
// ─── Progress Calculations ────────────────────────────────────────────────── // ─── Progress Calculations ──────────────────────────────────────────────────
export function getApplicationManaCostForTick(manaPerHour: number): number { export function getApplicationManaCostForTick(manaPerHour: number): number {
return manaPerHour * 0.04; return manaPerHour * HOURS_PER_TICK;
} }
export function getApplicationRemainingTime(currentProgress: number, required: number): number { export function getApplicationRemainingTime(currentProgress: number, required: number): number {
+44 -66
View File
@@ -6,67 +6,55 @@ import type { ComputedEffects } from './effects/upgrade-effects.types';
import { calculateEnchantingXP } from './data/attunements'; import { calculateEnchantingXP } from './data/attunements';
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects'; import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects'; import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects';
import { HOURS_PER_TICK } from './constants';
import { EQUIPMENT_TYPES } from './data/equipment'; 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 ────────────────────────────────────────── // ─── Design Creation & Calculation ──────────────────────────────────────────
// Validate effects for a design against equipment category
export function validateDesignEffects( export function validateDesignEffects(
effects: DesignEffect[], effects: DesignEffect[],
equipmentTypeId: string, equipmentTypeId: string,
enchantingLevel: number enchantingLevel: number
): { valid: boolean; reason?: string } { ): { valid: boolean; reason?: string } {
if (enchantingLevel < 1) { if (enchantingLevel < 1) return { valid: false, reason: 'Requires enchanting skill level 1' };
return { valid: false, reason: 'Requires enchanting skill level 1' };
}
const equipType = EQUIPMENT_TYPES[equipmentTypeId]; const equipType = EQUIPMENT_TYPES[equipmentTypeId];
if (!equipType) { if (!equipType || !equipType.category) return { valid: false, reason: 'Invalid equipment type or category' };
return { valid: false, reason: 'Invalid equipment type' };
}
const category = equipType.category;
if (!category) {
return { valid: false, reason: 'Invalid equipment category' };
}
for (const eff of effects) { for (const eff of effects) {
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId]; const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
if (!effectDef) { if (!effectDef) return { valid: false, reason: `Unknown effect: ${eff.effectId}` };
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 (!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 (eff.stacks > effectDef.maxStacks) return { valid: false, reason: `Stacks exceed maximum for ${eff.effectId}` };
} }
return { valid: true }; return { valid: true };
} }
// Create an enchantment design from validated inputs
export function createEnchantmentDesign( export function createEnchantmentDesign(
name: string, name: string,
equipmentType: string, equipmentType: string,
effects: DesignEffect[], effects: DesignEffect[],
efficiencyBonus: number = 0 efficiencyBonus: number = 0
): EnchantmentDesign { ): EnchantmentDesign {
const totalCapacityUsed = calculateDesignCapacityCost(effects, efficiencyBonus);
const designTime = calculateDesignTime(effects);
return { return {
id: `design_${Date.now()}`, id: `design_${Date.now()}`,
name, name,
equipmentType, equipmentType,
effects, effects,
totalCapacityUsed, totalCapacityUsed: calculateDesignCapacityCost(effects, efficiencyBonus),
designTime, designTime: calculateDesignTime(effects),
created: Date.now(), created: Date.now(),
}; };
} }
// ─── Capacity Cost Calculation ────────────────────────────────────────────── // ─── Capacity & Time Calculations ───────────────────────────────────────────
export function calculateDesignCapacityCost(effects: DesignEffect[], efficiencyBonus: number = 0): number { export function calculateDesignCapacityCost(effects: DesignEffect[], efficiencyBonus: number = 0): number {
return effects.reduce((total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus), 0); 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; 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 ─────────────────────────────────────────────────────── // ─── XP & Progression ───────────────────────────────────────────────────────
export function calculateEnchantingXpFromDesign(design: EnchantmentDesign): number { export function calculateEnchantingXpFromDesign(design: EnchantmentDesign): number {
@@ -88,37 +95,11 @@ export function calculateXpFromInstanceEnchantments(
let totalXp = 0; let totalXp = 0;
for (const ench of instance.enchantments) { for (const ench of instance.enchantments) {
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId]; const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
const baseCost = effectDef?.baseCapacityCost || 0; totalXp += calculateEnchantingXP((effectDef?.baseCapacityCost || 0) * ench.stacks);
totalXp += calculateEnchantingXP(baseCost * ench.stacks);
} }
return totalXp; 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 ────────────────────────────────────────────────── // ─── Progress Calculations ──────────────────────────────────────────────────
export interface DesignProgressUpdate { export interface DesignProgressUpdate {
@@ -128,21 +109,23 @@ export interface DesignProgressUpdate {
timeBonus: number; timeBonus: number;
} }
const INSTANT_DESIGN_CHANCE = 0.10;
export function calculateDesignProgress( export function calculateDesignProgress(
currentProgress: number, currentProgress: number,
required: number, required: number,
computedEffects: ComputedEffects, computedEffects: ComputedEffects,
isRepeatDesign: boolean isRepeatDesign: boolean
): DesignProgressUpdate { ): DesignProgressUpdate {
let progress = currentProgress + 0.04; let progress = currentProgress + DESIGN_PROGRESS_PER_TICK;
let timeBonus = 0; let timeBonus = 0;
if (isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)) { if (isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)) {
timeBonus = 0.04 * 0.25; timeBonus = DESIGN_PROGRESS_PER_TICK * HASTY_ENCHANTER_BONUS_MULTIPLIER;
progress += timeBonus; 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; progress = required;
} }
@@ -165,8 +148,7 @@ export function isSecondDesignSlotAvailable(
): boolean { ): boolean {
if (!designProgress && !designProgress2) return true; if (!designProgress && !designProgress2) return true;
if (!designProgress && designProgress2) return false; if (!designProgress && designProgress2) return false;
if (designProgress && !designProgress2 && hasEnchantMastery) return true; return !!(designProgress && !designProgress2 && hasEnchantMastery);
return false;
} }
// ─── Auto-save Completed Design ──────────────────────────────────────────── // ─── Auto-save Completed Design ────────────────────────────────────────────
@@ -181,13 +163,12 @@ export function createCompletedDesignFromProgress(
}, },
efficiencyBonus: number = 0 efficiencyBonus: number = 0
): EnchantmentDesign { ): EnchantmentDesign {
const totalCapacityCost = calculateDesignCapacityCost(progressData.effects, efficiencyBonus);
return { return {
id: progressData.designId, id: progressData.designId,
name: progressData.name, name: progressData.name,
equipmentType: progressData.equipmentType, equipmentType: progressData.equipmentType,
effects: progressData.effects, effects: progressData.effects,
totalCapacityUsed: totalCapacityCost, totalCapacityUsed: calculateDesignCapacityCost(progressData.effects, efficiencyBonus),
designTime: progressData.required, designTime: progressData.required,
created: Date.now(), created: Date.now(),
}; };
@@ -206,13 +187,10 @@ export function filterDesignsByEquipment(
equipment: { instanceId: string; totalCapacity: number; usedCapacity: number } | null equipment: { instanceId: string; totalCapacity: number; usedCapacity: number } | null
): DesignWithCapacityInfo[] { ): DesignWithCapacityInfo[] {
if (!equipment) return []; if (!equipment) return [];
const availableCapacity = equipment.totalCapacity - equipment.usedCapacity;
return designs.map(design => ({ return designs.map(design => ({
design, design,
fitsInEquipment: designFitsInEquipment(design, equipment), fitsInEquipment: (equipment.usedCapacity || 0) + design.totalCapacityUsed <= equipment.totalCapacity,
availableCapacity: equipment.totalCapacity - equipment.usedCapacity, availableCapacity,
})); }));
} }
function designFitsInEquipment(design: EnchantmentDesign, instance: { usedCapacity: number; totalCapacity: number }): boolean {
return (instance.usedCapacity || 0) + design.totalCapacityUsed <= instance.totalCapacity;
}
+35 -86
View File
@@ -6,10 +6,12 @@ import { CRAFTING_RECIPES, canCraftRecipe, type CraftingRecipe } from './data/cr
import { EQUIPMENT_TYPES } from './data/equipment'; import { EQUIPMENT_TYPES } from './data/equipment';
import { ok, fail, ErrorCode } from './utils/result'; import { ok, fail, ErrorCode } from './utils/result';
import type { Result } from './utils/result'; import type { Result } from './utils/result';
import { HOURS_PER_TICK } from './constants';
const MANA_REFUND_RATE = 0.5;
// ─── Equipment Crafting Validation ────────────────────────────────────────── // ─── Equipment Crafting Validation ──────────────────────────────────────────
// Check if equipment crafting can start
export function canStartEquipmentCrafting( export function canStartEquipmentCrafting(
blueprintId: string, blueprintId: string,
hasBlueprint: boolean, hasBlueprint: boolean,
@@ -17,38 +19,27 @@ export function canStartEquipmentCrafting(
currentMana: number, currentMana: number,
currentAction: string currentAction: string
): { canCraft: boolean; reason?: string; recipe?: CraftingRecipe; missingMaterials?: Record<string, number>; missingMana?: number } { ): { canCraft: boolean; reason?: string; recipe?: CraftingRecipe; missingMaterials?: Record<string, number>; missingMana?: number } {
if (currentAction !== 'meditate') { if (currentAction !== 'meditate') return { canCraft: false, reason: 'Must be in meditate state' };
return { canCraft: false, reason: 'Must be in meditate state' };
}
const recipe = CRAFTING_RECIPES[blueprintId]; const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) { if (!recipe) return { canCraft: false, reason: 'Invalid blueprint' };
return { canCraft: false, reason: 'Invalid blueprint' }; if (!hasBlueprint) return { canCraft: false, reason: 'Blueprint not acquired' };
}
if (!hasBlueprint) {
return { canCraft: false, reason: 'Blueprint not acquired' };
}
const { canCraft, missingMaterials } = canCraftRecipe(recipe, materials, currentMana); const { canCraft, missingMaterials } = canCraftRecipe(recipe, materials, currentMana);
if (canCraft) return { canCraft: true, recipe };
if (!canCraft) { const missingManaAmount = Math.max(0, recipe.manaCost - currentMana);
const missingMana = Math.max(0, recipe.manaCost - currentMana);
return { return {
canCraft: false, canCraft: false,
reason: missingMana > 0 ? 'Insufficient mana' : 'Missing materials', reason: missingManaAmount > 0 ? 'Insufficient mana' : 'Missing materials',
recipe, recipe,
missingMaterials, missingMaterials,
missingMana: missingMana > 0 ? missingMana : undefined, missingMana: missingManaAmount > 0 ? missingManaAmount : undefined,
}; };
} }
return { canCraft: true, recipe };
}
// ─── Equipment Crafting Execution ─────────────────────────────────────────── // ─── Equipment Crafting Execution ───────────────────────────────────────────
// Deduct crafting costs and initialize progress
export interface CraftingInitResult { export interface CraftingInitResult {
recipe: CraftingRecipe; recipe: CraftingRecipe;
newMaterials: Record<string, number>; newMaterials: Record<string, number>;
@@ -63,66 +54,53 @@ export function initializeEquipmentCrafting(
): CraftingInitResult { ): CraftingInitResult {
const recipe = CRAFTING_RECIPES[blueprintId]; const recipe = CRAFTING_RECIPES[blueprintId];
// Deduct materials
const newMaterials = { ...materials }; const newMaterials = { ...materials };
for (const [matId, amount] of Object.entries(recipe.materials)) { for (const [matId, amount] of Object.entries(recipe.materials)) {
newMaterials[matId] = (newMaterials[matId] || 0) - amount; newMaterials[matId] = (newMaterials[matId] || 0) - amount;
if (newMaterials[matId] <= 0) { if (newMaterials[matId] <= 0) delete newMaterials[matId];
delete newMaterials[matId];
} }
}
// Create progress
const progress: EquipmentCraftingProgress = {
blueprintId,
equipmentTypeId: recipe.equipmentTypeId,
progress: 0,
required: recipe.craftTime,
manaSpent: recipe.manaCost,
};
return { return {
recipe, recipe,
newMaterials, newMaterials,
manaCost: recipe.manaCost, manaCost: recipe.manaCost,
progress, progress: {
blueprintId,
equipmentTypeId: recipe.equipmentTypeId,
progress: 0,
required: recipe.craftTime,
manaSpent: recipe.manaCost,
},
}; };
} }
// ─── Crafting Progress ────────────────────────────────────────────────────── // ─── Crafting Progress ──────────────────────────────────────────────────────
// Calculate crafting progress after a tick
export interface CraftingTickResult { export interface CraftingTickResult {
progress: number; progress: number;
isComplete: boolean; isComplete: boolean;
} }
export function calculateCraftingTick(currentProgress: number, required: number): CraftingTickResult { export function calculateCraftingTick(currentProgress: number, required: number): CraftingTickResult {
const progress = currentProgress + 0.04; // HOURS_PER_TICK const progress = currentProgress + HOURS_PER_TICK;
return { return { progress, isComplete: progress >= required };
progress,
isComplete: progress >= required,
};
} }
// ─── Crafting Completion ─────────────────────────────────────────────────── // ─── Crafting Completion ───────────────────────────────────────────────────
// Create equipment instance from completed crafting const BASE_EQUIPMENT_QUALITY = 100;
export function completeEquipmentCrafting( export function completeEquipmentCrafting(
blueprintId: string, blueprintId: string,
recipe: CraftingRecipe recipe: CraftingRecipe
): Result<{ ): Result<{ instanceId: string; instance: EquipmentInstance; logMessage: string }> {
instanceId: string;
instance: EquipmentInstance;
logMessage: string;
}> {
const equipType = EQUIPMENT_TYPES[recipe.equipmentTypeId]; const equipType = EQUIPMENT_TYPES[recipe.equipmentTypeId];
if (!equipType) { if (!equipType) return fail(ErrorCode.INVALID_EQUIPMENT_TYPE, `Invalid equipment type: ${recipe.equipmentTypeId}`);
return fail(ErrorCode.INVALID_EQUIPMENT_TYPE, `Invalid equipment type: ${recipe.equipmentTypeId}`);
}
const instanceId = `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const instanceId = `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const newInstance: EquipmentInstance = { return ok({
instanceId,
instance: {
instanceId, instanceId,
typeId: recipe.equipmentTypeId, typeId: recipe.equipmentTypeId,
name: recipe.name, name: recipe.name,
@@ -130,41 +108,26 @@ export function completeEquipmentCrafting(
usedCapacity: 0, usedCapacity: 0,
totalCapacity: equipType.baseCapacity, totalCapacity: equipType.baseCapacity,
rarity: recipe.rarity, rarity: recipe.rarity,
quality: 100, quality: BASE_EQUIPMENT_QUALITY,
tags: [], tags: [],
}; },
return ok({
instanceId,
instance: newInstance,
logMessage: `🔨 Crafted ${recipe.name}!`, logMessage: `🔨 Crafted ${recipe.name}!`,
}); });
} }
// ─── Crafting Cancellation ────────────────────────────────────────────────── // ─── Crafting Cancellation ──────────────────────────────────────────────────
// Cancel active crafting and refund partial resources
export interface CraftingCancelResult { export interface CraftingCancelResult {
manaRefund: number; manaRefund: number;
logMessage: string; logMessage: string;
} }
export function cancelEquipmentCrafting(_blueprintId: string, manaSpent: number): CraftingCancelResult { export function cancelEquipmentCrafting(blueprintId: string, manaSpent: number): CraftingCancelResult {
const recipe = CRAFTING_RECIPES[blueprintId]; const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) { if (!recipe) return { manaRefund: 0, logMessage: 'Invalid crafting recipe.' };
return {
manaRefund: 0,
logMessage: 'Invalid crafting recipe.',
};
}
// Refund 50% of mana const manaRefund = Math.floor(manaSpent * MANA_REFUND_RATE);
const manaRefund = Math.floor(manaSpent * 0.5); return { manaRefund, logMessage: `🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.` };
return {
manaRefund,
logMessage: `🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.`,
};
} }
// ─── Recipe Information ───────────────────────────────────────────────────── // ─── Recipe Information ─────────────────────────────────────────────────────
@@ -179,23 +142,16 @@ export function getCraftableRecipes(
currentMana: number currentMana: number
): CraftingRecipe[] { ): CraftingRecipe[] {
const craftable: CraftingRecipe[] = []; const craftable: CraftingRecipe[] = [];
for (const blueprintId of blueprints) { for (const blueprintId of blueprints) {
const recipe = CRAFTING_RECIPES[blueprintId]; const recipe = CRAFTING_RECIPES[blueprintId];
if (!recipe) continue; if (!recipe) continue;
if (canCraftRecipe(recipe, materials, currentMana).canCraft) craftable.push(recipe);
const { canCraft } = canCraftRecipe(recipe, materials, currentMana);
if (canCraft) {
craftable.push(recipe);
} }
}
return craftable; return craftable;
} }
// ─── Material Management ──────────────────────────────────────────────────── // ─── Material Management ────────────────────────────────────────────────────
// Delete materials from inventory
export function deleteMaterials(materialId: string, amount: number, materials: Record<string, number>): { export function deleteMaterials(materialId: string, amount: number, materials: Record<string, number>): {
newMaterials: Record<string, number>; newMaterials: Record<string, number>;
deleted: number; deleted: number;
@@ -204,25 +160,18 @@ export function deleteMaterials(materialId: string, amount: number, materials: R
const deleted = Math.min(amount, currentAmount); const deleted = Math.min(amount, currentAmount);
const remaining = Math.max(0, currentAmount - amount); const remaining = Math.max(0, currentAmount - amount);
const newMaterials = { ...materials }; const newMaterials = { ...materials };
if (remaining <= 0) { if (remaining <= 0) {
delete newMaterials[materialId]; delete newMaterials[materialId];
} else { } else {
newMaterials[materialId] = remaining; newMaterials[materialId] = remaining;
} }
return { newMaterials, deleted };
return {
newMaterials,
deleted,
};
} }
// Get total material count
export function getMaterialCount(materials: Record<string, number>, materialId: string): number { export function getMaterialCount(materials: Record<string, number>, materialId: string): number {
return materials[materialId] || 0; return materials[materialId] || 0;
} }
// Add materials to inventory
export function addMaterials(materials: Record<string, number>, materialId: string, amount: number): Record<string, number> { export function addMaterials(materials: Record<string, number>, materialId: string, amount: number): Record<string, number> {
const newMaterials = { ...materials }; const newMaterials = { ...materials };
newMaterials[materialId] = (newMaterials[materialId] || 0) + amount; newMaterials[materialId] = (newMaterials[materialId] || 0) + amount;
+23 -50
View File
@@ -3,26 +3,21 @@
import type { EquipmentInstance, PreparationProgress } from './types'; import type { EquipmentInstance, PreparationProgress } from './types';
import { calculatePrepTime, calculatePrepManaCost, calculateManaPerHourForPrep } from './crafting-utils'; import { calculatePrepTime, calculatePrepManaCost, calculateManaPerHourForPrep } from './crafting-utils';
import { HOURS_PER_TICK } from './constants';
// ─── Preparation Validation ───────────────────────────────────────────────── // ─── Preparation Validation ─────────────────────────────────────────────────
// Check if an equipment instance can be prepared
export function canPrepareEquipment( export function canPrepareEquipment(
instance: EquipmentInstance | undefined, instance: EquipmentInstance | undefined,
currentTags: string[] currentTags: string[]
): { canPrepare: boolean; reason?: string } { ): { canPrepare: boolean; reason?: string } {
if (!instance) { if (!instance) return { canPrepare: false, reason: 'Equipment instance not found' };
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 (currentTags.includes('Ready for Enchantment')) {
return { canPrepare: false, reason: 'Equipment is already prepared for enchanting' };
}
return { canPrepare: true }; return { canPrepare: true };
} }
// Calculate preparation resource costs // ─── Preparation Costs ──────────────────────────────────────────────────────
export interface PreparationCosts { export interface PreparationCosts {
time: number; time: number;
manaTotal: number; manaTotal: number;
@@ -34,30 +29,20 @@ export function calculatePreparationCosts(totalCapacity: number): PreparationCos
const time = calculatePrepTime(totalCapacity); const time = calculatePrepTime(totalCapacity);
const manaTotal = calculatePrepManaCost(totalCapacity); const manaTotal = calculatePrepManaCost(totalCapacity);
const manaPerHour = calculateManaPerHourForPrep(totalCapacity, time); const manaPerHour = calculateManaPerHourForPrep(totalCapacity, time);
const manaPerTick = manaPerHour * 0.04; // HOURS_PER_TICK return { time, manaTotal, manaPerHour, manaPerTick: manaPerHour * HOURS_PER_TICK };
return { time, manaTotal, manaPerHour, manaPerTick };
} }
// ─── Preparation Progress ───────────────────────────────────────────────────
// Initialize preparation progress
export function initializePreparationProgress( export function initializePreparationProgress(
equipmentInstanceId: string, equipmentInstanceId: string,
totalCapacity: number, totalCapacity: number,
manaCostPaid: number = 0 manaCostPaid: number = 0
): PreparationProgress { ): PreparationProgress {
const costs = calculatePreparationCosts(totalCapacity); 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 { export interface PreparationTickResult {
progress: number; progress: number;
manaCostPaid: number; manaCostPaid: number;
@@ -68,15 +53,14 @@ export interface PreparationTickResult {
export function calculatePreparationTick( export function calculatePreparationTick(
currentProgress: number, currentProgress: number,
required: number, required: number,
currentManaCostPaid: number,
manaPerTick: number manaPerTick: number
): PreparationTickResult { ): PreparationTickResult {
const progress = currentProgress + 0.04; // HOURS_PER_TICK const progress = currentProgress + HOURS_PER_TICK;
const manaConsumed = manaPerTick; const manaConsumed = manaPerTick;
const manaCostPaid = manaPerTick; // Accumulated
return { return {
progress, progress,
manaCostPaid, manaCostPaid: currentManaCostPaid + manaConsumed,
manaConsumed, manaConsumed,
isComplete: progress >= required, isComplete: progress >= required,
}; };
@@ -84,51 +68,40 @@ export function calculatePreparationTick(
// ─── Preparation Completion ───────────────────────────────────────────────── // ─── 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( export function completePreparation(
instance: EquipmentInstance, instance: EquipmentInstance,
_manaSpent: number disenchantLevel: number = 0
): { ): { updatedInstance: EquipmentInstance; manaRecovered: number; logMessage: string } {
updatedInstance: EquipmentInstance; const recoveryRate = BASE_DISENCHANT_RECOVERY_RATE + disenchantLevel * DISENCHANT_RECOVERY_PER_LEVEL;
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
let totalRecovered = 0; let totalRecovered = 0;
for (const ench of instance.enchantments) { for (const ench of instance.enchantments) {
totalRecovered += Math.floor(ench.actualCost * recoveryRate); totalRecovered += Math.floor(ench.actualCost * recoveryRate);
} }
const updatedInstance: EquipmentInstance = { return {
updatedInstance: {
...instance, ...instance,
enchantments: [], enchantments: [],
usedCapacity: 0, usedCapacity: 0,
rarity: 'common', rarity: 'common',
tags: [...(instance.tags || []), 'Ready for Enchantment'], tags: [...(instance.tags || []), 'Ready for Enchantment'],
}; },
return {
updatedInstance,
manaRecovered: totalRecovered, manaRecovered: totalRecovered,
logMessage: `✅ Equipment prepared for enchanting! Recovered ${totalRecovered} mana.`, logMessage: `✅ Equipment prepared for enchanting! Recovered ${totalRecovered} mana.`,
}; };
} }
// Cancel preparation (no resource recovery for preparation itself)
export function cancelPreparation() { export function cancelPreparation() {
return { return { logMessage: 'Preparation cancelled.' };
logMessage: 'Preparation cancelled.',
};
} }
// ─── Preparation State Calculations ───────────────────────────────────────── // ─── Preparation State Calculations ─────────────────────────────────────────
export function getPreparationManaCostForTick(instance: EquipmentInstance): number { export function getPreparationManaCostForTick(instance: EquipmentInstance): number {
const costs = calculatePreparationCosts(instance.totalCapacity); return calculatePreparationCosts(instance.totalCapacity).manaPerTick;
return costs.manaPerTick;
} }
export function getPreparationRemainingTime(currentProgress: number, required: number): number { export function getPreparationRemainingTime(currentProgress: number, required: number): number {
+37 -71
View File
@@ -2,8 +2,22 @@
// Dynamic computation functions that depend on special effects // Dynamic computation functions that depend on special effects
import type { ComputedEffects } from './upgrade-effects.types'; import type { ComputedEffects } from './upgrade-effects.types';
import { SPECIAL_EFFECTS, hasSpecial } from './special-effects'; 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 * Compute regen with special effects that depend on dynamic values
*/ */
@@ -16,56 +30,47 @@ export function computeDynamicRegen(
): number { ): number {
let regen = baseRegen; 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)) { 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)) { 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)) { 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)) { if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_CORE)) {
regen += maxMana * 0.005; regen += maxMana * 0.005;
} }
// Mana Tide: Regen pulses ±50% (sinusoidal based on time) // Mana Tide: sinusoidal pulse
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TIDE)) { if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TIDE)) {
regen *= (1.0 + 0.5 * Math.sin(Date.now() / 10000)); 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)) { if (hasSpecial(effects, SPECIAL_EFFECTS.ETERNAL_FLOW)) {
return regen * effects.regenMultiplier; return regen * effects.regenMultiplier;
} }
// Steady Stream: Regen immune to incursion (skip incursion penalty only) // Steady Stream: skip incursion only
if (hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)) { if (!hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)) {
// incursion penalty is skipped, but regenMultiplier still applies below
} else {
// Apply incursion penalty
regen *= (1 - incursionStrength); regen *= (1 - incursionStrength);
} }
@@ -79,46 +84,7 @@ export function computeDynamicClickMana(
effects: ComputedEffects, effects: ComputedEffects,
baseClickMana: number baseClickMana: number
): number { ): number {
let clickMana = baseClickMana; return Math.floor((baseClickMana + effects.clickManaBonus) * effects.clickManaMultiplier);
// 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);
} }
/**
* 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;
}
+27 -46
View File
@@ -82,6 +82,20 @@ export const useGameStore = create<GameCoordinatorStore>()(
if (ctx.ui.gameOver || ctx.ui.paused) return; if (ctx.ui.gameOver || ctx.ui.paused) return;
// Shared setters object — used by every applyTickWrites call below
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const storeSetters = {
setGame: set,
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)),
};
// ── Phase 2: Compute — derive all updates ─────────────────────────── // ── Phase 2: Compute — derive all updates ───────────────────────────
const writes: TickWrites = { logs: [] }; const writes: TickWrites = { logs: [] };
const addLog = (msg: string) => writes.logs.push(msg); const addLog = (msg: string) => writes.logs.push(msg);
@@ -117,58 +131,35 @@ export const useGameStore = create<GameCoordinatorStore>()(
day += 1; day += 1;
} }
// Check for loop end // Shared insight params — reused for both loop-end and victory
if (day > MAX_DAY) { const insightParams = {
const insightGained = calcInsight({
maxFloorReached: ctx.combat.maxFloorReached, maxFloorReached: ctx.combat.maxFloorReached,
totalManaGathered: ctx.mana.totalManaGathered, totalManaGathered: ctx.mana.totalManaGathered,
signedPacts: ctx.prestige.signedPacts, signedPacts: ctx.prestige.signedPacts,
prestigeUpgrades: ctx.prestige.prestigeUpgrades, prestigeUpgrades: ctx.prestige.prestigeUpgrades,
skills: {}, skills: {} as Record<string, number>,
}, disciplineEffects); };
// Check for loop end
if (day > MAX_DAY) {
const insightGained = calcInsight(insightParams, disciplineEffects);
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`); addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false }; writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false };
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
writes.game = { day, hour }; writes.game = { day, hour };
applyTickWrites(writes, { applyTickWrites(writes, 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; return;
} }
// Check for victory // Check for victory (3× insight multiplier)
if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) { if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) {
const insightGained = calcInsight({ const insightGained = calcInsight(insightParams, disciplineEffects) * 3;
maxFloorReached: ctx.combat.maxFloorReached,
totalManaGathered: ctx.mana.totalManaGathered,
signedPacts: ctx.prestige.signedPacts,
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
skills: {},
}, disciplineEffects) * 3;
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`); addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true }; writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true };
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained }; writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
applyTickWrites(writes, { applyTickWrites(writes, 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; return;
} }
@@ -342,17 +333,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
elements, elements,
}; };
applyTickWrites(writes, { applyTickWrites(writes, 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)),
});
} catch (error: unknown) { } catch (error: unknown) {
// Log error to UI store if available, otherwise console error // Log error to UI store if available, otherwise console error
try { try {
+31 -132
View File
@@ -86,7 +86,6 @@ export const useManaStore = create<ManaStore>()(
spendRawMana: (amount: number) => { spendRawMana: (amount: number) => {
const state = get(); const state = get();
if (state.rawMana < amount) return false; if (state.rawMana < amount) return false;
set({ rawMana: state.rawMana - amount }); set({ rawMana: state.rawMana - amount });
return true; return true;
}, },
@@ -98,71 +97,35 @@ export const useManaStore = create<ManaStore>()(
})); }));
}, },
setMeditateTicks: (ticks: number) => { setMeditateTicks: (ticks: number) => set({ meditateTicks: ticks }),
set({ meditateTicks: ticks }); incrementMeditateTicks: () => set((s) => ({ meditateTicks: s.meditateTicks + 1 })),
}, resetMeditateTicks: () => set({ meditateTicks: 0 }),
incrementMeditateTicks: () => {
set((state) => ({ meditateTicks: state.meditateTicks + 1 }));
},
resetMeditateTicks: () => {
set({ meditateTicks: 0 });
},
convertMana: (element: string, amount: number) => { convertMana: (element: string, amount: number) => {
const state = get(); const state = get();
const elem = state.elements[element]; const elem = state.elements[element];
if (!elem?.unlocked) { if (!elem?.unlocked) return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`);
return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`);
}
const cost = MANA_PER_ELEMENT * amount; const cost = MANA_PER_ELEMENT * amount;
if (state.rawMana < cost) { if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
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 (elem.current >= elem.max) {
return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`);
}
const canConvert = Math.min( const canConvert = Math.min(amount, Math.floor(state.rawMana / MANA_PER_ELEMENT), elem.max - elem.current);
amount, if (canConvert <= 0) return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative 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({ set({
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT, rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
elements: { elements: { ...state.elements, [element]: { ...elem, current: elem.current + canConvert } },
...state.elements,
[element]: { ...elem, current: elem.current + canConvert },
},
}); });
return ok({ converted: canConvert }); return ok({ converted: canConvert });
}, },
unlockElement: (element: string, cost: number) => { unlockElement: (element: string, cost: number) => {
const state = get(); const state = get();
if (state.elements[element]?.unlocked) { if (state.elements[element]?.unlocked) return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already 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}`);
}
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 },
},
});
set({ rawMana: state.rawMana - cost, elements: { ...state.elements, [element]: { ...state.elements[element], unlocked: true } } });
return okVoid(); return okVoid();
}, },
@@ -170,15 +133,8 @@ export const useManaStore = create<ManaStore>()(
set((state) => { set((state) => {
const elem = state.elements[element]; const elem = state.elements[element];
if (!elem) return state; if (!elem) return state;
return { return {
elements: { elements: { ...state.elements, [element]: { ...elem, current: Math.min(elem.current + amount, max) } },
...state.elements,
[element]: {
...elem,
current: Math.min(elem.current + amount, max),
},
},
}; };
}); });
}, },
@@ -186,64 +142,35 @@ export const useManaStore = create<ManaStore>()(
spendElementMana: (element: string, amount: number) => { spendElementMana: (element: string, amount: number) => {
const state = get(); const state = get();
const elem = state.elements[element]; const elem = state.elements[element];
if (!elem) { if (!elem) return fail(ErrorCode.INVALID_ELEMENT, `Element ${element} does not exist`);
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}`);
}
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 },
},
});
set({ elements: { ...state.elements, [element]: { ...elem, current: elem.current - amount } } });
return okVoid(); return okVoid();
}, },
setElementMax: (max: number) => { setElementMax: (max: number) => {
set((state) => ({ set((state) => ({
elements: Object.fromEntries( elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])) as Record<string, ElementState>,
Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])
) as Record<string, ElementState>,
})); }));
}, },
craftComposite: (target: string, recipe: string[]) => { craftComposite: (target: string, recipe: string[]) => {
const state = get(); const state = get();
// Count required ingredients
const costs: Record<string, number> = {}; const costs: Record<string, number> = {};
recipe.forEach(r => { recipe.forEach(r => { costs[r] = (costs[r] || 0) + 1; });
costs[r] = (costs[r] || 0) + 1;
});
// Check if we have all ingredients
for (const [r, amt] of Object.entries(costs)) { for (const [r, amt] of Object.entries(costs)) {
if ((state.elements[r]?.current || 0) < amt) { if ((state.elements[r]?.current || 0) < amt) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`);
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`);
}
} }
// Deduct ingredients
const newElems = { ...state.elements }; const newElems = { ...state.elements };
for (const [r, amt] of Object.entries(costs)) { for (const [r, amt] of Object.entries(costs)) {
newElems[r] = { newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
...newElems[r],
current: newElems[r].current - amt,
};
} }
// Add crafted element
const targetElem = newElems[target]; const targetElem = newElems[target];
newElems[target] = { newElems[target] = { ...(targetElem || { current: 0, max: 10, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true };
...(targetElem || { current: 0, max: 10, unlocked: false }),
current: (targetElem?.current || 0) + 1,
unlocked: true,
};
set({ elements: newElems }); set({ elements: newElems });
return okVoid(); return okVoid();
}, },
@@ -252,27 +179,16 @@ export const useManaStore = create<ManaStore>()(
const state = get(); const state = get();
const elements = { ...state.elements }; const elements = { ...state.elements };
const unlockedElements = Object.entries(elements) const unlockedElements = Object.entries(elements).filter(([, e]) => e.unlocked && e.current < e.max);
.filter(([, e]) => e.unlocked && e.current < e.max); if (unlockedElements.length === 0 || rawMana < MANA_PER_ELEMENT) return null;
if (unlockedElements.length === 0 || rawMana < 100) return null;
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current)); unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
const [targetId, targetState] = unlockedElements[0]; const [targetId, targetState] = unlockedElements[0];
const canConvert = Math.min( const canConvert = Math.min(Math.floor(rawMana / MANA_PER_ELEMENT), targetState.max - targetState.current);
Math.floor(rawMana / 100),
targetState.max - targetState.current
);
if (canConvert <= 0) return null; if (canConvert <= 0) return null;
rawMana -= canConvert * 100; rawMana -= canConvert * MANA_PER_ELEMENT;
const updatedElements = { return { rawMana, elements: { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } } };
...elements,
[targetId]: { ...targetState, current: targetState.current + canConvert }
};
return { rawMana, elements: updatedElements };
}, },
resetMana: ( resetMana: (
@@ -283,24 +199,13 @@ export const useManaStore = create<ManaStore>()(
) => { ) => {
const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5; const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5;
const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10; const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
const elements = makeInitialElements(elementMax, prestigeUpgrades); set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) });
set({
rawMana: startingMana,
meditateTicks: 0,
totalManaGathered: 0,
elements,
});
}, },
}), }),
{ {
storage: createSafeStorage(), storage: createSafeStorage(),
name: 'mana-loop-mana', name: 'mana-loop-mana',
partialize: (state) => ({ partialize: (state) => ({ rawMana: state.rawMana, totalManaGathered: state.totalManaGathered, elements: state.elements }),
rawMana: state.rawMana,
totalManaGathered: state.totalManaGathered,
elements: state.elements,
}),
} }
) )
); );
@@ -311,16 +216,10 @@ export function makeInitialElements(
prestigeUpgrades: Record<string, number> = {} prestigeUpgrades: Record<string, number> = {}
): Record<string, ElementState> { ): Record<string, ElementState> {
const elemStart = (prestigeUpgrades.elemStart || 0) * 5; const elemStart = (prestigeUpgrades.elemStart || 0) * 5;
const elements: Record<string, ElementState> = {}; const elements: Record<string, ElementState> = {};
Object.keys(ELEMENTS).forEach(k => { for (const k of Object.keys(ELEMENTS)) {
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k); const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
elements[k] = { elements[k] = { current: isUnlocked ? elemStart : 0, max: elementMax, unlocked: isUnlocked };
current: isUnlocked ? elemStart : 0, }
max: elementMax,
unlocked: isUnlocked,
};
});
return elements; return elements;
} }