refactor: cleanup codebase — remove hydration guards, extract constants, fix bugs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
This commit is contained in:
@@ -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 (10–80), compound (90–110), exotic (120–140), 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,20 +87,19 @@
|
|||||||
- 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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Technology | Version | Purpose |
|
| Technology | Version | Purpose |
|
||||||
|------------|---------|---------|
|
|------------|---------|---------|
|
||||||
| **Next.js** | ^16.1.1 | Full-stack framework (App Router) |
|
| **Next.js** | ^16.1.1 | Full-stack framework (App Router) |
|
||||||
| **React** | ^19.0.0 | UI library |
|
| **React** | ^19.0.0 | UI library |
|
||||||
| **TypeScript** | ^5 | Type-safe development |
|
| **TypeScript** | ^5 | Type-safe development |
|
||||||
@@ -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 10–140)
|
||||||
|
│ │ └── guardian-encounters.ts # Procedural guardian lookup & combo bosses
|
||||||
|
│ ├── effects/ # Unified stat computation
|
||||||
|
│ │ └── discipline-effects.ts # Discipline → getUnifiedEffects()
|
||||||
|
│ ├── types/ # TypeScript types (disciplines, elements, etc.)
|
||||||
|
│ └── utils/ # Combat, floor, enemy, discipline math helpers
|
||||||
├── prisma/ # Database schema and migrations
|
├── 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 10–80)**: One guardian per base element + Transference. Static definitions with named guardians (Ignis Prime, Aqua Regia, etc.). Defeating them unlocks their associated mana types.
|
||||||
|
2. **Compound Elements (Floors 90–110)**: Metal, Sand, and Lightning guardians with procedurally generated names.
|
||||||
|
3. **Exotic Elements (Floors 120–140)**: Crystal, Stellar, and Void guardians - the most powerful single-element encounters.
|
||||||
|
4. **Combination Bosses (Floor 150+)**: Fully procedural dual-element guardians. Each one wields two base elements simultaneously (e.g. Fire+Water, Light+Dark) and grows stronger every 10 floors.
|
||||||
|
|
||||||
|
**Key Files**: `src/lib/game/data/guardian-data.ts`, `src/lib/game/data/guardian-encounters.ts`
|
||||||
|
|
||||||
### Combat System
|
### 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 T1–T5, 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
|
||||||
@@ -393,4 +404,4 @@ SOFTWARE.
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Climb the spire. Master the mana. Uncover the loop.</em>
|
<em>Climb the spire. Master the mana. Uncover the loop.</em>
|
||||||
</p>
|
</p>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
],
|
],
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 37 KiB |
@@ -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(() => {
|
||||||
.filter(([, state]) => state.active)
|
const map = new Map<string, number>();
|
||||||
.sort(([, a], [, b]) => {
|
Object.values(ATTUNEMENTS_DEF).forEach((d, i) => map.set(d.id, i));
|
||||||
const orderA = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === a.id);
|
return map;
|
||||||
const orderB = Object.values(ATTUNEMENTS_DEF).findIndex(d => d.id === b.id);
|
}, []);
|
||||||
return orderA - orderB;
|
|
||||||
});
|
const activeAttunements = useMemo(() => {
|
||||||
|
return Object.entries(attunements)
|
||||||
|
.filter(([, state]) => state.active)
|
||||||
|
.sort(([, a], [, b]) => (attunementOrder.get(a.id) ?? 0) - (attunementOrder.get(b.id) ?? 0));
|
||||||
|
}, [attunements, attunementOrder]);
|
||||||
|
|
||||||
const xpForNext = (level: number) => {
|
const xpForNext = (level: number) => {
|
||||||
if (level <= 1) return 0;
|
if (level <= 1) return 0;
|
||||||
|
|||||||
@@ -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,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,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,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,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,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,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,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,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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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: missingManaAmount > 0 ? 'Insufficient mana' : 'Missing materials',
|
||||||
reason: missingMana > 0 ? 'Insufficient mana' : 'Missing materials',
|
recipe,
|
||||||
recipe,
|
missingMaterials,
|
||||||
missingMaterials,
|
missingMana: missingManaAmount > 0 ? missingManaAmount : undefined,
|
||||||
missingMana: missingMana > 0 ? missingMana : 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,108 +54,80 @@ 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 = {
|
|
||||||
instanceId,
|
|
||||||
typeId: recipe.equipmentTypeId,
|
|
||||||
name: recipe.name,
|
|
||||||
enchantments: [],
|
|
||||||
usedCapacity: 0,
|
|
||||||
totalCapacity: equipType.baseCapacity,
|
|
||||||
rarity: recipe.rarity,
|
|
||||||
quality: 100,
|
|
||||||
tags: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
return ok({
|
return ok({
|
||||||
instanceId,
|
instanceId,
|
||||||
instance: newInstance,
|
instance: {
|
||||||
|
instanceId,
|
||||||
|
typeId: recipe.equipmentTypeId,
|
||||||
|
name: recipe.name,
|
||||||
|
enchantments: [],
|
||||||
|
usedCapacity: 0,
|
||||||
|
totalCapacity: equipType.baseCapacity,
|
||||||
|
rarity: recipe.rarity,
|
||||||
|
quality: BASE_EQUIPMENT_QUALITY,
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
logMessage: `🔨 Crafted ${recipe.name}!`,
|
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;
|
||||||
|
|||||||
@@ -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 = {
|
|
||||||
...instance,
|
|
||||||
enchantments: [],
|
|
||||||
usedCapacity: 0,
|
|
||||||
rarity: 'common',
|
|
||||||
tags: [...(instance.tags || []), 'Ready for Enchantment'],
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updatedInstance,
|
updatedInstance: {
|
||||||
|
...instance,
|
||||||
|
enchantments: [],
|
||||||
|
usedCapacity: 0,
|
||||||
|
rarity: 'common',
|
||||||
|
tags: [...(instance.tags || []), 'Ready for Enchantment'],
|
||||||
|
},
|
||||||
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 {
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
@@ -15,60 +29,51 @@ export function computeDynamicRegen(
|
|||||||
incursionStrength: number
|
incursionStrength: number
|
||||||
): 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
return regen * effects.regenMultiplier;
|
return regen * effects.regenMultiplier;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
+241
-260
@@ -82,277 +82,258 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
|
|
||||||
if (ctx.ui.gameOver || ctx.ui.paused) return;
|
if (ctx.ui.gameOver || ctx.ui.paused) return;
|
||||||
|
|
||||||
// ── Phase 2: Compute — derive all updates ───────────────────────────
|
// Shared setters object — used by every applyTickWrites call below
|
||||||
const writes: TickWrites = { logs: [] };
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const addLog = (msg: string) => writes.logs.push(msg);
|
const storeSetters = {
|
||||||
|
|
||||||
// Compute equipment and discipline effects
|
|
||||||
const equipmentEffects = computeEquipmentEffects(
|
|
||||||
ctx.crafting.equipmentInstances || {},
|
|
||||||
ctx.crafting.equippedInstances || {}
|
|
||||||
);
|
|
||||||
const disciplineEffects = computeDisciplineEffects();
|
|
||||||
const allSpecials = new Set<string>([
|
|
||||||
...equipmentEffects.specials,
|
|
||||||
...disciplineEffects.specials,
|
|
||||||
]);
|
|
||||||
const effects = { specials: allSpecials } as ComputedEffects;
|
|
||||||
|
|
||||||
const maxMana = computeMaxMana(
|
|
||||||
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} },
|
|
||||||
undefined,
|
|
||||||
disciplineEffects,
|
|
||||||
);
|
|
||||||
const baseRegen = computeRegen(
|
|
||||||
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} },
|
|
||||||
undefined,
|
|
||||||
disciplineEffects,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Time progression
|
|
||||||
let hour = ctx.game.hour + HOURS_PER_TICK;
|
|
||||||
let day = ctx.game.day;
|
|
||||||
if (hour >= 24) {
|
|
||||||
hour -= 24;
|
|
||||||
day += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for loop end
|
|
||||||
if (day > MAX_DAY) {
|
|
||||||
const insightGained = calcInsight({
|
|
||||||
maxFloorReached: ctx.combat.maxFloorReached,
|
|
||||||
totalManaGathered: ctx.mana.totalManaGathered,
|
|
||||||
signedPacts: ctx.prestige.signedPacts,
|
|
||||||
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
|
|
||||||
skills: {},
|
|
||||||
}, disciplineEffects);
|
|
||||||
|
|
||||||
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
|
||||||
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false };
|
|
||||||
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
|
||||||
writes.game = { day, hour };
|
|
||||||
applyTickWrites(writes, {
|
|
||||||
setGame: set,
|
setGame: set,
|
||||||
setUI: (w) => useUIStore.setState(w),
|
setUI: (w: any) => useUIStore.setState(w),
|
||||||
setPrestige: (w) => usePrestigeStore.setState(w),
|
setPrestige: (w: any) => usePrestigeStore.setState(w),
|
||||||
setMana: (w) => useManaStore.setState(w),
|
setMana: (w: any) => useManaStore.setState(w),
|
||||||
setCombat: (w) => useCombatStore.setState(w),
|
setCombat: (w: any) => useCombatStore.setState(w),
|
||||||
setCrafting: (w) => useCraftingStore.setState(w),
|
setCrafting: (w: any) => useCraftingStore.setState(w),
|
||||||
setAttunement: (w) => useAttunementStore.setState(w),
|
setAttunement: (w: any) => useAttunementStore.setState(w),
|
||||||
setDiscipline: (w) => useDisciplineStore.setState(w),
|
setDiscipline: (w: any) => useDisciplineStore.setState(w),
|
||||||
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
addLogs: (msgs: string[]) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
||||||
});
|
};
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for victory
|
// ── Phase 2: Compute — derive all updates ───────────────────────────
|
||||||
if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) {
|
const writes: TickWrites = { logs: [] };
|
||||||
const insightGained = calcInsight({
|
const addLog = (msg: string) => writes.logs.push(msg);
|
||||||
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!`);
|
// Compute equipment and discipline effects
|
||||||
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true };
|
const equipmentEffects = computeEquipmentEffects(
|
||||||
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
ctx.crafting.equipmentInstances || {},
|
||||||
applyTickWrites(writes, {
|
ctx.crafting.equippedInstances || {}
|
||||||
setGame: set,
|
);
|
||||||
setUI: (w) => useUIStore.setState(w),
|
const disciplineEffects = computeDisciplineEffects();
|
||||||
setPrestige: (w) => usePrestigeStore.setState(w),
|
const allSpecials = new Set<string>([
|
||||||
setMana: (w) => useManaStore.setState(w),
|
...equipmentEffects.specials,
|
||||||
setCombat: (w) => useCombatStore.setState(w),
|
...disciplineEffects.specials,
|
||||||
setCrafting: (w) => useCraftingStore.setState(w),
|
]);
|
||||||
setAttunement: (w) => useAttunementStore.setState(w),
|
const effects = { specials: allSpecials } as ComputedEffects;
|
||||||
setDiscipline: (w) => useDisciplineStore.setState(w),
|
|
||||||
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Incursion
|
const maxMana = computeMaxMana(
|
||||||
const incursionStrength = getIncursionStrength(day, hour);
|
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} },
|
||||||
|
undefined,
|
||||||
// Meditation bonus tracking
|
disciplineEffects,
|
||||||
let meditateTicks = ctx.mana.meditateTicks;
|
);
|
||||||
let meditationMultiplier = 1;
|
const baseRegen = computeRegen(
|
||||||
|
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} },
|
||||||
if (ctx.combat.currentAction === 'meditate') {
|
undefined,
|
||||||
meditateTicks++;
|
disciplineEffects,
|
||||||
meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1);
|
|
||||||
} else {
|
|
||||||
meditateTicks = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total attunement conversion per tick
|
|
||||||
let totalConversionPerTick = 0;
|
|
||||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
|
||||||
if (!state.active) return;
|
|
||||||
const def = ATTUNEMENTS_DEF[id];
|
|
||||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
|
||||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
|
||||||
totalConversionPerTick += scaledRate * HOURS_PER_TICK;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate effective regen
|
|
||||||
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
|
|
||||||
|
|
||||||
// Mana regeneration
|
|
||||||
let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
|
||||||
let elements = { ...ctx.mana.elements };
|
|
||||||
|
|
||||||
// Apply attunement conversion
|
|
||||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
|
||||||
if (!state.active) return;
|
|
||||||
const def = ATTUNEMENTS_DEF[id];
|
|
||||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
|
||||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
|
||||||
const conversionThisTick = scaledRate * HOURS_PER_TICK;
|
|
||||||
if (elements[def.primaryManaType]) {
|
|
||||||
elements[def.primaryManaType].current = Math.min(
|
|
||||||
elements[def.primaryManaType].max,
|
|
||||||
elements[def.primaryManaType].current + conversionThisTick
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let totalManaGathered = ctx.mana.totalManaGathered;
|
|
||||||
|
|
||||||
// Convert action — delegate to manaStore
|
|
||||||
if (ctx.combat.currentAction === 'convert') {
|
|
||||||
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
|
||||||
if (convertResult) {
|
|
||||||
rawMana = convertResult.rawMana;
|
|
||||||
elements = convertResult.elements;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pact ritual progress
|
|
||||||
if (ctx.prestige.pactRitualFloor !== null) {
|
|
||||||
const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor);
|
|
||||||
if (guardian) {
|
|
||||||
const pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1;
|
|
||||||
const requiredTime = guardian.pactTime * pactAffinityBonus;
|
|
||||||
const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK;
|
|
||||||
|
|
||||||
if (newProgress >= requiredTime) {
|
|
||||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
|
||||||
writes.prestige = {
|
|
||||||
...(writes.prestige || {}),
|
|
||||||
signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor],
|
|
||||||
defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor),
|
|
||||||
pactRitualFloor: null,
|
|
||||||
pactRitualProgress: 0,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
writes.prestige = {
|
|
||||||
...(writes.prestige || {}),
|
|
||||||
pactRitualProgress: newProgress,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discipline tick — process active disciplines (XP accrual + mana drain)
|
|
||||||
const disciplineResult = useDisciplineStore.getState().processTick({
|
|
||||||
rawMana,
|
|
||||||
elements,
|
|
||||||
});
|
|
||||||
rawMana = disciplineResult.rawMana;
|
|
||||||
elements = disciplineResult.elements;
|
|
||||||
|
|
||||||
// Apply per-element regen from discipline effects (regen_{element})
|
|
||||||
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
|
|
||||||
if (key.startsWith('regen_') && key !== 'regenBonus') {
|
|
||||||
const element = key.replace('regen_', '');
|
|
||||||
if (elements[element]) {
|
|
||||||
elements[element] = {
|
|
||||||
...elements[element],
|
|
||||||
current: Math.min(
|
|
||||||
elements[element].max,
|
|
||||||
elements[element].current + value * HOURS_PER_TICK,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock enchantment effects from newly unlocked discipline perks
|
|
||||||
if (disciplineResult.unlockedEffects.length > 0) {
|
|
||||||
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
|
|
||||||
for (const effectId of disciplineResult.unlockedEffects) {
|
|
||||||
addLog(`✨ Discipline insight unlocked: ${effectId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combat — delegate to combatStore
|
|
||||||
if (ctx.combat.currentAction === 'climb') {
|
|
||||||
const combatResult = useCombatStore.getState().processCombatTick(
|
|
||||||
rawMana,
|
|
||||||
elements,
|
|
||||||
maxMana,
|
|
||||||
1,
|
|
||||||
(floor, wasGuardian) => {
|
|
||||||
if (wasGuardian) {
|
|
||||||
const defeatedGuardian = getGuardianForFloor(floor);
|
|
||||||
addLog(`\u2694\ufe0f ${defeatedGuardian?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`);
|
|
||||||
} else if (floor % 5 === 0) {
|
|
||||||
addLog(`🏰 Floor ${floor} cleared!`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(damage) => {
|
|
||||||
let dmg = damage;
|
|
||||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
|
|
||||||
dmg *= 2;
|
|
||||||
}
|
|
||||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
|
||||||
dmg *= 1.5;
|
|
||||||
}
|
|
||||||
return { rawMana, elements, modifiedDamage: dmg };
|
|
||||||
},
|
|
||||||
ctx.prestige.signedPacts,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
rawMana = combatResult.rawMana;
|
// Time progression
|
||||||
elements = combatResult.elements;
|
let hour = ctx.game.hour + HOURS_PER_TICK;
|
||||||
totalManaGathered += combatResult.totalManaGathered || 0;
|
let day = ctx.game.day;
|
||||||
|
if (hour >= 24) {
|
||||||
if (combatResult.logMessages) {
|
hour -= 24;
|
||||||
combatResult.logMessages.forEach(msg => addLog(msg));
|
day += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
writes.combat = {
|
// Shared insight params — reused for both loop-end and victory
|
||||||
...(writes.combat || {}),
|
const insightParams = {
|
||||||
currentFloor: combatResult.currentFloor,
|
maxFloorReached: ctx.combat.maxFloorReached,
|
||||||
floorHP: combatResult.floorHP,
|
totalManaGathered: ctx.mana.totalManaGathered,
|
||||||
floorMaxHP: combatResult.floorMaxHP,
|
signedPacts: ctx.prestige.signedPacts,
|
||||||
maxFloorReached: combatResult.maxFloorReached,
|
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
|
||||||
castProgress: combatResult.castProgress,
|
skills: {} as Record<string, number>,
|
||||||
equipmentSpellStates: combatResult.equipmentSpellStates,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 3: Write — batch all state updates ─────────────────────────
|
// Check for loop end
|
||||||
writes.game = { day, hour, incursionStrength };
|
if (day > MAX_DAY) {
|
||||||
writes.mana = {
|
const insightGained = calcInsight(insightParams, disciplineEffects);
|
||||||
rawMana,
|
|
||||||
meditateTicks,
|
|
||||||
totalManaGathered,
|
|
||||||
elements,
|
|
||||||
};
|
|
||||||
|
|
||||||
applyTickWrites(writes, {
|
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
||||||
setGame: set,
|
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false };
|
||||||
setUI: (w) => useUIStore.setState(w),
|
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
||||||
setPrestige: (w) => usePrestigeStore.setState(w),
|
writes.game = { day, hour };
|
||||||
setMana: (w) => useManaStore.setState(w),
|
applyTickWrites(writes, storeSetters);
|
||||||
setCombat: (w) => useCombatStore.setState(w),
|
return;
|
||||||
setCrafting: (w) => useCraftingStore.setState(w),
|
}
|
||||||
setAttunement: (w) => useAttunementStore.setState(w),
|
|
||||||
setDiscipline: (w) => useDisciplineStore.setState(w),
|
// Check for victory (3× insight multiplier)
|
||||||
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) {
|
||||||
});
|
const insightGained = calcInsight(insightParams, disciplineEffects) * 3;
|
||||||
|
|
||||||
|
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
|
||||||
|
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true };
|
||||||
|
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
||||||
|
applyTickWrites(writes, storeSetters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incursion
|
||||||
|
const incursionStrength = getIncursionStrength(day, hour);
|
||||||
|
|
||||||
|
// Meditation bonus tracking
|
||||||
|
let meditateTicks = ctx.mana.meditateTicks;
|
||||||
|
let meditationMultiplier = 1;
|
||||||
|
|
||||||
|
if (ctx.combat.currentAction === 'meditate') {
|
||||||
|
meditateTicks++;
|
||||||
|
meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1);
|
||||||
|
} else {
|
||||||
|
meditateTicks = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total attunement conversion per tick
|
||||||
|
let totalConversionPerTick = 0;
|
||||||
|
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||||
|
if (!state.active) return;
|
||||||
|
const def = ATTUNEMENTS_DEF[id];
|
||||||
|
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||||
|
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||||
|
totalConversionPerTick += scaledRate * HOURS_PER_TICK;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate effective regen
|
||||||
|
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
|
||||||
|
|
||||||
|
// Mana regeneration
|
||||||
|
let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||||
|
let elements = { ...ctx.mana.elements };
|
||||||
|
|
||||||
|
// Apply attunement conversion
|
||||||
|
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||||
|
if (!state.active) return;
|
||||||
|
const def = ATTUNEMENTS_DEF[id];
|
||||||
|
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||||
|
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||||
|
const conversionThisTick = scaledRate * HOURS_PER_TICK;
|
||||||
|
if (elements[def.primaryManaType]) {
|
||||||
|
elements[def.primaryManaType].current = Math.min(
|
||||||
|
elements[def.primaryManaType].max,
|
||||||
|
elements[def.primaryManaType].current + conversionThisTick
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let totalManaGathered = ctx.mana.totalManaGathered;
|
||||||
|
|
||||||
|
// Convert action — delegate to manaStore
|
||||||
|
if (ctx.combat.currentAction === 'convert') {
|
||||||
|
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
||||||
|
if (convertResult) {
|
||||||
|
rawMana = convertResult.rawMana;
|
||||||
|
elements = convertResult.elements;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pact ritual progress
|
||||||
|
if (ctx.prestige.pactRitualFloor !== null) {
|
||||||
|
const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor);
|
||||||
|
if (guardian) {
|
||||||
|
const pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1;
|
||||||
|
const requiredTime = guardian.pactTime * pactAffinityBonus;
|
||||||
|
const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK;
|
||||||
|
|
||||||
|
if (newProgress >= requiredTime) {
|
||||||
|
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||||
|
writes.prestige = {
|
||||||
|
...(writes.prestige || {}),
|
||||||
|
signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor],
|
||||||
|
defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor),
|
||||||
|
pactRitualFloor: null,
|
||||||
|
pactRitualProgress: 0,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
writes.prestige = {
|
||||||
|
...(writes.prestige || {}),
|
||||||
|
pactRitualProgress: newProgress,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discipline tick — process active disciplines (XP accrual + mana drain)
|
||||||
|
const disciplineResult = useDisciplineStore.getState().processTick({
|
||||||
|
rawMana,
|
||||||
|
elements,
|
||||||
|
});
|
||||||
|
rawMana = disciplineResult.rawMana;
|
||||||
|
elements = disciplineResult.elements;
|
||||||
|
|
||||||
|
// Apply per-element regen from discipline effects (regen_{element})
|
||||||
|
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
|
||||||
|
if (key.startsWith('regen_') && key !== 'regenBonus') {
|
||||||
|
const element = key.replace('regen_', '');
|
||||||
|
if (elements[element]) {
|
||||||
|
elements[element] = {
|
||||||
|
...elements[element],
|
||||||
|
current: Math.min(
|
||||||
|
elements[element].max,
|
||||||
|
elements[element].current + value * HOURS_PER_TICK,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock enchantment effects from newly unlocked discipline perks
|
||||||
|
if (disciplineResult.unlockedEffects.length > 0) {
|
||||||
|
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
|
||||||
|
for (const effectId of disciplineResult.unlockedEffects) {
|
||||||
|
addLog(`✨ Discipline insight unlocked: ${effectId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combat — delegate to combatStore
|
||||||
|
if (ctx.combat.currentAction === 'climb') {
|
||||||
|
const combatResult = useCombatStore.getState().processCombatTick(
|
||||||
|
rawMana,
|
||||||
|
elements,
|
||||||
|
maxMana,
|
||||||
|
1,
|
||||||
|
(floor, wasGuardian) => {
|
||||||
|
if (wasGuardian) {
|
||||||
|
const defeatedGuardian = getGuardianForFloor(floor);
|
||||||
|
addLog(`\u2694\ufe0f ${defeatedGuardian?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`);
|
||||||
|
} else if (floor % 5 === 0) {
|
||||||
|
addLog(`🏰 Floor ${floor} cleared!`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(damage) => {
|
||||||
|
let dmg = damage;
|
||||||
|
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
|
||||||
|
dmg *= 2;
|
||||||
|
}
|
||||||
|
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||||
|
dmg *= 1.5;
|
||||||
|
}
|
||||||
|
return { rawMana, elements, modifiedDamage: dmg };
|
||||||
|
},
|
||||||
|
ctx.prestige.signedPacts,
|
||||||
|
);
|
||||||
|
|
||||||
|
rawMana = combatResult.rawMana;
|
||||||
|
elements = combatResult.elements;
|
||||||
|
totalManaGathered += combatResult.totalManaGathered || 0;
|
||||||
|
|
||||||
|
if (combatResult.logMessages) {
|
||||||
|
combatResult.logMessages.forEach(msg => addLog(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
writes.combat = {
|
||||||
|
...(writes.combat || {}),
|
||||||
|
currentFloor: combatResult.currentFloor,
|
||||||
|
floorHP: combatResult.floorHP,
|
||||||
|
floorMaxHP: combatResult.floorMaxHP,
|
||||||
|
maxFloorReached: combatResult.maxFloorReached,
|
||||||
|
castProgress: combatResult.castProgress,
|
||||||
|
equipmentSpellStates: combatResult.equipmentSpellStates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3: Write — batch all state updates ─────────────────────────
|
||||||
|
writes.game = { day, hour, incursionStrength };
|
||||||
|
writes.mana = {
|
||||||
|
rawMana,
|
||||||
|
meditateTicks,
|
||||||
|
totalManaGathered,
|
||||||
|
elements,
|
||||||
|
};
|
||||||
|
|
||||||
|
applyTickWrites(writes, storeSetters);
|
||||||
} catch (error: unknown) {
|
} 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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user